npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nestarc/tenancy

v0.11.0

Published

Multi-tenancy module for NestJS with PostgreSQL Row Level Security (RLS) and Prisma support

Readme

@nestarc/tenancy

npm version npm downloads CI License: MIT Docs

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 REQUEST scope)
  • Prisma Client Extensions — Automatic set_config() before every query
  • 5 built-in extractors — Header, Subdomain, JWT Claim, Path, Composite (fallback chain)
  • Lifecycle hooksonTenantResolved / onTenantNotFound for logging, auditing, custom error handling
  • Auto-inject tenant ID — Optionally inject tenant_id into create / createMany / upsert operations
  • Shared models — Whitelist models that skip RLS (e.g., Country, Currency)
  • withoutTenant() — programmatic bypass for background jobs and admin queries
  • tenancyTransaction() — interactive transaction support with RLS
  • Fail-Closed modefailClosed: true blocks model queries without tenant context, preventing accidental data exposure
  • Testing utilitiesTestTenancyModule, withTenant(), expectTenantIsolation() via @nestarc/tenancy/testing
  • Event system — optional @nestjs/event-emitter integration for tenant.resolved, tenant.not_found, etc.
  • Microservice propagation — HTTP (propagateTenantHeaders()), Bull, Kafka, gRPC propagators with zero transport dependencies
  • Inbound context restorationTenantContextInterceptor auto-restores tenant context from incoming microservice messages
  • Error hierarchyTenantContextMissingError base class enables unified instanceof catch handling
  • CLI scaffoldingnpx @nestarc/tenancy init generates RLS policies and module config
  • CLI drift detectionnpx @nestarc/tenancy check validates SQL against Prisma schema
  • Multi-schema support@@schema() directives generate schema-qualified SQL (e.g., "auth"."users")
  • ccTLD-aware subdomain extraction — accurate parsing for .co.uk, .co.jp, .com.au, etc.
  • Framework-agnostic — public API uses TenancyRequest / TenancyResponse instead of Express types. Works with Express, Fastify, and raw Node.js HTTP
  • SQL injection safeset_config() with bind parameters, plus UUID validation by default
  • NestJS 10 & 11 compatible, Prisma 5 & 6 compatible (E2E-tested with Prisma 6; Prisma 5 unit-tested)

Performance

The benchmark separates extension overhead from row-count and database-role effects:

| Scenario | Purpose | |----------|---------| | Admin direct findMany over all rows | Context only; not used as the extension overhead baseline | | Admin tenant-filtered findMany with WHERE tenant_id | Same returned row count with RLS bypassed | | app_user manual RLS transaction | set_config + query, no extension | | app_user tenancy extension findMany | Same role, RLS policy, and returned row count as the manual RLS transaction | | app_user tenancy extension findFirst | Single-row reference path |

The headline number is extension findMany - manual RLS transaction, not extension vs unfiltered admin query. The script prints row counts, Node/PostgreSQL/Prisma versions, and p50/p95/p99 timings so results can be compared across environments.

Example result from Apple M1 Pro, Node v24.11.1, PostgreSQL 16.13, Prisma Client 6.19.2, 1005 total rows, 500 measured iterations:

| Scenario | Rows | Avg | P50 | P95 | P99 | |----------|------|-----|-----|-----|-----| | Admin direct findMany (all rows, no RLS) | 1005 | 3.983ms | 3.369ms | 5.444ms | 6.992ms | | Admin tenant-filtered findMany (WHERE tenant_id, no RLS) | 402 | 2.747ms | 2.736ms | 3.612ms | 4.686ms | | app_user manual RLS transaction (set_config + findMany) | 402 | 2.846ms | 2.614ms | 4.154ms | 5.177ms | | app_user tenancy extension findMany | 402 | 2.961ms | 2.766ms | 4.281ms | 4.800ms | | app_user tenancy extension findFirst | 1 | 1.217ms | 1.192ms | 1.522ms | 1.777ms |

Measured extension overhead: +0.115ms avg (+4.0%), +0.127ms p95 compared with the manual RLS transaction.

Reproduce: docker compose up -d --wait && npm run bench

