Oliver McNally
Add multi-tenancy to an existing ASP.NET Core application
Keep your organisations table as the tenant registry, resolve the tenant on each request, and isolate EF Core data with one call on the DbContext you already have.
Checked against Tenantry 0.5, .NET 10, EF Core 10 and PostgreSQL 16.
Most applications do not start multi-tenant. They gain a second customer, then a third, and every query gains a
WHERE OrganisationId = …. One query without it shows a customer another customer's data.
This post adds tenant isolation to an application like that with Tenantry Core, the open-source part of Tenantry. The organisations table stays the tenant registry, the tenant comes from the request, and EF Core applies it to every query and save. The context stays the one you have.
The starting point
An ASP.NET Core API over EF Core and PostgreSQL. Organisations are the customers, and each order belongs to one:
public class Organisation
{
public Guid Id { get; set; }
public string Name { get; set; } = "";
public string Slug { get; set; } = ""; // acme, as in acme.example.com
public bool IsActive { get; set; } = true;
}
public class Order
{
public int Id { get; set; }
public string Reference { get; set; } = "";
public decimal Total { get; set; }
public Guid OrganisationId { get; set; }
public Organisation? Organisation { get; set; }
}
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<Organisation> Organisations => Set<Organisation>();
public DbSet<Order> Orders => Set<Order>();
}Add the two packages:
dotnet add package Tenantry.AspNetCore
dotnet add package Tenantry.EfCore1. The organisations table is the tenant registry
Tenantry has no tenants table of its own. It reads tenants from a store, which you write over what you already have: it finds a tenant by id and lists them all. A third method maps what a request names to a tenant; its default reads the name as an id, so override it when requests use a slug or a domain instead.
public sealed record OrganisationTenant(Guid TenantId, string Name, bool IsActive) : ITenantDescriptor<Guid>;
public sealed class OrganisationTenantStore(AppDbContext db) : ITenantStore<Guid>
{
public async ValueTask<ITenantDescriptor<Guid>?> GetTenantAsync(Guid tenantId, CancellationToken ct = default) =>
await db.Organisations.AsNoTracking()
.Where(o => o.Id == tenantId)
.Select(o => new OrganisationTenant(o.Id, o.Name, o.IsActive))
.SingleOrDefaultAsync(ct);
public async ValueTask<IReadOnlyList<ITenantDescriptor<Guid>>> GetAllTenantsAsync(CancellationToken ct = default) =>
await db.Organisations.AsNoTracking()
.Select(o => new OrganisationTenant(o.Id, o.Name, o.IsActive))
.ToListAsync<ITenantDescriptor<Guid>>(ct);
// Requests name an organisation by its subdomain: acme.example.com.
public async ValueTask<ITenantDescriptor<Guid>?> FindByIdentifierAsync(string identifier, CancellationToken ct = default) =>
await db.Organisations.AsNoTracking()
.Where(o => o.Slug == identifier)
.Select(o => new OrganisationTenant(o.Id, o.Name, o.IsActive))
.SingleOrDefaultAsync(ct);
}The store only reads: creating, renaming and deactivating organisations stays in your code. It lists inactive
organisations too, because tools that work through every tenant, such as migrations, find them here; whether one may
be used is decided in the next step. The store is scoped, so it can use your DbContext, and Organisation is not
tenant-owned, so these queries are not filtered.
2. Resolve the tenant, and check that the caller may use it
builder.Services.AddTenantry<Guid>(tenant => tenant
.ResolveFromSubdomain(o => o.BaseDomains.Add("example.com")) // acme.example.com → acme
.UseStore<OrganisationTenantStore>()
.CacheTenants() // five minutes by default
.RequireTenantByDefault()
.ValidateTenantAccessByClaim("org_id") // the caller belongs to it
.ValidateTenantAccess((_, t) => t.As<OrganisationTenant>().IsActive));app.UseAuthentication();
app.UseTenantry();
app.UseAuthorization();The subdomain says which organisation a request is for, but anyone can type a subdomain. The access validators run
after the organisation is found and before it becomes current: the signed-in user's org_id claims must include its
id, and it must be active. A request for an organisation the user is not in gets 403, the same answer as for one that
does not exist, so a user cannot find out which others do. RequireTenantByDefault answers 400 to a request with no
organisation, unless the endpoint opts out with AllowMissingTenant(). UseTenantry() goes after
UseAuthentication() because the validators read the user.
With CacheTenants, a deactivated organisation is served from the cache until its entry expires; call
ITenantStoreCache<Guid>.Invalidate when you deactivate one.
3. Mark the tenant-owned entities
An entity is tenant-owned when it implements ITenantEntity<Guid>, whose one member is TenantId. Orders already
have that column, OrganisationId, so rename the property and keep the column and the relationship:
public class Order : ITenantEntity<Guid>
{
public int Id { get; set; }
public string Reference { get; set; } = "";
public decimal Total { get; set; }
public Guid TenantId { get; private set; } // was OrganisationId
public Organisation? Organisation { get; private set; }
}protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>(order =>
{
order.Property(o => o.TenantId).HasColumnName("OrganisationId");
order.HasOne(o => o.Organisation).WithMany().HasForeignKey(o => o.TenantId);
});
}No table changes and no data moves: your next migration changes only the model snapshot. The setter can be private,
because Tenantry sets TenantId on insert, and the compiler then finds the code that used to set OrganisationId by
hand.
4. One call on the context
builder.Services.AddDbContext<AppDbContext>(options => options
.UseNpgsql(builder.Configuration.GetConnectionString("App"))
.UseTenantry());AppDbContext is still a plain DbContext: no base class, no interface and no SaveChanges override. With
AddDbContextPool the call is the same, and a pooled context reads the tenant each time it is used rather than when it
was created.
What it does now
I ran the following against PostgreSQL 16, with two organisations, Acme and Globex.
Queries are filtered. In Acme's requests, db.Orders.ToListAsync() returns Acme's orders, and FindAsync with
the id of a Globex order returns null, so an endpoint that loads before it changes answers 404. With no tenant, a
query matches nothing rather than everything.
Inserts are stamped with the current tenant. An insert that names another tenant throws
TenantIsolationViolationException, and one with no tenant at all throws TenantNotResolvedException; nothing is
written either way.
Updates and deletes are checked twice. An entity can be changed only while its own tenant is current, and it must have been loaded or attached as that tenant. So an update built straight from a request body is refused, whichever organisation the order belongs to:
// Refused: a new instance has no tenant, so SaveChanges throws TenantIsolationViolationException.
db.Orders.Update(new Order { Id = id, Reference = input.Reference, Total = input.Total });
// Load it as the current tenant, then change it: Globex's order is not found in Acme's request.
var order = await db.Orders.FindAsync(id);
if (order is null) return Results.NotFound();
order.Reference = input.Reference;The second check is in the SQL. TenantId is a concurrency token, so the stored tenant is part of every UPDATE and
DELETE:
UPDATE "Orders" SET "Total" = @p0 WHERE "Id" = @p1 AND "OrganisationId" = @p2;An entity that pairs Globex's order id with Acme's tenant id, the forged case, passes the first check and matches no
row: EF Core throws DbUpdateConcurrencyException, and Globex's order is unchanged.
Bulk updates and deletes (ExecuteUpdate, ExecuteDelete) are filtered in the same way, and an ExecuteUpdate
may not set TenantId.
What it does not do
The isolation is in EF Core, not in the database:
- Raw SQL (
FromSql,SqlQuery,ExecuteSql) is not seen. Add the tenant to it yourself. IgnoreQueryFilters()turns the tenant filter off for that query, for administrators' reports. On EF Core 10,IgnoreQueryFilters([TenantryQueryFilters.Tenant])keeps your other filters.- A context whose options do not call
UseTenantry()is not isolated. - Anything that reaches the database directly is not checked. If that matters, add row-level security in the database, or give each tenant a database of its own.
The EF Core guide lists every case.
Background work
A job or a queue consumer has no subdomain. Open a scope for the organisation instead, with an injected
ITenantScopeFactory<Guid>:
await scopes.RunInScopeAsync(message.OrganisationId, async (scope, ct) =>
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// Filtered and stamped for this organisation, as in a request.
}, cancellationToken);Access validators run only for requests, so background work checks IsActive itself.
From here
The same store and resolution work with a database per tenant: UseConnectionStrings gives each organisation its
connection string, and AddDbContextPerTenantDatabase connects each context to it, pooled if you like. Tenant keys
can be int or string as well as Guid. The SecureApi sample
puts this together with JWT authentication and integration tests.