TenancyJS
Set up your stack

NestJS + Knex setup

Wire fail-closed multi-tenancy into a NestJS 11 + Knex app - a copy-pasteable setup-agent prompt, plus the manual walkthrough.

tenancy init doesn't scaffold NestJS yet, so this stack is a manual wire-up - a few files. This page gives you two ways to do it: hand the setup-agent prompt to your AI assistant, or follow the manual walkthrough. Either way, finish by running the two-tenant test - with fail-closed isolation you verify, you don't trust.

This is row-level on PostgreSQL. The Knex adapter is a callback facade (tenancy.run((db) => …)) backed by forced RLS - the database is the real backstop. That gives you two invariants to hold: only ever expose the callback facade (keep the base Knex private), and let a non-owner runtime role run the queries so the policy actually applies.

Setup-agent prompt

Paste this into an assistant that can edit your repo and run commands (Claude Code, Cursor, …). It executes in order and verifies each step. It's pinned to the real API, so it won't invent factory names.

# TenancyJS Setup Agent - NestJS 11 + Knex (row-level, PostgreSQL)

You are a setup agent. Add fail-closed multi-tenancy to this NestJS + Knex project with TenancyJS.
Execute every step in order. After each step, verify it applied before continuing. If a step does not
apply, say so and continue. Do NOT invent API names - if unsure, fetch the linked doc.

Source of truth (fetch if a detail is missing, do not guess):
- https://tenancyjs.pages.dev/docs/adapters/knex
- https://tenancyjs.pages.dev/docs/integrations/nestjs
- https://tenancyjs.pages.dev/docs/concepts/limitations
- https://tenancyjs.pages.dev/docs/guides/testing-isolation

## Pre-flight
- Run `node -v`; abort if it is not >= 24.
- Confirm `@nestjs/core` (v11) and `knex` are in package.json. Abort if this is not a NestJS + Knex
  project.
- Confirm the database is PostgreSQL. Knex tenancy is PostgreSQL-only; abort otherwise.

## Step 1 - Install
Run:
```sh
npm install tenancyjs-core@beta tenancyjs-adapter-knex@beta tenancyjs-integration-nest@beta tenancyjs-identifiers@beta knex pg
```
Verify all six appear in package.json dependencies.

## Step 2 - Forced-RLS migration (the database backstop)
Row-level on Knex is enforced by Postgres RLS, not the facade. Write a migration that runs as the table
OWNER and, for each tenant table (e.g. `posts` with a non-null `tenant_id` column):
- `ALTER TABLE posts ENABLE ROW LEVEL SECURITY;`
- `ALTER TABLE posts FORCE ROW LEVEL SECURITY;` (so the owner is bound too)
- Create policy `posts_tenant_isolation` USING/ WITH CHECK:
  `current_setting('tenancyjs.is_central', true) = 'on'
   OR tenant_id = current_setting('tenancyjs.tenant_id', true)`.
Create a dedicated RUNTIME role that the app connects as. It MUST NOT be the table owner and MUST NOT
have `BYPASSRLS` or `SUPERUSER` - otherwise FORCE RLS is silently skipped. Grant it only DML on the
tenant tables. Verify before continuing.

## Step 3 - tenancy.ts (the single source of truth)
Create `src/tenancy.ts`. It must:
- Construct ONE `TenancyManager<Tenant>` and export it.
- Construct the BASE Knex connecting as the non-owner RUNTIME role, and keep it private to this module.
- Create the facade with `createKnexTenancy({ manager, knex: baseKnex, tenantTables: { posts: {
  tenantColumn: "tenant_id", policyName: "posts_tenant_isolation" } } })` and export it as `tenancy`.
- Build a `TenantResolutionChain` with `new HeaderTenantResolver()` and a store whose `find(identifier)`
  looks the tenant up via the BASE knex and returns `[{ tenant: { id }, status }]` (or `[]`).
- Export `manager`, `resolver`, and `tenancy`. Do NOT export `baseKnex` for app use.

## Step 4 - Validate the policies at boot
In `main.ts`, before `app.listen(...)`, `await tenancy.validate()`. It confirms every tenant table has
RLS enabled + forced and the expected policy present, and that the connection is a non-bypassing role.
Fail the boot if it throws.

## Step 5 - Register TenancyModule
In `app.module.ts`, add `TenancyModule.forRoot({ manager, resolver })` to imports (import both from
`./tenancy`). Do not register a second guard/interceptor. Do NOT pass `tenancy` as `executor` - the
callback facade is called directly in the handler.

## Step 6 - Scope the routes
For every controller handler that touches tenant data:
- Add `@TenantRoute()` (from `tenancyjs-integration-nest`). The interceptor opens the scope.
- Run queries inside `tenancy.run((db) => db.table("posts").select(...))`. REMOVE any manual
  `where("tenant_id", …)` filters - RLS + the GUC scope the rows. Leave public routes unmarked.

## Step 7 - Central (cross-tenant) work
For admin/cross-tenant handlers, do NOT leave them unscoped. Wrap the work in
`manager.runInCentralContext(() => tenancy.run((db) => ...))`. This sets `tenancyjs.is_central` so the
policy opens; unscoped tenant access must fail closed.

## Step 8 - Lock the boundary
- Ensure `baseKnex` is never provided to controllers/services - only the `tenancy` facade.
- The facade rejects raw SQL and joins it can't prove tenant-safe. The DB is still the backstop, but
  don't reach around the facade with the base connection.

