@nestarc/tenancy
v0.13.0
Published
Multi-tenancy module for NestJS with PostgreSQL Row Level Security (RLS) and Prisma support
Maintainers
Readme
@nestarc/tenancy
Multi-tenancy module for NestJS with PostgreSQL Row Level Security (RLS) and Prisma support.
One line of code. Automatic tenant isolation.
Features
- RLS-based isolation — PostgreSQL enforces tenant boundaries at the database level
- AsyncLocalStorage — Zero-overhead request-scoped tenant context (no
REQUESTscope) - Prisma Client Extensions — Automatic
set_config()before every query - 5 built-in extractors — Header, Subdomain, JWT Claim, Path, Composite (fallback chain)
- Lifecycle hooks —
onTenantResolved/onTenantNotFoundfor logging, auditing, custom error handling - Auto-inject tenant ID — Optionally inject
tenant_idintocreate/createMany/upsertoperations - Shared models — Whitelist models that skip RLS (e.g.,
Country,Currency) withoutTenant()— programmatic bypass for background jobs and admin queriestenancyTransaction()— interactive transaction support with RLS- Fail-Closed mode —
failClosed: trueblocks model queries without tenant context, preventing accidental data exposure - Testing utilities —
TestTenancyModule,withTenant(),expectTenantIsolation()via@nestarc/tenancy/testing - Event system — optional
@nestjs/event-emitterintegration fortenant.resolved,tenant.not_found, etc. - Microservice propagation — HTTP (
propagateTenantHeaders()), Bull, Kafka, gRPC propagators with zero transport dependencies - Inbound context restoration —
TenantContextInterceptorauto-restores tenant context from incoming microservice messages - Error hierarchy —
TenantContextMissingErrorbase class enables unifiedinstanceofcatch handling - CLI scaffolding —
npx @nestarc/tenancy initgenerates RLS policies and module config - CLI drift detection —
npx @nestarc/tenancy checkvalidates SQL against Prisma schema - Multi-schema support —
@@schema()directives generate schema-qualified SQL (e.g.,"auth"."users") - ccTLD-aware subdomain extraction — accurate parsing for
.co.uk,.co.jp,.com.au, etc. - Framework-agnostic — public API uses
TenancyRequest/TenancyResponseinstead of Express types. Works with Express, Fastify, and raw Node.js HTTP - SQL injection safe —
set_config()with bind parameters, plus UUID validation by default - NestJS 10 & 11 compatible, Prisma 5 & 6 compatible (E2E-tested with Prisma 6; Prisma 5 unit-tested)
Performance
The benchmark separates extension overhead from row-count and database-role effects:
| Scenario | Purpose |
|----------|---------|
| Admin direct findMany over all rows | Context only; not used as the extension overhead baseline |
| Admin tenant-filtered findMany with WHERE tenant_id | Same returned row count with RLS bypassed |
| app_user manual RLS transaction | set_config + query, no extension |
| app_user tenancy extension findMany | Same role, RLS policy, and returned row count as the manual RLS transaction |
| app_user tenancy extension findFirst | Single-row reference path |
The headline number is extension findMany - manual RLS transaction, not extension vs unfiltered admin query. The script prints row counts, Node/PostgreSQL/Prisma versions, and p50/p95/p99 timings so results can be compared across environments.
Example result from Apple M1 Pro, Node v24.11.1, PostgreSQL 16.13, Prisma Client 6.19.2, 1005 total rows, 500 measured iterations:
| Scenario | Rows | Avg | P50 | P95 | P99 |
|----------|------|-----|-----|-----|-----|
| Admin direct findMany (all rows, no RLS) | 1005 | 3.983ms | 3.369ms | 5.444ms | 6.992ms |
| Admin tenant-filtered findMany (WHERE tenant_id, no RLS) | 402 | 2.747ms | 2.736ms | 3.612ms | 4.686ms |
| app_user manual RLS transaction (set_config + findMany) | 402 | 2.846ms | 2.614ms | 4.154ms | 5.177ms |
| app_user tenancy extension findMany | 402 | 2.961ms | 2.766ms | 4.281ms | 4.800ms |
| app_user tenancy extension findFirst | 1 | 1.217ms | 1.192ms | 1.522ms | 1.777ms |
Measured extension overhead: +0.115ms avg (+4.0%), +0.127ms p95 compared with the manual RLS transaction.
Reproduce:
docker compose up -d --wait && npm run bench
Prerequisites
- Node.js >= 18
- NestJS 10 or 11
- Prisma 5 or 6
- PostgreSQL (with RLS support). Use a patched minor release: CVE-2024-10976 is fixed in PostgreSQL 17.1, 16.5, 15.9, 14.14, 13.17, and 12.21.
Installation
npm install @nestarc/tenancyQuick Start
1. Enable RLS on your PostgreSQL tables
Every table that needs tenant isolation must have a tenant_id column and an RLS policy:
-- Ensure your table has a tenant_id column
ALTER TABLE users ADD COLUMN tenant_id TEXT NOT NULL;
-- Enable RLS (FORCE ensures table owners also obey policies)
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE users FORCE ROW LEVEL SECURITY;
-- Add an index for the policy column to avoid full table scans
CREATE INDEX IF NOT EXISTS tenancy_users_tenant_id_idx ON users (tenant_id);
-- Create isolation policy
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.current_tenant', true)::text);
-- The `true` parameter means missing_ok: returns NULL instead of error when unset.
-- At the database layer, queries without tenant context return 0 rows (not an error).
-- Repeat for each tenant-scoped tableCritical: RLS is bypassed by superusers and (without
FORCE ROW LEVEL SECURITY) by table owners. Create a dedicated application role that does not own the tables:CREATE ROLE app_user LOGIN PASSWORD 'your_password'; GRANT USAGE ON SCHEMA public TO app_user; GRANT SELECT, INSERT, UPDATE, DELETE ON your_table TO app_user;Use this role's connection string in your application. If you connect as a superuser, RLS policies are silently bypassed.
2. Register the module
import { TenancyModule } from '@nestarc/tenancy';
@Module({
imports: [
TenancyModule.forRoot({
tenantExtractor: 'X-Tenant-Id', // header name
}),
],
})
export class AppModule {}3. Extend your Prisma client
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { TenancyService, createPrismaTenancyExtension } from '@nestarc/tenancy';
@Injectable()
export class PrismaService implements OnModuleInit {
public readonly client;
constructor(private readonly tenancyService: TenancyService) {
const prisma = new PrismaClient();
this.client = prisma.$extends(
createPrismaTenancyExtension(tenancyService),
);
}
async onModuleInit() {
await this.client.$connect();
}
}Extension Options
createPrismaTenancyExtension(tenancyService, {
dbSettingKey: 'app.current_tenant', // PostgreSQL setting key (default)
autoInjectTenantId: true, // Auto-inject tenant_id on create/upsert
tenantIdField: 'tenant_id', // Column name for tenant ID (default)
sharedModels: ['Country', 'Currency'], // Models that skip RLS entirely
})| Option | Type | Default | Description |
|--------|------|---------|-------------|
| dbSettingKey | string | 'app.current_tenant' | PostgreSQL session variable name |
| autoInjectTenantId | boolean | false | Auto-inject tenant ID into create, createMany, createManyAndReturn, upsert |
| tenantIdField | string | 'tenant_id' | Column name to inject tenant ID into |
| sharedModels | string[] | [] | Models that bypass RLS (no set_config, no injection) |
| failClosed | boolean | true | Block queries when no tenant context is set (prevents accidental data exposure if RLS is misconfigured) |
| interactiveTransactionSupport | boolean | false | Enable transparent set_config inside interactive transactions. Validates Prisma compatibility at startup — throws immediately if unsupported. Alternative: tenancyTransaction() helper |
Important: If you customize
dbSettingKeyinTenancyModule.forRoot(), pass the same value tocreatePrismaTenancyExtension()andtenancyTransaction(). These are independent configurations that must match your PostgreSQLcurrent_setting()calls.
Note: By default, the Prisma extension uses batch transactions internally, which do not propagate
set_configinto interactive transactions ($transaction(async (tx) => ...)). EnableinteractiveTransactionSupport: truefor transparent handling, or use thetenancyTransaction()helper. See Interactive Transactions below.
Migration note: If you intentionally rely on model queries without tenant context falling through to PostgreSQL RLS, set
failClosed: falseexplicitly. PrefersharedModels,withoutTenant(), or a separate admin client for intentional unscoped access.
Interactive Transactions
The default Prisma extension wraps queries in batch transactions, which breaks inside $transaction(async (tx) => ...). Two approaches are available:
Option 1: tenancyTransaction() helper (recommended)
Uses only public Prisma APIs. Works with all Prisma versions.
import { tenancyTransaction } from '@nestarc/tenancy';
await tenancyTransaction(prisma, tenancyService, async (tx) => {
const user = await tx.user.findFirst();
await tx.order.create({ data: { userId: user.id } });
});Compatibility note:
interactiveTransactionSupport: truerelies on Prisma internal APIs. PrefertenancyTransaction()for new code because it uses public Prisma APIs. Use transparent support only when you accept Prisma-version compatibility risk and have E2E coverage for your Prisma version.
Option 2: Transparent mode
Sets RLS context automatically inside interactive transactions. Validates Prisma compatibility at startup.
const prisma = basePrisma.$extends(
createPrismaTenancyExtension(tenancyService, {
interactiveTransactionSupport: true,
})
);4. Use it
import { Injectable } from '@nestjs/common';
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
findAll() {
// Automatically filtered by RLS — only current tenant's data returned
return this.prisma.client.user.findMany();
}
}Send requests with the tenant header:
curl -H "X-Tenant-Id: 550e8400-e29b-41d4-a716-446655440000" http://localhost:3000/usersAll Prisma queries are automatically scoped to that tenant via RLS.
API
TenancyModule
// Synchronous
TenancyModule.forRoot({
tenantExtractor: 'X-Tenant-Id', // header name (string)
dbSettingKey: 'app.current_tenant', // PostgreSQL setting (default)
validateTenantId: (id) => UUID_REGEX.test(id), // sync or async (default: UUID)
})
// Async with factory
TenancyModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
tenantExtractor: config.get('TENANT_HEADER'),
}),
})
// Async with class
TenancyModule.forRootAsync({
useClass: TenancyConfigService,
})
// Async with existing provider
TenancyModule.forRootAsync({
useExisting: TenancyConfigService,
})TenancyService
@Injectable()
export class SomeService {
constructor(private readonly tenancy: TenancyService) {}
doSomething() {
const tenantOrNull = this.tenancy.getCurrentTenant(); // string | null
const tenantId = this.tenancy.getCurrentTenantOrThrow(); // string (throws if missing)
}
}@CurrentTenant() Decorator
import { Controller, Get } from '@nestjs/common';
import { CurrentTenant } from '@nestarc/tenancy';
@Controller('users')
export class UsersController {
@Get('me')
whoAmI(@CurrentTenant() tenantId: string) {
return { tenantId };
}
}@BypassTenancy() Decorator
Skip the TenancyGuard tenant-required check on specific routes (e.g., health checks, public endpoints).
Important:
@BypassTenancy()only bypasses the guard's tenant-required check. It does not clear tenant context. If a request includes a valid tenant header, downstream services and Prisma queries can still run inside that tenant context. UsetenancyService.withoutTenant()to explicitly run with no tenant context.
import { Controller, Get } from '@nestjs/common';
import { BypassTenancy } from '@nestarc/tenancy';
@Controller('health')
export class HealthController {
@BypassTenancy()
@Get()
check() {
return { status: 'ok' }; // No tenant header required
}
}Programmatic Bypass
Use withoutTenant() to clear the tenant context so the Prisma extension skips set_config(). With RLS enabled, this means queries return 0 rows — current_setting(..., true) returns NULL, so the equality policy does not match any tenant row.
// Background job — clears tenant context, Prisma extension skips set_config()
// With RLS enabled, queries return 0 rows (RLS blocks access when no tenant is set)
const result = await tenancyService.withoutTenant(async () => {
return prisma.user.findMany(); // Returns 0 rows when RLS is active
});withoutTenant() is primarily useful for:
- Shared tables (models listed in
sharedModels) — RLS is not applied, so all rows are returned - Tenant lookup during login — e.g., looking up a tenant record before the tenant context is established
- Code that uses a separate admin connection — see below
To actually query across all tenants, you need one of:
- A superuser/RLS-exempt database connection — use a separate
PrismaClientwith admin credentials that bypasses RLS:
@BypassTenancy()
@Get('/admin/users')
async listAllUsers() {
return this.tenancyService.withoutTenant(async () => {
return this.adminPrisma.user.findMany();
});
}- A PostgreSQL bypass policy — add a policy that allows access when a bypass flag is set:
CREATE POLICY admin_bypass ON users
USING (current_setting('app.bypass_rls', true) = 'on');// @BypassTenancy() bypasses the GUARD only (no 403 error).
// If a tenant header is present, Prisma still scopes to that tenant.
// If no tenant header is present, Prisma skips set_config() entirely.
@Get('/admin/users')
@BypassTenancy()
async getAllUsers() {
// With X-Tenant-Id header: returns that tenant's data
// Without X-Tenant-Id header: throws TenancyContextRequiredError by default
// For true cross-tenant access, use withoutTenant() + admin connection
return this.prisma.user.findMany();
}Tenant Extractors
Five built-in extractors cover common multi-tenancy patterns:
Header (default)
TenancyModule.forRoot({
tenantExtractor: 'X-Tenant-Id', // shorthand for HeaderTenantExtractor
})Subdomain
import { SubdomainTenantExtractor } from '@nestarc/tenancy';
TenancyModule.forRoot({
tenantExtractor: new SubdomainTenantExtractor({
excludeSubdomains: ['www', 'api'], // optional, defaults to ['www']
}),
validateTenantId: (id) => /^[a-z0-9-]+$/.test(id),
})
// tenant1.app.com → 'tenant1'Note: Uses the
pslpackage for accurate ccTLD parsing (installed automatically as a dependency).
JWT Claim
import { JwtClaimTenantExtractor } from '@nestarc/tenancy';
TenancyModule.forRoot({
tenantExtractor: new JwtClaimTenantExtractor({
claimKey: 'org_id', // JWT payload key
headerName: 'authorization', // optional, defaults to 'authorization'
}),
})
// Authorization: Bearer eyJ... → payload.org_idSecurity:
JwtClaimTenantExtractordecodes JWT claims and checks time-based claims such asexp/nbf, but it does not verify the JWT signature. Verify the token before tenant extraction, or validate the resolved tenant against authenticated user state inonTenantResolved.NestJS execution order is: Middleware → Guards → Interceptors → Pipes. Since
TenantMiddlewareruns at the middleware stage, a NestJS Guard (e.g.,@nestjs/passportAuthGuard) runs after the tenant is already resolved and cannot protect it.Middleware ordering:
TenancyModuleregistersTenantMiddlewareglobally via its ownconfigure()call. To run JWT verification before tenant extraction, you have two options:Option 1 (recommended) — Import an auth module before TenancyModule:
NestJS applies middleware in the order modules are initialized. If your auth middleware is registered in a module that is imported before
TenancyModule, it will run first.// auth.module.ts — registers JWT verification middleware globally @Module({}) export class AuthModule implements NestModule { configure(consumer: MiddlewareConsumer) { consumer .apply(JwtVerifyMiddleware) // verifies signature, populates req.user .forRoutes('*'); } } // app.module.ts — import AuthModule BEFORE TenancyModule @Module({ imports: [ AuthModule, // middleware runs first TenancyModule.forRoot({ tenantExtractor: new JwtClaimTenantExtractor({ claimKey: 'org_id' }), }), ], }) export class AppModule {}Option 2 — Verify the JWT claim in
onTenantResolved:If you need to ensure the resolved tenant matches the authenticated user, use the
onTenantResolvedhook. This does not replace signature verification but lets you add an authorization check after extraction:TenancyModule.forRoot({ tenantExtractor: new JwtClaimTenantExtractor({ claimKey: 'org_id' }), onTenantResolved: (tenantId, req) => { // req.user is populated by an upstream auth middleware if (req.user?.org_id !== tenantId) { throw new ForbiddenException('Tenant mismatch'); } }, })
Path Parameter
import { PathTenantExtractor } from '@nestarc/tenancy';
TenancyModule.forRoot({
tenantExtractor: new PathTenantExtractor({
pattern: '/api/tenants/:tenantId/resources',
paramName: 'tenantId',
}),
})
// /api/tenants/acme/resources → 'acme'Composite (Fallback Chain)
import {
CompositeTenantExtractor,
HeaderTenantExtractor,
SubdomainTenantExtractor,
JwtClaimTenantExtractor,
} from '@nestarc/tenancy';
TenancyModule.forRoot({
tenantExtractor: new CompositeTenantExtractor([
new HeaderTenantExtractor('X-Tenant-Id'),
new SubdomainTenantExtractor(),
new JwtClaimTenantExtractor({ claimKey: 'org_id' }),
]),
})
// Tries each extractor in order, returns the first non-null resultCustom Extractor
import { TenantExtractor, TenancyRequest } from '@nestarc/tenancy';
export class CookieTenantExtractor implements TenantExtractor {
extract(request: TenancyRequest): string | null {
return request.cookies?.['tenant_id'] ?? null;
}
}Framework-agnostic:
TenancyRequestis satisfied by ExpressRequest, FastifyFastifyRequest, and any object with aheadersproperty. If you need platform-specific properties, use type assertion:(request as import('express').Request).ip.
Lifecycle Hooks
React to tenant resolution events without extending the middleware:
TenancyModule.forRoot({
tenantExtractor: 'X-Tenant-Id',
onTenantResolved: async (tenantId, req) => {
// Runs inside AsyncLocalStorage context — getCurrentTenant() works here
logger.info({ tenantId, path: req.path }, 'tenant resolved');
await auditService.recordAccess(tenantId);
},
onTenantNotFound: (req, res) => {
// Option 1: Observation only (return void → next() is called)
logger.warn({ path: req.path }, 'no tenant');
// Option 2: Block the request (throw an exception)
throw new ForbiddenException('Tenant header required');
// Option 3: Return 'skip' to prevent next() — use res to send your own response
res.status(401).json({ message: 'Tenant header required' });
return 'skip';
},
})| Hook | Signature | When |
|------|-----------|------|
| onTenantResolved | (tenantId: string, req: TenancyRequest) => void \| Promise<void> | After successful extraction and validation |
| onTenantNotFound | (req: TenancyRequest, res: TenancyResponse) => void \| 'skip' \| Promise<void \| 'skip'> | When no tenant ID could be extracted |
Error Responses
| Scenario | Status | Message |
|----------|--------|---------|
| Missing tenant header (no @BypassTenancy) | 403 | Tenant ID is required |
| Invalid tenant ID format | 400 | Invalid tenant ID format |
| Extractor throws or rejects | Propagates | Original error; emits tenant.extraction_failed first |
| Cross-check mismatch | 403 | Tenant ID mismatch |
| crossCheck.required: true and no secondary tenant source | 403 | Cross-check source is required but returned null |
| Prisma query without tenant context (failClosed, default) | Throws | TenancyContextRequiredError |
| Non-HTTP context (WebSocket, gRPC) | — | Guard skips (no enforcement) |
Fail-Closed Mode
By default, model queries without a tenant context throw TenancyContextRequiredError. This avoids silent unscoped query paths when RLS is misconfigured or accidentally bypassed.
const prisma = new PrismaClient().$extends(
createPrismaTenancyExtension(tenancyService, {
failClosed: true, // default
})
);Queries are still allowed when:
- The model is listed in
sharedModels withoutTenant()is used (explicit bypass)
To restore the previous pass-through behavior, opt out explicitly:
const prisma = new PrismaClient().$extends(
createPrismaTenancyExtension(tenancyService, {
failClosed: false,
})
);Scope:
failClosedapplies to Prisma model operations (findMany,create,update, etc.). Raw queries ($queryRaw,$executeRaw) bypass the extension and are not covered — use parameterizedset_config()manually for raw queries.
Testing Utilities
Import from @nestarc/tenancy/testing:
import { TestTenancyModule, withTenant, expectTenantIsolation } from '@nestarc/tenancy/testing';
// 1. Use TestTenancyModule in unit/integration tests (no middleware or guard)
const module = await Test.createTestingModule({
imports: [TestTenancyModule.register()],
providers: [MyService],
}).compile();
// 2. Run code in a tenant context
const result = await withTenant('tenant-1', () => service.findAll());
// 3. Assert tenant isolation in E2E tests
await expectTenantIsolation(prisma.user, 'tenant-a-uuid', 'tenant-b-uuid');Event System
Optional integration with @nestjs/event-emitter. Install the package and import EventEmitterModule:
import { EventEmitterModule } from '@nestjs/event-emitter';
import { TenancyEvents } from '@nestarc/tenancy';
@Module({
imports: [
EventEmitterModule.forRoot(),
TenancyModule.forRoot({ tenantExtractor: 'x-tenant-id' }),
],
})
export class AppModule {}
// Listen for events anywhere in your app
@Injectable()
class TenantLogger {
@OnEvent(TenancyEvents.RESOLVED)
handleResolved({ tenantId }: { tenantId: string }) {
console.log(`Tenant resolved: ${tenantId}`);
}
}Events: tenant.resolved, tenant.not_found, tenant.extraction_failed, tenant.validation_failed, tenant.context_bypassed, tenant.cross_check_failed.
If @nestjs/event-emitter is not installed, events are silently skipped — no errors.
Tenant ID Forgery Prevention
Cross-validate the tenant ID against a secondary source to prevent header forgery:
import { JwtClaimTenantExtractor } from '@nestarc/tenancy';
TenancyModule.forRoot({
tenantExtractor: 'X-Tenant-Id',
crossCheck: {
extractor: new JwtClaimTenantExtractor({ claimKey: 'tenantId' }),
onFailed: 'reject', // 'reject' (default) | 'log'
required: false, // when true, rejects requests without cross-check source
},
})If the cross-check extractor returns null (e.g., no JWT present), validation is skipped by default — unauthenticated endpoints work normally. Set required: true to reject requests when the cross-check source is missing, enforcing that every request must have a verifiable secondary source. On mismatch, tenant.cross_check_failed event is emitted.
v0.12.0 migration: The flat
crossCheckExtractor/onCrossCheckFailedfields were removed. UsecrossCheck: { extractor, onFailed, required }.
// Before v0.12.0
TenancyModule.forRoot({
tenantExtractor: 'X-Tenant-Id',
crossCheckExtractor: new JwtClaimTenantExtractor({ claimKey: 'org_id' }),
onCrossCheckFailed: 'reject',
});
// v0.12.0+
TenancyModule.forRoot({
tenantExtractor: 'X-Tenant-Id',
crossCheck: {
extractor: new JwtClaimTenantExtractor({ claimKey: 'org_id' }),
onFailed: 'reject',
},
});Deprecation Policy
Deprecated public APIs are marked with @deprecated JSDoc and listed in the changelog. Unless a security issue requires faster removal, deprecated APIs are planned for removal two minor versions later or at the next major release, whichever comes first.
OpenTelemetry Integration
Optional integration with @opentelemetry/api. Install the package to enable automatic tenant context in traces:
npm install @opentelemetry/apiTenancyModule.forRoot({
tenantExtractor: 'X-Tenant-Id',
telemetry: {
spanAttributeKey: 'tenant.id', // default
createSpans: true, // create custom spans for tenant lifecycle
},
})When enabled, tenant.id is automatically added as a span attribute to the active span on every request. If createSpans is true, a tenant.resolved span is also created with the configured tenant attribute.
If @opentelemetry/api is not installed, telemetry is silently skipped — no errors.
Microservice Propagation
Forward the current tenant context to downstream services using propagateTenantHeaders(). Works with any HTTP client — zero dependencies.
import { propagateTenantHeaders } from '@nestarc/tenancy';
// With fetch
const res = await fetch('http://orders-service/api/orders', {
headers: { 'Content-Type': 'application/json', ...propagateTenantHeaders() },
});
// With axios
const res = await axios.get('http://orders-service/api/orders', {
headers: propagateTenantHeaders(),
});
// With @nestjs/axios HttpService
this.httpService.get('http://orders-service/api/orders', {
headers: propagateTenantHeaders(),
});By default, the function uses X-Tenant-Id as the header name. Pass a custom name if needed:
propagateTenantHeaders('X-Custom-Tenant'); // { 'X-Custom-Tenant': 'tenant-abc' }Returns an empty object {} when no tenant context is available (e.g., outside a request or inside withoutTenant()).
How it works:
propagateTenantHeaders()reads from the same staticAsyncLocalStorageused byTenancyContext. No dependency injection required — it works anywhere in the call stack.
For more control, use HttpTenantPropagator directly:
import { HttpTenantPropagator, TenancyContext } from '@nestarc/tenancy';
const propagator = new HttpTenantPropagator(new TenancyContext(), {
headerName: 'X-Tenant-Id',
});
const headers = propagator.getHeaders(); // { 'X-Tenant-Id': 'tenant-abc' }Message Queue & RPC Propagation
Transport-specific propagators for Bull, Kafka, and gRPC. All use structural typing with zero runtime dependencies on transport packages.
Bull (BullMQ)
import { BullTenantPropagator, TenancyContext } from '@nestarc/tenancy';
const propagator = new BullTenantPropagator(new TenancyContext());
// Producer: inject tenant into job data
await queue.add('process-order', propagator.inject({ orderId: '123' }));
// → { orderId: '123', __tenantId: 'tenant-abc' }
// Consumer: extract tenant from job data
const tenantId = propagator.extract(job.data); // 'tenant-abc'Kafka
import { KafkaTenantPropagator, TenancyContext } from '@nestarc/tenancy';
const propagator = new KafkaTenantPropagator(new TenancyContext());
// Producer: inject tenant into message headers
await producer.send({
topic: 'orders',
messages: [propagator.inject({ value: JSON.stringify(payload) })],
});
// Consumer: extract tenant from message
const tenantId = propagator.extract(message); // handles string & Buffer headersgRPC
import { GrpcTenantPropagator, TenancyContext } from '@nestarc/tenancy';
const propagator = new GrpcTenantPropagator(new TenancyContext());
// Client: inject tenant into metadata
const metadata = new Metadata();
propagator.inject(metadata); // sets 'x-tenant-id' key
// Server: extract tenant from metadata
const tenantId = propagator.extract(call.metadata);Inbound Context Restoration (Interceptor)
TenantContextInterceptor automatically restores tenant context from incoming microservice messages. It wraps handler execution in TenancyContext.run().
import { TenantContextInterceptor, TenancyContext } from '@nestarc/tenancy';
// Recommended: specify transport explicitly to avoid duck-typing ambiguity
app.useGlobalInterceptors(
new TenantContextInterceptor(new TenancyContext(), { transport: 'kafka' }),
);Supported transports: 'kafka' | 'bull' | 'grpc'.
HTTP is skipped —
TenantMiddleware+TenancyGuardalready handle HTTP tenant extraction. The interceptor is designed for RPC transports only.
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| transport | 'kafka' \| 'bull' \| 'grpc' | auto-detect | Explicit transport selection (recommended) |
| kafkaHeaderName | string | 'X-Tenant-Id' | Kafka message header name |
| bullDataKey | string | '__tenantId' | Bull job data key |
| grpcMetadataKey | string | 'x-tenant-id' | gRPC metadata key |
Tenant-Aware Caching
PostgreSQL RLS protects database rows, but it does not protect Redis, in-memory response caches, or other application cache stores. If two tenants hit the same route and the cache key is only the URL, an unscoped response cache can leak one tenant's data to another tenant.
Install Nest's optional cache runtime when you want response caching:
npm install @nestjs/cache-manager cache-managerRegister Nest caching alongside the tenancy module. Keep core tenancy imports from @nestarc/tenancy:
import { Module } from '@nestjs/common';
import { CacheModule } from '@nestjs/cache-manager';
import { TenancyModule } from '@nestarc/tenancy';
@Module({
imports: [
CacheModule.register(),
TenancyModule.forRoot({
tenantExtractor: 'X-Tenant-Id',
}),
],
})
export class AppModule {}Use TenantCacheInterceptor from the cache subpath on routes that should cache per tenant:
import { CacheTTL } from '@nestjs/cache-manager';
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { TenantCacheInterceptor } from '@nestarc/tenancy/cache';
@Controller('products')
export class ProductsController {
@UseInterceptors(TenantCacheInterceptor)
@CacheTTL(60)
@Get()
findAll() {
return this.productsService.findAll();
}
}By default, the interceptor turns Nest's base cache key into tenant:{tenantIdLength}:{tenantId}:{baseCacheKey}. The length prefix keeps tenant IDs containing : or another configured separator from colliding with opaque Nest cache keys. The base cache key is the same key Nest's CacheInterceptor would have used, including any @CacheKey() override.
For routes where the response is intentionally public or shared across tenants, opt in with @SharedTenantCache() from @nestarc/tenancy/cache:
import { CacheTTL } from '@nestjs/cache-manager';
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { BypassTenancy } from '@nestarc/tenancy';
import { SharedTenantCache, TenantCacheInterceptor } from '@nestarc/tenancy/cache';
@Controller('catalog')
export class CatalogController {
@BypassTenancy()
@SharedTenantCache()
@UseInterceptors(TenantCacheInterceptor)
@CacheTTL(300)
@Get()
publicCatalog() {
return this.catalogService.publicCatalog();
}
}@SharedTenantCache() affects cache keys only: shared routes use shared:{baseCacheKey} instead of a tenant-prefixed key. It does not bypass TenancyGuard, clear tenant context, or authorize access. If a public route should skip the tenant-required guard, it still needs @BypassTenancy().
To apply tenant-aware caching globally, register the interceptor as an APP_INTERCEPTOR. Optional cache interceptor settings are provided through the cache subpath token:
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { CacheModule } from '@nestjs/cache-manager';
import { TenancyModule } from '@nestarc/tenancy';
import {
TENANT_CACHE_INTERCEPTOR_OPTIONS,
TenantCacheInterceptor,
} from '@nestarc/tenancy/cache';
@Module({
imports: [
CacheModule.register(),
TenancyModule.forRoot({
tenantExtractor: 'X-Tenant-Id',
}),
],
providers: [
{ provide: APP_INTERCEPTOR, useClass: TenantCacheInterceptor },
{
provide: TENANT_CACHE_INTERCEPTOR_OPTIONS,
useValue: { hashTenantId: true },
},
],
})
export class AppModule {}Cache invalidation remains application- and store-specific. Invalidate every tenant-scoped key shape your application writes, including any shared cache keys you opt into.
Error Hierarchy
All tenancy context errors follow a class hierarchy for flexible catch handling:
Error
└── TenantContextMissingError ← getCurrentTenantOrThrow()
└── TenancyContextRequiredError ← Prisma fail-closed (has model, operation)import { TenantContextMissingError, TenancyContextRequiredError } from '@nestarc/tenancy';
try {
// any operation that requires tenant context
} catch (e) {
if (e instanceof TenantContextMissingError) {
// Catches both service-level and Prisma-level errors
}
if (e instanceof TenancyContextRequiredError) {
// Catches only Prisma fail-closed errors (e.model, e.operation available)
}
}Security
- SQL Injection: The Prisma extension uses
set_config()with bind parameters via$executeRawtagged template. This eliminates SQL injection risk at the database layer. Additionally, tenant IDs are validated by the middleware (UUID format by default). - Transaction-scoped:
set_config(key, value, TRUE)is equivalent toSET LOCAL— scoped to the batch transaction. No cross-request leakage via connection pool. - Custom validators: If your tenant IDs are not UUIDs, provide a
validateTenantIdfunction that rejects any unsafe input.
RLS Operational Notes
- Patch PostgreSQL: Use a currently supported PostgreSQL minor release. CVE-2024-10976 affects row-security policies in older 17.x, 16.x, 15.x, 14.x, 13.x, and 12.x patch releases.
- Index the tenant column: RLS policies behave like implicit filters. Add an index on
tenant_id(or your configured tenant column) for every tenant-scoped table. The CLI now generates this index andtenancy checkwarns when it is missing. - Keep policies simple: The generated policy is a direct equality check. If you replace it with subqueries or non-leakproof functions, validate query plans under realistic data volume.
- RLS is not resource isolation: It does not prevent noisy-neighbor CPU/IO issues, cache key leaks, or cross-tenant data in Redis/search queues. Include tenant IDs in non-database cache keys and job payloads.
- PgBouncer/Prisma: Prisma requires PgBouncer transaction mode, and prepared-statement settings depend on your PgBouncer version. Test RLS behavior with the same pooler mode used in production.
Security Considerations
Tenant ID is client-supplied by default. The built-in extractors (Header, Subdomain, Path) read tenant identifiers directly from the request without verifying the caller's authorization to access that tenant.
For production use, you must add a trust boundary — verify that the authenticated user belongs to the claimed tenant. Options:
- Use
JwtClaimTenantExtractorwith a pre-validated JWT (tenant ID embedded by your auth server) - Add validation in
onTenantResolvedhook — check the user's tenant membership - Use authentication middleware before the tenancy middleware to establish trust
Without a trust boundary, any client can access any tenant's data by changing the header value.
How It Works
HTTP Request (X-Tenant-Id: 550e8400-e29b-41d4-a716-446655440000)
→ TenantMiddleware (extracts & validates tenant ID)
→ AsyncLocalStorage (stores tenant context)
→ TenancyGuard (rejects if missing, unless @BypassTenancy)
→ Your Controller / Service
→ Prisma Extension ($transaction → set_config() → query)
→ PostgreSQL RLS (automatic row filtering)CLI
Scaffold RLS policies and module configuration from your Prisma schema:
npx @nestarc/tenancy initThis generates:
tenancy-setup.sql— PostgreSQL RLS policies, tenant indexes, roles, and grantstenancy.module-setup.ts— NestJS module registration code
Preview without writing files:
npx @nestarc/tenancy init --dry-runCheck if your SQL is in sync with the Prisma schema:
npx @nestarc/tenancy check
# With custom setting key:
npx @nestarc/tenancy check --db-setting-key=custom.tenant_keyValidates table coverage, tenant indexes, FORCE ROW LEVEL SECURITY, isolation/insert policies, and setting key consistency across all policies. Exits with code 0 (in sync) or 1 (drift detected).
License
MIT
