TenancyJS
Set up your stack

AdonisJS + Lucid setup

Wire fail-closed multi-tenancy into an AdonisJS 7 + Lucid 22 app with forced PostgreSQL RLS - a copy-pasteable setup-agent prompt, plus the manual walkthrough.

npx tenancy init scaffolds AdonisJS + Lucid end to end, so the fastest path is to run it and adapt. This page gives you two ways in: 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. Lucid row-level is a callback facade backed by forced RLS: the adapter scopes model operations through Lucid's hooks, and Postgres row-level security is the backstop that refuses cross-tenant rows even if a query slips the facade. That database enforcement is why this stack forces RLS - the runtime role can't read another tenant's rows no matter what SQL reaches it.

Running MySQL? This page is PostgreSQL (row-level + forced RLS). MySQL has no schema-per-tenant and its row-level mode is experimental and facade-only - use database-per-tenant on MySQL instead.

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 - AdonisJS 7 + Lucid 22 (row-level, PostgreSQL)

You are a setup agent. Add fail-closed multi-tenancy to this AdonisJS + Lucid 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/lucid
- https://tenancyjs.pages.dev/docs/integrations/adonis
- 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 `@adonisjs/core` (v7) and `@adonisjs/lucid` are in package.json. Abort if this is not an
  AdonisJS + Lucid project.
- Confirm the database is PostgreSQL and that `pg` is installed (it and `luxon` are Lucid peers).

