Skip to content
fullstackhero

Concept

Multitenancy deep dive

How tenancy is the default in every layer — Finbuckle strategies, EF Core global query filter, IGlobalEntity opt-out, named filters, cross-tenant query discipline.

views 0 Last updated

Multitenancy isn’t a module in fullstackhero. It’s a property of the platform — every EF Core entity is tenant-aware by default, every cache is tenant-scoped, every background job carries the tenant context that enqueued it, and every audit row records which tenant it belongs to. You opt out with IGlobalEntity, not in.

This page explains how that works mechanically, the design decisions behind it, and the rules for the rare cross-tenant query.

Three layers, one default

Tenancy is enforced at three layers, each protecting the next:

LayerWhereWhat enforces it
HTTP requestMultitenancy module (UseHeroMultiTenantDatabases() in Program.cs)Finbuckle’s UseMultiTenant() resolves the tenant from the tenant header or ?tenant= query and sets the context — first thing in the pipeline.
Data accessBuildingBlocks/PersistenceBaseDbContext applies Finbuckle’s tenant query filter to every entity that doesn’t opt out.
Background workBuildingBlocks/JobsFshJobFilter captures the tenant at enqueue; FshJobActivator restores it before the job runs.

You don’t write WHERE tenant_id = @tenantId in your queries. The filter is on every entity, every time, unless you explicitly bypass it.

Layer 1 — Tenant resolution

Finbuckle.MultiTenant 10 resolves the tenant on every request. The kit configures the chain:

// MultitenancyModule.ConfigureServices (simplified)
builder.Services.AddMultiTenant<AppTenantInfo>()
.WithClaimStrategy(ClaimConstants.Tenant) // 1. claim — no-op pre-auth (see below)
.WithHeaderStrategy(MultitenancyConstants.Identifier) // 2. "tenant" header — the primary resolver
.WithDelegateStrategy(/* ?tenant= query fallback */) // 3. query string
.WithDistributedCacheStore(TimeSpan.FromMinutes(60))
.WithStore<EFCoreStore<TenantDbContext, AppTenantInfo>>(ServiceLifetime.Scoped);

The kit caches resolved tenants in DistributedCacheStore for 60 minutes (Valkey backplane) to keep hot-path requests off the DB. The EFCoreStore is the source of truth.

The root-operator override

A SuperAdmin (whose JWT carries tenant=root) can scope a single request to another tenant. This is needed for cross-tenant admin operations like searching users in tenant X before starting impersonation.

The kit implements this as post-auth middleware, not as a Finbuckle strategy — because the strategy chain runs before auth, it can’t tell whether the caller is actually the root user. The middleware lives in MultitenancyModule.ConfigureMiddleware (which UseModuleMiddlewares runs right after UseAuthentication): it checks the caller’s tenant claim is root, reads the tenant header, looks the target up in the store, and swaps the resolved context via IMultiTenantContextSetter.

The same ConfigureMiddleware also hosts the deactivated/expired-tenant guard: every request from a non-root tenant is rejected if the tenant is inactive or past ValidUpto plus the configured grace window — enforced per request, not just at login.

Layer 2 — Data access

BaseDbContext (in BuildingBlocks/Persistence) is the EF Core base every module’s DbContext inherits. Its OnModelCreating does two things:

BuildingBlocks/Persistence/Context/BaseDbContext.cs
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
ArgumentNullException.ThrowIfNull(modelBuilder);
modelBuilder.AppendGlobalQueryFilter<ISoftDeletable>(QueryFilters.SoftDelete, s => !s.IsDeleted);
base.OnModelCreating(modelBuilder);
// Default-on tenant isolation: entities not marked IGlobalEntity get IsMultiTenant().
modelBuilder.ApplyTenantIsolationByDefault();
}

ApplyTenantIsolationByDefault() walks every entity that doesn’t implement IGlobalEntity and calls IsMultiTenant().AdjustUniqueIndexes() on it — Finbuckle’s hook to install the tenant filter (and widen unique indexes to include the tenant column). The result: a query like await db.Products.ToListAsync(ct) automatically becomes WHERE tenant_id = @currentTenantId.

SaveChangesAsync sets TenantNotSetMode = TenantNotSetMode.Overwrite so any entity entering Added state without an explicit TenantId inherits the current tenant. No entity.TenantId = current.GetTenant() lines in your handlers.

Two opt-outs

IGlobalEntity — for entities that need to live across tenants. The kit uses it for:

  • BillingPlan — the plan catalogue is platform-wide
  • ImpersonationGrant — operator impersonation spans tenants
  • OutboxMessage, InboxMessage — eventing infrastructure

If you mark an entity IGlobalEntity, the global tenant filter is not applied. Queries against the table return rows for every tenant; access control becomes the handler’s responsibility.

IgnoreQueryFilters([QueryFilters.SoftDelete]) — the kit registers SoftDelete as a named filter (the tenant filter stays anonymous, Finbuckle-owned), so individual queries can drop just the soft-delete filter without touching the tenant filter:

var trashed = await db.Products
.IgnoreQueryFilters([QueryFilters.SoftDelete])
.Where(p => p.IsDeleted)
.ToListAsync(ct).ConfigureAwait(false);

This is the pattern for “list trashed items” queries (see ListTrashedProductsQueryHandler and friends). The tenant filter still applies; the soft-delete filter doesn’t.

Cross-tenant queries (rare, but real)

Auditing needs to query across tenants — a root operator pulling another tenant’s audit trail. The real code, from the Auditing module’s GetAuditsQueryHandler:

