For AI coding agents
View as MarkdownThis page is written for a coding agent adding Tenantry to an application, and for the developer who reviews what the agent did. Follow the steps in order, then write the test in Verify isolation and check the list of mistakes. The other guides have the detail behind each step.
When to use Tenantry
Use it when one deployment of a .NET application serves several customers (tenants) whose data must be kept apart,
and the data is in EF Core: rows of a shared database tagged with a TenantId, or a database per tenant. It resolves
the tenant of each HTTP request, or lets background work run as a tenant, and EF Core then filters every query and
checks every save for that tenant.
Do not use it to separate users within one tenant: that is authorization. Without EF Core, Tenantry still resolves the tenant and keeps caches and options per tenant, but nothing keeps your data apart.
Add it to an ASP.NET Core app with EF Core
- Add the packages:
dotnet add package Tenantry.AspNetCoreanddotnet add package Tenantry.EfCore. - Pick the tenant key type (
Guid,string,intorlong) and use it everywhere below. - Make each tenant-owned entity implement
ITenantEntity<TKey>, or derive fromTenantEntity<TKey>. LeaveTenantIdunset when you add a row: Tenantry stamps it on save. Entities that every tenant shares (reference data, a product catalogue) implement nothing. - Add
UseTenantry()to theDbContextoptions, in a registration method of the application's own thatProgram.cscalls, so the isolation test can call the same one (Verify isolation). The context needs no base class and no other change. - Register Tenantry with a resolver, a store, and an access validator. A resolver that reads the request (a header, a route value, the query string, the host or subdomain) lets any caller name any tenant, so check the tenant against the authenticated user.
- Add
app.UseTenantry()afterapp.UseAuthentication(), and before the endpoints. Withoutapp.UseTenantResolution(), put it afterapp.UseAuthorization()too, so an anonymous caller gets 401 rather than 403, unless an authorization policy needs the tenant: then put it beforeapp.UseAuthorization(). Withapp.UseTenantResolution()(authentication settings per tenant),app.UseAuthorization()always comes afterapp.UseTenantry().
using Microsoft.EntityFrameworkCore;
using Tenantry;
public class Invoice : TenantEntity<Guid> // tenant-owned: filtered and stamped
{
public int Id { get; set; }
public decimal Amount { get; set; }
}
public class Currency // shared by every tenant: neither filtered nor stamped
{
public int Id { get; set; }
public string Code { get; set; } = "";
}
public class BillingDbContext(DbContextOptions<BillingDbContext> options) : DbContext(options)
{
public DbSet<Invoice> Invoices => Set<Invoice>();
public DbSet<Currency> Currencies => Set<Currency>();
}
public static class BillingRegistration
{
// The one registration of the context, which Program.cs and the isolation test both call.
public static IServiceCollection AddBillingDbContext(
this IServiceCollection services, Action<DbContextOptionsBuilder> database) =>
services.AddDbContext<BillingDbContext>(options =>
{
database(options);
options.UseTenantry();
});
}builder.Services.AddTenantry<Guid>(tenant => tenant
.ResolveFromHeader("X-Tenant-Id") // the tenant the caller asks for
.ValidateTenantAccessByClaim("tenant_id") // 403 unless the caller's token lists it
.RequireTenantByDefault() // 400 for an endpoint called without a tenant
.UseStore<EfCoreTenantStore>()); // your ITenantStore<Guid>, which lists the tenants
builder.Services.AddBillingDbContext(options => options.UseSqlServer(connectionString));
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseTenantry();
app.MapGet("/invoices", (BillingDbContext db) => db.Invoices.ToListAsync()); // only the current tenant's rowsGetting started has each step in full, Access control the validators, and
Tenant stores how to write a store. The SecureApi sample
is the shape to copy.
Add it to a worker, console or desktop app
- Add
Tenantry.EfCore(it bringsTenantry.Core). Make entities tenant-owned and addUseTenantry()as above. - Register Tenantry with a store:
AddTenantry<TKey>(tenant => tenant.UseStore<T>()). - Run each unit of work as a tenant with
ITenantScopeFactory<TKey>. With an id from outside (a queue message, a command-line argument), useRunInScopeAsync, which looks the tenant up and refuses an unknown or inactive one:
await scopes.RunInScopeAsync(message.TenantId, async (scope, ct) =>
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Orders.Add(new Order { Description = message.Description });
await db.SaveChangesAsync(ct);
}, cancellationToken);Non-HTTP hosts has the table that picks between RunInScopeAsync,
CreateScope and MakeCurrent. In short: an id from outside goes to RunInScopeAsync; a tenant you already loaded
from the store goes to CreateScope, or to ITenantContextSetter<TKey>.MakeCurrent when a scope already exists.
Verify isolation
Write a test that runs the application's own registration of its context with Tenantry's real services and two
tenants: one tenant's rows must not be visible to the other, and a row for another tenant must be refused. The test
calls the same AddBillingDbContext that Program.cs calls, and replaces only the database, so it fails if the
application's registration loses UseTenantry(). A test that registers the context again with its own
AddDbContext(... .UseTenantry()) passes whatever the application does, and proves nothing. This one runs on SQLite
in memory with xUnit:
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tenantry;
using Tenantry.EfCore;
using Xunit;
public sealed class IsolationTests : IAsyncLifetime
{
private static readonly TenantDescriptor<Guid> Acme = new() { TenantId = Guid.NewGuid(), Name = "Acme" };
private static readonly TenantDescriptor<Guid> Globex = new() { TenantId = Guid.NewGuid(), Name = "Globex" };
private readonly SqliteConnection _connection = new("Data Source=:memory:");
private ServiceProvider _services = null!;
public async ValueTask InitializeAsync()
{
await _connection.OpenAsync();
_services = new ServiceCollection()
.AddTenantry<Guid>(tenant => tenant.UseInMemoryStore([Acme, Globex]))
.AddBillingDbContext(options => options.UseSqlite(_connection)) // the application's own registration
.BuildServiceProvider(validateScopes: true);
await using var scope = _services.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<BillingDbContext>().Database.EnsureCreatedAsync();
}
public async ValueTask DisposeAsync()
{
await _services.DisposeAsync();
await _connection.DisposeAsync();
}
[Fact]
public async Task EachTenantSeesOnlyItsOwnRows_AndAnotherTenantsRowIsRefused()
{
var scopes = _services.GetRequiredService<ITenantScopeFactory<Guid>>();
await scopes.RunInScopeAsync(Acme.TenantId, async (scope, ct) =>
{
var db = scope.ServiceProvider.GetRequiredService<BillingDbContext>();
db.Invoices.Add(new Invoice { Amount = 10 });
await db.SaveChangesAsync(ct);
});
await scopes.RunInScopeAsync(Globex.TenantId, async (scope, ct) =>
{
var db = scope.ServiceProvider.GetRequiredService<BillingDbContext>();
Assert.Empty(await db.Invoices.ToListAsync(ct));
db.Invoices.Add(new Invoice { TenantId = Acme.TenantId, Amount = 20 });
await Assert.ThrowsAsync<TenantIsolationViolationException>(() => db.SaveChangesAsync(ct));
});
}
}Check that the test can fail: remove options.UseTenantry() from AddBillingDbContext and run it. It must fail;
put the call back. Tenantry's own tests run this test against a registration without UseTenantry() and check that it
fails. To check that every entity type is either tenant-owned or meant to be shared, assert that
TenantModel.FindUnisolatedEntityTypes(db.Model) lists only the types every tenant shares
(Entity types that are not tenant-owned).
A request test through WebApplicationFactory<Program> adds what this test cannot see: how requests name a tenant,
that the access validator refuses a caller, and the pipeline order (Testing).
Mistakes and the correct form
Tenantry.EfCore and Tenantry.AspNetCore carry analyzers that report most of these at build time
(TNY1001 to TNY3002). Fix what they report rather than suppress it, unless the code is meant to cross tenants.
- An entity with a
TenantIdproperty that does not implementITenantEntity<TKey>is not tenant-owned: every tenant reads and writes all its rows. ImplementITenantEntity<TKey>(or derive fromTenantEntity<TKey>). - An entity type that implements nothing is shared by every tenant. That is the design, for reference data. Do not
add filters or
TenantIdchecks of your own to such types; make the type tenant-owned if its rows belong to one tenant. To have the model list every shared type, mark them[SharedAcrossTenants]and setOnUnmarkedEntityType(Entity types that are not tenant-owned). Database.SqlQuery,SqlQueryRaw,ExecuteSql,ExecuteSqlRawandExecuteSqlInterpolatedare not isolated: no filter applies and no check sees what they change. Prefer LINQ orFromSqlon a tenant-owned set, which EF Core filters; otherwise add the tenant predicate yourself (What is and isn't isolated).IgnoreQueryFilters()removes the tenant filter from that query, so it reads every tenant's rows, andExecuteUpdateorExecuteDeleteafter it change them. Use it only in code meant to work across tenants, behind an authorization check of its own.- Resolving the tenant from a header, route value, query string, host or subdomain with no access validator lets any
caller act as any tenant. Add
ValidateTenantAccessByClaim(...)orValidateTenantAccess(...)in the sameAddTenantry. MakeCurrentandCreateScopetrust the descriptor they are given: they do not look it up or check that the tenant is active. Do not build aTenantDescriptorfrom an id that came from outside and pass it to them. Pass the id toRunInScopeAsync, or look the tenant up withITenantLookup<TKey>first.- A context from
AddDbContextPerTenantDatabaseis connected to one tenant's database. A query or save with it after the current tenant changes throwsTenantIsolationViolationException. Resolve a new context in each tenant's scope. RunInScopeAsyncreturns to the caller's synchronization context to start the work, so blocking on it (.Result,.Wait(),.GetAwaiter().GetResult()) on a desktop app's UI thread can deadlock. Await it (Desktop apps).- With
stringtenant ids, the database comparesTenantIdunder the column's collation, and SQL Server's and MySQL's defaults ignore case:acmeandACMEwould read and change each other's rows. UseGuidids, or ids the collation cannot confuse, or a binary collation onTenantId(string tenant ids).UseInMemoryStorerefuses two ids that differ only in case. - A tenant made current inside an
asynchelper is not current for the helper's caller. Make it current, or open the scope, in the method that does the work (theAsyncLocalmodel).
Rules for your AGENTS.md or CLAUDE.md
Copy this into the application's agent instructions:
## Multi-tenancy (Tenantry)
- Tenant-owned entities implement `ITenantEntity<TKey>` (or derive from `TenantEntity<TKey>`). A `TenantId`
property alone does nothing. Entities that implement neither are shared by every tenant on purpose.
- Never set `TenantId` on new rows and never filter by it in queries: `UseTenantry()` on the DbContext does both.
- Every `DbContext` that holds tenant-owned entities is registered with `.UseTenantry()`.
- `Database.SqlQuery`, `ExecuteSql*` and `IgnoreQueryFilters()` are not tenant-isolated. Do not use them for
tenant data unless the code is meant to cross tenants and says so.
- Header, route, query-string, host and subdomain resolvers are always paired with an access validator in the same
`AddTenantry`.
- Background work given a tenant id runs through `ITenantScopeFactory<TKey>.RunInScopeAsync(id, ...)`, and is
awaited, never blocked on.
- `CreateScope` and `MakeCurrent` only take a tenant read from the store, never one built from outside input.
- Resolve DbContexts inside the tenant's scope; do not keep one across tenants.
- Prefer `Guid` tenant ids. String ids must not differ only in case or accents, which SQL Server's and MySQL's default
collations ignore.
- Every change to tenant-owned data access keeps the isolation test passing: one tenant cannot read or write
another's rows.