diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..38127e1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ + + +# Changelog + +## Unreleased + +### Added + +- `hydra:operation` on collection GET responses (`hydra:Collection`): collection-level operations (create `POST`, custom collection actions), analogous to the existing item enrichment (#5) +- Security filtering for `hydra:operation`: operations whose `security` expression does not grant access to the current token are omitted, evaluated via API Platform's `ResourceAccessChecker`; item operations are checked with the loaded entity as `object` (#5) +- New config flag `hydra_operations.filter_operations_by_security` (default `true`) to switch the filtering off (#5) +- `Vary: Authorization` header on enriched responses while filtering is active — the advertised operations are user-dependent (#5) + +### Fixed + +- Duplicate `partial_uuid_item_provider` node in the bundle configuration tree (and the duplicate parameter assignment in the extension) + +## 0.3.1 and earlier + +See git history. diff --git a/README.md b/README.md index 7c89258..5d612ad 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,41 @@ Generic helpers for API Platform projects. - DB-JSON CLI tools (`db:export-json`, `db:import-json`) - `AbstractJsonSchemaInputCommand` base class for JSON-Schema-validated CLI +## Hydra operations (`hydra:operation`) + +`AddHydraOperationsSubscriber` enriches JSON-LD runtime responses with a `hydra:operation` array so clients can discover the available operations without parsing the static docs: + +- **Item GET responses** list the item-level operations (`GET`, `PUT`, `PATCH`, `DELETE`, custom `/resource/{id}/` actions) with the `{id}` placeholder resolved to the actual identifier. +- **Collection GET responses** (`hydra:Collection`) list the collection-level operations (`GET`, create `POST`, custom `/resource/` actions). + +### Security filtering (HATEOAS) + +By default each operation is checked against the current security token via API Platform's `ResourceAccessChecker` (`api_platform.security.resource_access_checker`); operations whose `security` expression does not grant access are omitted. The server therefore only advertises what the current user may actually execute — a UI can derive `canCreate`/`canUpdate`/`canDelete` directly from the runtime `hydra:operation` instead of the role-independent static docs. + +Rules: + +- Operations **without** a `security` expression stay visible (default: allowed). +- Item operations are evaluated with the loaded entity as `object` (when resolvable from the request); collection operations are evaluated with `object = null`. +- Only collection-level operations are checked on collection responses — one check per operation, never per `hydra:member`. +- Expression evaluation errors fail closed (the operation is hidden). +- Without `symfony/security-bundle` the checker service is absent and filtering is skipped. + +Configuration: + +```yaml +dmstr_api_platform_utils: + hydra_operations: + enabled: true + api_prefix: '/api' + event_priority: -10 + # set to false to restore the unfiltered (pre-0.4) behavior + filter_operations_by_security: true +``` + +### HTTP caching + +With filtering active, item and collection responses become **user-dependent**: the subscriber emits `Vary: Authorization` so shared HTTP caches never serve one user's operation set to another. The static `/api/docs.jsonld` stays role-independent and cacheable. If your stack caches API responses in a reverse proxy, verify it honors `Vary` — otherwise exclude these responses from caching. + ## License MIT © diemeisterei GmbH diff --git a/config/services.yaml b/config/services.yaml index 9c14d76..07d050e 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -52,6 +52,9 @@ services: arguments: $resourceMetadataFactory: '@api_platform.metadata.resource.metadata_collection_factory' $apiPrefix: '%dmstr_api_platform_utils.hydra_operations.api_prefix%' + # optional — absent without symfony/security-bundle; filtering is skipped then + $resourceAccessChecker: '@?api_platform.security.resource_access_checker' + $filterOperationsBySecurity: '%dmstr_api_platform_utils.hydra_operations.filter_operations_by_security%' tags: - { name: kernel.event_subscriber } diff --git a/src/DependencyInjection/ApiPlatformUtilsExtension.php b/src/DependencyInjection/ApiPlatformUtilsExtension.php index 358b099..f20687d 100644 --- a/src/DependencyInjection/ApiPlatformUtilsExtension.php +++ b/src/DependencyInjection/ApiPlatformUtilsExtension.php @@ -32,6 +32,7 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('dmstr_api_platform_utils.hydra_operations.enabled', $config['hydra_operations']['enabled']); $container->setParameter('dmstr_api_platform_utils.hydra_operations.api_prefix', $config['hydra_operations']['api_prefix']); $container->setParameter('dmstr_api_platform_utils.hydra_operations.event_priority', $config['hydra_operations']['event_priority']); + $container->setParameter('dmstr_api_platform_utils.hydra_operations.filter_operations_by_security', $config['hydra_operations']['filter_operations_by_security']); $container->setParameter('dmstr_api_platform_utils.custom_operation_hydra.enabled', $config['custom_operation_hydra']['enabled']); $container->setParameter('dmstr_api_platform_utils.custom_operation_hydra.api_prefix', $config['custom_operation_hydra']['api_prefix']); @@ -41,8 +42,6 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('dmstr_api_platform_utils.partial_uuid_item_provider.enabled', $config['partial_uuid_item_provider']['enabled']); - $container->setParameter('dmstr_api_platform_utils.partial_uuid_item_provider.enabled', $config['partial_uuid_item_provider']['enabled']); - // Load service definitions $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../../config')); $loader->load('services.yaml'); diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 63e9278..2aea5d2 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -76,6 +76,10 @@ public function getConfigTreeBuilder(): TreeBuilder ->defaultValue(-10) ->info('Event subscriber priority (negative runs after API Platform)') ->end() + ->booleanNode('filter_operations_by_security') + ->defaultTrue() + ->info('Omit operations from hydra:operation whose security expression does not grant access to the current token (requires symfony/security-bundle; operations without a security expression stay visible)') + ->end() ->end() ->end() @@ -119,17 +123,6 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() - - // Partial UUID Item Provider - ->arrayNode('partial_uuid_item_provider') - ->addDefaultsIfNotSet() - ->children() - ->booleanNode('enabled') - ->defaultFalse() - ->info('Decorate the Doctrine ORM item provider so that {id} URI variables are resolved as full OR partial UUIDs project-wide. Convenience layer — full UUIDs still take the fast indexed path. Off by default to avoid surprising existing consumers.') - ->end() - ->end() - ->end() ->end(); return $treeBuilder; diff --git a/src/EventSubscriber/AddHydraOperationsSubscriber.php b/src/EventSubscriber/AddHydraOperationsSubscriber.php index 7a5e37e..0a86f49 100644 --- a/src/EventSubscriber/AddHydraOperationsSubscriber.php +++ b/src/EventSubscriber/AddHydraOperationsSubscriber.php @@ -1,5 +1,5 @@ getMethod() !== 'GET') { return; } $uriTemplate = $operation->getUriTemplate(); - if (!$uriTemplate || !str_contains($uriTemplate, '{id}')) { + if (!$uriTemplate) { return; } + $isItemRequest = str_contains($uriTemplate, '{id}'); // Only process JSON-LD content $contentType = $response->headers->get('Content-Type'); @@ -66,10 +80,23 @@ public function addHydraOperations(ResponseEvent $event): void } $data = json_decode($content, true); - if (!is_array($data) || !isset($data['@id']) || !isset($data['@type'])) { + if (!is_array($data)) { return; } + if ($isItemRequest) { + if (!isset($data['@id']) || !isset($data['@type'])) { + return; + } + } else { + // Collection responses are typed hydra:Collection (possibly among + // other types); skip anything else (e.g. custom GET endpoints). + $types = (array) ($data['@type'] ?? []); + if (!in_array('hydra:Collection', $types, true)) { + return; + } + } + // Get resource class $resourceClass = $operation->getClass(); if (!$resourceClass) { @@ -77,13 +104,25 @@ public function addHydraOperations(ResponseEvent $event): void } try { - // Get the resource ID from the @id field - $resourceId = $data['id'] ?? null; - if (!$resourceId) { - // Try to extract from @id - $atId = $data['@id'] ?? ''; - if (preg_match('/\/([^\/]+)$/', $atId, $matches)) { - $resourceId = $matches[1]; + $resourceId = null; + $subject = null; + if ($isItemRequest) { + // Get the resource ID from the @id field + $resourceId = $data['id'] ?? null; + if (!$resourceId) { + // Try to extract from @id + $atId = $data['@id'] ?? ''; + if (preg_match('/\/([^\/]+)$/', $atId, $matches)) { + $resourceId = $matches[1]; + } + } + + // The loaded entity — API Platform stores the controller + // result in the `data` request attribute. Used as `object` + // when evaluating operation security expressions. + $requestData = $request->attributes->get('data'); + if (is_object($requestData)) { + $subject = $requestData; } } @@ -100,9 +139,14 @@ public function addHydraOperations(ResponseEvent $event): void continue; } - // Only include item operations (not collection operations) + // Item responses advertise item operations, collection + // responses advertise collection operations. $opUriTemplate = $op->getUriTemplate(); - if (!$opUriTemplate || !str_contains($opUriTemplate, '{id}')) { + if (!$opUriTemplate || str_contains($opUriTemplate, '{id}') !== $isItemRequest) { + continue; + } + + if (!$this->isOperationGranted($op, $subject)) { continue; } @@ -164,15 +208,67 @@ public function addHydraOperations(ResponseEvent $event): void $data['hydra:operation'] = $operations; $response->setContent(json_encode($data)); } + + // With security filtering active the advertised operations depend + // on the current token — mark the response as per-credential for + // any shared HTTP cache. Also emitted when all operations were + // filtered out (an empty result is user-dependent information too). + if ($this->isFilteringActive()) { + $vary = $response->getVary(); + if (!in_array('Authorization', $vary, true)) { + $response->setVary(array_merge($vary, ['Authorization'])); + } + } } catch (\Exception $e) { // Silently fail - don't break the API } } + private function isFilteringActive(): bool + { + return $this->filterOperationsBySecurity && null !== $this->resourceAccessChecker; + } + + /** + * An operation is advertised when filtering is inactive, when it carries + * no security expression (default: allowed), or when the expression + * grants access for the current token. Evaluation errors fail closed + * (operation hidden) — the actual request would fail anyway. + */ + private function isOperationGranted(HttpOperation $op, ?object $subject): bool + { + if (!$this->isFilteringActive()) { + return true; + } + + $security = $op->getSecurity(); + if (null === $security || '' === (string) $security) { + return true; + } + + $resourceClass = $op->getClass(); + if (!$resourceClass) { + return true; + } + + try { + // `object` is always defined (null on collections / when the + // loaded entity is not resolvable) so expressions referencing it + // evaluate instead of raising an undefined-variable error. + return $this->resourceAccessChecker->isGranted( + $resourceClass, + (string) $security, + ['object' => $subject] + ); + } catch (\Throwable) { + return false; + } + } + private function generateTitle(HttpOperation $operation): string { $method = $operation->getMethod() ?? 'GET'; - $name = $operation->getName(); + $name = $operation->getName() ?? ''; $uriTemplate = $operation->getUriTemplate() ?? ''; // Detect if this is a standard CRUD operation or a custom operation diff --git a/tests/EventSubscriber/AddHydraOperationsSubscriberTest.php b/tests/EventSubscriber/AddHydraOperationsSubscriberTest.php new file mode 100644 index 0000000..4adb1c8 --- /dev/null +++ b/tests/EventSubscriber/AddHydraOperationsSubscriberTest.php @@ -0,0 +1,290 @@ + $this->createItemGetOperation(), + 'thing_get_collection' => $this->createCollectionGetOperation(), + 'thing_post' => new Post(uriTemplate: '/things', shortName: 'Thing', class: DummyThing::class, security: self::ADMIN_EXPRESSION), + 'thing_scan' => new Post(uriTemplate: '/things/scan', shortName: 'Thing', class: DummyThing::class, security: self::ADMIN_EXPRESSION, description: 'Scan things'), + 'thing_put' => new Put(uriTemplate: '/things/{id}', shortName: 'Thing', class: DummyThing::class, security: self::ADMIN_EXPRESSION), + 'thing_patch' => new Patch(uriTemplate: '/things/{id}', shortName: 'Thing', class: DummyThing::class, security: self::ADMIN_EXPRESSION), + 'thing_delete' => new Delete(uriTemplate: '/things/{id}', shortName: 'Thing', class: DummyThing::class, security: self::ADMIN_EXPRESSION), + ]); + + $resource = (new ApiResource(shortName: 'Thing', class: DummyThing::class)) + ->withRoutePrefix('/admin') + ->withOperations($operations); + + $factory = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $factory->method('create')->willReturn( + new ResourceMetadataCollection(DummyThing::class, [$resource]) + ); + + return $factory; + } + + private function createSubscriber( + ?ResourceAccessCheckerInterface $checker, + bool $filterOperationsBySecurity = true, + ): AddHydraOperationsSubscriber { + return new AddHydraOperationsSubscriber( + $this->createMetadataFactory(), + '/api', + $checker, + $filterOperationsBySecurity, + ); + } + + private function dispatch( + AddHydraOperationsSubscriber $subscriber, + HttpOperation $currentOperation, + array $responseData, + ?object $entity = null, + ): Response { + $request = new Request(); + $request->attributes->set('_api_operation', $currentOperation); + if (null !== $entity) { + $request->attributes->set('data', $entity); + } + + $response = new Response( + json_encode($responseData), + 200, + ['Content-Type' => 'application/ld+json; charset=utf-8'] + ); + + $event = new ResponseEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + $response + ); + + $subscriber->addHydraOperations($event); + + return $response; + } + + private function dispatchItem(AddHydraOperationsSubscriber $subscriber, ?object $entity = null): Response + { + return $this->dispatch($subscriber, $this->createItemGetOperation(), [ + '@id' => '/api/admin/things/abc12345', + '@type' => 'Thing', + 'id' => 'abc12345', + ], $entity); + } + + private function dispatchCollection(AddHydraOperationsSubscriber $subscriber): Response + { + return $this->dispatch($subscriber, $this->createCollectionGetOperation(), [ + '@id' => '/api/admin/things', + '@type' => 'hydra:Collection', + 'hydra:member' => [], + ]); + } + + /** @return string[] */ + private function methodsFrom(Response $response): array + { + $data = json_decode($response->getContent(), true); + + return array_map( + static fn (array $op): string => $op['hydra:method'], + $data['hydra:operation'] ?? [] + ); + } + + public function testItemResponseOmitsDeniedWriteOperations(): void + { + $subscriber = $this->createSubscriber(new SpyAccessChecker(false)); + $response = $this->dispatchItem($subscriber); + + $this->assertSame(['GET'], $this->methodsFrom($response)); + } + + public function testItemResponseKeepsAllOperationsForGrantedUser(): void + { + $subscriber = $this->createSubscriber(new SpyAccessChecker(true)); + $response = $this->dispatchItem($subscriber); + + $methods = $this->methodsFrom($response); + sort($methods); + $this->assertSame(['DELETE', 'GET', 'PATCH', 'PUT'], $methods); + } + + public function testCollectionResponseOmitsDeniedPostOperations(): void + { + $subscriber = $this->createSubscriber(new SpyAccessChecker(false)); + $response = $this->dispatchCollection($subscriber); + + $this->assertSame(['GET'], $this->methodsFrom($response)); + } + + public function testCollectionResponseKeepsAllOperationsForGrantedUser(): void + { + $subscriber = $this->createSubscriber(new SpyAccessChecker(true)); + $response = $this->dispatchCollection($subscriber); + + $methods = $this->methodsFrom($response); + sort($methods); + // GetCollection + create POST + custom scan POST + $this->assertSame(['GET', 'POST', 'POST'], $methods); + + $data = json_decode($response->getContent(), true); + $ids = array_column($data['hydra:operation'], '@id'); + $this->assertContains('/api/admin/things/scan', $ids); + $this->assertContains('/api/admin/things', $ids); + } + + public function testCollectionOperationsAreCheckedWithoutObjectContext(): void + { + $checker = new SpyAccessChecker(true); + $subscriber = $this->createSubscriber($checker); + $this->dispatchCollection($subscriber); + + $this->assertNotEmpty($checker->calls); + foreach ($checker->calls as [$resourceClass, $expression, $extraVariables]) { + $this->assertSame(DummyThing::class, $resourceClass); + $this->assertSame(self::ADMIN_EXPRESSION, $expression); + $this->assertArrayHasKey('object', $extraVariables); + $this->assertNull($extraVariables['object']); + } + } + + public function testItemOperationsAreCheckedWithLoadedEntityAsObject(): void + { + $checker = new SpyAccessChecker(true); + $subscriber = $this->createSubscriber($checker); + $entity = new DummyThing(); + $this->dispatchItem($subscriber, $entity); + + $this->assertNotEmpty($checker->calls); + foreach ($checker->calls as [, , $extraVariables]) { + $this->assertSame($entity, $extraVariables['object']); + } + } + + public function testOperationsWithoutSecurityExpressionSkipTheChecker(): void + { + $checker = new SpyAccessChecker(false); + $subscriber = $this->createSubscriber($checker); + $this->dispatchItem($subscriber); + + // Only the three secured write operations are evaluated; the GET + // without a security expression must never hit the checker. + $this->assertCount(3, $checker->calls); + } + + public function testFilteringCanBeDisabledByConfiguration(): void + { + $checker = new SpyAccessChecker(false); + $subscriber = $this->createSubscriber($checker, filterOperationsBySecurity: false); + $response = $this->dispatchItem($subscriber); + + $methods = $this->methodsFrom($response); + sort($methods); + $this->assertSame(['DELETE', 'GET', 'PATCH', 'PUT'], $methods); + $this->assertSame([], $checker->calls); + } + + public function testMissingAccessCheckerDisablesFiltering(): void + { + $subscriber = $this->createSubscriber(null); + $response = $this->dispatchItem($subscriber); + + $methods = $this->methodsFrom($response); + sort($methods); + $this->assertSame(['DELETE', 'GET', 'PATCH', 'PUT'], $methods); + } + + public function testVaryAuthorizationIsSetWhenFilteringIsActive(): void + { + $subscriber = $this->createSubscriber(new SpyAccessChecker(false)); + $response = $this->dispatchItem($subscriber); + + $this->assertContains('Authorization', $response->getVary()); + } + + public function testVaryAuthorizationIsNotSetWhenFilteringIsInactive(): void + { + $subscriber = $this->createSubscriber(null); + $response = $this->dispatchItem($subscriber); + + $this->assertNotContains('Authorization', $response->getVary()); + } + + public function testItemIdIsSubstitutedIntoOperationUrls(): void + { + $subscriber = $this->createSubscriber(new SpyAccessChecker(true)); + $response = $this->dispatchItem($subscriber); + + $data = json_decode($response->getContent(), true); + foreach ($data['hydra:operation'] as $op) { + $this->assertSame('/api/admin/things/abc12345', $op['@id']); + } + } +} + +final class DummyThing +{ +} + +final class SpyAccessChecker implements ResourceAccessCheckerInterface +{ + /** @var array */ + public array $calls = []; + + public function __construct(private readonly bool $granted) + { + } + + public function isGranted(string $resourceClass, string $expression, array $extraVariables = []): bool + { + $this->calls[] = [$resourceClass, $expression, $extraVariables]; + + return $this->granted; + } +}