## Step 1 - Scaffold or wire manually
`npx tenancy init` scaffolds this exact stack end to end. Offer to run it, then adapt the result to
this repo. If the user prefers explicit wiring (or `init` doesn't fit), do Steps 2–8 by hand.

## Step 2 - Install
Run:
```sh
npm install tenancyjs-core@beta tenancyjs-adapter-lucid@beta tenancyjs-integration-adonis@beta tenancyjs-identifiers@beta
```
Verify all four appear in package.json dependencies. `tenancyjs-identifiers` MUST be a direct
dependency - AdonisJS only resolves it transitively otherwise, and config import fails.

## Step 3 - config/tenancy.ts (the single source of truth)
Create `config/tenancy.ts`. It must:
- Construct ONE `TenancyManager<Tenant>`.
- Build the Lucid adapter with `createLucidTenancy({ manager, database: db, tenantModels: [...] })`
  (`db` from `@adonisjs/lucid/services/db`). Give each tenant model an explicit entry:
  `{ model: Post, tenantAttribute: "tenantId", tenantColumn: "tenant_id", policyName: "posts_tenant_isolation" }`.
- Build a `TenantResolutionChain` with `new HeaderTenantResolver()` and a store whose `find(identifier)`
  returns `[{ tenant: { id }, status }]` (or `[]`).
- Export `defineAdonisTenancyConfig({ manager, resolver, tenancy })` as default, passing `tenancy` as a
  lazy factory `() => createLucidTenancy(...)` (config loads before providers boot).

## Step 4 - Forced-RLS migration
Create a migration that installs RLS as the DB OWNER and runs the app as a NON-owner runtime role:
- The runtime role must NOT own the tables, must NOT have BYPASSRLS, and must NOT be a superuser
  (or FORCE RLS is silently skipped for it).
- On each tenant table: `ENABLE ROW LEVEL SECURITY` and `FORCE ROW LEVEL SECURITY`.
- Create policy `posts_tenant_isolation` whose USING and WITH CHECK both read the request GUCs:
  `tenant_id = current_setting('tenancyjs.tenant_id', true)` OR
  `current_setting('tenancyjs.is_central', true) = 'true'`.
Verify the policy exists (`\d+ posts`) and the runtime role is non-owner.

## Step 5 - Register provider + middleware
- In `adonisrc.ts`, add `() => import('tenancyjs-integration-adonis/provider')` to `providers`.
- Add `app/middleware/tenant_middleware.ts` re-exporting `TenancyMiddleware` as default, register it as
  a NAMED middleware `tenant` in `start/kernel.ts`.
- In `start/routes.ts`, apply `.use(middleware.tenant())` to tenant route GROUPS only. Central routes
  omit it - never apply it globally.

## Step 6 - Validate at startup
Ensure `await tenancy.validate()` runs before the first `tenancy.run(...)` and THROWS if invalid (the
web provider validates automatically; Ace commands and scripts must call it themselves).

## Step 7 - Central (cross-tenant) work
For admin/cross-tenant handlers, do NOT leave them unscoped. Open an explicit central scope through
application-owned code (`manager.runInCentralContext(...)`); unscoped tenant access must fail closed.

## Step 8 - Prove it
Create a two-tenant test (`node ace test`): write a row under tenant A and tenant B with the SAME
primary key; assert querying as A never returns B's row; and assert that raw SQL run as the runtime
role with NO GUC set returns 0 rows (RLS backstop). In tests, call `config.tenancy.validate()` yourself -
the /testing helper does not auto-validate. Follow https://tenancyjs.pages.dev/docs/guides/testing-isolation.

## Step 9 - Verify
- `node ace build` - report errors.
- Boot the app (`node ace serve`) and confirm it serves.
- Run `node ace 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 8 - run it, including the raw-as-runtime-role check, before you trust it.

Manual walkthrough

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

Install

npm install tenancyjs-core@beta tenancyjs-adapter-lucid@beta tenancyjs-integration-adonis@beta tenancyjs-identifiers@beta

@adonisjs/lucid, luxon, and pg are AdonisJS peers - make sure @adonisjs/lucid and pg are present. Keep tenancyjs-identifiers a direct dependency; Adonis only resolves it transitively otherwise, and config/tenancy.ts fails to import.

One config/tenancy.ts

The manager, the Lucid adapter, and the resolver all live here - one source of truth. The adapter is a callback facade; the AdonisJS config wraps it as a lazy factory.

config/tenancy.ts
import { TenancyManager } from "tenancyjs-core";
import { createLucidTenancy } from "tenancyjs-adapter-lucid";
import { HeaderTenantResolver, TenantResolutionChain } from "tenancyjs-identifiers";
import { defineAdonisTenancyConfig } from "tenancyjs-integration-adonis";
import db from "@adonisjs/lucid/services/db";
import Post from "#models/post";

export interface Tenant {
  readonly id: string;
}

const manager = new TenancyManager<Tenant>();

const tenancy = createLucidTenancy({
  manager,
  database: db,
  tenantModels: [
    {
      model: Post,
      tenantAttribute: "tenantId",
      tenantColumn: "tenant_id",
      policyName: "posts_tenant_isolation",
    },
  ],
});

const resolver = new TenantResolutionChain<Tenant>({
  resolvers: [new HeaderTenantResolver({ headerName: "x-tenant-id" })],
  store: {
    async find(identifier) {
      const row = await lookupTenant(identifier.value); // your DB lookup
      return row ? [{ tenant: { id: row.id }, status: row.status }] : [];
    },
  },
});

export default defineAdonisTenancyConfig<Tenant>({
  manager,
  resolver,
  tenancy: () => tenancy, // lazy: config loads before providers boot
});

Force RLS in a migration

The facade is one guard; forced RLS is the database backstop. Install the tables and policy as the DB owner, and run the app as a non-owner runtime role (no BYPASSRLS, not a superuser - otherwise FORCE is skipped for it).

database/migrations/xxxx_force_rls_posts.ts
import { BaseSchema } from "@adonisjs/lucid/schema";

export default class extends BaseSchema {
  async up() {
    // run as the OWNER role that owns the table
    this.schema.raw(`ALTER TABLE posts ENABLE ROW LEVEL SECURITY`);
    this.schema.raw(`ALTER TABLE posts FORCE ROW LEVEL SECURITY`);
    this.schema.raw(`
      CREATE POLICY posts_tenant_isolation ON posts
      USING (
        tenant_id = current_setting('tenancyjs.tenant_id', true)
        OR current_setting('tenancyjs.is_central', true) = 'true'
      )
      WITH CHECK (
        tenant_id = current_setting('tenancyjs.tenant_id', true)
        OR current_setting('tenancyjs.is_central', true) = 'true'
      )
    `);
  }

  async down() {
    this.schema.raw(`DROP POLICY IF EXISTS posts_tenant_isolation ON posts`);
  }
}

The adapter sets tenancyjs.tenant_id (and tenancyjs.is_central in central scope) per transaction; the policy reads them. No GUC ⇒ no rows.

Register the provider and middleware

adonisrc.ts
providers: [
  // ...existing providers
  () => import("tenancyjs-integration-adonis/provider"),
],
app/middleware/tenant_middleware.ts
export { TenancyMiddleware as default } from "tenancyjs-integration-adonis";
start/kernel.ts
export const middleware = router.named({
  // ...existing named middleware
  tenant: () => import("#middleware/tenant_middleware"),
});

Apply it to tenant route groups only - central routes stay unmarked:

start/routes.ts
router
  .group(() => {
    // your tenant-scoped routes
  })
  .use(middleware.tenant());

Run app work inside both contexts

The middleware opens the request's tenant scope; run the manager scope and the Lucid scope together, and your models read isolated automatically - no where tenantId:

app/controllers/posts_controller.ts
const posts = await manager.runWithTenant(tenant, () =>
  tenancy.run(async () => {
    const posts = await Post.query().preload("comments");
    return posts;
  }),
);

Validate the adapter at startup and fail closed if it can't prove isolation:

const { valid } = await tenancy.validate();
if (!valid) throw new Error("Tenancy validation failed");

Cross-tenant admin work runs through an explicit central scope in application-owned code - never opened by a request.

Prove isolation

Write the two-tenant colliding-id test with node ace test: same primary key under A and B, assert A never reads B's row, and assert a raw query run as the runtime role with no GUC returns 0 rows (the RLS backstop). In tests, call config.tenancy.validate() yourself - the /testing helper does not auto-validate.

In Ace commands, scripts, and tests, call tenancy.validate() yourself - only the web provider validates automatically. A resolution failure fails closed, never central.

Full lifecycle and the security boundary: AdonisJS integration · Lucid adapter · Testing isolation.

On this page