npm package: @juano-morello/nest-tenant
Description: NestJS multi-tenant data isolation via PostgreSQL Row-Level Security
Latest version: 0.1.0

# @juanomorello/nest-tenant

> NestJS multi-tenant data isolation via PostgreSQL Row-Level Security (RLS) and Schema-per-Tenant strategies.

[![npm version](https://img.shields.io/npm/v/@juanomorello/nest-tenant.svg)](https://www.npmjs.com/package/@juanomorello/nest-tenant)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![CI](https://img.shields.io/github/actions/workflow/status/juanomorello/nest-tenant/ci.yml?branch=main)](https://github.com/juanomorello/nest-tenant/actions)

## 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.set` via `@nestjs/event-emitter`
- **Configurable guard** — global `TenantGuard` with `@PublicRoute()` exclusions, path patterns, and `RegExp` support
- **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`, and `policy:generate` commands

## Installation

```bash
npm install @juanomorello/nest-tenant
```

### Peer Dependencies

Ensure the following are installed in your project:

```bash
npm install @nestjs/common @nestjs/core pg reflect-metadata rxjs
```

If using the JWT resolver, also install:

```bash
npm install jsonwebtoken
```

### Optional 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

```typescript
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](#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](#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:

```typescript
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`).

```typescript
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.

```typescript
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.

```typescript
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:

```typescript
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:

```typescript
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.

```typescript
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.

```typescript
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.

```typescript
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 `HybridStrategy` that composes multiple strategies in sequence. Pass an array of `TenantIsolationStrategy` instances 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

```typescript
import { TenantTypeOrmModule } from '@juanomorello/nest-tenant/typeorm';

@Module({
  imports: [
    TenantModule.forRoot({ /* ... */ }),
    TenantTypeOrmModule.forRoot(),
  ],
})
export class AppModule {}
```

Inject `TenantTypeOrmAdapter` in your services:

```typescript
@Injectable()
class OrderService {
  constructor(private readonly tenantTypeOrm: TenantTypeOrmAdapter) {}

  async getOrders() {
    return this.tenantTypeOrm.withClient(async (queryRunner) => {
      return queryRunner.query('SELECT * FROM orders');
    });
  }
}
```

### Prisma

```typescript
import { TenantPrismaModule } from '@juanomorello/nest-tenant/prisma';

@Module({
  imports: [
    TenantModule.forRoot({ /* ... */ }),
    TenantPrismaModule.forRoot({ client: new PrismaClient() }),
  ],
})
export class AppModule {}
```

Inject `TenantPrismaAdapter` in your services:

```typescript
@Injectable()
class OrderService {
  constructor(private readonly tenantPrisma: TenantPrismaAdapter) {}

  async getOrders() {
    return this.tenantPrisma.withClient(async (prisma) => {
      return prisma.order.findMany();
    });
  }
}
```

### Drizzle

```typescript
import { TenantDrizzleModule } from '@juanomorello/nest-tenant/drizzle';

@Module({
  imports: [
    TenantModule.forRoot({ /* ... */ }),
    TenantDrizzleModule.forRoot({ schema: mySchema }),
  ],
})
export class AppModule {}
```

Inject `TenantDrizzleAdapter` in your services:

```typescript
@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.

```typescript
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.

```bash
# 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. |

```typescript
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

```bash
npm run test:unit
```

### Run Integration Tests

Integration tests use [Testcontainers](https://testcontainers.com/) and require Docker:

```bash
npm run test:integration
```

### Run All Tests

```bash
npm test
```

### Build

```bash
npm run build
```

### Lint

```bash
npm run lint
```

### Documentation Build

```bash
npm run docs:build
```

## Documentation

Full documentation with guides, API reference, and architecture diagrams is available at the [documentation site](https://juanomorello.github.io/nest-tenant/).

## License

[MIT](LICENSE)