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
67 changes: 33 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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\'
)',
),
],
)]
Expand All @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions src/Attribute/MultiTenancy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {}


Expand Down Expand Up @@ -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;
}
}
1 change: 0 additions & 1 deletion src/Attribute/MultiTenancy/FilterStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,4 @@ enum FilterStrategy

case FirstMatch;
case AnyMatch;
case Strict;
}
4 changes: 1 addition & 3 deletions src/ConditionResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

Expand Down
2 changes: 1 addition & 1 deletion tests/Fixture/Entity/StrictEntity.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion tests/Fixture/Entity/StrictNoContextFreeEntity.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use Rentpost\Doctrine\MultiTenancy\Attribute\MultiTenancy;

#[MultiTenancy(
strategy: MultiTenancy\FilterStrategy::Strict,
strict: true,
filters: [
new MultiTenancy\Filter(
context: ['customer'],
Expand Down
8 changes: 1 addition & 7 deletions tests/Unit/Attribute/MultiTenancy/FilterStrategyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
9 changes: 9 additions & 0 deletions tests/Unit/Attribute/MultiTenancyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}


Expand Down Expand Up @@ -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());
}
}
Loading