Articles · Tenancy & Security ·020
Multi-Tenancy: Resolution, Enforcement, Isolation
Runnable sample on GitHub
Sample:
samples/020-multi-tenancy· Subsystem: 2.9 · Prerequisites: 010 — Authorization, 007 — Projections
Overview
A multi-tenant service serves many customers (tenants) from shared infrastructure while guaranteeing that one tenant can never see another’s data. Relay treats tenancy as a first-class, fail-closed cross-cutting concern with three layers:
- Resolution —
TenantResolutionMiddlewarederives the current tenant from the request (header, claim, subdomain, or route) and publishes it into an ambientITenantContextAccessor. - Enforcement — a pipeline behavior (priority 1, just after authorization) refuses to run a tenant-scoped command/query when no tenant is established.
- Isolation — the ambient tenant flows everywhere: it prefixes query-cache keys, stamps outbox/ inbox/saga/scheduler rows, scopes projections per tenant, and (in the EF Core package) drives PostgreSQL row-level security.
This article covers all three and the sample exercises resolution + enforcement without a database.
Why this exists
Hand-rolled multi-tenancy leaks. Someone forgets a WHERE tenant_id = @t, or a cached query built for
tenant A is served to tenant B, or a background job runs with no tenant and touches everyone’s rows. The
failure mode is a cross-tenant data breach — the worst bug a SaaS can ship. Relay makes the safe path
the default: an undecorated message is tenant-scoped, so forgetting to think about tenancy fails
closed (the request is rejected) rather than fails open (it runs globally). The tenant is established
once, at the edge, and propagated automatically, so no individual query has to remember to filter.
When to use this
- Any SaaS where one deployment serves multiple customers with isolated data.
- When you need per-tenant cache isolation, per-tenant event projections, or per-tenant rate limits.
- When compliance requires provable isolation (RLS gives you a database-enforced guarantee).
When not to use this
- Single-tenant / internal apps. Don’t pay the complexity; leave tenancy uninstalled (messages run globally, no enforcement behavior is registered).
- Tenant-per-deployment. If each customer gets their own isolated stack, the database is the boundary — you don’t need in-process tenant resolution.
- “Tenant” that’s really authorization scope. If you just need “users only see their own rows,” that’s authorization (article 010), not multi-tenancy.
Concepts
Sources & resolution. RelayTenancyOptions.Sources is an ordered list of TenantSource
(Header, Claim, Subdomain, Route). The middleware tries each in order; the first to yield an
identifier wins. The identifier is mapped to a TenantInfo via the ITenantRegistry (the default
InMemoryTenantRegistry is seeded from options.KnownTenants). A raw GUID identifier is accepted as a
tenant id even if unknown (toggle with AllowGuidIdentifierFallback); an unknown non-GUID stays global.
Ambient context. ITenantContextAccessor holds the current TenantContext in an AsyncLocal, so
it flows through async calls. TenantContext.For(id) / TenantContext.None; HasTenant /
TenantId. Enter(context) returns a scope that restores the prior tenant on dispose — used when a
background worker processes a row on behalf of its owning tenant.
Fail-closed enforcement. [TenantScoped] (or no attribute) ⇒ a tenant is required;
[GlobalOperation] ⇒ runs without one. The TenancyEnforcementBehavior throws TenantRequiredException
otherwise — before the handler runs. Note the contrast with the authorization guard (article 010):
authorization fails startup if [Require*] attributes exist without AddRelayAuth(); tenancy enforces
per message at runtime, and undecorated defaults to scoped.
Isolation bridges. Installing tenancy replaces several framework seams with tenant-aware bridges: the query-cache key provider (prefixes keys per tenant — security-critical), the saga tenant provider, the scheduled-message tenant stamper, and the projection tenant source. You wire one call; isolation propagates.
Architecture
flowchart TD
Req[HTTP request] --> MW[TenantResolutionMiddleware]
MW -->|Header/Claim/Subdomain/Route| Reg[ITenantRegistry]
Reg --> Ctx[ITenantContextAccessor<br/>ambient TenantContext]
Ctx --> Authz[Authorization behavior - prio 0]
Authz --> Tenancy[Tenancy enforcement - prio 1]
Tenancy -->|TenantScoped + no tenant| Reject[TenantRequiredException]
Tenancy -->|tenant present / GlobalOperation| Handler[Command/Query handler]
Ctx -.cache key prefix.-> Cache[(Query cache)]
Ctx -.SET LOCAL app.current_tenant.-> RLS[(Postgres RLS)]
Ctx -.stamp tenant_id.-> Outbox[(Outbox / Scheduler / Sagas)]
Building it step by step
1. Declare each message’s tenancy posture
[TenantScoped] public sealed class CreateInvoiceCommand : ICommand<Unit>; // requires a tenant
public sealed class ArchiveInvoiceCommand : ICommand<Unit>; // undecorated ⇒ scoped too
[GlobalOperation] public sealed class RebuildSearchIndexCommand : ICommand; // runs without a tenant
2. Enforce (fail-closed)
var behavior = new TenancyEnforcementBehavior<CreateInvoiceCommand, Unit>(accessor /* no tenant */);
await behavior.Handle(new CreateInvoiceCommand(), default, next); // throws TenantRequiredException
With a tenant set (accessor.Set(TenantContext.For(id))) the same call proceeds to next. A
[GlobalOperation] message proceeds regardless.
3. Resolve from the request
var middleware = new TenantResolutionMiddleware(next, options, resolvers, registry, accessor, logger);
// request with header "X-Tenant-Id: acme" + a registered TenantInfo(id, "acme")
await middleware.InvokeAsync(httpContext); // accessor.Current.TenantId == id inside next
4. Production wiring
services.AddRelayTenancy(o =>
{
o.Sources = new List<TenantSource> { TenantSource.Header };
o.KnownTenants.Add(new TenantInfo(acmeId, "acme"));
});
app.UseRelayTenantContext(); // resolution middleware, early in the pipeline
For database-enforced isolation, the EF Core package adds TenantStorageMode.SharedTable (RLS via
SET LOCAL app.current_tenant = '<id>' per transaction), SchemaPerTenant, or DatabasePerTenant.
Complete source code
| File | Shows |
|---|---|
Messages.cs |
[TenantScoped] / undecorated / [GlobalOperation] |
EnforcementTests.cs |
fail-closed enforcement for commands and queries |
ResolutionTests.cs |
header resolution, GUID fallback, global default |
Running the example
dotnet test samples/020-multi-tenancy/Tenancy.Sample.Tests
Testing
Resolution and enforcement are pure and DB-free: construct the behavior with a hand-set
TenantContextAccessor, or run the middleware over a DefaultHttpContext. This is how the framework’s
own TenancyEnforcementTests and TenancyCoreTests work. RLS isolation is the part that genuinely
needs PostgreSQL — the framework verifies it with a Testcontainers integration test that inserts rows as
three tenants and asserts each session sees only its own (plus global) rows under a non-superuser role.
Production considerations
- Choose the storage mode deliberately. SharedTable+RLS is the default (one schema, a policy filters rows) — cheapest and database-enforced. SchemaPerTenant and DatabasePerTenant give stronger isolation and noisy-neighbor control at higher operational cost (migrations × tenants, connection routing).
- RLS is defense in depth, not the only defense. Keep the application-level tenant scoping and the database policy. Two independent guards; either alone is a single point of failure.
- Background work must re-establish the tenant. A scheduled command or saga timeout runs with no
HTTP request — the scheduler stamps
tenant_idon the row and re-enters the tenant on delivery. If you spawn your own background work, callaccessor.Enter(TenantContext.For(id)). - Cache keys are prefixed per tenant by the tenancy bridge — verify it’s installed, or a
[Cacheable]query can leak across tenants (the framework has a dedicated security test for exactly this). - The default is scoped. Audit your
[GlobalOperation]messages like you audit[AllowAnonymous]— each one is an explicit decision to cross the tenant boundary.
Common mistakes
- Forgetting
[GlobalOperation]on a genuinely global command (e.g. tenant onboarding) — it fails closed withTenantRequiredException. That’s the system working; add the attribute. - Resolving the tenant too late.
UseRelayTenantContext()must run before anything that reads the tenant (auth, endpoints). Put it early. - Trusting a tenant header without a registry in a system that should only serve known tenants — set
AllowGuidIdentifierFallback = falseand seedKnownTenantsso a forged id stays global/rejected. - Relying on RLS while connected as a superuser — superusers bypass row-level security. Run the app under a non-superuser role.
Tradeoffs
- Shared vs. isolated storage. SharedTable is cheap and simple but a bad policy/query is a breach; database-per-tenant is bulletproof but multiplies operational surface. Most start shared and graduate specific large tenants.
- Convenience vs. explicitness. Defaulting to scoped is safe but means every global operation needs an attribute. That friction is intentional — crossing tenants should be conscious.
- Ambient context vs. explicit passing.
AsyncLocalis invisible (easy to forget it must be set) but keeps signatures clean. The enforcement guard turns “forgot to set it” into a loud failure.
Alternatives
- Manual
WHERE tenant_id = @teverywhere — works, until one query forgets. No central guarantee. - Finbuckle.MultiTenant is a mature general-purpose .NET multi-tenancy library (resolution + EF Core query filters). Relay’s tenancy is integrated with its own cache/outbox/saga/projection seams, which a general library can’t reach; use Relay’s when you’re on Relay.
- Separate deployments per tenant — maximal isolation, maximal cost; viable for a handful of large customers, not for thousands.
Lessons from production systems
- The cross-tenant leak is the incident that ends trust. Fail-closed defaults and a database-level RLS guard are cheap insurance against the one query that forgets.
- The subtle leaks are in the cross-cutting paths — a cache shared across tenants, a background job with no tenant, an event projected into the wrong tenant’s read model. Centralizing the tenant in the ambient context and bridging it into those seams is what closes them.
- Teams that started single-tenant and retrofitted tenancy paid dearly. If multi-tenancy is even plausible, model the tenant boundary from the first commit.
Should you use this?
| If you… | Then… |
|---|---|
| run a multi-customer SaaS | yes — install tenancy and default to scoped |
| are single-tenant | leave it off; messages run globally |
| need provable isolation | add SharedTable + RLS (database-enforced) |
| have a few huge tenants | consider schema- or database-per-tenant for those |
Next steps
- 025 — Reference Architecture: tenancy composed with event sourcing, the outbox, and projections in one service.
- 009 — Caching Queries: the cache keys that tenancy prefixes per tenant.