From 756e35a4b2ff3399d1e0f8a0254508091acda975 Mon Sep 17 00:00:00 2001 From: "t.munk (workhorse)" Date: Wed, 28 Oct 2026 00:56:27 +0000 Subject: [PATCH] test(hydra): prove the operation filter with a permission attribute [*] Replace the role name in the security expression of the Hydra operation filter test with the example permission attribute is_granted('thing:write') and add two regression tests: for collection operations the expression reaches the ResourceAccessChecker unchanged, with object = null, and the checker's answer decides whether the POST operations are advertised. README and CHANGELOG (Unreleased) state that the filter is expression-neutral and that this bundle defines no permissions of its own. No change under src/ or config/. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++ README.md | 1 + .../AddHydraOperationsSubscriberTest.php | 63 ++++++++++++++++--- 3 files changed, 61 insertions(+), 9 deletions(-) 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)