Skip to content

TenantGuard

Startup model validation, a SaveChanges guard, Roslyn analyzers and an isolation test kit that prove an EF Core multi-tenant model leaks nothing.

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.

A terminal walkthrough in four steps. At build, dotnet build warns TG001 on an IgnoreQueryFilters call. At startup, dotnet run fails with TGM001: Invoice has a tenant property but no query filter references it. On SaveChanges, TenantGuard blocks an Invoice for tenant acme added while the current tenant is globex, and throws TenantIsolationViolationException. In tests, the isolation probe reports that Customer's read, find and bulk delete checks failed.
4analyzersTG001 to TG004, at build
8model rulesTGM001 to TGM008, at startup
3write states guardedAdded, Modified and Deleted, on SaveChanges
6probe checksper entity, for every pair of tenants, 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.