Prerequisites

  • Node.js >= 18
  • NestJS 10 or 11
  • Prisma 5 or 6
  • PostgreSQL (with RLS support). Use a patched minor release: CVE-2024-10976 is fixed in PostgreSQL 17.1, 16.5, 15.9, 14.14, 13.17, and 12.21.

Installation

npm install @nestarc/tenancy

Quick Start

1. Enable RLS on your PostgreSQL tables

Every table that needs tenant isolation must have a tenant_id column and an RLS policy:

-- Ensure your table has a tenant_id column
ALTER TABLE users ADD COLUMN tenant_id TEXT NOT NULL;

-- Enable RLS (FORCE ensures table owners also obey policies)
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE users FORCE ROW LEVEL SECURITY;

-- Add an index for the policy column to avoid full table scans
CREATE INDEX IF NOT EXISTS tenancy_users_tenant_id_idx ON users (tenant_id);

-- Create isolation policy
CREATE POLICY tenant_isolation ON users
  USING (tenant_id = current_setting('app.current_tenant', true)::text);

-- The `true` parameter means missing_ok: returns NULL instead of error when unset.
-- At the database layer, queries without tenant context return 0 rows (not an error).
-- Repeat for each tenant-scoped table

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

import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { TenancyService, createPrismaTenancyExtension } from '@nestarc/tenancy';

@Injectable()
export class PrismaService implements OnModuleInit {
  public readonly client;

  constructor(private readonly tenancyService: TenancyService) {
    const prisma = new PrismaClient();
    this.client = prisma.$extends(
      createPrismaTenancyExtension(tenancyService),
    );
  }

  async onModuleInit() {
    await this.client.$connect();
  }
}

Extension Options

createPrismaTenancyExtension(tenancyService, {
  dbSettingKey: 'app.current_tenant',  // PostgreSQL setting key (default)
  autoInjectTenantId: true,            // Auto-inject tenant_id on create/upsert
  tenantIdField: 'tenant_id',          // Column name for tenant ID (default)
  sharedModels: ['Country', 'Currency'], // Models that skip RLS entirely
})

| Option | Type | Default | Description | |--------|------|---------|-------------| | dbSettingKey | string | 'app.current_tenant' | PostgreSQL session variable name | | autoInjectTenantId | boolean | false | Auto-inject tenant ID into create, createMany, createManyAndReturn, upsert | | tenantIdField | string | 'tenant_id' | Column name to inject tenant ID into | | sharedModels | string[] | [] | Models that bypass RLS (no set_config, no injection) | | failClosed | boolean | true | Block queries when no tenant context is set (prevents accidental data exposure if RLS is misconfigured) | | interactiveTransactionSupport | boolean | false | Enable transparent set_config inside interactive transactions. Validates Prisma compatibility at startup — throws immediately if unsupported. Alternative: tenancyTransaction() helper |

Important: If you customize dbSettingKey in TenancyModule.forRoot(), pass the same value to createPrismaTenancyExtension() and tenancyTransaction(). These are independent configurations that must match your PostgreSQL current_setting() calls.

Note: By default, the Prisma extension uses batch transactions internally, which do not propagate set_config into interactive transactions ($transaction(async (tx) => ...)). Enable interactiveTransactionSupport: true for transparent handling, or use the tenancyTransaction() helper. See Interactive Transactions below.

Migration note: If you intentionally rely on model queries without tenant context falling through to PostgreSQL RLS, set failClosed: false explicitly. Prefer sharedModels, withoutTenant(), or a separate admin client for intentional unscoped access.

Interactive Transactions

The default Prisma extension wraps queries in batch transactions, which breaks inside $transaction(async (tx) => ...). Two approaches are available:

Option 1: tenancyTransaction() helper (recommended)

Uses only public Prisma APIs. Works with all Prisma versions.

import { tenancyTransaction } from '@nestarc/tenancy';

await tenancyTransaction(prisma, tenancyService, async (tx) => {
  const user = await tx.user.findFirst();
  await tx.order.create({ data: { userId: user.id } });
});

Option 2: Transparent mode

Sets RLS context automatically inside interactive transactions. Validates Prisma compatibility at startup.

const prisma = basePrisma.$extends(
  createPrismaTenancyExtension(tenancyService, {
    interactiveTransactionSupport: true,
  })
);

