At build
TG001 to TG004 flag IgnoreQueryFilters() and raw SQL without a tenant predicate, unless the code says why.
One app, four mistakes, and the layer that catches each: the analyzers at build, the model
rules at startup, the write guard on SaveChanges, and the isolation probe in tests.
Query filters scope what a context reads. Everything below gets around them, and each path has a layer that stops it.
flowchart LR
subgraph leaks["How a tenant reads or writes another's rows"]
nofilter["An entity with<br/>no query filter"]
frozen["A filter frozen to<br/>one tenant's value"]
ignore["IgnoreQueryFilters()"]
rawsql["Raw SQL without<br/>a tenant predicate"]
write["A write with another<br/>tenant's TenantId"]
end
subgraph guard["TenantGuard"]
analyzers["Analyzers<br/>at build"]
rules["Model rules<br/>at startup"]
writeguard["Write guard<br/>on SaveChanges"]
probe["Isolation probe<br/>in tests"]
end
nofilter --> rules
frozen --> rules
ignore --> analyzers
rawsql --> analyzers
write --> writeguard
rules --> probe
writeguard --> probe
| Leak | At build | At startup | On SaveChanges | In tests |
|---|---|---|---|---|
| An entity with a tenant property and no filter | Yes: TGM001 | Yes: the probe stops on the rule error | ||
| A filter that captured a local or a constant | Yes: TGM003, TGM004 | Yes, on the rule error | ||
IgnoreQueryFilters() without a reason |
Yes: TG001 | |||
| Raw SQL that doesn’t mention the tenant column | Yes: TG002, or TG004 when it’s built at runtime | |||
| A write, move or delete of another tenant’s row | Yes: TenantIsolationViolationException, before the database |
Yes: UpdateGuard, InsertGuard, DeleteGuard | ||
A bulk ExecuteDelete or ExecuteUpdate across tenants |
Not seen: it skips SaveChanges |
Yes: BulkIsolation | ||
| A new entity added months later | Yes: every rule runs on every entity | Yes, once it’s tenant-scoped | Yes: every tenant-scoped entity is probed |
An invoicing app with every tenant in one database starts with a filter on every entity.
A year of ordinary changes later, a new entity ships without one, a support report adds
IgnoreQueryFilters(), an import job saves acme’s rows as globex’s, and a refactor freezes
a filter to the first tenant. None of them fails the build or the tests. With TenantGuard,
each fails at the first moment it can: in the pull request, at startup, or on the first bad
save.
At build
TG001 to TG004 flag IgnoreQueryFilters() and raw SQL without a tenant predicate, unless the code says why.
At startup
TGM001 to TGM008 fail the host when an entity with a tenant property has no query filter, or when the filter is frozen to one value.
On SaveChanges
The write guard throws before the database sees a write, move or delete of another tenant’s row.
In your tests
The isolation probe seeds a row per tenant and proves, entity by entity, that one tenant can’t reach another’s.
It works on Finbuckle, on any of the tenancy packages, and on hand-rolled query filters. Install it with dotnet add package TenantGuard.EntityFrameworkCore --prerelease.