diff --git a/README.md b/README.md index f44d569..394ad70 100644 --- a/README.md +++ b/README.md @@ -109,21 +109,6 @@ 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. @@ -185,15 +170,14 @@ class Product } ``` -#### Example with multiple context filters and a strategy +### Filter Strategies + +When multiple filters match the current context, the `FilterStrategy` determines how they combine: -In the previous examples, we've been using the default `FilterStrategy::AnyMatch`, which means that -any of the contexts that evaluate as true have had their where clause applied. In this example, you -can see that we've applied a `FilterStrategy::FirstMatch`, which means the filter contextual filter -will be applied - only. +- **`FilterStrategy::AnyMatch`** (default) — All matching filters are AND'd together. +- **`FilterStrategy::FirstMatch`** — Only the first matching filter is applied; subsequent filters are skipped. -**Keep in mind, if you do not provide a context, it's assumed to be in context, and that filter will -be applied, meaning any subsequent filters will never be evaluated.** +**Keep in mind, if you do not provide a context, it's assumed to be in context, and that filter will be applied, meaning any subsequent filters will never be evaluated (under `FirstMatch`).** ```php use Doctrine\ORM\Mapping as ORM; @@ -203,9 +187,10 @@ use Rentpost\Doctrine\MultiTenancy\Attribute\MultiTenancy; #[MultiTenancy( strategy: MultiTenancy\FilterStrategy::FirstMatch, filters: [ - new MuiltiTenancy\Filter( - context: ['admin'] - where: '$this.company_id = {companyId}'), + new MultiTenancy\Filter( + context: ['admin'], + where: '$this.company_id = {companyId}', + ), new MultiTenancy\Filter( context: ['other'], ignore: true, @@ -215,8 +200,8 @@ use Rentpost\Doctrine\MultiTenancy\Attribute\MultiTenancy; where: '$this.id IN( SELECT product_id FROM product_group - WHERE status = 'published' - )' + WHERE status = \'published\' + )', ), ], )] @@ -226,24 +211,23 @@ class Product } ``` -One other thing to note about the above example is the `ignore` parameter. This allows for you to -specify a context where no filters will be applied. This can be especially useful in combination -with the `FilterStrategy::FirstMatch` strategy. The combination of these two allows you to entirely, -or selectively, ignore all multi-tenancy for an entity - for a given context, that is. +The `ignore` parameter above allows you to specify a context where no filter conditions will be applied. This can be especially useful in combination with `FilterStrategy::FirstMatch`, allowing you to entirely, or selectively, ignore all multi-tenancy for an entity — for a given context. -#### Example with Strict strategy (deny by default) +### Strict Mode -`FilterStrategy::Strict` flips to a "deny by default" model. It processes filters like `AnyMatch` (all matching filters AND'd together), but after processing, if any active `ContextProvider` is not covered by any filter's `context:` array, `1 = 0` is appended — denying all results. +Setting `strict: true` flips to a "deny by default" model. Filters are still processed per the chosen `FilterStrategy`, but after processing, if any active `ContextProvider` is not covered by any filter's `context:` array, `1 = 0` is appended — denying all results. This eliminates the need to explicitly deny every uncovered context. Only contexts that are declared in a filter (even with `ignore: true`) are permitted. +Note: `strict` is orthogonal to `FilterStrategy` — it works with either `AnyMatch` or `FirstMatch`. + ```php use Doctrine\ORM\Mapping as ORM; use Rentpost\Doctrine\MultiTenancy\Attribute\MultiTenancy; #[ORM\Entity] #[MultiTenancy( - strategy: MultiTenancy\FilterStrategy::Strict, + strict: true, filters: [ new MultiTenancy\Filter(where: '$this.company_id = {companyId}'), new MultiTenancy\Filter( @@ -272,6 +256,21 @@ In this example: - A `manager` gets the additional scoping filter - Any other active context (e.g. `guest`) is automatically denied — no need to add explicit deny filters +#### 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 `strict: true`, ambient contexts would cause universal denial since they're never listed in a filter's `context:` array. To prevent this, implement `AmbientContextProviderInterface` (a marker interface extending `ContextProviderInterface`) on those providers. Strict mode will skip them during its coverage check. + +```php +use Rentpost\Doctrine\MultiTenancy\AmbientContextProviderInterface; + +class UserContextProvider implements AmbientContextProviderInterface +{ + // ... +} +``` + ## Using ConditionResolver for Raw SQL Queries The `Filter` class (Doctrine's `SQLFilter`) only applies to DQL queries. For raw SQL queries in repositories, use `ConditionResolver` directly to get the same multi-tenancy conditions: diff --git a/src/Attribute/MultiTenancy.php b/src/Attribute/MultiTenancy.php index e93b913..ca4c88e 100644 --- a/src/Attribute/MultiTenancy.php +++ b/src/Attribute/MultiTenancy.php @@ -27,6 +27,7 @@ public function __construct( private readonly bool $enable = true, private readonly array $filters = [], private readonly FilterStrategy $strategy = FilterStrategy::AnyMatch, + private readonly bool $strict = false, ) {} @@ -57,4 +58,14 @@ public function getFilterStrategy(): FilterStrategy { return $this->strategy; } + + + /** + * Whether uncovered active contexts should be denied (appending 1 = 0). + * Works alongside any FilterStrategy. + */ + public function isStrict(): bool + { + return $this->strict; + } } diff --git a/src/Attribute/MultiTenancy/FilterStrategy.php b/src/Attribute/MultiTenancy/FilterStrategy.php index 6cac778..cfcaf48 100644 --- a/src/Attribute/MultiTenancy/FilterStrategy.php +++ b/src/Attribute/MultiTenancy/FilterStrategy.php @@ -14,5 +14,4 @@ enum FilterStrategy case FirstMatch; case AnyMatch; - case Strict; } diff --git a/src/ConditionResolver.php b/src/ConditionResolver.php index 6c233ec..1f24857 100644 --- a/src/ConditionResolver.php +++ b/src/ConditionResolver.php @@ -196,9 +196,7 @@ public function resolve(string $entityClass, string $tableAlias): string } } - if ($multiTenancy->getFilterStrategy() === FilterStrategy::Strict - && $this->hasUncoveredContexts($filters) - ) { + if ($multiTenancy->isStrict() && $this->hasUncoveredContexts($filters)) { $whereClauses[] = '1 = 0'; } diff --git a/tests/Fixture/Entity/StrictEntity.php b/tests/Fixture/Entity/StrictEntity.php index d73a5a3..8079ea6 100644 --- a/tests/Fixture/Entity/StrictEntity.php +++ b/tests/Fixture/Entity/StrictEntity.php @@ -7,7 +7,7 @@ use Rentpost\Doctrine\MultiTenancy\Attribute\MultiTenancy; #[MultiTenancy( - strategy: MultiTenancy\FilterStrategy::Strict, + strict: true, filters: [ new MultiTenancy\Filter(where: '$this.store_id = {storeId}'), new MultiTenancy\Filter( diff --git a/tests/Fixture/Entity/StrictNoContextFreeEntity.php b/tests/Fixture/Entity/StrictNoContextFreeEntity.php index ad2c6e2..43e7165 100644 --- a/tests/Fixture/Entity/StrictNoContextFreeEntity.php +++ b/tests/Fixture/Entity/StrictNoContextFreeEntity.php @@ -7,7 +7,7 @@ use Rentpost\Doctrine\MultiTenancy\Attribute\MultiTenancy; #[MultiTenancy( - strategy: MultiTenancy\FilterStrategy::Strict, + strict: true, filters: [ new MultiTenancy\Filter( context: ['customer'], diff --git a/tests/Unit/Attribute/MultiTenancy/FilterStrategyTest.php b/tests/Unit/Attribute/MultiTenancy/FilterStrategyTest.php index a26858a..beeb746 100644 --- a/tests/Unit/Attribute/MultiTenancy/FilterStrategyTest.php +++ b/tests/Unit/Attribute/MultiTenancy/FilterStrategyTest.php @@ -22,14 +22,8 @@ public function testAnyMatchCaseExists(): void } - public function testStrictCaseExists(): void - { - $this->assertSame('Strict', FilterStrategy::Strict->name); - } - - public function testCaseCount(): void { - $this->assertCount(3, FilterStrategy::cases()); + $this->assertCount(2, FilterStrategy::cases()); } } diff --git a/tests/Unit/Attribute/MultiTenancyTest.php b/tests/Unit/Attribute/MultiTenancyTest.php index 0a20d0f..2711319 100644 --- a/tests/Unit/Attribute/MultiTenancyTest.php +++ b/tests/Unit/Attribute/MultiTenancyTest.php @@ -19,6 +19,7 @@ public function testDefaults(): void $this->assertTrue($mt->isEnabled()); $this->assertSame([], $mt->getFilters()); $this->assertSame(FilterStrategy::AnyMatch, $mt->getFilterStrategy()); + $this->assertFalse($mt->isStrict()); } @@ -50,4 +51,12 @@ public function testFirstMatchStrategy(): void $this->assertSame(FilterStrategy::FirstMatch, $mt->getFilterStrategy()); } + + + public function testStrict(): void + { + $mt = new MultiTenancy(strict: true); + + $this->assertTrue($mt->isStrict()); + } }