## Step 9 - Prove it
Create a two-tenant test (Vitest or Jest): create tenant A and tenant B, write a row under EACH with the
SAME primary key, assert querying as A never returns B's row. THEN, as the runtime role with NO GUC set,
run a raw `SELECT * FROM posts` and assert it returns 0 rows (proof RLS - not the facade - is enforcing).
Follow https://tenancyjs.pages.dev/docs/guides/testing-isolation.

## Step 10 - Verify
- `npx nest build` (or `npm run build`) - report errors.
- Boot the app (`npm run start`); confirm `tenancy.validate()` passes and it serves.
- Run the two-tenant test and report the result.

Report a summary of every file created/changed and any issue found.

AI wiring is a starting point, not proof. The only thing that demonstrates isolation is the two-tenant colliding-id test in Step 9 - including the raw-as-runtime-role read with no GUC - run it before you trust the result.

Manual walkthrough

Prefer to wire it yourself? The same steps, with the code.

Install

npm install tenancyjs-core@beta tenancyjs-adapter-knex@beta tenancyjs-integration-nest@beta tenancyjs-identifiers@beta knex pg

Forced-RLS migration

The database is the backstop. Install tables and the policy as the owner; the app connects as a separate non-owner role so FORCE actually binds it.

migrations/0001_posts_rls.ts
import type { Knex } from "knex";

export async function up(knex: Knex): Promise<void> {
  await knex.schema.createTable("posts", (t) => {
    t.uuid("id").primary();
    t.uuid("tenant_id").notNullable().index();
    t.string("title").notNullable();
  });

  await knex.raw(`ALTER TABLE posts ENABLE ROW LEVEL SECURITY`);
  await knex.raw(`ALTER TABLE posts FORCE ROW LEVEL SECURITY`);
  await knex.raw(`
    CREATE POLICY posts_tenant_isolation ON posts
      USING (
        current_setting('tenancyjs.is_central', true) = 'on'
        OR tenant_id = current_setting('tenancyjs.tenant_id', true)::uuid
      )
      WITH CHECK (
        current_setting('tenancyjs.is_central', true) = 'on'
        OR tenant_id = current_setting('tenancyjs.tenant_id', true)::uuid
      )
  `);

  // The runtime role the app connects as: NOT the owner, no BYPASSRLS, no SUPERUSER.
  await knex.raw(`GRANT SELECT, INSERT, UPDATE, DELETE ON posts TO app_runtime`);
}

If the connecting role owns the table or has BYPASSRLS/SUPERUSER, FORCE is skipped and every tenant sees every row. tenancy.validate() (next steps) checks for this.

One tenancy.ts

The manager, the Knex facade, and the resolver all live here - one source of truth. The base Knex connects as app_runtime and never leaves this module.

src/tenancy.ts
import { TenancyManager } from "tenancyjs-core";
import { createKnexTenancy } from "tenancyjs-adapter-knex";
import { HeaderTenantResolver, TenantResolutionChain } from "tenancyjs-identifiers";
import Knex from "knex";

export interface Tenant {
  readonly id: string;
}

export const manager = new TenancyManager<Tenant>();

// Connect as the NON-OWNER runtime role; keep this private to the module.
const baseKnex = Knex({ client: "pg", connection: process.env.DATABASE_URL });

// The ONLY database handle the app may use - a callback facade backed by forced RLS.
export const tenancy = createKnexTenancy({
  manager,
  knex: baseKnex,
  tenantTables: {
    posts: { tenantColumn: "tenant_id", policyName: "posts_tenant_isolation" },
  },
});

export const resolver = new TenantResolutionChain({
  resolvers: [new HeaderTenantResolver()], // X-Tenant-Id
  store: {
    async find(identifier) {
      const tenant = await baseKnex("tenants")
        .where({ id: identifier.value })
        .first();
      return tenant ? [{ tenant: { id: tenant.id }, status: tenant.status }] : [];
    },
  },
});

Validate policies at boot, then register the module

src/main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { tenancy } from "./tenancy";

async function bootstrap() {
  await tenancy.validate(); // RLS enabled + forced, policy present, role non-bypassing
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();
src/app.module.ts
import { Module } from "@nestjs/common";
import { TenancyModule } from "tenancyjs-integration-nest";
import { manager, resolver } from "./tenancy";

@Module({
  imports: [TenancyModule.forRoot({ manager, resolver })],
})
export class AppModule {}

Scope your routes

Mark tenant routes; the interceptor opens the scope, then call tenancy.run(...) in the handler - no manual tenant_id filter, and do not pass the facade as executor.

src/posts.controller.ts
import { Controller, Get } from "@nestjs/common";
import { TenantRoute } from "tenancyjs-integration-nest";
import { tenancy } from "./tenancy";

@Controller("posts")
export class PostsController {
  @Get()
  @TenantRoute()
  list() {
    return tenancy.run((db) => db.table("posts").select("id", "title"));
  }
}

Prove isolation

Write the two-tenant colliding-id test: same primary key under A and B, assert A never reads B's row - then, as the runtime role with no GUC set, run a raw SELECT * FROM posts and assert it returns 0 rows. That last check proves the database, not the facade, is enforcing isolation.

Cross-tenant admin work runs in an explicit central scope - manager.runInCentralContext(() => tenancy.run((db) => …)) sets tenancyjs.is_central so the policy opens. There is no request-controlled central mode; a resolution failure fails closed, never central.

Full lifecycle and the security boundary: NestJS integration · Knex adapter · Limitations.

On this page