interactiveTransactionSupport relies on Prisma internal APIs. If your Prisma version is incompatible, extension creation throws immediately with a clear error message. Use tenancyTransaction() as a fallback.

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/users

All Prisma queries are automatically scoped to that tenant via RLS.

API

TenancyModule

// Synchronous
TenancyModule.forRoot({
  tenantExtractor: 'X-Tenant-Id',           // header name (string)
  dbSettingKey: 'app.current_tenant',        // PostgreSQL setting (default)
  validateTenantId: (id) => UUID_REGEX.test(id), // sync or async (default: UUID)
})

// Async with factory
TenancyModule.forRootAsync({
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    tenantExtractor: config.get('TENANT_HEADER'),
  }),
})

// Async with class
TenancyModule.forRootAsync({
  useClass: TenancyConfigService,
})

// Async with existing provider
TenancyModule.forRootAsync({
  useExisting: TenancyConfigService,
})

TenancyService

@Injectable()
export class SomeService {
  constructor(private readonly tenancy: TenancyService) {}

  doSomething() {
    const tenantOrNull = this.tenancy.getCurrentTenant();    // string | null
    const tenantId = this.tenancy.getCurrentTenantOrThrow(); // string (throws if missing)
  }
}

@CurrentTenant() Decorator

import { Controller, Get } from '@nestjs/common';
import { CurrentTenant } from '@nestarc/tenancy';

@Controller('users')
export class UsersController {
  @Get('me')
  whoAmI(@CurrentTenant() tenantId: string) {
    return { tenantId };
  }
}

@BypassTenancy() Decorator

Skip the TenancyGuard tenant-required check on specific routes (e.g., health checks, public endpoints).

Important: @BypassTenancy() only bypasses the guard — it does not clear the tenant context. If the request contains a tenant header, TenantMiddleware still sets the context, so getCurrentTenant() may return a value and Prisma queries will still be RLS-filtered. To explicitly run without tenant context, use withoutTenant().

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 rowscurrent_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:

  1. A superuser/RLS-exempt database connection — use a separate PrismaClient with admin credentials that bypasses RLS:
// adminPrisma uses a superuser connection — not subject to RLS
const allUsers = await tenancyService.withoutTenant(async () => {
  return adminPrisma.user.findMany(); // Returns ALL tenants' data
});
  1. 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 psl package 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_id

Security: This extractor does not verify the JWT signature. You must ensure JWT signature verification happens at the middleware level — not in a NestJS Guard.

NestJS execution order is: Middleware → Guards → Interceptors → Pipes. Since TenantMiddleware runs at the middleware stage, a NestJS Guard (e.g., @nestjs/passport AuthGuard) runs after the tenant is already resolved and cannot protect it.

Middleware ordering: TenancyModule registers TenantMiddleware globally via its own configure() 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 onTenantResolved hook. 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 result

Custom 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: TenancyRequest is satisfied by Express Request, Fastify FastifyRequest, and any object with a headers property. If you need platform-specific properties, use type assertion: (request as import('express').Request).ip.

Lifecycle Hooks

React to tenant resolution events without extending the middleware:

TenancyModule.forRoot({
  tenantExtractor: 'X-Tenant-Id',
  onTenantResolved: async (tenantId, req) => {
    // Runs inside AsyncLocalStorage context — getCurrentTenant() works here
    logger.info({ tenantId, path: req.path }, 'tenant resolved');
    await auditService.recordAccess(tenantId);
  },
  onTenantNotFound: (req, res) => {
    // Option 1: Observation only (return void → next() is called)
    logger.warn({ path: req.path }, 'no tenant');

    // Option 2: Block the request (throw an exception)
    throw new ForbiddenException('Tenant header required');

    // Option 3: Return 'skip' to prevent next() — use res to send your own response
    res.status(401).json({ message: 'Tenant header required' });
    return 'skip';
  },
})

| Hook | Signature | When | |------|-----------|------| | onTenantResolved | (tenantId: string, req: TenancyRequest) => void \| Promise<void> | After successful extraction and validation | | onTenantNotFound | (req: TenancyRequest, res: TenancyResponse) => void \| 'skip' \| Promise<void \| 'skip'> | When no tenant ID could be extracted |

Error Responses

