Skip to content

Commit a068b72

Browse files
PAMulliganPaul Mulliganclaude
authored
feat: add multi-tenant SaaS template (--multi-tenant) (#133)
Extends setup-project.sh with a --multi-tenant flag (composable with any platform target) that scaffolds tenant isolation: - src/tenancy/ modules: strategy config (TENANCY_STRATEGY=row|schema), tenants registry schema + tenantScopedColumns() helper, tenant resolution middleware (JWT claim, subdomain, X-Tenant-ID header), tenant-scoped query facade + SET LOCAL app.tenant_id helper (row strategy), and per-tenant schema + search_path helpers (schema strategy) - Row Level Security policy template (fail-closed FOR ALL policy per tenant-scoped table), shipped as src/db/rls-policies.sql to apply via a custom Drizzle migration - Tenant-isolation test suite: resolution + SQL scoping tests run everywhere; an opt-in live PostgreSQL suite (TENANCY_TEST_DATABASE_URL) proves RLS visibility, WITH CHECK enforcement, and search_path isolation as a non-superuser role - docs/api-development/multi-tenancy.md documenting the trade-offs between row-based and schema-based isolation and usage of both - CI: validate templates/multi-tenant and add a node-multi-tenant generated-project job that also runs the tenancy tests Co-authored-by: Paul Mulligan <paul@Crowley.local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 1b9e7dc commit a068b72

13 files changed

Lines changed: 1664 additions & 23 deletions

File tree

.github/workflows/ci.yml

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ jobs:
166166
fi
167167
done
168168
169-
for dir in templates/shared templates/cloudflare-workers templates/node-server templates/docker templates/aws-lambda templates/railway templates/fly; do
169+
for dir in templates/shared templates/cloudflare-workers templates/node-server templates/docker templates/aws-lambda templates/railway templates/fly templates/multi-tenant; do
170170
if [ -d "$dir" ]; then
171171
echo " $dir: OK"
172172
else
@@ -236,13 +236,21 @@ jobs:
236236
# freshly generated projects (e.g. the TypeScript 6 @types auto-include
237237
# removal, #119) fails here instead of only on user machines.
238238
generated-projects:
239-
name: Generate & Type-check (${{ matrix.platform }})
239+
name: Generate & Type-check (${{ matrix.label }})
240240
runs-on: ubuntu-latest
241241
timeout-minutes: 10
242242
strategy:
243243
fail-fast: false
244244
matrix:
245-
platform: [cloudflare, node, lambda, railway, fly]
245+
include:
246+
- { platform: cloudflare, label: cloudflare }
247+
- { platform: node, label: node }
248+
- { platform: lambda, label: lambda }
249+
- { platform: railway, label: railway }
250+
- { platform: fly, label: fly }
251+
# Multi-tenant variant: also runs the tenant-isolation tests, since
252+
# the tenancy scaffolding ships its own test suite.
253+
- { platform: node, label: node-multi-tenant, flags: --multi-tenant }
246254
steps:
247255
- uses: actions/checkout@v7
248256

@@ -253,11 +261,16 @@ jobs:
253261
- run: corepack enable
254262

255263
- name: Generate project
256-
run: ./scripts/setup-project.sh demo-${{ matrix.platform }} --${{ matrix.platform }}
264+
run: ./scripts/setup-project.sh demo-${{ matrix.label }} --${{ matrix.platform }} ${{ matrix.flags }}
257265

258266
- name: Type-check generated project
259267
run: pnpm typecheck
260-
working-directory: demo-${{ matrix.platform }}/api
268+
working-directory: demo-${{ matrix.label }}/api
269+
270+
- name: Run tenant-isolation tests
271+
if: matrix.flags == '--multi-tenant'
272+
run: pnpm test
273+
working-directory: demo-${{ matrix.label }}/api
261274

262275
check-links:
263276
name: Check Markdown Links

CLAUDE.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ project-root/
4141
│ ├── aws-lambda/ # SAM template, esbuild config, OIDC deploy workflow
4242
│ ├── railway/ # Railway config-as-code (railway.toml, nixpacks.toml)
4343
│ ├── fly/ # Fly.io config (fly.toml)
44-
│ └── docker/ # Dockerfile, docker-compose
44+
│ ├── docker/ # Dockerfile, docker-compose
45+
│ └── multi-tenant/ # RLS policy template for --multi-tenant projects
4546
├── docs/
4647
│ ├── schema-to-api/ # Pipeline guide
4748
│ └── api-development/ # Development standards
@@ -61,6 +62,7 @@ project-root/
6162
```bash
6263
# Setup a new API project
6364
./scripts/setup-project.sh my-api --cloudflare # or --node / --lambda / --railway / --fly
65+
# add --multi-tenant for tenant-isolated SaaS scaffolding (see docs/api-development/multi-tenancy.md)
6466

6567
# Run tests with coverage
6668
./scripts/run-tests.sh
@@ -493,9 +495,10 @@ pnpm drizzle-kit studio # Open database GUI
493495
./scripts/setup-project.sh my-api --lambda # New AWS Lambda (SAM) project
494496
./scripts/setup-project.sh my-api --railway # New Railway project
495497
./scripts/setup-project.sh my-api --fly # New Fly.io project
498+
./scripts/setup-project.sh my-api --node --multi-tenant # Multi-tenant SaaS (row/schema isolation)
496499
```
497500

498501
---
499502

500-
**Last Updated:** 2026-06-12
503+
**Last Updated:** 2026-07-07
501504
**Architecture:** 24 agents, 12 skills, 4 plugins + gh CLI, 9 scripts

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ pnpm install
3838

3939
# Initialize a new API project
4040
./scripts/setup-project.sh my-api --cloudflare # or --node / --lambda / --railway / --fly
41+
# add --multi-tenant for tenant-isolated SaaS scaffolding
4142

4243
# Start development
4344
cd api && pnpm dev
@@ -183,6 +184,7 @@ Full catalog: [`.claude/CUSTOM-AGENTS-GUIDE.md`](.claude/CUSTOM-AGENTS-GUIDE.md)
183184
| `templates/railway/` | Railway config-as-code + Nixpacks build config |
184185
| `templates/fly/` | Fly.io config (HTTP service, health check, autoscaling) |
185186
| `templates/docker/` | Extended Docker with Redis + pgAdmin |
187+
| `templates/multi-tenant/` | RLS policy template for tenant isolation (`--multi-tenant` flag) |
186188

187189
## Part of the PMDS Framework Series
188190

docs/api-development/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,12 @@ Nerva versions APIs by URL prefix -- `/api/v1/...` -- configured in the `api.ver
353353

354354
See the [API versioning guide](./api-versioning.md) for all four strategies (URL prefix, header, content negotiation, query parameter) and how to implement each in Hono, the deprecation and sunset headers, the breaking vs. non-breaking reference, and a migration checklist for version bumps.
355355

356+
## Multi-Tenancy
357+
358+
Generate tenant-isolated SaaS projects with `./scripts/setup-project.sh my-saas --node --multi-tenant`. Two isolation strategies are supported, selected via `TENANCY_STRATEGY`: row-based (shared tables with a `tenant_id` column, tenant-scoped query helpers, and PostgreSQL Row Level Security as a fail-closed backstop) and schema-based (one PostgreSQL schema per tenant, switched with transaction-scoped `search_path`). Tenants are resolved per request from the JWT claim, subdomain, or `X-Tenant-ID` header.
359+
360+
See the [multi-tenancy guide](./multi-tenancy.md) for the trade-offs between the two strategies, the resolution middleware, RLS policy setup, per-tenant schema migrations, pooling caveats, and how to run the tenant-isolation test suite against a live database.
361+
356362
## Performance
357363

358364
### Query Optimization
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
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

Comments
 (0)