|
| 1 | +# Multi-Tenancy Guide |
| 2 | + |
| 3 | +How to build tenant-isolated SaaS APIs with Nerva. Generate a project with the |
| 4 | +scaffolding included: |
| 5 | + |
| 6 | +```bash |
| 7 | +./scripts/setup-project.sh my-saas --node --multi-tenant # any platform flag works |
| 8 | +``` |
| 9 | + |
| 10 | +This adds `api/src/tenancy/` (tenant registry schema, resolution middleware, |
| 11 | +scoped query helpers for both isolation strategies), an RLS policy template at |
| 12 | +`api/src/db/rls-policies.sql`, and tenant-isolation tests at |
| 13 | +`api/tests/tenancy.test.ts`. |
| 14 | + |
| 15 | +## The two isolation strategies |
| 16 | + |
| 17 | +Nerva supports both mainstream PostgreSQL tenancy models. Pick one per |
| 18 | +deployment with `TENANCY_STRATEGY` (`row` is the default); the resolution |
| 19 | +middleware and `tenants` registry are shared, only the data layer differs. |
| 20 | + |
| 21 | +### Row-based isolation (`TENANCY_STRATEGY=row`) |
| 22 | + |
| 23 | +All tenants share the same tables. Every tenant-scoped table carries a |
| 24 | +`tenant_id` foreign key, every query filters on it, and PostgreSQL Row Level |
| 25 | +Security enforces the boundary inside the database. |
| 26 | + |
| 27 | +``` |
| 28 | +┌─────────────────────────────────────┐ |
| 29 | +│ public │ |
| 30 | +│ tenants (id, name, slug, plan) │ |
| 31 | +│ projects (id, tenant_id, ...) │ ← one table, all tenants, |
| 32 | +│ orders (id, tenant_id, ...) │ RLS + WHERE tenant_id = $1 |
| 33 | +└─────────────────────────────────────┘ |
| 34 | +``` |
| 35 | + |
| 36 | +### Schema-based isolation (`TENANCY_STRATEGY=schema`) |
| 37 | + |
| 38 | +Each tenant gets its own PostgreSQL schema containing a full copy of the |
| 39 | +resource tables. Queries run with `search_path` pointed at the tenant's |
| 40 | +schema; tables need no `tenant_id` column. |
| 41 | + |
| 42 | +``` |
| 43 | +┌────────────────┐ ┌────────────────┐ ┌────────────────┐ |
| 44 | +│ public │ │ tenant_acme │ │ tenant_globex │ |
| 45 | +│ tenants │ │ projects │ │ projects │ |
| 46 | +│ (registry) │ │ orders │ │ orders │ |
| 47 | +└────────────────┘ └────────────────┘ └────────────────┘ |
| 48 | +``` |
| 49 | + |
| 50 | +## Trade-offs: which strategy to choose |
| 51 | + |
| 52 | +| Dimension | Row-based (`tenant_id` + RLS) | Schema-based (schema per tenant) | |
| 53 | +|-----------|-------------------------------|----------------------------------| |
| 54 | +| Isolation strength | Logical — enforced by RLS policies and query filters | Structural — data lives in physically separate namespaces | |
| 55 | +| Tenant count | Scales to millions of tenants | Practical up to low thousands; catalogs, backups, and migrations degrade beyond that | |
| 56 | +| Tenant onboarding | One `INSERT INTO tenants` | `CREATE SCHEMA` + run migrations for the new schema | |
| 57 | +| Migrations | One migration run for everyone | One run **per tenant schema**; slow rollouts, risk of drift if a run fails midway | |
| 58 | +| Cross-tenant queries (admin dashboards, aggregate analytics) | Trivial — omit the filter with a privileged role | Painful — `UNION` across schemas or ETL into a warehouse | |
| 59 | +| Per-tenant backup / restore / export | Row-filtered dumps (fiddly) | `pg_dump --schema=tenant_x` (natural) | |
| 60 | +| Noisy-neighbor blast radius | Shared tables and indexes; one tenant's bloat affects all | Separate indexes per tenant; still shared CPU/IO on one server | |
| 61 | +| Per-tenant customization (extra columns, retention) | Hard — schema is shared | Easy — schemas can diverge (at the cost of drift) | |
| 62 | +| Connection pooling | Pool-friendly: tenant context is per-transaction (`SET LOCAL`) | Pool-friendly with `SET LOCAL search_path`, but plan caches grow per schema | |
| 63 | +| Compliance optics | "Logically isolated" — fine for most SOC 2 / GDPR setups | "Physically separated" — easier story for strict enterprise/regulated buyers | |
| 64 | +| Operational complexity | Low | Medium-high (migration runner, schema lifecycle, monitoring per schema) | |
| 65 | + |
| 66 | +**Default to row-based.** It scales further, keeps migrations and analytics |
| 67 | +simple, and RLS gives you database-enforced isolation without physical |
| 68 | +separation. Choose schema-based when you have a bounded number of high-value |
| 69 | +tenants that demand stronger separation, per-tenant restore/export, or |
| 70 | +per-tenant schema divergence — or as a stepping stone to database-per-tenant |
| 71 | +for your largest customers. (Full database-per-tenant is out of scope for this |
| 72 | +template; it is the same trade-off pushed one level up.) |
| 73 | + |
| 74 | +## Tenant resolution |
| 75 | + |
| 76 | +`tenantResolver` (in `src/tenancy/middleware.ts`) maps each request to a |
| 77 | +tenant and stores it on the Hono context. Three sources are supported, tried |
| 78 | +in priority order (default `['jwt', 'subdomain', 'header']`): |
| 79 | + |
| 80 | +| Source | Example | Trust | |
| 81 | +|--------|---------|-------| |
| 82 | +| JWT claim | `tenant_id` claim in the verified token | Signed — cannot be spoofed. Default first. | |
| 83 | +| Subdomain | `acme.api.example.com` → slug `acme` | Controlled by DNS/routing. Good for public APIs. | |
| 84 | +| `X-Tenant-ID` header | `X-Tenant-ID: <uuid>` | **Spoofable.** Only trust behind a gateway that strips it from external traffic, or verify membership in `lookupTenant`. | |
| 85 | + |
| 86 | +```typescript |
| 87 | +import { eq } from 'drizzle-orm'; |
| 88 | +import { tenantResolver, getTenant } from './tenancy/middleware'; |
| 89 | +import { tenants, type Tenant } from './tenancy/schema'; |
| 90 | +import { loadTenancyConfig } from './tenancy/config'; |
| 91 | + |
| 92 | +const tenancy = loadTenancyConfig(); |
| 93 | + |
| 94 | +app.use( |
| 95 | + '/api/*', |
| 96 | + tenantResolver({ |
| 97 | + baseDomain: tenancy.baseDomain, |
| 98 | + headerName: tenancy.headerName, |
| 99 | + jwtClaim: tenancy.jwtClaim, |
| 100 | + lookupTenant: async (ref) => |
| 101 | + db.query.tenants.findFirst({ |
| 102 | + where: ref.source === 'subdomain' |
| 103 | + ? eq(tenants.slug, ref.value) |
| 104 | + : eq(tenants.id, ref.value), |
| 105 | + }), |
| 106 | + }), |
| 107 | +); |
| 108 | +``` |
| 109 | + |
| 110 | +Unresolvable requests get `400 TENANT_UNRESOLVED`; unknown tenants get `404 |
| 111 | +TENANT_NOT_FOUND` (404 rather than 403, so probes cannot enumerate tenants). |
| 112 | +When you mount `hono/jwt`, mount it *before* the resolver so the verified |
| 113 | +payload is available, and put per-tenant caching inside `lookupTenant` — it |
| 114 | +runs on every request. |
| 115 | + |
| 116 | +## Row strategy in practice |
| 117 | + |
| 118 | +### Schema additions |
| 119 | + |
| 120 | +`src/tenancy/schema.ts` defines the `tenants` registry and a |
| 121 | +`tenantScopedColumns()` helper. Every resource table spreads it and indexes |
| 122 | +the column: |
| 123 | + |
| 124 | +```typescript |
| 125 | +export const orders = pgTable('orders', { |
| 126 | + id: uuid('id').defaultRandom().primaryKey(), |
| 127 | + ...tenantScopedColumns(), // tenant_id uuid NOT NULL REFERENCES tenants(id) |
| 128 | + total: integer('total').notNull(), |
| 129 | +}, (table) => [index('orders_tenant_id_idx').on(table.tenantId)]); |
| 130 | +``` |
| 131 | + |
| 132 | +The generated `drizzle.config.ts` already includes `src/tenancy/schema.ts`, |
| 133 | +so `pnpm drizzle-kit generate` picks these tables up. |
| 134 | + |
| 135 | +### Scoped queries |
| 136 | + |
| 137 | +Isolation is enforced twice, deliberately: |
| 138 | + |
| 139 | +1. **Application layer** — `createTenantDb` returns a facade whose every |
| 140 | + method injects the tenant predicate, so handlers cannot forget it: |
| 141 | + |
| 142 | + ```typescript |
| 143 | + app.get('/api/projects', async (c) => { |
| 144 | + const tenant = getTenant<Tenant>(c); |
| 145 | + const tdb = createTenantDb(db, tenant.id); |
| 146 | + return c.json(await tdb.findMany(projects)); // WHERE tenant_id = $1 |
| 147 | + }); |
| 148 | + |
| 149 | + // findFirst / insert / update / delete are scoped the same way: |
| 150 | + await tdb.insert(projects, { name: 'Q3 launch' }); // tenant_id injected |
| 151 | + await tdb.update(projects, { name: 'Q4' }, eq(projects.id, id)); |
| 152 | + ``` |
| 153 | + |
| 154 | + For hand-written queries, compose `withTenantFilter(table, tenantId, ...)` |
| 155 | + into the `where` clause — same pattern as the soft-delete helpers. |
| 156 | + |
| 157 | +2. **Database layer (RLS)** — `withTenant` binds `app.tenant_id` for one |
| 158 | + transaction; the policies compare each row against it: |
| 159 | + |
| 160 | + ```typescript |
| 161 | + const rows = await withTenant(db, tenant.id, (tx) => |
| 162 | + tx.select().from(projects).where(withTenantFilter(projects, tenant.id)), |
| 163 | + ); |
| 164 | + ``` |
| 165 | + |
| 166 | + The application filter gives correct results and index-friendly plans; RLS |
| 167 | + is the backstop that turns a forgotten filter into *zero rows* instead of |
| 168 | + a cross-tenant data leak. |
| 169 | + |
| 170 | +### Enabling RLS |
| 171 | + |
| 172 | +The policy template is copied to `api/src/db/rls-policies.sql`. Ship it as a |
| 173 | +custom migration: |
| 174 | + |
| 175 | +```bash |
| 176 | +pnpm drizzle-kit generate --custom --name enable-rls |
| 177 | +# paste the SQL (one block per tenant-scoped table) into the new migration |
| 178 | +pnpm drizzle-kit migrate |
| 179 | +``` |
| 180 | + |
| 181 | +Each block enables + forces RLS and adds one `FOR ALL` policy whose `USING` |
| 182 | +clause hides other tenants' rows and whose `WITH CHECK` clause blocks writing |
| 183 | +them (including `UPDATE ... SET tenant_id = <other>`). The policy reads |
| 184 | +`current_setting('app.tenant_id', true)` and **fails closed**: no tenant |
| 185 | +context, no rows. |
| 186 | + |
| 187 | +Two things bypass RLS and must be avoided for the API's database user: |
| 188 | + |
| 189 | +- **Superusers and `BYPASSRLS` roles** ignore policies entirely. The default |
| 190 | + `postgres` user in most Docker images is a superuser — create a dedicated |
| 191 | + application role (the template's header comment has the statements). |
| 192 | +- **Table owners** ignore policies unless `FORCE ROW LEVEL SECURITY` is set — |
| 193 | + the template sets it on every table. |
| 194 | + |
| 195 | +## Schema strategy in practice |
| 196 | + |
| 197 | +Onboarding creates the schema, then applies migrations to it: |
| 198 | + |
| 199 | +```typescript |
| 200 | +import { createTenantSchema, withTenantSchema } from './tenancy/schema-scope'; |
| 201 | + |
| 202 | +await db.insert(tenants).values({ name: 'Acme', slug: 'acme' }); |
| 203 | +await createTenantSchema(db, 'acme'); // CREATE SCHEMA tenant_acme |
| 204 | +``` |
| 205 | + |
| 206 | +Queries run inside `withTenantSchema`, which points `search_path` at the |
| 207 | +tenant's schema with `SET LOCAL` (transaction-scoped, so pooled connections |
| 208 | +never leak a tenant): |
| 209 | + |
| 210 | +```typescript |
| 211 | +const rows = await withTenantSchema(db, tenant.slug, (tx) => |
| 212 | + tx.select().from(projects), // resolves to tenant_acme.projects |
| 213 | +); |
| 214 | +``` |
| 215 | + |
| 216 | +Slugs are validated (`[a-z0-9_-]{1,48}`) before being turned into |
| 217 | +identifiers; anything else throws rather than getting escaped into DDL. |
| 218 | + |
| 219 | +### Migrations per tenant schema |
| 220 | + |
| 221 | +Migrations must run once per schema. A minimal runner using Drizzle's |
| 222 | +programmatic migrator (Node targets): |
| 223 | + |
| 224 | +```typescript |
| 225 | +// scripts/migrate-tenants.ts — run with: pnpm tsx scripts/migrate-tenants.ts |
| 226 | +import { drizzle } from 'drizzle-orm/postgres-js'; |
| 227 | +import { migrate } from 'drizzle-orm/postgres-js/migrator'; |
| 228 | +import postgres from 'postgres'; |
| 229 | +import { tenants } from '../src/tenancy/schema'; |
| 230 | +import { tenantSchemaName } from '../src/tenancy/schema-scope'; |
| 231 | + |
| 232 | +const client = postgres(process.env.DATABASE_URL!, { max: 1 }); |
| 233 | +const db = drizzle(client); |
| 234 | + |
| 235 | +for (const tenant of await db.select().from(tenants)) { |
| 236 | + const schema = tenantSchemaName(tenant.slug); |
| 237 | + await client.unsafe(`set search_path to "${schema}", public`); |
| 238 | + await migrate(db, { |
| 239 | + migrationsFolder: './src/db/migrations', |
| 240 | + migrationsSchema: schema, // per-schema migration journal |
| 241 | + }); |
| 242 | + console.log(`migrated ${schema}`); |
| 243 | +} |
| 244 | +await client.end(); |
| 245 | +``` |
| 246 | + |
| 247 | +Run it in CI/CD after deploying, and at onboarding for the new tenant's |
| 248 | +schema. Keep resource tables **out of `public`** under this strategy: |
| 249 | +`search_path` falls through to `public`, so a stray shared copy of a table |
| 250 | +would silently serve requests for any tenant whose schema is missing it. |
| 251 | + |
| 252 | +## Caveats that apply to both strategies |
| 253 | + |
| 254 | +- **`SET LOCAL` + poolers.** Both `withTenant` and `withTenantSchema` bind |
| 255 | + tenant context with `set_config(..., true)` inside a transaction, which is |
| 256 | + safe with connection pooling (including transaction-mode PgBouncer). Never |
| 257 | + use session-level `SET` for tenant context on pooled connections. |
| 258 | +- **The `tenants` registry is not tenant-scoped.** The resolver must read it |
| 259 | + before any tenant context exists; don't add RLS to it (or give it only a |
| 260 | + permissive read policy). |
| 261 | +- **Admin/cross-tenant paths must be explicit.** Analytics jobs or support |
| 262 | + tooling that legitimately crosses tenants should use a separate privileged |
| 263 | + role and never share code paths with request handlers. |
| 264 | + |
| 265 | +## Testing tenant isolation |
| 266 | + |
| 267 | +`api/tests/tenancy.test.ts` runs in two layers: |
| 268 | + |
| 269 | +- **Always on** (no database needed): resolution from subdomain/header/JWT, |
| 270 | + precedence and rejection cases, and SQL-level assertions that every |
| 271 | + `createTenantDb` method and both `SET LOCAL` helpers emit tenant-scoped |
| 272 | + statements. |
| 273 | +- **Live suite** (opt-in): proves isolation against a real PostgreSQL — RLS |
| 274 | + visibility per tenant, fail-closed with no context, `WITH CHECK` blocking |
| 275 | + cross-tenant writes, and `search_path` schema isolation — running as a |
| 276 | + dedicated non-superuser role, the same shape production should use. |
| 277 | + |
| 278 | +```bash |
| 279 | +docker compose up -d |
| 280 | +TENANCY_TEST_DATABASE_URL=postgresql://nerva:nerva_secret@localhost:5432/nerva_db pnpm test |
| 281 | +``` |
| 282 | + |
| 283 | +Treat the live suite as a required CI gate for any real multi-tenant product: |
| 284 | +run it against the CI database service so a policy regression fails the build |
| 285 | +instead of leaking a tenant's data. |
0 commit comments