| Scenario | Status | Message | |----------|--------|---------| | Missing tenant header (no @BypassTenancy) | 403 | Tenant ID is required | | Invalid tenant ID format | 400 | Invalid tenant ID format | | Extractor throws or rejects | Propagates | Original error; emits tenant.extraction_failed first | | Cross-check mismatch | 403 | Tenant ID mismatch | | crossCheck.required: true and no secondary tenant source | 403 | Cross-check source is required but returned null | | Prisma query without tenant context (failClosed, default) | Throws | TenancyContextRequiredError | | Non-HTTP context (WebSocket, gRPC) | — | Guard skips (no enforcement) |

Fail-Closed Mode

By default, model queries without a tenant context throw TenancyContextRequiredError. This avoids silent unscoped query paths when RLS is misconfigured or accidentally bypassed.

const prisma = new PrismaClient().$extends(
  createPrismaTenancyExtension(tenancyService, {
    failClosed: true, // default
  })
);

Queries are still allowed when:

  • The model is listed in sharedModels
  • withoutTenant() is used (explicit bypass)

To restore the previous pass-through behavior, opt out explicitly:

const prisma = new PrismaClient().$extends(
  createPrismaTenancyExtension(tenancyService, {
    failClosed: false,
  })
);

Scope: failClosed applies to Prisma model operations (findMany, create, update, etc.). Raw queries ($queryRaw, $executeRaw) bypass the extension and are not covered — use parameterized set_config() manually for raw queries.

Testing Utilities

Import from @nestarc/tenancy/testing:

import { TestTenancyModule, withTenant, expectTenantIsolation } from '@nestarc/tenancy/testing';

// 1. Use TestTenancyModule in unit/integration tests (no middleware or guard)
const module = await Test.createTestingModule({
  imports: [TestTenancyModule.register()],
  providers: [MyService],
}).compile();

// 2. Run code in a tenant context
const result = await withTenant('tenant-1', () => service.findAll());

// 3. Assert tenant isolation in E2E tests
await expectTenantIsolation(prisma.user, 'tenant-a-uuid', 'tenant-b-uuid');

Event System

Optional integration with @nestjs/event-emitter. Install the package and import EventEmitterModule:

import { EventEmitterModule } from '@nestjs/event-emitter';
import { TenancyEvents } from '@nestarc/tenancy';

@Module({
  imports: [
    EventEmitterModule.forRoot(),
    TenancyModule.forRoot({ tenantExtractor: 'x-tenant-id' }),
  ],
})
export class AppModule {}

// Listen for events anywhere in your app
@Injectable()
class TenantLogger {
  @OnEvent(TenancyEvents.RESOLVED)
  handleResolved({ tenantId }: { tenantId: string }) {
    console.log(`Tenant resolved: ${tenantId}`);
  }
}

Events: tenant.resolved, tenant.not_found, tenant.extraction_failed, tenant.validation_failed, tenant.context_bypassed, tenant.cross_check_failed.

If @nestjs/event-emitter is not installed, events are silently skipped — no errors.

Tenant ID Forgery Prevention

Cross-validate the tenant ID against a secondary source to prevent header forgery:

import { JwtClaimTenantExtractor } from '@nestarc/tenancy';

TenancyModule.forRoot({
  tenantExtractor: 'X-Tenant-Id',
  crossCheck: {
    extractor: new JwtClaimTenantExtractor({ claimKey: 'tenantId' }),
    onFailed: 'reject',  // 'reject' (default) | 'log'
    required: false,      // when true, rejects requests without cross-check source
  },
})

If the cross-check extractor returns null (e.g., no JWT present), validation is skipped by default — unauthenticated endpoints work normally. Set required: true to reject requests when the cross-check source is missing, enforcing that every request must have a verifiable secondary source. On mismatch, tenant.cross_check_failed event is emitted.

Deprecated format: The flat crossCheckExtractor / onCrossCheckFailed fields still work but emit a deprecation warning. Deprecated since v0.10.0; planned removal in v0.12.0.

Deprecation Policy

Deprecated public APIs are marked with @deprecated JSDoc and listed in the changelog. Unless a security issue requires faster removal, deprecated APIs are planned for removal two minor versions later or at the next major release, whichever comes first.

OpenTelemetry Integration

Optional integration with @opentelemetry/api. Install the package to enable automatic tenant context in traces:

