diff --git a/README.md b/README.md index 5c64155..f44d569 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,21 @@ Using the `Admin` example above, we might return `admin` as an "identifier". The As with the `ValueHolder`, this will all be more clear when viewing the example attributes below. +#### Ambient Context Providers + +Some context providers represent ambient environmental state rather than primary access contexts — for example, "is any role active?" or "is a user logged in?". These are always active and don't represent discrete access levels. + +When using `FilterStrategy::Strict`, ambient contexts could cause universal denial, if they're not defined in a filter's `context:` array. To prevent this, implement `AmbientContextProviderInterface` (a marker interface extending `ContextProviderInterface`) on those providers. Strict will skip them during its coverage check. + +```php +use Rentpost\Doctrine\MultiTenancy\AmbientContextProviderInterface; + +class UserContextProvider implements AmbientContextProviderInterface +{ + // ... +} +``` + ## Usage After you've gotten everything setup, the hard part is out of the way. Taking the time to properly evaludate how you'll setup your `ValueHolder` and `ContextProvider` classes will go a long way in making the usage clean and simple. diff --git a/src/AmbientContextProviderInterface.php b/src/AmbientContextProviderInterface.php new file mode 100644 index 0000000..c9cb3bc --- /dev/null +++ b/src/AmbientContextProviderInterface.php @@ -0,0 +1,18 @@ + + */ +interface AmbientContextProviderInterface extends ContextProviderInterface {} diff --git a/src/ConditionResolver.php b/src/ConditionResolver.php index 09e265c..6c233ec 100644 --- a/src/ConditionResolver.php +++ b/src/ConditionResolver.php @@ -221,6 +221,10 @@ private function hasUncoveredContexts(array $filters): bool } foreach ($this->listener->getContextProviders() as $contextProvider) { + if ($contextProvider instanceof AmbientContextProviderInterface) { + continue; + } + if ($contextProvider->isContextual() && !isset($coveredContexts[$contextProvider->getIdentifier()])) { return true; } diff --git a/tests/Fixture/StubAmbientContextProvider.php b/tests/Fixture/StubAmbientContextProvider.php new file mode 100644 index 0000000..5900399 --- /dev/null +++ b/tests/Fixture/StubAmbientContextProvider.php @@ -0,0 +1,28 @@ +identifier; + } + + + public function isContextual(): bool + { + return $this->contextual; + } +} diff --git a/tests/Unit/ConditionResolverTest.php b/tests/Unit/ConditionResolverTest.php index 56e7064..ca93907 100644 --- a/tests/Unit/ConditionResolverTest.php +++ b/tests/Unit/ConditionResolverTest.php @@ -16,10 +16,11 @@ use Rentpost\Doctrine\MultiTenancy\Tests\Fixture\Entity\Order; use Rentpost\Doctrine\MultiTenancy\Tests\Fixture\Entity\Product; use Rentpost\Doctrine\MultiTenancy\Tests\Fixture\Entity\Review; -use Rentpost\Doctrine\MultiTenancy\Tests\Fixture\Entity\UnmappedEntity; use Rentpost\Doctrine\MultiTenancy\Tests\Fixture\Entity\StrictEntity; use Rentpost\Doctrine\MultiTenancy\Tests\Fixture\Entity\StrictNoContextFreeEntity; +use Rentpost\Doctrine\MultiTenancy\Tests\Fixture\Entity\UnmappedEntity; use Rentpost\Doctrine\MultiTenancy\Tests\Fixture\Entity\Wishlist; +use Rentpost\Doctrine\MultiTenancy\Tests\Fixture\StubAmbientContextProvider; use Rentpost\Doctrine\MultiTenancy\Tests\Fixture\StubContextProvider; use Rentpost\Doctrine\MultiTenancy\Tests\Fixture\StubValueHolder; @@ -433,4 +434,46 @@ public function testStrictMultipleActiveContextsOneCovered(): void $result, ); } + + + public function testStrictAmbientContextIsSkipped(): void + { + $listener = $this->createListener( + [new StubValueHolder('storeId', '42')], + [ + new StubContextProvider('staff', false), + new StubContextProvider('customer', false), + new StubContextProvider('publisher', false), + new StubAmbientContextProvider('role', true), + new StubAmbientContextProvider('user', true), + ], + ); + $resolver = new ConditionResolver($listener); + + // StrictEntity: role and user are active but ambient → skipped, no denial + $result = $resolver->resolve(StrictEntity::class, 't0'); + + $this->assertSame('t0.store_id = 42', $result); + } + + + public function testStrictAmbientContextDoesNotMaskUncovered(): void + { + $listener = $this->createListener( + [new StubValueHolder('storeId', '42')], + [ + new StubContextProvider('staff', false), + new StubContextProvider('customer', false), + new StubContextProvider('publisher', false), + new StubContextProvider('vendor', true), + new StubAmbientContextProvider('role', true), + ], + ); + $resolver = new ConditionResolver($listener); + + // StrictEntity: role is ambient (skipped), but vendor is active and uncovered → denied + $result = $resolver->resolve(StrictEntity::class, 't0'); + + $this->assertSame('t0.store_id = 42 AND 1 = 0', $result); + } }