diff --git a/CHANGELOG.md b/CHANGELOG.md index 08f586c..b4fab1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ # Changelog +## Unreleased + +### Changed + +- Tests and documentation only, no behaviour change: the Hydra operation filter test uses a permission attribute (`is_granted('thing:write')`) instead of a role name in the `security` expression, and a regression test proves the expression reaches the `ResourceAccessChecker` unchanged with `object = null` for collection operations. The filter is expression-neutral; this bundle defines no permissions of its own. README: how to use the filter with permission attributes. + ## 0.6.0 (2026-10-15) ### Added diff --git a/README.md b/README.md index 1f405c4..eab6a5f 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Rules: - 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. +- The filter is expression-neutral: it hands the operation's `security` expression to the `ResourceAccessChecker` unchanged. A permission attribute such as `is_granted('thing:write')` (an example; this bundle defines no permissions of its own) is evaluated like any other expression. Collection operations are checked with `object = null`, so the voter answering the attribute must accept a `null` subject. With Symfony's default access-decision strategy the operation stays hidden when no voter answers the attribute. Configuration: diff --git a/tests/EventSubscriber/AddHydraOperationsSubscriberTest.php b/tests/EventSubscriber/AddHydraOperationsSubscriberTest.php index 4adb1c8..12c30a9 100644 --- a/tests/EventSubscriber/AddHydraOperationsSubscriberTest.php +++ b/tests/EventSubscriber/AddHydraOperationsSubscriberTest.php @@ -26,11 +26,12 @@ final class AddHydraOperationsSubscriberTest extends TestCase { - private const ADMIN_EXPRESSION = "is_granted('ROLE_ADMIN')"; + private const WRITE_EXPRESSION = "is_granted('thing:write')"; /** - * Item operations: GET carries no security expression, the write - * operations require ROLE_ADMIN. + * Item operations: GET carries no `security` expression; the write + * operations require the permission attribute `thing:write`, an example + * attribute, not a permission defined by this bundle. */ private function createItemGetOperation(): Get { @@ -47,11 +48,11 @@ private function createMetadataFactory(): ResourceMetadataCollectionFactoryInter $operations = new Operations([ 'thing_get' => $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), + 'thing_post' => new Post(uriTemplate: '/things', shortName: 'Thing', class: DummyThing::class, security: self::WRITE_EXPRESSION), + 'thing_scan' => new Post(uriTemplate: '/things/scan', shortName: 'Thing', class: DummyThing::class, security: self::WRITE_EXPRESSION, description: 'Scan things'), + 'thing_put' => new Put(uriTemplate: '/things/{id}', shortName: 'Thing', class: DummyThing::class, security: self::WRITE_EXPRESSION), + 'thing_patch' => new Patch(uriTemplate: '/things/{id}', shortName: 'Thing', class: DummyThing::class, security: self::WRITE_EXPRESSION), + 'thing_delete' => new Delete(uriTemplate: '/things/{id}', shortName: 'Thing', class: DummyThing::class, security: self::WRITE_EXPRESSION), ]); $resource = (new ApiResource(shortName: 'Thing', class: DummyThing::class)) @@ -188,7 +189,50 @@ public function testCollectionOperationsAreCheckedWithoutObjectContext(): void $this->assertNotEmpty($checker->calls); foreach ($checker->calls as [$resourceClass, $expression, $extraVariables]) { $this->assertSame(DummyThing::class, $resourceClass); - $this->assertSame(self::ADMIN_EXPRESSION, $expression); + $this->assertSame(self::WRITE_EXPRESSION, $expression); + $this->assertArrayHasKey('object', $extraVariables); + $this->assertNull($extraVariables['object']); + } + } + + public function testCollectionOperationsWithPermissionAttributeAreAdvertisedWhenGranted(): void + { + // The filter is expression-neutral: it hands the operation's `security` + // expression to the ResourceAccessChecker unchanged, so a permission + // attribute works without any change to the bundle. + $checker = new SpyAccessChecker(true); + $subscriber = $this->createSubscriber($checker); + $response = $this->dispatchCollection($subscriber); + + $methods = $this->methodsFrom($response); + sort($methods); + $this->assertSame(['GET', 'POST', 'POST'], $methods); + + $this->assertNotEmpty($checker->calls); + foreach ($checker->calls as [$resourceClass, $expression, $extraVariables]) { + $this->assertSame(DummyThing::class, $resourceClass); + $this->assertSame("is_granted('thing:write')", $expression); + $this->assertArrayHasKey('object', $extraVariables); + $this->assertNull($extraVariables['object']); + } + } + + public function testCollectionOperationsWithPermissionAttributeAreHiddenWhenDenied(): void + { + // The filter is expression-neutral: it hands the operation's `security` + // expression to the ResourceAccessChecker unchanged, so a denied + // permission attribute hides the operation like any other expression. + $checker = new SpyAccessChecker(false); + $subscriber = $this->createSubscriber($checker); + $response = $this->dispatchCollection($subscriber); + + $this->assertSame(['GET'], $this->methodsFrom($response)); + + // create POST + custom scan POST + $this->assertCount(2, $checker->calls); + foreach ($checker->calls as [$resourceClass, $expression, $extraVariables]) { + $this->assertSame(DummyThing::class, $resourceClass); + $this->assertSame("is_granted('thing:write')", $expression); $this->assertArrayHasKey('object', $extraVariables); $this->assertNull($extraVariables['object']); } @@ -288,3 +332,4 @@ public function isGranted(string $resourceClass, string $expression, array $extr return $this->granted; } } +// - revised 2026-10-28 (permission attribute instead of role name in the security expression)