// Cross-tenant access requires an explicit permission check first…
var allowed = await _permissions
.HasPermissionAsync(userId, AuditingPermissions.AuditTrails.ViewCrossTenant, ct)
.ConfigureAwait(false);
if (!allowed)
{
throw new ForbiddenException("Cross-tenant audit access requires Permissions.AuditTrails.ViewCrossTenant.");
}
// …then bypass the filter and IMMEDIATELY re-scope to the requested tenant.
return _dbContext.AuditRecords
.AsNoTracking()
.IgnoreQueryFilters()
.Where(a => a.TenantId == requested);

The pattern: gate with an explicit permission, use IgnoreQueryFilters() deliberately, then re-filter explicitly to the exact scope the caller asked for — never return rows for all tenants. Don’t sprinkle this; reserve it for specific admin / audit query handlers.

Layer 3 — Background work

Hangfire jobs run on threads that have no HttpContext, so the tenant context resolved on the originating request is gone by the time the job dequeues. Two pieces in BuildingBlocks/Jobs handle this:

  • At enqueue timeFshJobFilter reads the current tenant (via IMultiTenantContextAccessor) and user id, and stores them as Hangfire job parameters.
  • At execute timeFshJobActivator reads the stored parameters back and pushes the tenant onto IMultiTenantContextSetter (and the user onto ICurrentUserInitializer) before the job body runs. The job sees the same tenant the request did.

The same pattern shows up in integration-event handlers: WebhookFanoutHandler installs the event’s TenantId via IMultiTenantContextSetter before querying the subscription table, then restores the previous context in a finally. Forget this and the Finbuckle query filter returns no rows on the background thread.

Per-tenant connection strings

Each AppTenantInfo row carries an optional ConnectionString. When set, BaseDbContext.OnConfiguring switches the DbContext to that connection at runtime. This lets a high-value tenant get its own database without changing application code.

When not set, all tenants share the kit’s main database with row-level isolation via the global query filter. For most teams, row-level isolation is sufficient through the kit’s lifecycle and scales further than you’d expect — Postgres handles hundreds of thousands of tenants in one database fine. Reach for per-tenant databases only when you have a compliance requirement (HIPAA, PCI-DSS) or a single tenant whose data volume genuinely needs its own physical store.

Caching tenant-scoped data

The Caching block (HybridCache) is tenant-aware by convention: include tenantId in every cache key. The kit’s pattern:

await cache.GetOrCreateAsync(
$"product:{tenantId}:{productId}",
async (ct) => await db.Products.FirstAsync(...),
tags: [$"tenant:{tenantId}"],
cancellationToken: ct).ConfigureAwait(false);

The tag-based eviction lets you invalidate a tenant’s entire cache on plan change, suspension, or sign-out (CacheKeys.Tags.Tenant(...) is the kit’s helper for the tag):

await cache.RemoveByTagAsync(CacheKeys.Tags.Tenant(tenantId), ct).ConfigureAwait(false);

L1 (in-memory) cache has no backplane; the kit’s DefaultLocalCacheExpiration (2 minutes) bounds the cross-node staleness window after a peer’s RemoveByTag.

The documented exception: Billing

One module deliberately steps outside the default. BillingDbContext is a plain DbContextnot BaseDbContext — so the auto-applied tenant filter does not govern it. Subscriptions, invoices, and usage snapshots instead carry an explicit TenantId column, and the billing query services filter on it by hand.

This is intentional: billing is a finance/admin concern that routinely queries across tenants — the monthly invoice job iterates every active tenant, and operators pull cross-tenant invoice reports. Forcing it through the per-request tenant filter would fight the feature. The trade-off is that billing handlers own their tenant scoping: every query that should be tenant-bound must say so (.Where(x => x.TenantId == current)), because nothing does it for them.

The plan catalogue (BillingPlan) goes further and is IGlobalEntity — a single platform-wide list of plans shared by all tenants. See the Billing module.

Per-tenant migrations & provisioning

Because each tenant can have its own schema (and optionally its own database), migrations run per tenant. The DbMigrator applies the tenant catalog first, then walks each tenant’s per-module schema, serialized by a Postgres advisory lock so two migrator instances can’t collide.

New tenants are provisioned asynchronously. CreateTenantCommand returns immediately, buffers the operator-supplied admin password in ITenantInitialPasswordBuffer (a singleton — there is no hard-coded default password), and queues a Hangfire job that walks ordered, resumable steps (Database → Migrations → Seeding → CacheWarm). A tenant is only activated once provisioning reaches Completed; a failed step is resumable via RetryTenantProvisioning. Full lifecycle in the Multitenancy module.

Testing isolation

Isolation is verified, not assumed. Integration tests at src/Tests/Integration.Tests/Tests/Multitenancy/ assert that data created under tenant A is invisible to tenant B, that the header override is gated to the root operator, and that seeding is tenant-scoped.

What tenancy is NOT

  • Tenant isolation is not a security boundary against compromised code. A bug that calls IgnoreQueryFilters() returns cross-tenant data. Architecture tests can catch obvious cases; code review catches the rest. Keep the bypass calls audited.
  • Tenant context is not propagated to outbound HTTP calls automatically. When you call a third-party API on behalf of a tenant, attach the tenant id explicitly to the request (header, OAuth scope, audit metadata).
  • Tenant identity is not user identity. A user can belong to multiple tenants (the operator impersonation case). Use ICurrentUser.GetUserId() for user identity; use ICurrentUser.GetTenant() for the resolved tenant of the current request.