Work with tenant isolation — tenant-owned entities (IHaveTenant/IMayHaveTenant) and the AppDbContext filter and stamping, ITenantContext and its scopes, tenant vs platform scope, User.IsPlatform, memberships and seats, switching tenants, background work outside a request, and isolation tests. Use when adding data that belongs to a tenant, reading or changing the active tenant, writing code that runs without a request, or reviewing anything that could leak rows across tenants.
Install
npx skillscat add ma496/easy-for-net/multi-tenancy Install via the SkillsCat registry.
Multi-tenancy
One database, rows tagged with a TenantId. Isolation is automatic if an entity carries a tenant
marker and code never widens a query without saying so. Everything tenancy-shaped lives in theTenancy slice (Features/Tenancy/Core); ITenantContext, TenantContextExtensions,ITenantMembershipService, ITenantMembershipQuery (membership reads as composable id queries) andTenantSeats are [AllowOutside], so any slice may use them.
1. Scopes: tenant, platform, unresolved
ITenantContext (Features/Tenancy/Core/TenantContext.cs, scoped per DI scope) is always in one of
three states:
| State | IsResolved |
CurrentTenantId |
Reads see | New rows get |
|---|---|---|---|---|
| Tenant | true |
the tenant's id | that tenant's rows | that tenant |
| Platform | true |
null |
rows with TenantId == null |
no tenant (IMayHaveTenant only) |
| Unresolved | false |
throws TenantScopeNotEstablishedException |
nothing — tenant-filtered queries throw | save throws |
tenantContext.IsPlatformScope() (from TenantContextExtensions) is the non-throwing way to ask
"resolved and no tenant". Change the scope only with the disposable handles, disposed in reverse order:BeginTenant(id), BeginPlatformScope(), BeginUnscoped().
In a request Processors/TenantContextProcessor (a global pre-processor) opens the scope from the
session's tenant_id claim alone — tenant scope when it names one, platform scope when it does not. No
database read; an unauthenticated request stays unresolved. The token carries only the account andsid; tenant_id comes from the session record, which sign-in, refresh, POST /tenants/switch andPOST /tenants/exit write to the session store and which every request reads and projects onto its
principal before authorization. A change that takes a tenant away ends the affected sessions at once
through Backend.Features.Identity.Core.Sessions.ISessionRevocationService, after the change commits:
removing a member or replacing their tenant roles ends that user's sessions in that tenant; suspending
or deleting a tenant, or changing its plan, ends every session in it. Their access tokens answer 401 on
the next request and their refresh tokens are refused. Refresh still re-checks membership and tenant
status (TokenService.SetRenewalPrivilegesAsync) and drops a tenant that was suspended, deleted or
left. A new Tenancy endpoint that changes who may act in a tenant, or with what, must revoke the same
way — never inside the transaction, and never by touching the session store. So endpoints never set
the scope — they read it:
using Backend.Features.Tenancy.Core;
namespace Backend.Features.Invoices.Endpoints.Invoices;
sealed class InvoiceSummaryEndpoint(AppDbContext dbContext, ITenantContext tenantContext)
: EndpointWithoutRequest<InvoiceSummaryResponse>
{
public override void Configure()
{
Get("summary");
Group<InvoicesGroup>();
Permissions(Allow.Invoice_View); // a Tenant-scoped permission keeps platform scope out
}
public override async Task HandleAsync(CancellationToken ct)
{
if (tenantContext.CurrentTenantId is not { } tenantId)
{
this.ThrowError(ErrorCodes.NoActiveTenant);
}
// Already restricted to the active tenant by the query filter - no Where(TenantId) needed.
var count = await dbContext.Invoices.CountAsync(ct);
await Send.ResponseAsync(new InvoiceSummaryResponse { TenantId = tenantId, Count = count }, cancellation: ct);
}
}Which scope an endpoint runs in is decided by its permission's PermissionScope, not by an
attribute: a Tenant permission is never in a platform-scope session, a Platform one never in a
tenant session, Both in either (the endpoint then answers about the platform's own rows or the
tenant's). See the permissions skill.
2. Making an entity tenant-owned
Implement one marker from Backend.ShareData.Entities.Base:
IHaveTenant—Guid TenantId; every row belongs to exactly one tenant. Writing one with noTenantIdset in platform or unresolved scope throws.IMayHaveTenant—Guid? TenantId;nullis a platform-owned row (platform roles, global
notifications, account-owned files). Use it only when such rows genuinely exist.
using Backend.ShareData.Entities.Base;
namespace Backend.Features.Invoices.Core.Entities;
public class Invoice : AuditableEntity<Guid>, ISoftDelete, IHaveTenant
{
public Guid TenantId { get; set; }
public string Number { get; set; } = null!;
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
}AppDbContext then, with nothing registered by hand:
- adds a query filter named
"Tenant"(e.TenantId == CurrentTenantId), read per query, beside the"SoftDelete"filter; - on save stamps an unset
TenantIdwith the active tenant, throwsTenantAttributionExceptionwhen a
new row names a different tenant than the active one, and throws on any change to an existing row'sTenantId. Never copy a tenant id from a request payload onto a row.
Tests/Architect/TenantScopingTests fails for any entity with neither marker unless it is listed in_exemptEntities with a written reason (e.g. UserRole derives its tenant through Role). A payload
reporting the tenant implements the DTO marker that matches the entity's: IHaveTenantDto orIMayHaveTenantDto (Backend.Base.Dto). The rest of entity work — configuration, DbSet,
migration — is the backend-entity skill.
Unique indexes must lead with TenantId, or one tenant's value blocks another's. For anIMayHaveTenant kind add .AreNullsDistinct(false) so platform rows are unique among themselves too
(RoleConfiguration, which gives the raw column behind a normalized one its own unique index on the
same terms). Name each index after its columns (backend-entity):
builder.HasIndex(x => new { x.TenantId, x.NumberNormalized })
.IsUnique()
.AreNullsDistinct(false)
.HasDatabaseName("IX_Invoices_TenantId_NumberNormalized");3. Reading across tenants — explicitly
query.AcrossAllTenants() (Extensions/TenantQueryExtension.cs) drops the "Tenant" filter only;
soft-delete stays. It is the sanctioned way to read across tenants, and every use must restate the
tenant it means in its own Where:
var roles = await dbContext.Roles
.AcrossAllTenants()
.Where(role => role.TenantId == tenantId)
.ToListAsync(ct);Use it when the caller legitimately addresses a tenant other than the active one (a platform account
administering a tenant's members), before any scope exists (minting a session), or for global
maintenance (AuthTokenCleanService). Avoid bare IgnoreQueryFilters() — it also resurrects
soft-deleted rows.
4. Code that runs outside a request
Seeders, Hangfire jobs and startup code start unresolved: tenant-filtered queries and saves throw
rather than silently reading every tenant or writing an unattributed row. Pass the tenant id into the
job and open the scope yourself:
public async Task RecalculateAsync(Guid tenantId, CancellationToken ct)
{
using var scope = tenantContext.BeginTenant(tenantId);
var values = await featureValueResolver.ResolveAsync(FeatureTarget.ForTenant(tenantId), ct);
// ... tenant-filtered reads and stamped writes for this tenant only
}DataSeedershows every variant:AcrossAllTenants()for lookups,BeginPlatformScope()to write
the platformAdminrole,BeginTenant(TenancyConstants.BootstrapTenantId)for the bootstrap tenant.- Writing platform rows from inside a tenant request: wrap the add and the save in
BeginPlatformScope()(NotificationService.NewGlobalNotificationAsync, account-owned uploads inFileService). Stamping happens atSaveChangesAsync, so save any pending tenant rows first or they
are flushed and stamped in the wrong scope. IFeatureCheckerneeds a resolved scope; outside a request useIFeatureValueResolverwith a named
target (feature-managementskill). Jobs over many tenants use one DI scope (and so oneITenantContext) per tenant, never one shared context across concurrent branches.- Settings likewise: the slice's
Core/<X>SettingsProvider.csdeclares them beside its permissions and
features providers, andISettingProvider.GetAsync<T>()reads the acting scope, so outside a
request callGetAsync<T>(tenantId)with the tenant the job was given (nullfor the platform's
value) — see thesettingsskill.
5. Platform accounts (User.IsPlatform)
User is global — one identity across tenants, reached through TenantMembership rows. IsPlatform
names the account's tier and travels as the is_platform claim (ICurrentUserService.IsPlatform()).
It is not authority: authorize on permissions. It is read only where the tier is the question —
sign-in (a platform account naming no tenant signs in with none), HangfireAuthorizationFilter,POST /tenants/exit, UserCreateEndpoint (IsPlatform = tenantContext.IsPlatformScope(), never from
the request), which scope GET /permissions/define answers for, and RoleListEndpoint naming another
tenant from platform scope. A platform account enters only tenants it is a member of and acts there on
that tenant's roles alone.
Platform accounts are administered from platform scope only: IUserService.TenantUsers(), the tenant
member endpoints and role user counts exclude them unless the caller acts in platform scope. Tenant
user lists go through TenantUsers(), never Users().
6. Memberships and seats
ITenantMembershipService owns membership and names the tenant explicitly on every call (it works from
platform scope too): IsMemberAsync, AddAsync (grants the tenant's administrator role to its first
member and enforces Identity.MaxUserCount under a tenant row lock), ReplaceRolesAsync,RemoveAsync (both refuse to remove the last tenant administrator), GetSeatsAsync (TenantSeats:Used, Limit, IsFull; platform accounts take no seat). A write path that creates a member itself
reserves the seat in the same transaction:
await using var transaction = await dbContext.Database.BeginTransactionAsync(ct);
await tenantMembershipService.ReserveSeatAsync(tenantId, ct);
await userService.CreateAsync(entity, request.Password);
await transaction.CommitAsync(ct);7. The host, switching and exiting
The host chooses, it never grants. ITenantHostResolver (Features/Tenancy/Core/Hosting,[AllowOutside]) parses Request.Host against Web:DefaultDomain (the platform origin) andWeb:TenantOriginTemplate ({tenant} as the first host label): Tenant(identifier), Platform, orNone for an unknown host or an empty template, which turns the whole rule off. It reads no database;TenantUrl(identifier) (null under an empty template) and PlatformUrl() build links, andRequestOrigin(context) — the host's tenant origin, else the platform origin — is what an emailed link
starts from, so a reset or verification link returns to the host it was asked for on. Sign-up creates a
tenant, so it answers only on the platform host or under None (tenantHostMismatch on a tenant
host) and links its verification email to the new tenant's origin. The only anonymous lookups of a
tenant by identifier are GET /tenants/host (what the request's host names, for the web app),GET /tenants/tls-allowed?domain= (whether a host deserves a certificate) and an anonymousGET /localization/resources/{culture} on a tenant host, which names that tenant to the localization
service explicitly and opens no tenant scope. An identifier is a host label, soTenantValidationRules.TenantIdentifier(...) refuses infrastructure labels (app, www, api, …)
and the platform host's own with tenantIdentifierReserved. The session is still the only authority:
sign-in on a tenant host targets that tenant and still checks membership and status; on the platform
host only a platform account signs in, with no tenant. TenantHostSessionMiddleware then refuses with
403 tenantHostMismatch any authenticated request (the hub included) whose session's tenant_identifier
claim is not the host's tenant, or that acts in a tenant on the platform host. Never authorize from the
host, and never add a database read to resolve it on an ordinary request. Renaming a tenant's identifier
ends every session in it, since they are bound to the old host.
POST /tenants/switch (TenantSwitchEndpoint) requires a live membership and an active tenant, then
reissues the session via ITenantAuthorizationService.ReissueSessionAsync(userId, tenantId, tenantIdentifier), passing the
tenant's normalized identifier so the new session is bound to its host. POST /tenants/exit does the
same with null, null, platform accounts only. Both answer tenantHostMismatch
on a resolved host: there, changing tenant is navigation to the other tenant's host (and a sign-in
there), and switch/exit remain for bearer clients and hosts that resolve to None. The web app calls
neither: each host keeps its own session, so another tenant is a plain link, opening in a new tab
(TenantLink), to the url GET /account/get-info reports for each tenant, or to platformUrl —
never an address built from a domain written in the web app (sign-up alone builds one, from thetenantUrlTemplate GET /tenants/host answers). Sign-in names the tenant its host is and takes no
tenant. store/tenant-cache.test.ts fails if anything in the web app calls /tenants/switch or/tenants/exit again. lib/utils/tenant-routing.ts decides which screens work
without a tenant (platformAccessiblePathPrefixes), which are platform-only
(platformOnlyPathPrefixes, and platformOnlyPathPatterns for one record's screens such as/tenants/{id}/features), and where a caller with a stale or missing tenant lands
(isWithoutUsableTenant → /select-tenant, which lists the caller's tenants as links); a new screen is tenant-only until listed there.
8. Testing isolation
Derive from TenancyTestsBase (Tests/Features/Tenancy) and create everything you assert on —
tests run in parallel, and seeded tenants (TestTenants.BootstrapTenantId, SecondTenantId) are
read, never written. SetAuthTokenAsync() is the bootstrap tenant's tenantadmin;SetPlatformAdminAuthTokenAsync() is admin in platform scope. See the backend-tests skill.
[Fact]
public async Task Another_Tenants_Invoice_Is_Not_Found()
{
var home = await CreateTenantAsync();
var other = await CreateTenantAsync();
var roleId = await CreateTenantRoleAsync(home.Id, Allow.Invoice_View);
var member = await CreateTenantUserAsync(home.Id, roleId);
var foreignId = Guid.Empty;
await TenantScopedAsync(other.Id, async () =>
{
var invoice = new Invoice { Number = "B-1" };
DbContext.Invoices.Add(invoice);
await DbContext.SaveChangesAsync(TestContext.Current.CancellationToken);
foreignId = invoice.Id;
});
await SignInAsAsync(member.Username, home.Id);
var (rsp, _) = await Client.GETAsync<InvoiceGetEndpoint, InvoiceGetRequest, ProblemDetails>(new() { Id = foreignId });
rsp.StatusCode.Should().Be(HttpStatusCode.NotFound);
}Cover platform scope for Both endpoints too (SignInAsPlatformAdministratorEnteringAsync puts a
platform account inside a tenant); ClientForAsync gives a second identity its own client.
Pitfalls
- An exemption in
TenantScopingTeststo silence a missing marker — only for kinds with no tenant. AcrossAllTenants()without a tenant predicate, or a tenant id taken from the request: a leak.- An anonymous endpoint reading a per-tenant setting or sending mail from the ambient scope: a
signed-in caller still carries its tenant scope there, so name the target — the platform for
account-level work (sign-up, password reset, verification:GetAsync<T>(null),EnqueueForPlatform), or the tenant being entered once its membership is checked (sign-in). - A new endpoint that changes a membership, a tenant's status or its plan without revoking the
affected sessions throughISessionRevocationServiceafter it commits: the sessions keep acting on
what they were created with until they are replaced.