npm install @opentelemetry/api
TenancyModule.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 static AsyncLocalStorage used by TenancyContext. 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 headers

gRPC

import { GrpcTenantPropagator, TenancyContext } from '@nestarc/tenancy';

const propagator = new GrpcTenantPropagator(new TenancyContext());

// Client: inject tenant into metadata
const metadata = new Metadata();
propagator.inject(metadata); // sets 'x-tenant-id' key

// Server: extract tenant from metadata
const tenantId = propagator.extract(call.metadata);

Inbound Context Restoration (Interceptor)

TenantContextInterceptor automatically restores tenant context from incoming microservice messages. It wraps handler execution in TenancyContext.run().

import { TenantContextInterceptor, TenancyContext } from '@nestarc/tenancy';

// Recommended: specify transport explicitly to avoid duck-typing ambiguity
app.useGlobalInterceptors(
  new TenantContextInterceptor(new TenancyContext(), { transport: 'kafka' }),
);

Supported transports: 'kafka' | 'bull' | 'grpc'.

HTTP is skippedTenantMiddleware + TenancyGuard already 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 |

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 $executeRaw tagged template. This eliminates SQL injection risk at the database layer. Additionally, tenant IDs are validated by the middleware (UUID format by default).
  • Transaction-scoped: set_config(key, value, TRUE) is equivalent to SET LOCAL — scoped to the batch transaction. No cross-request leakage via connection pool.
  • Custom validators: If your tenant IDs are not UUIDs, provide a validateTenantId function that rejects any unsafe input.

RLS Operational Notes

  • Patch PostgreSQL: Use a currently supported PostgreSQL minor release. CVE-2024-10976 affects row-security policies in older 17.x, 16.x, 15.x, 14.x, 13.x, and 12.x patch releases.
  • Index the tenant column: RLS policies behave like implicit filters. Add an index on tenant_id (or your configured tenant column) for every tenant-scoped table. The CLI now generates this index and tenancy check warns when it is missing.
  • Keep policies simple: The generated policy is a direct equality check. If you replace it with subqueries or non-leakproof functions, validate query plans under realistic data volume.
  • RLS is not resource isolation: It does not prevent noisy-neighbor CPU/IO issues, cache key leaks, or cross-tenant data in Redis/search queues. Include tenant IDs in non-database cache keys and job payloads.
  • PgBouncer/Prisma: Prisma requires PgBouncer transaction mode, and prepared-statement settings depend on your PgBouncer version. Test RLS behavior with the same pooler mode used in production.

Security Considerations

Tenant ID is client-supplied by default. The built-in extractors (Header, Subdomain, Path) read tenant identifiers directly from the request without verifying the caller's authorization to access that tenant.

For production use, you must add a trust boundary — verify that the authenticated user belongs to the claimed tenant. Options:

  1. Use JwtClaimTenantExtractor with a pre-validated JWT (tenant ID embedded by your auth server)
  2. Add validation in onTenantResolved hook — check the user's tenant membership
  3. Use authentication middleware before the tenancy middleware to establish trust

Without a trust boundary, any client can access any tenant's data by changing the header value.

How It Works

HTTP Request (X-Tenant-Id: 550e8400-e29b-41d4-a716-446655440000)
  → TenantMiddleware (extracts & validates tenant ID)
    → AsyncLocalStorage (stores tenant context)
      → TenancyGuard (rejects if missing, unless @BypassTenancy)
        → Your Controller / Service
          → Prisma Extension ($transaction → set_config() → query)
            → PostgreSQL RLS (automatic row filtering)

CLI

Scaffold RLS policies and module configuration from your Prisma schema:

npx @nestarc/tenancy init

This generates:

  • tenancy-setup.sql — PostgreSQL RLS policies, tenant indexes, roles, and grants
  • tenancy.module-setup.ts — NestJS module registration code

Preview without writing files:

npx @nestarc/tenancy init --dry-run

Check if your SQL is in sync with the Prisma schema:

npx @nestarc/tenancy check
# With custom setting key:
npx @nestarc/tenancy check --db-setting-key=custom.tenant_key

Validates table coverage, tenant indexes, FORCE ROW LEVEL SECURITY, isolation/insert policies, and setting key consistency across all policies. Exits with code 0 (in sync) or 1 (drift detected).

License

MIT