Write guard
The write guard stops a SaveChanges that would write, move or delete a row of a tenant
other than the context’s current one, before anything reaches the database.
UseTenantGuard registers it, as an interceptor that implements both SavingChanges and
SavingChangesAsync.
What each entry is checked against
Section titled “What each entry is checked against”For every entry whose entity type is tenant-scoped, against the current tenant:
| State | Check |
|---|---|
| Added | The tenant value equals the current tenant. If it’s not set and StampOnAdd is on, it’s set to the current tenant; otherwise it’s a violation. |
| Modified | Both the tenant property’s OriginalValue and its CurrentValue equal the current tenant. A changed tenant value is a violation: rows don’t move across tenants. |
| Deleted | The OriginalValue equals the current tenant. |
Values are compared with the tenant property’s ValueComparer, so the tenant can be any
type EF Core maps.
Owned entity types are skipped: an entry of an owned type isn’t checked, and changing only the owned part of another tenant’s row isn’t blocked yet (0014).
The current tenant
Section titled “The current tenant”The current tenant comes from CurrentTenant(context). When it returns null,
NoTenantPolicy.Reject (the default) makes any tenant-scoped write a violation, and
Allow skips the check and logs once per SaveChanges at Information level.
A tenant-scoped write with no CurrentTenant configured, or with a current tenant that
isn’t of the tenant property’s type, throws InvalidOperationException naming the context,
or the entity and the property (Exceptions).
StampOnAdd
Section titled “StampOnAdd”StampOnAdd treats null and the property’s sentinel (its type’s default, unless set with
HasSentinel) as not set. It stamps only when the save has no violations, so a blocked save
leaves the entities as they were.
Violations
Section titled “Violations”Violations throw TenantIsolationViolationException, an InvalidOperationException, which
lists each one: the entity type, its state, its key values (when IncludeKeysInErrors is
on, the default), and the expected and actual tenant. Exceptions has the
message. No other value of the entity is in the exception or the logs.
A model built for another tenant
Section titled “A model built for another tenant”Under AllowCapturedValues(), a model whose filters capture
the tenant remembers the tenant it was built for. When a save writes a tenant-scoped row
and the context’s current tenant is a different one, the guard blocks the whole save, before
it checks any entry: the context has another tenant’s model, so what it read came from that
tenant. The exception names both tenants
and has no Violations. With no current tenant, NoTenantPolicy applies as before.
Bypass
Section titled “Bypass”using (context.TenantGuard().Bypass("why")) switches the write guard off for that context
instance until the scope is disposed, and logs a Warning. It doesn’t switch the query filters
off. Options has the details.
What it doesn’t see
Section titled “What it doesn’t see”ExecuteUpdate and ExecuteDelete don’t go through SaveChanges, so the write guard
doesn’t see them. The query filter scopes them, and the probe’s BulkIsolation check proves
it for each entity. Raw SQL is the analyzers’ job (TG002, TG004).