Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
63 changes: 54 additions & 9 deletions tests/EventSubscriber/AddHydraOperationsSubscriberTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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))
Expand Down Expand Up @@ -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']);
}
Expand Down Expand Up @@ -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)
Loading