Multi-tenant SaaS
A SaaS backend where one process serves many tenants and must not leak data between them. Three load-bearing primitives make this safe and ergonomic:
- Row-level security on the database. Every query automatically constrained to the current tenant.
- Contextual injection in the DI container. The same service token can resolve different per-tenant providers without your code knowing.
- Tenant-scoped rate limits. Free / pro / enterprise tiers per tenant, not per process.
This recipe extends the API service stack — read that first; the differences here are tenant-isolation patterns layered on top.
Shape
- Identity → tenant. JWT carries
tenantId; auth middleware sets it on the request context. - RLS at every query.
@Policy,@Filter,@Allow,@Denyon repositories constrain results bytenantId. - Per-tenant resolution. A
STORAGEtoken resolves to S3 for enterprise tenants, local disk for the free tier — same code, different backend. - Tiered limits. Each tenant's rate limit reflects their plan.
- Per-tenant cache keys. Cache keys include the tenant prefix so reads never cross tenants.
Architecture
Setting the tenant context
Custom auth-middleware step that, after JWT verify, stamps the
tenantId onto the request context:
import { Injectable, Inject } from '@omnitron-dev/titan';
import { ContextManager, createContextKey }
from '@omnitron-dev/titan/nexus';
import { JWTService, JWT_SERVICE_TOKEN } from '@omnitron-dev/titan-auth';
export const TENANT_ID = createContextKey<string>('tenant.id');
export const USER_TIER = createContextKey<string>('user.tier');
@Injectable()
class TenantContextMiddleware {
constructor(
@Inject(JWT_SERVICE_TOKEN) private readonly jwt: JWTService,
private readonly context: ContextManager,
) {}
async handle(request: { headers: Record<string, string> }, next: () => Promise<unknown>) {
const token = request.headers['authorization']?.replace(/^Bearer /, '');
if (token) {
// IJWTPayload uses snake_case `tenant_id`; `tier` is a custom claim.
const claims = await this.jwt.verify(token);
// `set` lives on the ContextProvider, not on the manager: the manager
// selects and scopes providers, the provider holds the values.
const ctx = this.context.getCurrentContext();
ctx.set(TENANT_ID, claims.tenant_id as string);
ctx.set(USER_TIER, claims.tier as string);
}
return next();
}
}
Wire as a Netron middleware (or in your transport adapter), so it
runs before every @Service method.
Per-tenant DI — contextual providers
The intent: a single STORAGE token resolves to S3 for enterprise
tenants and local disk for the free tier — same service code, different
backend, no if statements leaking into business logic.
createContextAwareProvider is an identity helper over the
ContextAwareProvider interface — a single object with a
provide(context) method and an optional canProvide(context) guard
(createContextAwareProvider in packages/titan/src/nexus/context.ts). There is no
strategies array and no factory key; the branching lives inside
provide, and the tenant is read from the resolution context's
metadata:
import { createContextAwareProvider, createToken }
from '@omnitron-dev/titan/nexus';
const STORAGE = createToken<IStorage>('Storage');
const storageProvider = createContextAwareProvider<IStorage>({
// `context` is the active ResolutionContext; read tenant from its metadata.
provide(context) {
const tenant = context.metadata?.['tenant'] as { id: string; tier: string } | undefined;
return tenant?.tier === 'enterprise' ? new S3Storage() : new LocalDiskStorage();
},
});
The tenant lands in context.metadata because
ContextManager.createResolutionContext folds the active
ContextProvider into metadata (so the TENANT_ID your middleware
set, plus the built-in ContextKeys.Tenant, are visible there). The
built-in ResolutionStrategy classes (TenantStrategy, etc.) are a
separate mechanism — ContextManager.selectProvider() uses them to
pick among several registered providers; they are not passed into
createContextAwareProvider.
Resolve storageProvider in Scope.Request so the provide
callback re-runs per request (a Singleton would cache the first
tenant's backend for everyone). See
DI / Contextual Injection for the full
contextual-provider model, @InjectContext, and writing a custom
ResolutionStrategy.
RLS on every repository
import { BaseRepository, Repository, Policy, Filter, BypassRLS }
from '@omnitron-dev/titan-database';
import { rlsContext } from '@kysera/rls';
@Repository<Order>({ table: 'orders' })
@Policy({ skipFor: ['admin', 'service_role'] }) // these roles bypass RLS
// BaseRepository is an alias of @kysera/repository's ContextAwareRepository,
// which takes two parameters: the DB schema and the table name. The row type
// comes from Database['orders'] — there is no third parameter.
class OrdersRepository extends BaseRepository<Database, 'orders'> {
// Filter: auto-adds a WHERE clause to reads. The context is the
// @kysera/rls auth context (ctx.auth), NOT the DI ContextManager.
@Filter({ operations: ['select', 'update', 'delete'] })
tenantFilter(ctx: { auth: { tenantId: string } }) {
return { tenant_id: ctx.auth.tenantId };
}
@BypassRLS()
async adminListAllAcrossTenants() {
// Reserved for admin / system flows
return this.findAll();
}
}
The RLS context is established with rlsContext.runAsync(...) (from
@kysera/rls) around the query — wire it from your auth middleware so
every request runs inside its tenant's context:
import { rlsContext } from '@kysera/rls';
return rlsContext.runAsync(
{ auth: { userId, tenantId, roles, isSystem: false }, timestamp: new Date() },
async () => this.ordersRepo.findAll(), // automatically filtered by tenant_id
);
Every regular query through OrdersRepository is then constrained to
the tenant in the active RLS context. @BypassRLS is the explicit
escape hatch for cross-tenant operations — every use should be
audited.
RLS enforcement requires the Kysera
rlsPluginto be active on the connection (add it tokysera.plugins). The@Policy/@Filter/@Allow/@Denydecorators declare the rules; the plugin enforces them.
Tenant-scoped cache keys
@Cacheable's keyGenerator/tags callbacks receive the method
arguments ((...args)), not an ambient context. So the tenantId
the key is prefixed with must be reachable from the args — the simplest
contract is to pass it explicitly:
import { Cacheable, CacheInvalidate } from '@omnitron-dev/titan-cache';
@Service('users@1.0.0')
class UsersService {
@Public()
@Cacheable({
cacheName: 'users',
keyGenerator: (tenantId: string, id: string) => `${tenantId}:u:${id}`,
ttl: 60,
tags: (tenantId: string, id: string) => [`tenant:${tenantId}:user:${id}`],
})
async findById(tenantId: string, id: string) {
return this.repo.find(id); // RLS applies inside the repo
}
@CacheInvalidate({
cacheName: 'users',
tags: (tenantId: string, input: { id: string }) => [`tenant:${tenantId}:user:${input.id}`],
})
async update(tenantId: string, input: { id: string; patch: Partial<User> }) {
return this.repo.update(input.id, input.patch);
}
}
Cache keys are prefixed with tenantId; a cache hit for tenant A
cannot be returned to tenant B. (If you prefer to derive the
tenant from the request context instead of a parameter, read it from
the nexus ContextManager inside the method body and build the key
with keyPrefix + a per-call cache handle rather than keyGenerator.)
Tenant-tier rate limits
TitanRateLimitModule.forRoot({
storageType: 'redis',
strategy: 'sliding-window',
defaultTier: { name: 'free', limit: 100, windowMs: 60_000 },
tiers: { // each tier needs a `name`
free: { name: 'free', limit: 100, windowMs: 60_000 },
pro: { name: 'pro', limit: 1_000, windowMs: 60_000 },
enterprise: { name: 'enterprise', limit: 100_000, windowMs: 60_000 },
},
})
In your service. @RateLimit takes an options object only; keyGenerator
receives the method args (not a context), and tier is a tier-name
string. Pass the tenant (and its resolved tier) as arguments:
@Public()
@RateLimit({
tier: 'pro', // tier name from this tenant's plan
keyGenerator: (tenantId: string) => `tenant:${tenantId}`,
})
async create(tenantId: string, input: CreateInput) { /* … */ }
The decorator also needs the limiter injected as
__rateLimitService__(@Inject(RATE_LIMIT_SERVICE_TOKEN)) — see the API service stack — or it silently allows every request.
Cross-module wiring notes
| Concern | Wiring detail |
|---|---|
| Context propagation | Two distinct contexts: the nexus ContextManager (set in middleware) feeds ResolutionStrategy + per-tenant DI; the @kysera/rls auth context (set via rlsContext.runAsync) feeds RLS @Filter/@Allow rules. Cache/rate-limit keys come from method args |
| RLS bypass | @BypassRLS is structural — every use case needs a written justification; pair with an audit log |
| Cache key prefix | Always fold the tenant into the keyGenerator (from a method arg) — never key on a raw id |
| Per-tenant database (advanced) | For physical isolation, register multiple named connections (TitanDatabaseModule.forRoot({ connections: { tenantA, tenantB }})) + contextual provider picks the right one |
| Rate-limit key | Tenant-scoped key prevents one tenant from exhausting another's allowance |
| JWT claims | tenantId and tier must be signed claims on the JWT — they cannot be supplied by the client |
| Strategy purity | A ResolutionStrategy's applies/select (and a provider's provide) must be pure functions of the context — side effects make them untestable and order-dependent |
Production checklist
- JWT carries
tenantIdandtieras signed claims (not header-supplied) - Every repository has
@Filterfor tenant scope OR@BypassRLSjustified - Every cache key includes the tenant prefix — no exceptions
- Rate-limit keys include the tenant prefix — same
-
STORAGE(or other tenant-conditional providers) resolved per request (per-request scope) — aSingletonwould cache the first tenant's instance for everyone - Cross-tenant queries (admin flows) audited — every
@BypassRLScall logged -
TENANT_IDandUSER_TIERcontext keys defined in one place and imported everywhere — typos in keys silently break isolation - Integration tests cover cross-tenant attempts — assert leakage attempts get empty results, not access
Anti-patterns specific to multi-tenant
- Caching without tenant prefix. Cache hit for tenant A returned
to tenant B. Easy to miss in code review; catch with a lint rule
that requires
keyGeneratoron@Cacheable. Singletoncontextual providers. First tenant's instance cached forever. AlwaysScope.Request.- Per-tenant database connections without pool limits. A pod with 100 tenants × pool max 20 = 2_000 connections; Postgres rejects after a few hundred.
@BypassRLSwithout an audit trail. Use a per-method audit decorator that logs every bypass call.tenantIdfrom a header. Easily spoofed. Always derive from signed JWT claims.
See also
- API service stack — the base this recipe extends
- DI / Contextual Injection — the underlying pattern
titan-databaseRLS —@Policy,@Filter,@Allow,@Deny,@BypassRLStitan-cache—keyGeneratorfor per-tenant keystitan-ratelimit— tiered plans