@nestarc/tenancy
v0.16.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 transaction-local
set_config()for tenant-scoped Prisma model operations - 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 - Live DB doctor —
npx @nestarc/tenancy doctoraudits the runtime role, RLS catalogs, policy drift, and optional fail-closed behavior - PgBouncer matrix — transaction-mode isolation, forced backend reuse/replacement, and Prisma 6/7 are covered by a real-database CI matrix
- 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 HTTP UUID-like validation by default and explicit RPC validation during 0.x - NestJS 10 & 11 compatible, Prisma 7 first-class with a Prisma 6 compatibility lane
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.14, Prisma Client 7.9.1, 1005 total rows, 500 measured iterations:
| Scenario | Rows | Avg | P50 | P95 | P99 |
|----------|------|-----|-----|-----|-----|
| Admin direct findMany (all rows, no RLS) | 1005 | 1.779ms | 1.585ms | 3.199ms | 5.261ms |
| Admin tenant-filtered findMany (WHERE tenant_id, no RLS) | 402 | 1.081ms | 0.972ms | 1.643ms | 3.616ms |
| app_user manual RLS transaction (set_config + findMany) | 402 | 2.375ms | 2.253ms | 3.057ms | 5.337ms |
| app_user tenancy extension findMany | 402 | 2.372ms | 2.276ms | 2.891ms | 5.987ms |
| app_user tenancy extension findFirst | 1 | 1.605ms | 1.561ms | 2.209ms | 2.695ms |
Measured extension overhead: -0.003ms avg (-0.1%), -0.166ms p95 compared with the manual RLS transaction. Treat sub-millisecond differences as run-to-run noise; the important result is that the extension remains on par with the equivalent manual transaction.
Reproduce:
docker compose up -d --wait && npm run bench
Prerequisites
- Node.js 22.13 or newer within Node 22, or Node.js 24
- NestJS 10 or 11
- Prisma 7 (recommended) or Prisma 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.
Support and Compatibility
@nestarc/tenancy is pre-1.0. Security fixes are provided for the latest
published minor release line only; 0.16.x is the current supported line. See
the security policy for reporting and response targets.
Package compatibility ranges and repository verification are related, but they are not the same claim:
| Area | v0.16.0 contract | Current repository evidence |
|------|--------------------|----------------------------------------|
| Node.js | ^22.13.0 \|\| ^24.0.0 | Lint, unit/coverage, and build run on exact 22.13.0, the current Node 22 release, and the current Node 24 release. Database and infrastructure jobs run on current Node 22; publishing runs on current Node 24. |
| NestJS | Peer range ^10.0.0 \|\| ^11.0.0 | A strict, isolated packed-tarball consumer matrix covers exact NestJS 10.4.22 and 11.2.1 across both supported Prisma majors on current Node 22. The locked primary graph uses NestJS 11.2.1; separate fully published ecosystem lanes preserve exact NestJS 10.4.20 for the legacy graph and use exact 11.2.1 for the modern graph. |
| Prisma | Peer range ^6.0.0 \|\| ^7.0.0 | The packed consumer matrix covers exact Prisma 6.19.3 and 7.10.0 with each supported NestJS major. The locked primary and direct PostgreSQL lanes use 7.10.0, the PgBouncer matrix uses both exact versions, and separate fully published ecosystem lanes cover the exact legacy 6.19.3 and modern 7.10.0 graphs. |
The four-way consumer matrix installs the actual packed tarball with
--strict-peer-deps and without --force, --legacy-peer-deps, or another
peer bypass, then runs declaration typechecking and a minimal Nest/Prisma
runtime smoke. Its Nest 10 + Prisma 6 lane
also verifies the optional cache/event lower-bound representatives
@nestjs/[email protected], [email protected], and
@nestjs/[email protected] with [email protected]; its Nest 11 +
Prisma 7 lane verifies the repository-locked supported representatives 3.1.3,
7.2.8, and 3.1.0 respectively with [email protected]. The latter fixture
also pins [email protected] and [email protected] so the cache module's peer and
declaration dependencies are reproducible. Optional peer ranges are not an
arbitrary cross-product: cache module 2 pairs with cache-manager 5, cache module
3 pairs with cache-manager 6/7, event-emitter 2 is verified on NestJS 10, and
event-emitter 3 supports NestJS 10/11. These exact versions describe the
top-level lane targets, not new patch-level minimums inside the declared peer
ranges. Each run intentionally resolves a fresh transitive graph so CI detects
upstream install drift; it is not a byte-for-byte frozen dependency snapshot.
Prisma data-path behavior remains the responsibility of the direct
PostgreSQL and Prisma 6/7 PgBouncer lanes rather than being duplicated in every
install-only consumer lane. The supported 0.16.x line declares Node.js
^22.13.0 || ^24.0.0. Older 0.15.x artifacts retain their published
Node.js >=20.19.0 metadata, but Node.js 20 is
upstream EOL and is not
supported by 0.16.x. Node 20 consumers must upgrade their runtime or remain on
the unsupported 0.15.x line. Node 26 support is not yet declared and requires
separate validation.
The Nestarc ecosystem gates are artifact-explicit and independent:
npm run test:e2e:ecosystem:published-onlypreserves the committed, fully published NestJS 10.4.20 / Prisma 6.19.3 legacy graph.npm run test:e2e:ecosystem:modern:published-onlyinstalls the separate, fully published NestJS 11.2.1 / Prisma 7.10.0 modern graph. It verifies the complete committed lock and installed inventory against public npm registry resolutions, exact versions, SHA-512 integrity, and non-link/non-symlink isolation before running the API key → tenancy → RBAC → RLS/outbox → jobs → webhook real-database flow.
Hosted CI runs these as separate ecosystem-e2e and ecosystem-modern-e2e
jobs. Release validation reuses the complete CI workflow, so both published
graphs must pass before the publish job can run. The modern lane accepts no
candidate tarball or sibling source override. An unpublished tenancy tarball is
tested only through the legacy graph with
npm run test:e2e:ecosystem:local-artifact -- --tenancy-tarball <absolute.tgz>;
only tenancy is replaced, while the five sibling packages remain
registry-locked. Neither runner discovers adjacent repositories automatically.
See the legacy fixture contract and
modern fixture contract for the
exact package tuples and commands.
The automatic tenant-isolation guarantee does not currently cover:
- Raw Prisma operations (
$queryRaw/$executeRaw), which bypass the model extension. - WebSocket inbound tenant enforcement or context restoration; the supported non-HTTP transports are Kafka, Bull, and gRPC.
- Prisma Data Proxy, managed poolers, or custom PgBouncer configurations. These remain outside the repository support guarantee; deployment owners must validate their exact configuration with equivalent matrix scenarios.
See Fail-Closed Mode, Inbound Context Restoration, and the PgBouncer Support Contract for the corresponding operational requirements.
Installation
npm install @nestarc/tenancy
npm install @prisma/client @prisma/adapter-pg pg dotenv
npm install --save-dev prismaQuick 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);
-- Keep a reset transaction-local setting from matching an empty TEXT tenant.
-- AS RESTRICTIVE combines this guard with every permissive tenant policy.
CREATE POLICY tenant_context_guard_users ON users
AS RESTRICTIVE
USING (NULLIF(current_setting('app.current_tenant', true), '') IS NOT NULL)
WITH CHECK (NULLIF(current_setting('app.current_tenant', true), '') IS NOT NULL);
-- The `true` parameter means missing_ok: returns NULL instead of error when unset.
-- The restrictive guard also treats PostgreSQL's reset empty string as no context.
-- At the database layer, queries without tenant context return 0 rows (not an error).
-- Repeat for each tenant-scoped tableThis example uses a PostgreSQL TEXT tenant column. For a native UUID
column, keep the column side uncast so PostgreSQL can use the tenant index and
use the generated UUID predicate:
USING (
tenant_id = NULLIF(current_setting('app.current_tenant', true), '')::uuid
);NULLIF(..., '') preserves fail-closed behavior after a transaction-local
custom setting is cleaned up and PostgreSQL exposes its reset value as an empty
string. For UUID it prevents an invalid reset-value cast; for TEXT the generated
restrictive policy prevents that reset value from matching or inserting an
empty tenant ID. A missing or reset setting therefore matches no row.
Critical: 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
Configure Prisma 7 to generate the client into your source tree and keep the connection URL in Prisma Config:
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}// prisma.config.ts
import 'dotenv/config';
import { defineConfig, env } from 'prisma/config';
export default defineConfig({
schema: 'prisma/schema.prisma',
datasource: {
url: env('DATABASE_URL'),
},
});Run npx prisma generate, then extend the generated client:
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient, type Prisma } from './generated/prisma/client';
import {
TenancyService,
createPrismaTenancyExtension,
tenancyTransaction,
type TenancyTransactionOptions,
} from '@nestarc/tenancy';
@Injectable()
export class PrismaService implements OnModuleInit {
private readonly baseClient;
public readonly client;
constructor(private readonly tenancyService: TenancyService) {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
});
this.baseClient = new PrismaClient({ adapter });
this.client = this.baseClient.$extends(
createPrismaTenancyExtension(tenancyService),
);
}
withTenantTransaction<T>(
callback: (tx: Prisma.TransactionClient) => Promise<T>,
options?: TenancyTransactionOptions,
): Promise<T> {
return tenancyTransaction(
this.baseClient,
this.tenancyService,
callback,
options,
);
}
async onModuleInit() {
await this.client.$connect();
}
}The example uses the Prisma 7 prisma-client generator and its required PostgreSQL driver adapter. Prisma 6 consumers can keep their existing client construction and apply the same extension to their base client.
Extension Options
createPrismaTenancyExtension(tenancyService, {
autoInjectTenantId: true, // Auto-inject tenant_id on create/upsert
tenantIdField: 'tenant_id', // Logical Prisma field name (default)
sharedModels: ['Country', 'Currency'], // Models that skip RLS entirely
})| Option | Type | Default | Description |
|--------|------|---------|-------------|
| dbSettingKey | string | Inherited from TenancyService | Optional compatibility assertion; normally omit it in module-based applications. A mismatch fails before extension creation. |
| autoInjectTenantId | boolean | false | Auto-inject tenant ID into create, createMany, createManyAndReturn, upsert |
| tenantIdField | string | 'tenant_id' | Logical Prisma field 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 | Deprecated. Compatibility-only transparent mode based on Prisma internals. Supported through v0.16.x; scheduled for removal in v0.17.0. Use tenancyTransaction() for interactive transactions |
Important: Configure a custom
dbSettingKeyonce inTenancyModule.forRoot()orforRootAsync().createPrismaTenancyExtension()andtenancyTransaction()inherit the canonical value fromTenancyService. An explicitly repeated identical value remains accepted for compatibility, while a different value fails before extension creation or transaction start. PostgreSQL RLScurrent_setting()calls must still use the same key.
Migration note: Existing repeated identical values can be removed gradually. If a custom key currently exists only on the extension or helper, add it to
TenancyModulebefore removing those options. Standalone consumers that constructTenancyServicedirectly may continue to pass an explicit custom key. Changing the key itself also requires updating the RLS policies and passing the same--db-setting-keytocheckanddoctor.
Note: By default, the Prisma extension uses batch transactions internally, which do not propagate
set_configinto interactive transactions ($transaction(async (tx) => ...)). Use thetenancyTransaction()helper. The deprecatedinteractiveTransactionSupport: truemode remains only as a compatibility path through v0.16.x. 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. The supported Prisma 6 and 7 majors are covered by the real-database PgBouncer matrix.
const tenantId = tenancyService.getCurrentTenantOrThrow();
await prismaService.withTenantTransaction(async (tx) => {
const user = await tx.user.findFirstOrThrow();
await tx.order.create({ data: { userId: user.id, tenant_id: tenantId } });
}, {
maxWait: 2_000, // Wait to start the transaction (ms)
timeout: 5_000, // Maximum transaction duration (ms)
isolationLevel: 'Serializable', // Optional PostgreSQL isolation level
});withTenantTransaction() above is a narrow application wrapper around the
exported tenancyTransaction() helper. It keeps the raw client private so
ordinary application code cannot accidentally bypass the extension.
The helper forwards Prisma's public interactive transaction options (maxWait, timeout, and isolationLevel). It resolves the canonical database setting key and tenant before starting the transaction, applies transaction-local set_config() before invoking your callback, and propagates transaction-start, context-setup, callback, timeout, and database errors unchanged. A mismatched explicit key fails before $transaction() is called.
maxWait enforcement belongs to the Prisma runtime. The verified Prisma 7.10.0 PrismaPg adapter and Prisma 6.19.3 native engine reject when their client connection pool cannot start in time. Prisma 6.19.3 PrismaPg accepts the option but does not enforce it under adapter-pool contention; the matrix keeps this as a negative contract. If bounded transaction admission is required on Prisma 6, use the native engine or enforce admission before calling the helper.
The wrapper passes its raw, unextended Prisma client to the helper; direct helper
users must do the same. Use only the callback's tx client inside the
transaction. Because that transaction client does not run the extension's model
hooks, autoInjectTenantId, sharedModels, and failClosed do not apply there.
Writes must provide the configured logical tenant field explicitly (as above) or
use a reviewed database default; RLS still uses the transaction-local tenant
setting. The helper itself remains fail-closed: it calls
getCurrentTenantOrThrow() and rejects before opening $transaction() when
tenant context is missing.
Compatibility note:
interactiveTransactionSupport: trueis deprecated because it relies on Prisma internal APIs. It remains supported through v0.16.x and is scheduled for removal in v0.17.0. Existing users should keep an exact-version E2E lane while migrating totenancyTransaction(). See the deprecated API removal ADR for a before/after migration and the transparent-mode differences.
Option 2: Deprecated transparent compatibility mode
This mode remains available for existing consumers, but is not recommended for new code. Startup checks the required _createItxClient hook, but cannot guarantee the full internal transaction metadata shape and can silently miss an interactive transaction after a Prisma internal change.
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/usersPrisma model operations executed through the extension are automatically scoped to that tenant via RLS. Raw operations are outside this automatic contract.
API
TenancyModule
// Synchronous
TenancyModule.forRoot({
tenantExtractor: 'X-Tenant-Id', // header name (string)
dbSettingKey: 'app.current_tenant', // PostgreSQL setting (default)
// Optional sync/async override; omitted HTTP validation uses a UUID-like format
validateTenantId: (id) => /^org_[a-z0-9-]+$/.test(id),
})
// 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)
const settingKey = this.tenancy.getDbSettingKey(); // canonical PostgreSQL setting
}
}@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 |
| WebSocket context | — | TenancyGuard skips it; no built-in restoration or enforcement |
| Kafka, Bull, or gRPC context | Policy-dependent | Configure TenantContextInterceptor; the HTTP guard does not handle RPC |
| Explicit Kafka, Bull, or gRPC validator returns/resolves false | Throws | BadRequestException: Invalid tenant ID format; handler is not invoked |
| Explicit Kafka, Bull, or gRPC validator throws/rejects | Propagates | Original validator error; handler is not invoked |
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 = basePrisma.$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 = basePrisma.$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. UsetenancyTransaction()and execute the raw operation through its transaction client, or use an equivalent explicit transaction that performs parameterizedset_config()and the raw operation on the same transaction connection.
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, tenant.context_missing, tenant.context_invalid.
If @nestjs/event-emitter is not installed, events are silently skipped — no errors.
Built-in request-bearing event producers emit only requestSummary (method,
path, ip, userAgent, and host) so listeners do not accidentally retain credentials,
cookies, bodies, or framework-specific request references. v0.16.0 removes the
deprecated optional request field from TenantResolvedEvent,
TenantNotFoundEvent, TenantExtractionFailedEvent,
TenantValidationFailedEvent, and TenantCrossCheckFailedEvent. Built-in
middleware and guard producers have not included the raw request object since
v0.11.0. Migrate listeners to the optional event.requestSummary; JavaScript or
custom emitters must also stop attaching event.request. Summary fields are observability metadata,
not authorization inputs, and applications should still apply their own
redaction and retention policy to values such as path, host, IP address, and
user agent. See
the deprecated API removal ADR for the migration and privacy rationale.
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.
HTTP-only contract:
crossCheckandonTenantResolvedare executed byTenantMiddleware.TenantContextInterceptordoes not apply them to RPC messages, and they do not replace RPC producer authentication or authorization.
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.
The exact schedule and migration contract are recorded in the deprecated API removal ADR.
| API | Added | Deprecated | Last supported | Removal target | Replacement |
|-----|-------|------------|----------------|----------------|-------------|
| interactiveTransactionSupport | v0.6.0 | v0.15.0 | v0.16.x | v0.17.0 | tenancyTransaction() (public Prisma APIs) |
The event payload optional request field was deprecated in v0.11.0 and
removed in v0.16.0 after v0.15.x as its last supported line. Use
requestSummary, available since v0.11.0.
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 {
TenantContextDiagnostics,
TenantContextInterceptor,
TenancyContext,
type TenantIdValidator,
} from '@nestarc/tenancy';
const validateTenantId: TenantIdValidator = (tenantId) =>
/^org_[a-z0-9-]+$/.test(tenantId);
const diagnostics = app.get(TenantContextDiagnostics);
// Specify the transport explicitly to avoid duck-typing ambiguity.
app.useGlobalInterceptors(
new TenantContextInterceptor(new TenancyContext(), {
transport: 'kafka',
validateTenantId,
diagnostics,
resource: 'orders',
}),
);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 |
| validateTenantId | TenantIdValidator | unset during 0.x | Optional sync/async validator run before tenant context and handler execution |
| diagnostics | TenantContextDiagnostics | none | Module-backed missing/invalid-context event and telemetry reporting |
| resource | string | none | Stable, low-cardinality, non-sensitive topic, queue, service, or handler name |
0.x compatibility: HTTP extraction uses built-in UUID-like validation when
TenancyModuleOptions.validateTenantIdis omitted. RPC restoration historically accepted any non-empty string, so the interceptor preserves that default throughout 0.x. PassvalidateTenantIdexplicitly now. The planned v1.0.0 default is the same UUID-like validation as HTTP; custom identifiers will continue to require a custom validator. See the compatibility ADR.
When an explicit RPC validator returns false, the handler is not invoked and the interceptor rejects with BadRequestException('Invalid tenant ID format'). A supplied module-resolved TenantContextDiagnostics reports the exported InvalidTenantContextDiagnostic payload (transport, operation: 'consume', and optional stable resource) to the optional event and telemetry integrations. When configured, this emits tenant.context_invalid, adds an active-span event of the same name, and increments nestarc.tenancy.invalid_context with tenant.transport, tenant.operation, and optional tenant.resource attributes. The interceptor never copies the rejected ID or raw carrier contents into that payload. Keep the caller-supplied resource non-sensitive; do not place tenant/user IDs or secrets in it. Invalid input is independent of missingContext.policy and always rejects.
RPC Trust Boundary
RPC carrier values are tenant claims, not authenticated identities. TenantContextInterceptor does not authenticate producers, verify message signatures, configure broker/channel security, or authorize a producer for the claimed tenant. The HTTP crossCheck and onTenantResolved contracts are not automatically applied to RPC messages.
Authenticate the producer or channel and authorize that principal for the claimed tenant before tenant-scoped handler work. validateTenantId provides format or allow-list validation only; successful validation and context restoration are not authorization.
Non-HTTP Missing-Context Diagnostics
The default policy is ignore, which preserves the existing pass-through behavior. Opt in at module level with warn for observation or throw to fail closed:
TenancyModule.forRoot({
tenantExtractor: 'X-Tenant-Id',
missingContext: { policy: 'warn' }, // 'ignore' | 'warn' | 'throw'
});Resolve the configured diagnostics object when constructing transport propagators manually. Use a stable, low-cardinality resource such as a queue, topic, service, cache, or index name:
import {
BullTenantPropagator,
TenantContextDiagnostics,
TenancyContext,
} from '@nestarc/tenancy';
const diagnostics = app.get(TenantContextDiagnostics);
const propagator = new BullTenantPropagator(new TenancyContext(), {
diagnostics,
resource: 'orders',
});warn and throw both emit tenant.context_missing, add a tenant.context_missing event to the active OpenTelemetry span, and increment nestarc.tenancy.missing_context. Telemetry attributes are tenant.transport, tenant.operation, and optional tenant.resource. The throw policy raises TenantContextMissingError after reporting. HTTP extraction is intentionally outside this policy because middleware and TenancyGuard already define its fail-closed contract. An RPC value rejected by an explicit validator uses the separate, always-rejecting tenant.context_invalid path described above; it is not treated as missing.
The same diagnostics object can be supplied to TenantContextInterceptor, TenantCacheInterceptor, TenantResourceKey, and TenantSearch. TenantResourceKey creates collision-safe Redis/search keys, while TenantSearch is a vendor-neutral adapter boundary that never invokes the adapter without tenant scope:
const keys = new TenantResourceKey(new TenancyContext(), {
transport: 'redis',
resource: 'response-cache',
diagnostics,
});
const search = new TenantSearch(new TenancyContext(), searchAdapter, {
index: 'products',
diagnostics,
});With ignore or warn, a missing resource key/search scope returns null and no Redis/search operation is performed. With throw, it fails before the adapter or resource is accessed.
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. HTTP tenant IDs are validated by the middleware (UUID-like format by default); RPC validation is explicit during 0.x. - Transaction-scoped:
set_config(key, value, TRUE)is equivalent toSET LOCALand is scoped to the database transaction. The supported PgBouncer transaction-mode matrix verifies A → B → no-context isolation on reused physical backends. - Custom validators: Reuse a
TenantIdValidatorin HTTP module options and RPC interceptor options when tenant IDs are not UUIDs or require an allow-list. Format validation does not authenticate or authorize the caller or producer.
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: Use the verified transaction-mode contract below and re-run it for any production-specific pooler configuration.
PgBouncer Support Contract
The verified pooler contract covers the repository's pinned, self-hosted PgBouncer transaction mode configuration for pooled application queries. The matrix currently verifies PostgreSQL 16.14, PgBouncer 1.25.2, Prisma 6.19.3, and Prisma 7.10.0.
- Configure
pool_mode = transactionandmax_prepared_statements = 200. With the tested PgBouncer 1.25.2 configuration, do not add the legacypgbouncer=trueURL parameter. - Use a direct PostgreSQL URL for Prisma CLI, migration, and test setup operations. Route application queries through the PgBouncer URL.
- Session mode is not a supported application contract. The matrix keeps a pool-size-one negative test that demonstrates backend pinning and a second client remaining queued until the first disconnects.
- Pool-size-one tests force the same physical backend through tenant A, tenant B, no-context, commit, callback rollback, database-error rollback, and high logical concurrency scenarios. The timeout lane separately verifies rollback and clean state while allowing PgBouncer or the client pool to replace the backend.
- A pool-size-two lane verifies real overlap on two backends, clean state on both, and clean replacement sessions after PgBouncer
RECONNECT. tenancyTransaction()is the canonical path. Its timeout, isolation, custom-key, context-setup failure, and rollback contracts are exercised against both supported Prisma majors.maxWaitis positive-tested on Prisma 7PrismaPgand Prisma 6 native, with the Prisma 6PrismaPglimitation fixed as a negative contract. The batch extension and deprecated transparent compatibility mode are tested separately.- The runner fails fast unless the Prisma CLI, client, and PostgreSQL adapter all use the same exact supported version in major 6 or 7.
- Prisma Data Proxy, managed PgBouncer services, and other custom pooler settings remain outside the repository support guarantee. Deployment owners must validate the exact production mode and prepared-statement settings with equivalent isolation, rollback, reuse, and concurrency scenarios.
Reproduce the pinned local matrix with Docker:
npm run test:e2e:pgbouncerCI and release workflows run this command against the pinned Prisma 6.19.3 and 7.10.0 lanes. See docker-compose.yml, scripts/test-pgbouncer-e2e.js, and the PgBouncer E2E specification.
Security Considerations
Tenant ID is caller-supplied by default. HTTP extractors read request data, and RPC restoration reads Kafka headers, Bull job data, or gRPC metadata. Neither source proves that the caller or producer may access the claimed tenant.
For production use, you must add a trust boundary — verify that the authenticated caller or producer belongs to the claimed tenant. HTTP options include:
- 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
For RPC, authenticate the broker/channel or message producer and perform principal-to-tenant authorization before tenant-scoped work. Tenant ID format validation, successful context restoration, broker delivery, and PostgreSQL RLS do not perform that authorization. Without this boundary, a caller or producer that can choose the carrier value can cause work to run under another tenant.
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:
Before running init, declare a required scalar field mapped to the physical
tenant_id column on every tenant-scoped model. The CLI derives the policy cast
from the Prisma field metadata, not from the interactive tenantFormat choice:
| Prisma tenant field | Generated policy type |
| --- | --- |
| String, String @db.Text | TEXT |
| String @db.VarChar(n), String @db.Char(n) | TEXT |
| String @db.Uuid | UUID |
Field-level mapping is supported, for example
tenantId String @map("tenant_id") @db.Uuid. When auto-injection is enabled,
all non-shared models must use the same logical Prisma field name; the generated
extension setup emits that name as tenantIdField. Shared models are excluded
from tenant-field validation.
The CLI does not guess from value shape: String @db.VarChar(36) remains a
text policy. Missing, duplicate, nullable, list, ignored (@ignore), non-String, Unsupported,
or unsupported native tenant fields such as Citext, Xml, Inet, Bit, and
VarBit stop scaffolding before either output file is written. tenantFormat
controls inbound ID validation only; it does not select the database storage
type or policy cast.
npx @nestarc/tenancy initThis generates:
tenancy-setup.sql— PostgreSQL RLS policies, tenant indexes, roles, and grantstenancy.module-setup.ts— NestJS module registration code
Run tenancy-setup.sql as a standalone migration with a client that stops on the
first SQL error. For psql, use:
psql -X -v ON_ERROR_STOP=1 -f tenancy-setup.sql "$DATABASE_URL"-X ignores settings in .psqlrc. Do not run this script with
ON_ERROR_ROLLBACK=on: its per-statement savepoints can let execution continue
to the final COMMIT after an error. Other clients must also stop on the first
error and then issue ROLLBACK or close the connection. Under that execution
contract, the generated BEGIN / COMMIT makes its roles, grants, indexes, RLS
flags, and policies all-or-nothing. Do not nest the script inside a caller-owned
transaction. Its ALTER TABLE statements hold table locks until commit, so
schedule large production schemas in an appropriate maintenance window.
Models without @@schema are emitted as explicitly qualified
"public"."Table" targets, so search_path cannot redirect the generated DDL
to a shadow table. Inside a generated section, tenancy check requires the
intact boundary markers, transaction envelope, guarded policies, and model-bound
table/schema targets. It also flags unqualified targets; declare @@schema for
every non-public model. Markerless legacy SQL remains structurally accepted,
but it must still satisfy the current tenant-policy semantics, including the
restrictive non-empty context guard described below.
The same generated script is safe to reapply sequentially; concurrent applies
are not part of this guarantee. Existing policies with the generated table/name
pair are preserved instead of being dropped or rewritten, including policies
that have drifted from the generated expression. Use tenancy doctor against
the application role to detect applied policy drift. When replacement is
intentional, review the live policy and place an explicit DROP POLICY directly
after BEGIN in a temporary reviewed execution copy of the generated
transaction. Do not keep that state-reversing statement in the canonical
tenancy-setup.sql; tenancy check intentionally rejects it. Running the
temporary copy with the fail-fast contract makes the drop and recreation atomic;
a separate autocommitted drop can leave a policy gap if the later setup fails.
Existing TEXT schemas keep the same canonical
current_setting(..., true)::text isolation and insert predicates. Generated
SQL now adds a separate AS RESTRICTIVE context-guard policy so PostgreSQL's
reset empty string cannot match or insert a tenant_id='' row. The guard has a
new deterministic name and is added automatically on a sequential reapply;
tenancy check reports a missing or invalid guard in both canonical generated
and markerless legacy SQL, and tenancy doctor reports a missing or drifted live
guard. A markerless file may keep its legacy policy names, but its guard must be
AS RESTRICTIVE and use
NULLIF(current_setting(..., true), '') IS NOT NULL in both USING and
WITH CHECK. If a generated policy name already exists, the drift-preservation
rule still applies, so review and replace it through the transaction procedure
above. For an existing deployment, preview or regenerate with init --dry-run,
run tenancy check, apply the reviewed SQL, and finish with live tenancy doctor
verification.
To adopt a native UUID column, add @db.Uuid, preview the regenerated SQL with
init --dry-run, and perform any TEXT-to-UUID column/data conversion as a
separate reviewed Prisma/PostgreSQL migration; init never changes column types
or data. Isolation/insert policy names do not include the column type, and
sequential reapply preserves an existing same-name policy. Replace an old
text/manual policy only through the reviewed transaction procedure above, then
run tenancy check on the canonical file and `tenancy doctor --activ
