@juano-morello/nest-tenant
v0.1.0
Published
NestJS multi-tenant data isolation via PostgreSQL Row-Level Security
Readme
@juanomorello/nest-tenant
NestJS multi-tenant data isolation via PostgreSQL Row-Level Security (RLS) and Schema-per-Tenant strategies.
Features
- Row-Level Security isolation — tenant data is enforced at the PostgreSQL level via
SET LOCAL set_config() - Multiple resolver types — extract tenant ID from HTTP headers, JWT claims, subdomains, or a custom function/class
- AsyncLocalStorage context — tenant context propagated automatically across the entire request lifecycle
- RLS bypass mechanism —
@BypassRLS()decorator for admin/cross-tenant operations with audit events - Tenant events —
tenant.resolved,tenant.bypass,tenant.error,tenant.session.setvia@nestjs/event-emitter - Configurable guard — global
TenantGuardwith@PublicRoute()exclusions, path patterns, andRegExpsupport - SAVEPOINT transactions — nested
withTransaction()calls use real PostgreSQL SAVEPOINTs - Connection tracking — graceful shutdown waits for active connections to drain
- Tenant ID validation — configurable max length and custom validator function
- Multi-strategy isolation — choose RLS (default), Schema-per-Tenant, or implement a custom
TenantIsolationStrategy - ORM adapters — first-class TypeORM, Prisma, and Drizzle integration via sub-path imports
- PolicyBuilder DSL — fluent API for generating PostgreSQL RLS policy SQL
- CLI scaffolding —
nest-tenant init,migration:create, andpolicy:generatecommands
Installation
npm install @juanomorello/nest-tenantPeer Dependencies
Ensure the following are installed in your project:
npm install @nestjs/common @nestjs/core pg reflect-metadata rxjsIf using the JWT resolver, also install:
npm install jsonwebtokenOptional Peer Dependencies
If using an ORM adapter, install the corresponding package:
| Adapter | Install |
|---------|---------|
| TypeORM | npm install typeorm @nestjs/typeorm |
| Prisma | npm install @prisma/client |
| Drizzle | npm install drizzle-orm |
Quick Start
import { Module } from '@nestjs/common';
import { Pool } from 'pg';
import { TenantModule, TenantContext } from '@juanomorello/nest-tenant';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
@Module({
imports: [
TenantModule.forRoot({
resolver: { type: 'header', headerName: 'x-tenant-id' },
database: { pool },
}),
],
})
export class AppModule {}
// In any service:
@Injectable()
class OrderService {
constructor(private readonly tenant: TenantContext) {}
async getOrders() {
return this.tenant.withConnection(async (conn) => {
const result = await conn.query('SELECT * FROM orders');
return result.rows; // automatically filtered by RLS
});
}
}Every request with an x-tenant-id header will have RLS enforced — queries only return rows belonging to that tenant.
Configuration Reference
TenantModuleOptions
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| resolver | ResolverConfig | (required) | How to extract the tenant ID from each request. See Resolver Types. |
| database.pool | Pool | (required) | A pg.Pool instance. The library does not create or own this pool. |
| database.sessionVariable | string | 'app.current_tenant' | PostgreSQL session variable set via set_config(). Must be in namespace.name format. |
| database.maxTenantIdLength | number | 256 | Maximum allowed length for tenant IDs. Must be > 0. |
| database.tenantIdValidator | (id: string) => boolean | undefined | Custom validation function. Return true to accept, false to reject. |
| database.shutdownTimeoutMs | number | 5000 | Max time (ms) to wait for active connections to drain during onModuleDestroy. |
| guard.enabled | boolean | true | Whether to register TenantGuard as a global guard. |
| guard.excludePaths | Array<string \| RegExp> | [] | URL paths that bypass the guard. Supports exact strings, trailing * wildcards, and RegExp. |
| guard.onMissingTenant | 'throw' \| 'skip' | 'throw' | Behavior when no tenant is resolved on a non-public route. |
| guard.httpStatus | number | 403 | HTTP status code for TenantNotFoundError. Must be 400–599. |
| bypass.auditEnabled | boolean | true | Emit tenant.bypass event when @BypassRLS() is used. |
| context.propagateToLogs | boolean | false | (experimental) Add tenant_id to structured log context. Not yet implemented. |
| strategy | 'rls' \| 'schema' \| TenantIsolationStrategy | 'rls' | Tenant data isolation strategy. See Isolation Strategies. |
| schema.nameTemplate | string | 'tenant_{{tenantId}}' | Template for schema names. Use {{tenantId}} as placeholder. Only used when strategy is 'schema'. |
| schema.autoCreate | boolean | false | Automatically create the schema if it doesn't exist. Only used when strategy is 'schema'. |
| schema.sharedSchema | string | 'public' | Shared schema appended to the search path. Only used when strategy is 'schema'. |
Async Configuration
Use TenantModule.forRootAsync() when options depend on other providers:
TenantModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
resolver: { type: 'header' },
database: {
pool: new Pool({ connectionString: config.get('DATABASE_URL') }),
},
}),
});Resolver Types
Header Resolver
Reads the tenant ID from an HTTP request header (default: x-tenant-id).
TenantModule.forRoot({
resolver: { type: 'header', headerName: 'x-tenant-id' },
database: { pool },
});JWT Resolver
Extracts the tenant ID from a JWT claim. Optionally verifies the token signature.
TenantModule.forRoot({
resolver: {
type: 'jwt',
claimKey: 'tenantId', // dot-path to the claim (default: 'tenantId')
jwtSecret: 'my-secret', // omit to skip verification
},
database: { pool },
});Subdomain Resolver
Extracts the tenant ID from the request hostname subdomain.
TenantModule.forRoot({
resolver: {
type: 'subdomain',
position: 0, // leftmost subdomain segment (default)
},
database: { pool },
});
// acme.example.com → tenant ID = 'acme'Custom Resolver — Function
Provide a plain function for simple custom logic:
TenantModule.forRoot({
resolver: {
type: 'custom',
resolver: (req) => req.headers['x-api-key'] as string | null,
},
database: { pool },
});Custom Resolver — Class
Implement the TenantResolver interface for full dependency injection:
import { Injectable } from '@nestjs/common';
import { TenantResolver, TenantRequest } from '@juanomorello/nest-tenant';
@Injectable()
class DatabaseResolver implements TenantResolver {
constructor(private readonly tenantRepo: TenantRepository) {}
async resolve(request: TenantRequest): Promise<string | null> {
const apiKey = request.headers['x-api-key'] as string;
if (!apiKey) return null;
const tenant = await this.tenantRepo.findByApiKey(apiKey);
return tenant?.id ?? null;
}
}
// Then in module config:
TenantModule.forRoot({
resolver: { type: 'custom', resolver: DatabaseResolver },
database: { pool },
});Isolation Strategies
The library supports multiple tenant data isolation strategies. The default is RLS.
RLS (Default)
Uses SET LOCAL set_config() to set a PostgreSQL session variable. RLS policies filter rows based on this variable. This is the default behavior — no strategy option needed.
TenantModule.forRoot({
resolver: { type: 'header' },
database: { pool },
});Schema-per-Tenant
Uses SET search_path to isolate tenants by PostgreSQL schema. Each tenant's data lives in a dedicated schema.
TenantModule.forRoot({
resolver: { type: 'header' },
database: { pool },
strategy: 'schema',
schema: {
nameTemplate: 'tenant_{{tenantId}}',
autoCreate: true,
},
});Custom Strategy
Implement the TenantIsolationStrategy interface for full control over how tenant isolation is applied to each connection.
import { TenantIsolationStrategy } from '@juanomorello/nest-tenant';
import { PoolClient } from 'pg';
class MyCustomStrategy implements TenantIsolationStrategy {
readonly name = 'custom';
async applyIsolation(client: PoolClient, tenantId: string): Promise<void> {
await client.query(`SET search_path = 'tenant_${tenantId}', public`);
await client.query(`SELECT set_config('app.current_tenant', $1, true)`, [tenantId]);
}
}
TenantModule.forRoot({
resolver: { type: 'header' },
database: { pool },
strategy: new MyCustomStrategy(),
});Tip: The library also exports a built-in
HybridStrategythat composes multiple strategies in sequence. Pass an array ofTenantIsolationStrategyinstances to its constructor.
ORM Adapters
Each ORM adapter is imported from a dedicated sub-path and provides a withClient() method via the TenantOrmAdapter<TClient> interface. The underlying connection has SET LOCAL (or the configured strategy) already applied.
TypeORM
import { TenantTypeOrmModule } from '@juanomorello/nest-tenant/typeorm';
@Module({
imports: [
TenantModule.forRoot({ /* ... */ }),
TenantTypeOrmModule.forRoot(),
],
})
export class AppModule {}Inject TenantTypeOrmAdapter in your services:
@Injectable()
class OrderService {
constructor(private readonly tenantTypeOrm: TenantTypeOrmAdapter) {}
async getOrders() {
return this.tenantTypeOrm.withClient(async (queryRunner) => {
return queryRunner.query('SELECT * FROM orders');
});
}
}Prisma
import { TenantPrismaModule } from '@juanomorello/nest-tenant/prisma';
@Module({
imports: [
TenantModule.forRoot({ /* ... */ }),
TenantPrismaModule.forRoot({ client: new PrismaClient() }),
],
})
export class AppModule {}Inject TenantPrismaAdapter in your services:
@Injectable()
class OrderService {
constructor(private readonly tenantPrisma: TenantPrismaAdapter) {}
async getOrders() {
return this.tenantPrisma.withClient(async (prisma) => {
return prisma.order.findMany();
});
}
}Drizzle
import { TenantDrizzleModule } from '@juanomorello/nest-tenant/drizzle';
@Module({
imports: [
TenantModule.forRoot({ /* ... */ }),
TenantDrizzleModule.forRoot({ schema: mySchema }),
],
})
export class AppModule {}Inject TenantDrizzleAdapter in your services:
@Injectable()
class OrderService {
constructor(private readonly tenantDrizzle: TenantDrizzleAdapter) {}
async getOrders() {
return this.tenantDrizzle.withClient(async (db) => {
return db.select().from(orders);
});
}
}PolicyBuilder
The PolicyBuilder provides a fluent API for generating PostgreSQL RLS policy SQL.
import { PolicyBuilder } from '@juanomorello/nest-tenant/policy-builder';
const sql = PolicyBuilder
.sessionVariable('app.current_tenant')
.table('orders')
.tenantColumn('tenant_id')
.forRole('app_user')
.build()
.table('products')
.tenantColumn('tenant_id')
.build()
.toSQL();The toSQL() method returns an array of SQL statements (e.g., ALTER TABLE ... ENABLE ROW LEVEL SECURITY, CREATE POLICY ...).
Use .toMigration(filePath) to write the generated SQL directly to a migration file with a timestamped header.
CLI
The nest-tenant CLI provides scaffolding and code generation commands.
# Interactive wizard for project setup
npx nest-tenant init
# Generate a timestamped SQL migration with RLS policy template
npx nest-tenant migration:create <name>
# Generate RLS policies from a YAML/JSON config file
npx nest-tenant policy:generate --config <file>Sub-path Exports
| Import Path | Description |
|-------------|-------------|
| @juanomorello/nest-tenant | Core module, context, guards, decorators, events, strategies |
| @juanomorello/nest-tenant/typeorm | TypeORM adapter and module |
| @juanomorello/nest-tenant/prisma | Prisma adapter and module |
| @juanomorello/nest-tenant/drizzle | Drizzle adapter and module |
| @juanomorello/nest-tenant/policy-builder | PolicyBuilder DSL |
API Reference
TenantContext (Injectable Service)
The primary service consumers inject. Provides all tenant-related operations.
| Method | Returns | Description |
|--------|---------|-------------|
| getId() | string | Current tenant ID. Throws TenantNotFoundError if no context. |
| getIdOrNull() | string \| null | Current tenant ID, or null. Does not throw. |
| hasTenant() | boolean | Whether a tenant context is active with a non-null ID. |
| getConnection() | Promise<TenantScopedConnection> | Acquire a tenant-scoped connection (BEGIN + SET LOCAL already executed). Caller must call release(). |
| withConnection(cb) | Promise<T> | Execute callback with auto-managed connection (BEGIN/COMMIT/ROLLBACK). |
| withTransaction(cb) | Promise<T> | Execute callback in a transaction. Uses SAVEPOINTs when nested inside withConnection(). |
| getBypassConnection() | Promise<PoolClient> | (deprecated) Raw connection without RLS. Use withBypassConnection() instead. |
| withBypassConnection(cb) | Promise<T> | Execute callback with a bypass connection that is auto-released. Requires @BypassRLS(). |
TenantScopedConnection
Returned by getConnection(). All queries are automatically scoped by RLS.
| Member | Type | Description |
|--------|------|-------------|
| query(text, values?) | Promise<QueryResult<T>> | Execute a parameterized SQL query. |
| release() | Promise<void> | Commit the transaction and return the connection to the pool. Idempotent. |
| rollback() | Promise<void> | Rollback the transaction and return the connection to the pool. |
| client | PoolClient | Underlying pg.PoolClient (advanced use only). |
| tenantId | string | The tenant ID this connection is scoped to. |
Decorators
| Decorator | Target | Description |
|-----------|--------|-------------|
| @CurrentTenant() | Parameter | Injects the current tenant ID as a route handler parameter. |
| @PublicRoute() | Method / Class | Marks a route as public — skips the TenantGuard check. |
| @BypassRLS() | Method | Enables RLS bypass for the request. Required for getBypassConnection() / withBypassConnection(). |
Guards
| Guard | Description |
|-------|-------------|
| TenantGuard | Global guard ensuring tenant context on non-public routes. Registered automatically when guard.enabled is true (default). |
Events
Subscribe to tenant lifecycle events via @nestjs/event-emitter:
| Event Constant | Payload Type | Description |
|----------------|-------------|-------------|
| TENANT_RESOLVED | TenantResolvedEvent | Tenant ID successfully extracted from a request. |
| TENANT_BYPASS | TenantBypassEvent | A @BypassRLS() connection was acquired. |
| TENANT_ERROR | TenantErrorEvent | Tenant resolution or session management failed. |
| TENANT_SESSION_SET | TenantSessionSetEvent | SET LOCAL executed on a connection. |
import { OnEvent } from '@nestjs/event-emitter';
import { TENANT_RESOLVED, TenantResolvedEvent } from '@juanomorello/nest-tenant';
@Injectable()
class AuditListener {
@OnEvent(TENANT_RESOLVED)
handleTenantResolved(event: TenantResolvedEvent) {
console.log(`Tenant ${event.tenantId} resolved via ${event.resolver}`);
}
}Error Types
| Error | HTTP Status | Description |
|-------|-------------|-------------|
| TenantNotFoundError | 403 (configurable) | No tenant context on a protected route. |
| TenantForbiddenError | 403 | Tenant ID resolved but not permitted (reserved for future allowlist). |
| TenantSessionError | 500 | SET LOCAL / set_config() failed on a database connection. |
Testing
Run Unit Tests
npm run test:unitRun Integration Tests
Integration tests use Testcontainers and require Docker:
npm run test:integrationRun All Tests
npm testBuild
npm run buildLint
npm run lintDocumentation Build
npm run docs:buildDocumentation
Full documentation with guides, API reference, and architecture diagrams is available at the documentation site.
