diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5540d5b..722cf8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -166,7 +166,7 @@ jobs: fi done - for dir in templates/shared templates/cloudflare-workers templates/node-server templates/docker templates/aws-lambda templates/railway templates/fly; do + for dir in templates/shared templates/cloudflare-workers templates/node-server templates/docker templates/aws-lambda templates/railway templates/fly templates/multi-tenant; do if [ -d "$dir" ]; then echo " $dir: OK" else @@ -236,13 +236,21 @@ jobs: # freshly generated projects (e.g. the TypeScript 6 @types auto-include # removal, #119) fails here instead of only on user machines. generated-projects: - name: Generate & Type-check (${{ matrix.platform }}) + name: Generate & Type-check (${{ matrix.label }}) runs-on: ubuntu-latest timeout-minutes: 10 strategy: fail-fast: false matrix: - platform: [cloudflare, node, lambda, railway, fly] + include: + - { platform: cloudflare, label: cloudflare } + - { platform: node, label: node } + - { platform: lambda, label: lambda } + - { platform: railway, label: railway } + - { platform: fly, label: fly } + # Multi-tenant variant: also runs the tenant-isolation tests, since + # the tenancy scaffolding ships its own test suite. + - { platform: node, label: node-multi-tenant, flags: --multi-tenant } steps: - uses: actions/checkout@v7 @@ -253,11 +261,16 @@ jobs: - run: corepack enable - name: Generate project - run: ./scripts/setup-project.sh demo-${{ matrix.platform }} --${{ matrix.platform }} + run: ./scripts/setup-project.sh demo-${{ matrix.label }} --${{ matrix.platform }} ${{ matrix.flags }} - name: Type-check generated project run: pnpm typecheck - working-directory: demo-${{ matrix.platform }}/api + working-directory: demo-${{ matrix.label }}/api + + - name: Run tenant-isolation tests + if: matrix.flags == '--multi-tenant' + run: pnpm test + working-directory: demo-${{ matrix.label }}/api check-links: name: Check Markdown Links diff --git a/CLAUDE.md b/CLAUDE.md index 3ea98f8..f5a03c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,8 @@ project-root/ │ ├── aws-lambda/ # SAM template, esbuild config, OIDC deploy workflow │ ├── railway/ # Railway config-as-code (railway.toml, nixpacks.toml) │ ├── fly/ # Fly.io config (fly.toml) -│ └── docker/ # Dockerfile, docker-compose +│ ├── docker/ # Dockerfile, docker-compose +│ └── multi-tenant/ # RLS policy template for --multi-tenant projects ├── docs/ │ ├── schema-to-api/ # Pipeline guide │ └── api-development/ # Development standards @@ -61,6 +62,7 @@ project-root/ ```bash # Setup a new API project ./scripts/setup-project.sh my-api --cloudflare # or --node / --lambda / --railway / --fly +# add --multi-tenant for tenant-isolated SaaS scaffolding (see docs/api-development/multi-tenancy.md) # Run tests with coverage ./scripts/run-tests.sh @@ -493,9 +495,10 @@ pnpm drizzle-kit studio # Open database GUI ./scripts/setup-project.sh my-api --lambda # New AWS Lambda (SAM) project ./scripts/setup-project.sh my-api --railway # New Railway project ./scripts/setup-project.sh my-api --fly # New Fly.io project +./scripts/setup-project.sh my-api --node --multi-tenant # Multi-tenant SaaS (row/schema isolation) ``` --- -**Last Updated:** 2026-06-12 +**Last Updated:** 2026-07-07 **Architecture:** 24 agents, 12 skills, 4 plugins + gh CLI, 9 scripts \ No newline at end of file diff --git a/README.md b/README.md index de6302d..bdd67f9 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ pnpm install # Initialize a new API project ./scripts/setup-project.sh my-api --cloudflare # or --node / --lambda / --railway / --fly +# add --multi-tenant for tenant-isolated SaaS scaffolding # Start development cd api && pnpm dev @@ -183,6 +184,7 @@ Full catalog: [`.claude/CUSTOM-AGENTS-GUIDE.md`](.claude/CUSTOM-AGENTS-GUIDE.md) | `templates/railway/` | Railway config-as-code + Nixpacks build config | | `templates/fly/` | Fly.io config (HTTP service, health check, autoscaling) | | `templates/docker/` | Extended Docker with Redis + pgAdmin | +| `templates/multi-tenant/` | RLS policy template for tenant isolation (`--multi-tenant` flag) | ## Part of the PMDS Framework Series diff --git a/docs/api-development/README.md b/docs/api-development/README.md index 4c5b555..77910c3 100644 --- a/docs/api-development/README.md +++ b/docs/api-development/README.md @@ -353,6 +353,12 @@ Nerva versions APIs by URL prefix -- `/api/v1/...` -- configured in the `api.ver 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. +## Multi-Tenancy + +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. + +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. + ## Performance ### Query Optimization diff --git a/docs/api-development/multi-tenancy.md b/docs/api-development/multi-tenancy.md new file mode 100644 index 0000000..9c7304a --- /dev/null +++ b/docs/api-development/multi-tenancy.md @@ -0,0 +1,285 @@ +# Multi-Tenancy Guide + +How to build tenant-isolated SaaS APIs with Nerva. Generate a project with the +scaffolding included: + +```bash +./scripts/setup-project.sh my-saas --node --multi-tenant # any platform flag works +``` + +This adds `api/src/tenancy/` (tenant registry schema, resolution middleware, +scoped query helpers for both isolation strategies), an RLS policy template at +`api/src/db/rls-policies.sql`, and tenant-isolation tests at +`api/tests/tenancy.test.ts`. + +## The two isolation strategies + +Nerva supports both mainstream PostgreSQL tenancy models. Pick one per +deployment with `TENANCY_STRATEGY` (`row` is the default); the resolution +middleware and `tenants` registry are shared, only the data layer differs. + +### Row-based isolation (`TENANCY_STRATEGY=row`) + +All tenants share the same tables. Every tenant-scoped table carries a +`tenant_id` foreign key, every query filters on it, and PostgreSQL Row Level +Security enforces the boundary inside the database. + +``` +┌─────────────────────────────────────┐ +│ public │ +│ tenants (id, name, slug, plan) │ +│ projects (id, tenant_id, ...) │ ← one table, all tenants, +│ orders (id, tenant_id, ...) │ RLS + WHERE tenant_id = $1 +└─────────────────────────────────────┘ +``` + +### Schema-based isolation (`TENANCY_STRATEGY=schema`) + +Each tenant gets its own PostgreSQL schema containing a full copy of the +resource tables. Queries run with `search_path` pointed at the tenant's +schema; tables need no `tenant_id` column. + +``` +┌────────────────┐ ┌────────────────┐ ┌────────────────┐ +│ public │ │ tenant_acme │ │ tenant_globex │ +│ tenants │ │ projects │ │ projects │ +│ (registry) │ │ orders │ │ orders │ +└────────────────┘ └────────────────┘ └────────────────┘ +``` + +## Trade-offs: which strategy to choose + +| Dimension | Row-based (`tenant_id` + RLS) | Schema-based (schema per tenant) | +|-----------|-------------------------------|----------------------------------| +| Isolation strength | Logical — enforced by RLS policies and query filters | Structural — data lives in physically separate namespaces | +| Tenant count | Scales to millions of tenants | Practical up to low thousands; catalogs, backups, and migrations degrade beyond that | +| Tenant onboarding | One `INSERT INTO tenants` | `CREATE SCHEMA` + run migrations for the new schema | +| Migrations | One migration run for everyone | One run **per tenant schema**; slow rollouts, risk of drift if a run fails midway | +| Cross-tenant queries (admin dashboards, aggregate analytics) | Trivial — omit the filter with a privileged role | Painful — `UNION` across schemas or ETL into a warehouse | +| Per-tenant backup / restore / export | Row-filtered dumps (fiddly) | `pg_dump --schema=tenant_x` (natural) | +| 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 | +| Per-tenant customization (extra columns, retention) | Hard — schema is shared | Easy — schemas can diverge (at the cost of drift) | +| Connection pooling | Pool-friendly: tenant context is per-transaction (`SET LOCAL`) | Pool-friendly with `SET LOCAL search_path`, but plan caches grow per schema | +| Compliance optics | "Logically isolated" — fine for most SOC 2 / GDPR setups | "Physically separated" — easier story for strict enterprise/regulated buyers | +| Operational complexity | Low | Medium-high (migration runner, schema lifecycle, monitoring per schema) | + +**Default to row-based.** It scales further, keeps migrations and analytics +simple, and RLS gives you database-enforced isolation without physical +separation. Choose schema-based when you have a bounded number of high-value +tenants that demand stronger separation, per-tenant restore/export, or +per-tenant schema divergence — or as a stepping stone to database-per-tenant +for your largest customers. (Full database-per-tenant is out of scope for this +template; it is the same trade-off pushed one level up.) + +## Tenant resolution + +`tenantResolver` (in `src/tenancy/middleware.ts`) maps each request to a +tenant and stores it on the Hono context. Three sources are supported, tried +in priority order (default `['jwt', 'subdomain', 'header']`): + +| Source | Example | Trust | +|--------|---------|-------| +| JWT claim | `tenant_id` claim in the verified token | Signed — cannot be spoofed. Default first. | +| Subdomain | `acme.api.example.com` → slug `acme` | Controlled by DNS/routing. Good for public APIs. | +| `X-Tenant-ID` header | `X-Tenant-ID: ` | **Spoofable.** Only trust behind a gateway that strips it from external traffic, or verify membership in `lookupTenant`. | + +```typescript +import { eq } from 'drizzle-orm'; +import { tenantResolver, getTenant } from './tenancy/middleware'; +import { tenants, type Tenant } from './tenancy/schema'; +import { loadTenancyConfig } from './tenancy/config'; + +const tenancy = loadTenancyConfig(); + +app.use( + '/api/*', + tenantResolver({ + baseDomain: tenancy.baseDomain, + headerName: tenancy.headerName, + jwtClaim: tenancy.jwtClaim, + lookupTenant: async (ref) => + db.query.tenants.findFirst({ + where: ref.source === 'subdomain' + ? eq(tenants.slug, ref.value) + : eq(tenants.id, ref.value), + }), + }), +); +``` + +Unresolvable requests get `400 TENANT_UNRESOLVED`; unknown tenants get `404 +TENANT_NOT_FOUND` (404 rather than 403, so probes cannot enumerate tenants). +When you mount `hono/jwt`, mount it *before* the resolver so the verified +payload is available, and put per-tenant caching inside `lookupTenant` — it +runs on every request. + +## Row strategy in practice + +### Schema additions + +`src/tenancy/schema.ts` defines the `tenants` registry and a +`tenantScopedColumns()` helper. Every resource table spreads it and indexes +the column: + +```typescript +export const orders = pgTable('orders', { + id: uuid('id').defaultRandom().primaryKey(), + ...tenantScopedColumns(), // tenant_id uuid NOT NULL REFERENCES tenants(id) + total: integer('total').notNull(), +}, (table) => [index('orders_tenant_id_idx').on(table.tenantId)]); +``` + +The generated `drizzle.config.ts` already includes `src/tenancy/schema.ts`, +so `pnpm drizzle-kit generate` picks these tables up. + +### Scoped queries + +Isolation is enforced twice, deliberately: + +1. **Application layer** — `createTenantDb` returns a facade whose every + method injects the tenant predicate, so handlers cannot forget it: + + ```typescript + app.get('/api/projects', async (c) => { + const tenant = getTenant(c); + const tdb = createTenantDb(db, tenant.id); + return c.json(await tdb.findMany(projects)); // WHERE tenant_id = $1 + }); + + // findFirst / insert / update / delete are scoped the same way: + await tdb.insert(projects, { name: 'Q3 launch' }); // tenant_id injected + await tdb.update(projects, { name: 'Q4' }, eq(projects.id, id)); + ``` + + For hand-written queries, compose `withTenantFilter(table, tenantId, ...)` + into the `where` clause — same pattern as the soft-delete helpers. + +2. **Database layer (RLS)** — `withTenant` binds `app.tenant_id` for one + transaction; the policies compare each row against it: + + ```typescript + const rows = await withTenant(db, tenant.id, (tx) => + tx.select().from(projects).where(withTenantFilter(projects, tenant.id)), + ); + ``` + + The application filter gives correct results and index-friendly plans; RLS + is the backstop that turns a forgotten filter into *zero rows* instead of + a cross-tenant data leak. + +### Enabling RLS + +The policy template is copied to `api/src/db/rls-policies.sql`. Ship it as a +custom migration: + +```bash +pnpm drizzle-kit generate --custom --name enable-rls +# paste the SQL (one block per tenant-scoped table) into the new migration +pnpm drizzle-kit migrate +``` + +Each block enables + forces RLS and adds one `FOR ALL` policy whose `USING` +clause hides other tenants' rows and whose `WITH CHECK` clause blocks writing +them (including `UPDATE ... SET tenant_id = `). The policy reads +`current_setting('app.tenant_id', true)` and **fails closed**: no tenant +context, no rows. + +Two things bypass RLS and must be avoided for the API's database user: + +- **Superusers and `BYPASSRLS` roles** ignore policies entirely. The default + `postgres` user in most Docker images is a superuser — create a dedicated + application role (the template's header comment has the statements). +- **Table owners** ignore policies unless `FORCE ROW LEVEL SECURITY` is set — + the template sets it on every table. + +## Schema strategy in practice + +Onboarding creates the schema, then applies migrations to it: + +```typescript +import { createTenantSchema, withTenantSchema } from './tenancy/schema-scope'; + +await db.insert(tenants).values({ name: 'Acme', slug: 'acme' }); +await createTenantSchema(db, 'acme'); // CREATE SCHEMA tenant_acme +``` + +Queries run inside `withTenantSchema`, which points `search_path` at the +tenant's schema with `SET LOCAL` (transaction-scoped, so pooled connections +never leak a tenant): + +```typescript +const rows = await withTenantSchema(db, tenant.slug, (tx) => + tx.select().from(projects), // resolves to tenant_acme.projects +); +``` + +Slugs are validated (`[a-z0-9_-]{1,48}`) before being turned into +identifiers; anything else throws rather than getting escaped into DDL. + +### Migrations per tenant schema + +Migrations must run once per schema. A minimal runner using Drizzle's +programmatic migrator (Node targets): + +```typescript +// scripts/migrate-tenants.ts — run with: pnpm tsx scripts/migrate-tenants.ts +import { drizzle } from 'drizzle-orm/postgres-js'; +import { migrate } from 'drizzle-orm/postgres-js/migrator'; +import postgres from 'postgres'; +import { tenants } from '../src/tenancy/schema'; +import { tenantSchemaName } from '../src/tenancy/schema-scope'; + +const client = postgres(process.env.DATABASE_URL!, { max: 1 }); +const db = drizzle(client); + +for (const tenant of await db.select().from(tenants)) { + const schema = tenantSchemaName(tenant.slug); + await client.unsafe(`set search_path to "${schema}", public`); + await migrate(db, { + migrationsFolder: './src/db/migrations', + migrationsSchema: schema, // per-schema migration journal + }); + console.log(`migrated ${schema}`); +} +await client.end(); +``` + +Run it in CI/CD after deploying, and at onboarding for the new tenant's +schema. Keep resource tables **out of `public`** under this strategy: +`search_path` falls through to `public`, so a stray shared copy of a table +would silently serve requests for any tenant whose schema is missing it. + +## Caveats that apply to both strategies + +- **`SET LOCAL` + poolers.** Both `withTenant` and `withTenantSchema` bind + tenant context with `set_config(..., true)` inside a transaction, which is + safe with connection pooling (including transaction-mode PgBouncer). Never + use session-level `SET` for tenant context on pooled connections. +- **The `tenants` registry is not tenant-scoped.** The resolver must read it + before any tenant context exists; don't add RLS to it (or give it only a + permissive read policy). +- **Admin/cross-tenant paths must be explicit.** Analytics jobs or support + tooling that legitimately crosses tenants should use a separate privileged + role and never share code paths with request handlers. + +## Testing tenant isolation + +`api/tests/tenancy.test.ts` runs in two layers: + +- **Always on** (no database needed): resolution from subdomain/header/JWT, + precedence and rejection cases, and SQL-level assertions that every + `createTenantDb` method and both `SET LOCAL` helpers emit tenant-scoped + statements. +- **Live suite** (opt-in): proves isolation against a real PostgreSQL — RLS + visibility per tenant, fail-closed with no context, `WITH CHECK` blocking + cross-tenant writes, and `search_path` schema isolation — running as a + dedicated non-superuser role, the same shape production should use. + +```bash +docker compose up -d +TENANCY_TEST_DATABASE_URL=postgresql://nerva:nerva_secret@localhost:5432/nerva_db pnpm test +``` + +Treat the live suite as a required CI gate for any real multi-tenant product: +run it against the CI database service so a policy regression fails the build +instead of leaking a tenant's data. diff --git a/scripts/setup-project.sh b/scripts/setup-project.sh index 8f47778..6cc2006 100755 --- a/scripts/setup-project.sh +++ b/scripts/setup-project.sh @@ -3,7 +3,7 @@ set -euo pipefail # ============================================================================ # setup-project.sh - Initialize a new Nerva API project -# Usage: ./scripts/setup-project.sh [--cloudflare|--node|--lambda|--railway|--fly] [--dry-run] +# Usage: ./scripts/setup-project.sh [--cloudflare|--node|--lambda|--railway|--fly] [--multi-tenant] [--dry-run] # ============================================================================ RED='\033[0;31m' @@ -26,13 +26,14 @@ TEMPLATES_DIR="$PROJECT_ROOT/templates" if [[ $# -lt 1 ]]; then error "Missing project name." - echo "Usage: $0 [--cloudflare|--node|--lambda|--railway|--fly] [--dry-run]" - echo " --cloudflare Set up for Cloudflare Workers deployment" - echo " --node Set up for Node.js / Docker deployment (default)" - echo " --lambda Set up for AWS Lambda deployment (SAM + API Gateway HTTP API)" - echo " --railway Set up for Railway deployment (Docker build + managed PostgreSQL)" - echo " --fly Set up for Fly.io deployment (Docker build + Fly Machines + managed PostgreSQL)" - echo " --dry-run Preview what would be created without making changes" + echo "Usage: $0 [--cloudflare|--node|--lambda|--railway|--fly] [--multi-tenant] [--dry-run]" + echo " --cloudflare Set up for Cloudflare Workers deployment" + echo " --node Set up for Node.js / Docker deployment (default)" + echo " --lambda Set up for AWS Lambda deployment (SAM + API Gateway HTTP API)" + echo " --railway Set up for Railway deployment (Docker build + managed PostgreSQL)" + echo " --fly Set up for Fly.io deployment (Docker build + Fly Machines + managed PostgreSQL)" + echo " --multi-tenant Add multi-tenant SaaS scaffolding (tenant resolution, scoped queries, RLS)" + echo " --dry-run Preview what would be created without making changes" exit 1 fi @@ -40,16 +41,18 @@ PROJECT_NAME="$1" shift PLATFORM="node" +MULTI_TENANT=false DRY_RUN=false while [[ $# -gt 0 ]]; do case "$1" in - --cloudflare) PLATFORM="cloudflare"; shift ;; - --node) PLATFORM="node"; shift ;; - --lambda) PLATFORM="lambda"; shift ;; - --railway) PLATFORM="railway"; shift ;; - --fly) PLATFORM="fly"; shift ;; - --dry-run) DRY_RUN=true; shift ;; - *) error "Unknown option: $1"; exit 1 ;; + --cloudflare) PLATFORM="cloudflare"; shift ;; + --node) PLATFORM="node"; shift ;; + --lambda) PLATFORM="lambda"; shift ;; + --railway) PLATFORM="railway"; shift ;; + --fly) PLATFORM="fly"; shift ;; + --multi-tenant) MULTI_TENANT=true; shift ;; + --dry-run) DRY_RUN=true; shift ;; + *) error "Unknown option: $1"; exit 1 ;; esac done @@ -85,6 +88,17 @@ write_file() { fi } +# Reads heredoc content from stdin; appends it to an existing generated file. +append_file() { + local dest="$1" + if $DRY_RUN; then + dryrun "Would append to: $dest" + cat > /dev/null + else + cat >> "$dest" + fi +} + run_cmd() { if $DRY_RUN; then dryrun "Would run: $*" @@ -212,7 +226,25 @@ else run_cmd pnpm add @hono/node-server fi -write_file "$API_DIR/drizzle.config.ts" << 'DEOF' +if $MULTI_TENANT; then + write_file "$API_DIR/drizzle.config.ts" << 'DEOF' +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + // Tenancy tables (tenants registry + tenant-scoped resources) live in + // src/tenancy/schema.ts alongside the base schema. + schema: ['./src/db/schema.ts', './src/tenancy/schema.ts'], + out: './src/db/migrations', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL!, + }, + verbose: true, + strict: true, +}); +DEOF +else + write_file "$API_DIR/drizzle.config.ts" << 'DEOF' import { defineConfig } from 'drizzle-kit'; export default defineConfig({ @@ -226,6 +258,7 @@ export default defineConfig({ strict: true, }); DEOF +fi copy_file "$TEMPLATES_DIR/snippets/shared/src/db/schema.ts" "$API_DIR/src/db/schema.ts" copy_file "$TEMPLATES_DIR/snippets/shared/src/db/client.ts" "$API_DIR/src/db/client.ts" @@ -387,6 +420,47 @@ ENVEOF success "Node.js / Docker configured." fi +# ---- Multi-tenancy (optional) ---- +if $MULTI_TENANT; then + step "Setting up multi-tenancy..." + + make_dirs "$API_DIR/src/tenancy" + copy_file "$TEMPLATES_DIR/snippets/shared/src/tenancy/config.ts" "$API_DIR/src/tenancy/config.ts" + copy_file "$TEMPLATES_DIR/snippets/shared/src/tenancy/schema.ts" "$API_DIR/src/tenancy/schema.ts" + copy_file "$TEMPLATES_DIR/snippets/shared/src/tenancy/middleware.ts" "$API_DIR/src/tenancy/middleware.ts" + copy_file "$TEMPLATES_DIR/snippets/shared/src/tenancy/row-scope.ts" "$API_DIR/src/tenancy/row-scope.ts" + copy_file "$TEMPLATES_DIR/snippets/shared/src/tenancy/schema-scope.ts" "$API_DIR/src/tenancy/schema-scope.ts" + + # RLS policies ship as a SQL template to paste into a custom Drizzle + # migration (pnpm drizzle-kit generate --custom --name enable-rls). + copy_file "$TEMPLATES_DIR/multi-tenant/rls-policies.sql" "$API_DIR/src/db/rls-policies.sql" + + # Tenant-isolation tests: resolution + query scoping run everywhere; the + # live RLS/search_path suite activates when TENANCY_TEST_DATABASE_URL is set. + copy_file "$TEMPLATES_DIR/snippets/shared/tests/tenancy.test.ts" "$API_DIR/tests/tenancy.test.ts" + + if [[ "$PLATFORM" == "cloudflare" ]]; then + ENV_EXAMPLE_FILE=".dev.vars.example" + else + ENV_EXAMPLE_FILE=".env.example" + fi + append_file "$API_DIR/$ENV_EXAMPLE_FILE" << 'MTENVEOF' + +# --- Multi-tenancy --- +# Isolation strategy: row (shared tables + tenant_id + RLS, the default) or +# schema (one PostgreSQL schema per tenant). See src/tenancy/config.ts. +TENANCY_STRATEGY=row +# Base domain for subdomain tenant resolution (acme.api.example.com -> acme). +# Remove or leave unset to disable the subdomain source. +TENANCY_BASE_DOMAIN=api.example.com +# Optional overrides (defaults shown): +# TENANCY_HEADER_NAME=X-Tenant-ID +# TENANCY_JWT_CLAIM=tenant_id +MTENVEOF + + success "Multi-tenancy configured (src/tenancy/, strategy '${CYAN}row${NC}' by default)." +fi + # ---- Postman collection ---- step "Generating Postman collection..." @@ -759,6 +833,68 @@ A `release_command` in `fly.toml` could run migrations on each deploy instead, but the production image prunes dev dependencies (no drizzle-kit), so the proxy approach is the default -- see the comments in `fly.toml`. READMEFLY + fi + if $MULTI_TENANT; then + cat << 'READMEMT' + +## Multi-tenancy + +This project was generated with `--multi-tenant`. Tenant isolation lives in +`api/src/tenancy/`: + +| File | Purpose | +|------|---------| +| `config.ts` | Strategy selection (`TENANCY_STRATEGY=row\|schema`) + resolution settings | +| `schema.ts` | `tenants` registry table, `tenantScopedColumns()` helper, example scoped table | +| `middleware.ts` | Resolves the tenant per request from JWT claim, subdomain, or `X-Tenant-ID` header | +| `row-scope.ts` | Row strategy: tenant-filtered query facade + `withTenant()` RLS transaction helper | +| `schema-scope.ts` | Schema strategy: per-tenant schema creation + `search_path` transaction helper | + +Two isolation strategies are supported, selected via `TENANCY_STRATEGY` in the +environment: + +- **row** (default): all tenants share tables; every tenant-scoped table has a + `tenant_id` column, every query is filtered by it, and PostgreSQL Row Level + Security (`api/src/db/rls-policies.sql`) enforces the boundary in the + database even if a query forgets the filter. Apply the policies with a + custom migration: `pnpm drizzle-kit generate --custom --name enable-rls`, + paste the SQL in, then `pnpm drizzle-kit migrate`. +- **schema**: each tenant gets its own PostgreSQL schema; queries run inside + `withTenantSchema()`, which points `search_path` at the tenant's schema for + the duration of a transaction. + +Wire the resolver into the app and scope every query: + +```typescript +import { tenantResolver, getTenant } from './tenancy/middleware'; +import { createTenantDb } from './tenancy/row-scope'; +import { tenants, projects, type Tenant } from './tenancy/schema'; + +app.use('/api/*', tenantResolver({ + baseDomain: config.TENANCY_BASE_DOMAIN, + lookupTenant: async (ref) => + db.query.tenants.findFirst({ + where: ref.source === 'subdomain' + ? eq(tenants.slug, ref.value) + : eq(tenants.id, ref.value), + }), +})); + +app.get('/api/projects', async (c) => { + const tenant = getTenant(c); + const tdb = createTenantDb(db, tenant.id); + return c.json(await tdb.findMany(projects)); +}); +``` + +`api/tests/tenancy.test.ts` covers tenant resolution and query scoping out of +the box; set `TENANCY_TEST_DATABASE_URL` to also run the live PostgreSQL +suite that proves RLS and `search_path` isolation end to end. + +The trade-offs between the two strategies (and when to pick which) are +documented in the Nerva framework repo under +`docs/api-development/multi-tenancy.md`. +READMEMT fi cat << 'READMESTATIC' @@ -817,6 +953,9 @@ echo -e "${GREEN}============================================${NC}" echo "" echo -e " Name: ${CYAN}$PROJECT_NAME${NC}" echo -e " Platform: ${CYAN}$PLATFORM${NC}" +if $MULTI_TENANT; then + echo -e " Tenancy: ${CYAN}multi-tenant${NC} (row strategy default; see api/src/tenancy/)" +fi echo -e " Location: ${CYAN}$API_DIR${NC}" echo "" if ! $DRY_RUN; then diff --git a/templates/multi-tenant/rls-policies.sql b/templates/multi-tenant/rls-policies.sql new file mode 100644 index 0000000..9d6bf70 --- /dev/null +++ b/templates/multi-tenant/rls-policies.sql @@ -0,0 +1,54 @@ +-- ============================================================================ +-- Row Level Security policies for row-based multi-tenancy. +-- +-- Applies to the ROW strategy only (TENANCY_STRATEGY=row). Under the schema +-- strategy, isolation comes from per-tenant schemas + search_path instead. +-- +-- How it works: +-- * withTenant() (src/tenancy/row-scope.ts) runs queries inside a +-- transaction with `app.tenant_id` set via SET LOCAL. +-- * The policy below compares each row's tenant_id to that setting. +-- * When the setting is absent, current_setting(..., true) returns NULL / +-- empty string, the comparison is never true, and NO rows are visible or +-- writable — fail closed, not open. +-- +-- Apply as a custom Drizzle migration so it deploys with the schema: +-- pnpm drizzle-kit generate --custom --name enable-rls +-- # paste this file's statements into the generated migration, then: +-- pnpm drizzle-kit migrate +-- +-- IMPORTANT — who RLS applies to: +-- * FORCE ROW LEVEL SECURITY makes the policy apply to the table owner too. +-- * Superusers and roles with BYPASSRLS always bypass RLS. Do not run the +-- API as a superuser (the default `postgres` user in many docker images +-- is one) — create a dedicated application role: +-- +-- CREATE ROLE app_user LOGIN PASSWORD '...'; +-- GRANT USAGE ON SCHEMA public TO app_user; +-- GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user; +-- ALTER DEFAULT PRIVILEGES IN SCHEMA public +-- GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user; +-- ============================================================================ + +-- --------------------------------------------------------------------------- +-- projects — copy this block for EVERY tenant-scoped table. +-- --------------------------------------------------------------------------- +ALTER TABLE projects ENABLE ROW LEVEL SECURITY; +ALTER TABLE projects FORCE ROW LEVEL SECURITY; + +-- One FOR ALL policy covers SELECT/INSERT/UPDATE/DELETE: +-- USING — which existing rows are visible (SELECT/UPDATE/DELETE). +-- WITH CHECK — which new/updated rows are allowed (INSERT/UPDATE), so a +-- request cannot write rows for another tenant, and UPDATE +-- cannot re-home a row to a different tenant. +-- nullif(..., '') guards against PostgreSQL returning an empty string (rather +-- than NULL) for a setting that was assigned earlier in the session. +CREATE POLICY tenant_isolation ON projects + FOR ALL + USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid) + WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid); + +-- The tenants registry itself is NOT tenant-scoped: the resolver middleware +-- must read it before any tenant context exists. Leave RLS off for it, or add +-- a permissive read-only policy if your application role should not see the +-- full tenant list. diff --git a/templates/snippets/shared/src/tenancy/config.ts b/templates/snippets/shared/src/tenancy/config.ts new file mode 100644 index 0000000..1ab0f76 --- /dev/null +++ b/templates/snippets/shared/src/tenancy/config.ts @@ -0,0 +1,57 @@ +import { z } from 'zod'; + +/** + * Multi-tenancy configuration. + * + * Nerva supports two tenant isolation strategies, selected per deployment via + * the TENANCY_STRATEGY environment variable: + * + * - `row` (default): all tenants share the same tables; every tenant-scoped + * table carries a `tenant_id` column, queries are filtered by it (see + * ./row-scope.ts), and PostgreSQL Row Level Security enforces the boundary + * in the database itself (see src/db/rls-policies.sql). + * - `schema`: each tenant gets its own PostgreSQL schema containing a full + * copy of the resource tables; queries run with `search_path` pointed at + * the tenant's schema (see ./schema-scope.ts). + * + * The trade-offs between the two are documented in + * docs/api-development/multi-tenancy.md in the Nerva framework repository. + */ + +export const TENANCY_STRATEGIES = ['row', 'schema'] as const; + +export type TenancyStrategy = (typeof TENANCY_STRATEGIES)[number]; + +const tenancyEnvSchema = z.object({ + TENANCY_STRATEGY: z.enum(TENANCY_STRATEGIES).default('row'), + // Base domain for subdomain tenant resolution, e.g. `api.example.com` so + // that `acme.api.example.com` resolves to the tenant with slug `acme`. + // Leave unset to disable the subdomain source. + TENANCY_BASE_DOMAIN: z.string().min(1).optional(), + TENANCY_HEADER_NAME: z.string().min(1).default('X-Tenant-ID'), + TENANCY_JWT_CLAIM: z.string().min(1).default('tenant_id'), +}); + +export interface TenancyConfig { + strategy: TenancyStrategy; + baseDomain: string | undefined; + headerName: string; + jwtClaim: string; +} + +/** + * Parse tenancy settings from an environment object. Defaults to + * `process.env`; Cloudflare Workers should pass the request's env bindings + * instead (matching how src/config.ts is loaded on that target). + */ +export function loadTenancyConfig( + env: Record = process.env, +): TenancyConfig { + const parsed = tenancyEnvSchema.parse(env); + return { + strategy: parsed.TENANCY_STRATEGY, + baseDomain: parsed.TENANCY_BASE_DOMAIN, + headerName: parsed.TENANCY_HEADER_NAME, + jwtClaim: parsed.TENANCY_JWT_CLAIM, + }; +} diff --git a/templates/snippets/shared/src/tenancy/middleware.ts b/templates/snippets/shared/src/tenancy/middleware.ts new file mode 100644 index 0000000..16ff109 --- /dev/null +++ b/templates/snippets/shared/src/tenancy/middleware.ts @@ -0,0 +1,145 @@ +import type { Context, MiddlewareHandler } from 'hono'; + +/** + * Tenant resolution middleware. + * + * Resolves the tenant for each request from up to three sources, in the + * configured order, and stores it on the Hono context under `tenant`: + * + * - `jwt`: a claim on the verified JWT payload (requires `hono/jwt` or + * equivalent auth middleware mounted BEFORE this one, so `jwtPayload` is + * already on the context). The claim is signed, so it cannot be spoofed — + * this is the default first source. + * - `subdomain`: the label in front of `baseDomain` + * (`acme.api.example.com` → slug `acme`). + * - `header`: an `X-Tenant-ID` header. Anyone can set a header, so only + * trust this source for service-to-service traffic behind a gateway that + * strips it from external requests, or combine it with authentication + * that verifies tenant membership in `lookupTenant`. + * + * Resolution alone selects WHICH tenant the request is for; the isolation + * guarantees come from scoping every query (./row-scope.ts or + * ./schema-scope.ts) and from the database policies (RLS / search_path). + */ + +export type TenantSource = 'jwt' | 'subdomain' | 'header'; + +export interface TenantRef { + source: TenantSource; + /** Tenant slug (subdomain source) or tenant id (header / JWT claim). */ + value: string; +} + +export interface TenantResolverOptions { + /** + * Map a resolved reference to a tenant, or null/undefined when it does not + * exist. Typically a (cached) lookup against the `tenants` table. This is + * also the place to verify that the authenticated user belongs to the + * tenant when resolution and authentication use different sources. + */ + lookupTenant: (ref: TenantRef, c: Context) => Promise; + /** Sources to try, in priority order. Default: `['jwt', 'subdomain', 'header']`. */ + sources?: readonly TenantSource[]; + /** Base domain for the subdomain source, e.g. `api.example.com`. Without it the subdomain source never matches. */ + baseDomain?: string; + /** Header for the header source. Default: `X-Tenant-ID`. */ + headerName?: string; + /** JWT payload claim holding the tenant id. Default: `tenant_id`. */ + jwtClaim?: string; +} + +/** Context Variables provided by {@link tenantResolver}, for typing Hono apps: + * `new Hono<{ Variables: TenantVariables }>()`. */ +export interface TenantVariables { + tenant: TTenant; +} + +const DEFAULT_SOURCES: readonly TenantSource[] = ['jwt', 'subdomain', 'header']; +const DEFAULT_HEADER_NAME = 'X-Tenant-ID'; +const DEFAULT_JWT_CLAIM = 'tenant_id'; +const TENANT_CONTEXT_KEY = 'tenant'; + +function subdomainOf(c: Context, baseDomain: string | undefined): string | undefined { + if (!baseDomain) return undefined; + // Real servers populate the Host header; tests driving app.request() with a + // full URL may not, so fall back to the request URL's host. + const rawHost = c.req.header('host') ?? new URL(c.req.url).host; + const host = rawHost.split(':')[0]?.toLowerCase(); + if (!host) return undefined; + const suffix = `.${baseDomain.toLowerCase()}`; + if (!host.endsWith(suffix)) return undefined; + const label = host.slice(0, -suffix.length); + // Reject empty and nested labels (`a.b.api.example.com` is not a tenant). + if (!label || label.includes('.')) return undefined; + return label; +} + +function jwtClaimOf(c: Context, claim: string): string | undefined { + // Set by hono/jwt (or compatible auth middleware) after verifying the token. + const payload = (c.var as Record)['jwtPayload']; + if (typeof payload !== 'object' || payload === null) return undefined; + const value = (payload as Record)[claim]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +export function tenantResolver( + options: TenantResolverOptions, +): MiddlewareHandler { + const sources = options.sources ?? DEFAULT_SOURCES; + const headerName = options.headerName ?? DEFAULT_HEADER_NAME; + const jwtClaim = options.jwtClaim ?? DEFAULT_JWT_CLAIM; + + return async (c, next) => { + let ref: TenantRef | undefined; + for (const source of sources) { + let value: string | undefined; + if (source === 'jwt') { + value = jwtClaimOf(c, jwtClaim); + } else if (source === 'subdomain') { + value = subdomainOf(c, options.baseDomain); + } else { + value = c.req.header(headerName); + } + if (value) { + ref = { source, value }; + break; + } + } + + if (!ref) { + return c.json( + { + error: { + code: 'TENANT_UNRESOLVED', + message: 'Request could not be mapped to a tenant.', + }, + }, + 400, + ); + } + + const tenant = await options.lookupTenant(ref, c); + if (tenant == null) { + // 404 (not 403) so probing requests cannot distinguish "tenant exists + // but is not yours" from "tenant does not exist". + return c.json( + { error: { code: 'TENANT_NOT_FOUND', message: 'Unknown tenant.' } }, + 404, + ); + } + + c.set(TENANT_CONTEXT_KEY, tenant); + return next(); + }; +} + +/** Read the tenant that {@link tenantResolver} stored on the context. */ +export function getTenant(c: Context): TTenant { + const tenant = (c.var as Record)[TENANT_CONTEXT_KEY]; + if (tenant == null) { + throw new Error( + 'No tenant on the request context. Is tenantResolver mounted before this handler?', + ); + } + return tenant as TTenant; +} diff --git a/templates/snippets/shared/src/tenancy/row-scope.ts b/templates/snippets/shared/src/tenancy/row-scope.ts new file mode 100644 index 0000000..a5a9f86 --- /dev/null +++ b/templates/snippets/shared/src/tenancy/row-scope.ts @@ -0,0 +1,217 @@ +import { + and, + eq, + sql, + type SQL, + type TablesRelationalConfig, +} from 'drizzle-orm'; +import type { + PgColumn, + PgDatabase, + PgQueryResultHKT, + PgTable, + PgTransaction, + PgUpdateSetSource, +} from 'drizzle-orm/pg-core'; + +/** + * Row-based tenant isolation (shared tables + `tenant_id` column). + * + * Isolation is enforced twice, on purpose: + * + * 1. **Application layer** — every query goes through {@link createTenantDb} + * (or, for hand-written queries, {@link withTenantFilter}), so the + * `tenant_id` predicate cannot be forgotten. + * 2. **Database layer** — Row Level Security policies (src/db/rls-policies.sql) + * check `tenant_id` against the `app.tenant_id` session setting, which + * {@link withTenant} sets for the duration of a transaction. If a query + * slips through without a filter, RLS returns zero rows instead of + * leaking another tenant's data (fail closed). + * + * Belt and braces: the app filter gives correct results and index-friendly + * plans; RLS guards against the query that forgot the filter. + */ + +/** PostgreSQL session setting the RLS policies read the current tenant from. */ +export const TENANT_ID_SETTING = 'app.tenant_id'; + +/** + * Minimal column shape a table must expose to participate in row-based + * tenancy: a `tenantId` column (see `tenantScopedColumns()` in ./schema.ts). + */ +export interface TenantScopedColumns { + tenantId: PgColumn; +} + +/** A Drizzle pgTable that carries the tenant scoping column. */ +export type TenantScopedTable = PgTable & TenantScopedColumns; + +/** + * Query filter matching only rows that belong to the given tenant + * (`WHERE tenant_id = $tenantId`). + */ +export function tenantFilter(table: TenantScopedColumns, tenantId: string): SQL { + return eq(table.tenantId, tenantId); +} + +/** + * Combine the tenant filter with any number of additional conditions, ANDed + * together. `undefined` conditions are ignored, mirroring Drizzle's `and()`. + * + * @example + * db.select().from(projects).where(withTenantFilter(projects, tenantId, eq(projects.name, name))); + */ +export function withTenantFilter( + table: TenantScopedColumns, + tenantId: string, + ...conditions: Array +): SQL | undefined { + return and(tenantFilter(table, tenantId), ...conditions); +} + +/** + * Run `fn` inside a transaction with the `app.tenant_id` session setting + * bound to the tenant (SET LOCAL semantics — the setting evaporates when the + * transaction ends, so pooled connections are never left carrying a tenant). + * RLS policies read this setting; queries issued outside `withTenant` see + * zero tenant-scoped rows once RLS is enabled. + * + * @example + * const rows = await withTenant(db, tenant.id, (tx) => + * tx.select().from(projects).where(withTenantFilter(projects, tenant.id)), + * ); + */ +export async function withTenant< + T, + TQueryResult extends PgQueryResultHKT, + TFullSchema extends Record, + TSchema extends TablesRelationalConfig, +>( + db: PgDatabase, + tenantId: string, + fn: (tx: PgTransaction) => Promise, +): Promise { + return db.transaction(async (tx) => { + // set_config(..., true) = SET LOCAL: scoped to this transaction only. + await tx.execute(sql`select set_config(${TENANT_ID_SETTING}, ${tenantId}, true)`); + return fn(tx); + }); +} + +/** + * Tenant-scoped query facade returned by {@link createTenantDb}. Every method + * injects the tenant predicate automatically, so route/service code cannot + * forget it. + */ +export interface TenantDb { + readonly tenantId: string; + findMany( + table: TTable, + ...conditions: Array + ): Promise>; + findFirst( + table: TTable, + ...conditions: Array + ): Promise; + insert( + table: TTable, + values: Omit, + ): Promise; + update( + table: TTable, + set: PgUpdateSetSource, + ...conditions: Array + ): Promise>; + delete( + table: TTable, + ...conditions: Array + ): Promise; +} + +/** + * Create a {@link TenantDb} bound to one tenant — typically once per request, + * from the tenant the middleware resolved: + * + * @example + * app.get('/projects', async (c) => { + * const tenant = getTenant(c); + * const tdb = createTenantDb(db, tenant.id); + * return c.json(await tdb.findMany(projects)); + * }); + */ +export function createTenantDb< + TQueryResult extends PgQueryResultHKT, + TFullSchema extends Record, + TSchema extends TablesRelationalConfig, +>(db: PgDatabase, tenantId: string): TenantDb { + return { + tenantId, + + async findMany( + table: TTable, + ...conditions: Array + ): Promise> { + const rows = await db + .select() + .from(table as PgTable) + .where(withTenantFilter(table, tenantId, ...conditions)); + return rows as Array; + }, + + async findFirst( + table: TTable, + ...conditions: Array + ): Promise { + const rows = await db + .select() + .from(table as PgTable) + .where(withTenantFilter(table, tenantId, ...conditions)) + .limit(1); + return rows[0] as TTable['$inferSelect'] | undefined; + }, + + async insert( + table: TTable, + values: Omit, + ): Promise { + const rows = await db + .insert(table) + .values({ ...values, tenantId } as unknown as TTable['$inferInsert']) + .returning(); + const row = rows[0]; + if (!row) { + throw new Error('Tenant-scoped insert returned no rows.'); + } + return row as TTable['$inferSelect']; + }, + + async update( + table: TTable, + set: PgUpdateSetSource, + ...conditions: Array + ): Promise> { + if ('tenantId' in (set as Record)) { + throw new Error( + 'Tenant-scoped update() must not change tenantId — rows cannot be moved between tenants.', + ); + } + const rows = await db + .update(table) + .set(set) + .where(withTenantFilter(table, tenantId, ...conditions)) + .returning(); + return rows as Array; + }, + + async delete( + table: TTable, + ...conditions: Array + ): Promise { + const rows = await db + .delete(table) + .where(withTenantFilter(table, tenantId, ...conditions)) + .returning(); + return rows.length; + }, + }; +} diff --git a/templates/snippets/shared/src/tenancy/schema-scope.ts b/templates/snippets/shared/src/tenancy/schema-scope.ts new file mode 100644 index 0000000..fec6c38 --- /dev/null +++ b/templates/snippets/shared/src/tenancy/schema-scope.ts @@ -0,0 +1,99 @@ +import { sql, type TablesRelationalConfig } from 'drizzle-orm'; +import type { + PgDatabase, + PgQueryResultHKT, + PgTransaction, +} from 'drizzle-orm/pg-core'; + +/** + * Schema-based tenant isolation (one PostgreSQL schema per tenant). + * + * Each tenant gets its own schema (`tenant_`) containing a full copy of + * the resource tables. Queries run inside {@link withTenantSchema}, which + * points `search_path` at the tenant's schema for the duration of a + * transaction — unqualified table names (which is what Drizzle emits for + * tables defined with `pgTable`) then resolve to the tenant's copy. + * + * `public` stays on the search path (after the tenant schema) so the shared + * `tenants` registry and any extensions remain reachable. Resource tables + * must therefore NOT also exist in `public`, or a missing tenant table would + * silently fall through to the shared one — keep `public` free of resource + * tables under this strategy. + * + * Migrations must be applied once per tenant schema. See + * docs/api-development/multi-tenancy.md for a migration runner example. + */ + +export const TENANT_SCHEMA_PREFIX = 'tenant_'; + +// Lowercase alphanumerics + underscore only, and short enough to stay under +// PostgreSQL's 63-byte identifier limit with the prefix. Anything else is +// rejected rather than escaped: schema names end up in DDL, log lines, and +// backups, so keep them boring. +const NORMALIZED_SLUG_PATTERN = /^[a-z0-9_]{1,48}$/; + +/** + * Derive the schema name for a tenant slug (`acme-corp` → `tenant_acme_corp`). + * Throws on slugs that would produce an unsafe or over-long identifier. + */ +export function tenantSchemaName(slug: string): string { + const normalized = slug.toLowerCase().replaceAll('-', '_'); + if (!NORMALIZED_SLUG_PATTERN.test(normalized)) { + throw new Error( + `Invalid tenant slug for schema isolation: ${JSON.stringify(slug)}. ` + + 'Slugs must be 1-48 characters of [a-z0-9_-].', + ); + } + return `${TENANT_SCHEMA_PREFIX}${normalized}`; +} + +/** + * Create the tenant's schema (idempotent). Called during tenant onboarding; + * run migrations against the new schema afterwards to create its tables. + * + * @returns the schema name. + */ +export async function createTenantSchema< + TQueryResult extends PgQueryResultHKT, + TFullSchema extends Record, + TSchema extends TablesRelationalConfig, +>( + db: PgDatabase, + slug: string, +): Promise { + const schemaName = tenantSchemaName(slug); + await db.execute(sql`create schema if not exists ${sql.identifier(schemaName)}`); + return schemaName; +} + +/** + * Run `fn` inside a transaction with `search_path` pointed at the tenant's + * schema (SET LOCAL semantics — the setting evaporates when the transaction + * ends, so pooled connections are never left pointing at a tenant). + * + * @example + * const rows = await withTenantSchema(db, tenant.slug, (tx) => + * tx.select().from(projects), + * ); + */ +export async function withTenantSchema< + T, + TQueryResult extends PgQueryResultHKT, + TFullSchema extends Record, + TSchema extends TablesRelationalConfig, +>( + db: PgDatabase, + slug: string, + fn: (tx: PgTransaction) => Promise, +): Promise { + const schemaName = tenantSchemaName(slug); + return db.transaction(async (tx) => { + // set_config(..., true) = SET LOCAL: scoped to this transaction only. + // schemaName is validated by tenantSchemaName and passed as a bound + // parameter, so it cannot smuggle SQL. + await tx.execute( + sql`select set_config('search_path', ${`${schemaName}, public`}, true)`, + ); + return fn(tx); + }); +} diff --git a/templates/snippets/shared/src/tenancy/schema.ts b/templates/snippets/shared/src/tenancy/schema.ts new file mode 100644 index 0000000..31eed4e --- /dev/null +++ b/templates/snippets/shared/src/tenancy/schema.ts @@ -0,0 +1,66 @@ +import { index, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; + +/** + * Tenancy schema additions. + * + * The `tenants` registry table always lives in the `public` schema, under + * both isolation strategies — it is how a request is mapped to a tenant. + * + * - Row-based strategy: every resource table spreads {@link tenantScopedColumns} + * to get a `tenant_id` foreign key (see `projects` below for the pattern), + * and RLS policies enforce the boundary (src/db/rls-policies.sql). + * - Schema-based strategy: resource tables are created once per tenant schema + * and do NOT need a `tenant_id` column; only `tenants` stays in `public`. + */ + +export const tenants = pgTable('tenants', { + id: uuid('id').defaultRandom().primaryKey(), + name: text('name').notNull(), + // URL-safe identifier used for subdomain resolution (`acme.api.example.com`) + // and, under the schema strategy, to derive the schema name (`tenant_acme`). + slug: text('slug').notNull().unique(), + plan: text('plan').notNull().default('free'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}); + +export type Tenant = typeof tenants.$inferSelect; +export type NewTenant = typeof tenants.$inferInsert; + +/** + * Columns every tenant-scoped table must include under the row strategy. + * A function (not a shared object) because Drizzle column builders are bound + * to the table that consumes them — each table needs fresh instances. + * + * @example + * export const orders = pgTable('orders', { + * id: uuid('id').defaultRandom().primaryKey(), + * ...tenantScopedColumns(), + * // resource columns... + * }, (table) => [index('orders_tenant_id_idx').on(table.tenantId)]); + */ +export const tenantScopedColumns = () => ({ + tenantId: uuid('tenant_id') + .notNull() + .references(() => tenants.id, { onDelete: 'cascade' }), +}); + +/** + * Example tenant-scoped resource table demonstrating the row-based pattern: + * `tenant_id` FK via {@link tenantScopedColumns} plus an index on it (every + * query filters by tenant, so the index is not optional). Replace with your + * real resource tables, keeping both pieces. + */ +export const projects = pgTable( + 'projects', + { + id: uuid('id').defaultRandom().primaryKey(), + ...tenantScopedColumns(), + name: text('name').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [index('projects_tenant_id_idx').on(table.tenantId)], +); + +export type Project = typeof projects.$inferSelect; +export type NewProject = typeof projects.$inferInsert; diff --git a/templates/snippets/shared/tests/tenancy.test.ts b/templates/snippets/shared/tests/tenancy.test.ts new file mode 100644 index 0000000..0011eeb --- /dev/null +++ b/templates/snippets/shared/tests/tenancy.test.ts @@ -0,0 +1,555 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { Hono } from 'hono'; +import { eq } from 'drizzle-orm'; +import { PgDialect } from 'drizzle-orm/pg-core'; +import postgres from 'postgres'; +import { loadTenancyConfig } from '../src/tenancy/config.js'; +import { tenants, projects, type Tenant } from '../src/tenancy/schema.js'; +import { + getTenant, + tenantResolver, + type TenantRef, +} from '../src/tenancy/middleware.js'; +import { + createTenantDb, + tenantFilter, + withTenant, + withTenantFilter, + TENANT_ID_SETTING, +} from '../src/tenancy/row-scope.js'; +import { + createTenantSchema, + tenantSchemaName, + withTenantSchema, +} from '../src/tenancy/schema-scope.js'; + +const dialect = new PgDialect(); +const render = (clause: Parameters[0] | undefined) => + clause ? dialect.sqlToQuery(clause) : undefined; + +const TENANT_A: Tenant = { + id: '00000000-0000-4000-8000-00000000000a', + name: 'Acme', + slug: 'acme', + plan: 'pro', + createdAt: new Date(0), +}; +const TENANT_B: Tenant = { + id: '00000000-0000-4000-8000-00000000000b', + name: 'Globex', + slug: 'globex', + plan: 'free', + createdAt: new Date(0), +}; + +// --------------------------------------------------------------------------- +// Configuration (strategy selection) +// --------------------------------------------------------------------------- + +describe('loadTenancyConfig', () => { + it('defaults to the row strategy', () => { + expect(loadTenancyConfig({}).strategy).toBe('row'); + }); + + it('selects the schema strategy via TENANCY_STRATEGY', () => { + expect(loadTenancyConfig({ TENANCY_STRATEGY: 'schema' }).strategy).toBe('schema'); + }); + + it('rejects unknown strategies', () => { + expect(() => loadTenancyConfig({ TENANCY_STRATEGY: 'shard' })).toThrow(); + }); + + it('applies resolution defaults and overrides', () => { + const defaults = loadTenancyConfig({}); + expect(defaults.headerName).toBe('X-Tenant-ID'); + expect(defaults.jwtClaim).toBe('tenant_id'); + expect(defaults.baseDomain).toBeUndefined(); + + const custom = loadTenancyConfig({ + TENANCY_BASE_DOMAIN: 'api.example.com', + TENANCY_HEADER_NAME: 'X-Org', + TENANCY_JWT_CLAIM: 'org_id', + }); + expect(custom.baseDomain).toBe('api.example.com'); + expect(custom.headerName).toBe('X-Org'); + expect(custom.jwtClaim).toBe('org_id'); + }); +}); + +// --------------------------------------------------------------------------- +// Tenant resolution middleware +// --------------------------------------------------------------------------- + +describe('tenantResolver', () => { + const lookupTenant = async (ref: TenantRef): Promise => { + const bySlug: Record = { acme: TENANT_A, globex: TENANT_B }; + const byId: Record = { + [TENANT_A.id]: TENANT_A, + [TENANT_B.id]: TENANT_B, + }; + if (ref.source === 'subdomain') return bySlug[ref.value] ?? null; + return byId[ref.value] ?? null; + }; + + type TestEnv = { Variables: { jwtPayload?: Record; tenant: Tenant } }; + + function makeApp(jwtPayload?: Record) { + const app = new Hono(); + if (jwtPayload) { + // Stands in for hono/jwt, which stores the verified payload the same way. + app.use('*', async (c, next) => { + c.set('jwtPayload', jwtPayload); + await next(); + }); + } + app.use('*', tenantResolver({ lookupTenant, baseDomain: 'api.example.com' })); + app.get('/whoami', (c) => c.json(getTenant(c))); + return app; + } + + it('resolves the tenant from the subdomain', async () => { + const res = await makeApp().request('http://acme.api.example.com/whoami'); + expect(res.status).toBe(200); + expect(((await res.json()) as Tenant).id).toBe(TENANT_A.id); + }); + + it('strips the port before matching the subdomain', async () => { + const res = await makeApp().request('http://globex.api.example.com:8787/whoami'); + expect(res.status).toBe(200); + expect(((await res.json()) as Tenant).id).toBe(TENANT_B.id); + }); + + it('resolves the tenant from the X-Tenant-ID header', async () => { + const res = await makeApp().request('http://localhost/whoami', { + headers: { 'X-Tenant-ID': TENANT_B.id }, + }); + expect(res.status).toBe(200); + expect(((await res.json()) as Tenant).slug).toBe('globex'); + }); + + it('resolves the tenant from the JWT claim', async () => { + const res = await makeApp({ sub: 'user-1', tenant_id: TENANT_A.id }).request( + 'http://localhost/whoami', + ); + expect(res.status).toBe(200); + expect(((await res.json()) as Tenant).slug).toBe('acme'); + }); + + it('prefers the (signed) JWT claim over subdomain and header', async () => { + const res = await makeApp({ tenant_id: TENANT_A.id }).request( + 'http://globex.api.example.com/whoami', + { headers: { 'X-Tenant-ID': TENANT_B.id } }, + ); + expect(((await res.json()) as Tenant).id).toBe(TENANT_A.id); + }); + + it('honours a custom source order', async () => { + const app = new Hono(); + app.use( + '*', + tenantResolver({ + lookupTenant, + baseDomain: 'api.example.com', + sources: ['subdomain', 'jwt'], + }), + ); + app.get('/whoami', (c) => c.json(getTenant(c))); + const res = await app.request('http://globex.api.example.com/whoami', { + headers: { 'X-Tenant-ID': TENANT_A.id }, // header source disabled + }); + expect(((await res.json()) as Tenant).id).toBe(TENANT_B.id); + }); + + it('returns 404 for an unknown tenant', async () => { + const res = await makeApp().request('http://nope.api.example.com/whoami'); + expect(res.status).toBe(404); + }); + + it('returns 400 when no source yields a tenant', async () => { + const res = await makeApp().request('http://localhost/whoami'); + expect(res.status).toBe(400); + }); + + it('does not treat nested subdomains as tenants', async () => { + const res = await makeApp().request('http://a.b.api.example.com/whoami'); + expect(res.status).toBe(400); + }); + + it('does not treat the bare base domain as a tenant', async () => { + const res = await makeApp().request('http://api.example.com/whoami'); + expect(res.status).toBe(400); + }); + + it('getTenant throws when the resolver is not mounted', async () => { + const app = new Hono(); + app.onError((err, c) => c.text(err.message, 500)); + app.get('/whoami', (c) => c.json(getTenant(c))); + const res = await app.request('http://localhost/whoami'); + expect(res.status).toBe(500); + expect(await res.text()).toContain('tenantResolver'); + }); +}); + +// --------------------------------------------------------------------------- +// Row-based scoping (tenant_id filters) +// --------------------------------------------------------------------------- + +describe('tenantFilter / withTenantFilter', () => { + it('produces a `tenant_id = $x` predicate', () => { + const query = render(tenantFilter(projects, TENANT_A.id))!; + expect(query.sql).toContain('"tenant_id"'); + expect(query.params).toContain(TENANT_A.id); + }); + + it('ANDs the tenant filter with extra conditions', () => { + const query = render(withTenantFilter(projects, TENANT_A.id, eq(projects.name, 'x')))!; + expect(query.sql).toContain('"tenant_id"'); + expect(query.sql).toContain('"name"'); + expect(query.sql.toLowerCase()).toContain('and'); + }); +}); + +/** + * Chainable stub standing in for a Drizzle PgDatabase: records the clauses + * each query was built with and resolves with the configured rows, so tenant + * scoping can be asserted without PostgreSQL. + */ +function makeStubDb(rows: unknown[] = []) { + const calls: { + where?: unknown; + values?: Record; + set?: unknown; + executed: unknown[]; + } = { executed: [] }; + + const selectChain = { + from: vi.fn(() => selectChain), + where: vi.fn((clause: unknown) => { + calls.where = clause; + return selectChain; + }), + limit: vi.fn(() => Promise.resolve(rows)), + then: (resolve: (value: unknown[]) => unknown, reject?: (reason: unknown) => unknown) => + Promise.resolve(rows).then(resolve, reject), + }; + const writeChain = { + values: vi.fn((values: Record) => { + calls.values = values; + return writeChain; + }), + set: vi.fn((set: unknown) => { + calls.set = set; + return writeChain; + }), + where: vi.fn((clause: unknown) => { + calls.where = clause; + return writeChain; + }), + returning: vi.fn(() => Promise.resolve(rows)), + }; + const tx = { + execute: vi.fn((query: unknown) => { + calls.executed.push(query); + return Promise.resolve([]); + }), + }; + const db = { + select: vi.fn(() => selectChain), + insert: vi.fn(() => writeChain), + update: vi.fn(() => writeChain), + delete: vi.fn(() => writeChain), + execute: tx.execute, + transaction: vi.fn((fn: (tx: unknown) => Promise) => fn(tx)), + }; + return { db, calls, tx }; +} + +describe('createTenantDb', () => { + it('findMany always includes the tenant predicate', async () => { + const { db, calls } = makeStubDb([]); + await createTenantDb(db as never, TENANT_A.id).findMany(projects); + const query = render(calls.where as never)!; + expect(query.sql).toContain('"tenant_id"'); + expect(query.params).toContain(TENANT_A.id); + }); + + it('findFirst combines the tenant predicate with caller conditions', async () => { + const { db, calls } = makeStubDb([]); + await createTenantDb(db as never, TENANT_A.id).findFirst( + projects, + eq(projects.name, 'x'), + ); + const query = render(calls.where as never)!; + expect(query.sql).toContain('"tenant_id"'); + expect(query.sql).toContain('"name"'); + expect(query.params).toContain(TENANT_A.id); + }); + + it('insert injects tenantId into the values', async () => { + const { db, calls } = makeStubDb([{ id: 'p1' }]); + await createTenantDb(db as never, TENANT_A.id).insert(projects, { name: 'x' }); + expect(calls.values).toMatchObject({ name: 'x', tenantId: TENANT_A.id }); + }); + + it('update scopes the where clause to the tenant', async () => { + const { db, calls } = makeStubDb([]); + await createTenantDb(db as never, TENANT_A.id).update(projects, { name: 'y' }); + const query = render(calls.where as never)!; + expect(query.sql).toContain('"tenant_id"'); + expect(query.params).toContain(TENANT_A.id); + }); + + it('update refuses to re-home a row to another tenant', async () => { + const { db } = makeStubDb([]); + const tdb = createTenantDb(db as never, TENANT_A.id); + await expect( + tdb.update(projects, { tenantId: TENANT_B.id } as never), + ).rejects.toThrow(/tenantId/); + }); + + it('delete scopes the where clause to the tenant and returns the count', async () => { + const { db, calls } = makeStubDb([{ id: 'p1' }, { id: 'p2' }]); + const deleted = await createTenantDb(db as never, TENANT_A.id).delete(projects); + expect(deleted).toBe(2); + const query = render(calls.where as never)!; + expect(query.sql).toContain('"tenant_id"'); + }); +}); + +describe('withTenant (RLS session setting)', () => { + it('sets app.tenant_id with SET LOCAL semantics inside the transaction', async () => { + const { db, calls, tx } = makeStubDb(); + const result = await withTenant(db as never, TENANT_A.id, async (scopedTx) => { + expect(scopedTx).toBe(tx); + return 'done'; + }); + expect(result).toBe('done'); + const query = render(calls.executed[0] as never)!; + expect(query.sql).toContain('set_config'); + expect(query.sql).toContain('true'); // is_local => SET LOCAL + expect(query.params).toEqual([TENANT_ID_SETTING, TENANT_A.id]); + }); +}); + +// --------------------------------------------------------------------------- +// Schema-based scoping (search_path) +// --------------------------------------------------------------------------- + +describe('tenantSchemaName', () => { + it('derives a prefixed, normalized schema name', () => { + expect(tenantSchemaName('acme')).toBe('tenant_acme'); + expect(tenantSchemaName('Acme-Corp')).toBe('tenant_acme_corp'); + }); + + it('rejects slugs that are not safe identifiers', () => { + expect(() => tenantSchemaName('')).toThrow(); + expect(() => tenantSchemaName('bad;drop table tenants')).toThrow(); + expect(() => tenantSchemaName('spaced slug')).toThrow(); + expect(() => tenantSchemaName('a'.repeat(49))).toThrow(); + }); +}); + +describe('createTenantSchema / withTenantSchema', () => { + it('creates the schema with a quoted identifier', async () => { + const { db, calls } = makeStubDb(); + const name = await createTenantSchema(db as never, 'acme'); + expect(name).toBe('tenant_acme'); + const query = render(calls.executed[0] as never)!; + expect(query.sql.toLowerCase()).toContain('create schema if not exists'); + expect(query.sql).toContain('"tenant_acme"'); + }); + + it('points search_path at the tenant schema for the transaction only', async () => { + const { db, calls } = makeStubDb(); + await withTenantSchema(db as never, 'acme', async () => 'ok'); + const query = render(calls.executed[0] as never)!; + expect(query.sql).toContain('set_config'); + expect(query.sql).toContain('true'); // is_local => SET LOCAL + expect(query.params).toEqual(['tenant_acme, public']); + }); + + it('never runs a query for an invalid slug', async () => { + const { db, tx } = makeStubDb(); + await expect(withTenantSchema(db as never, 'bad slug', async () => 'x')).rejects.toThrow(); + expect(tx.execute).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Live database isolation tests (opt-in) +// +// Verifies the RLS policies and search_path isolation against a real +// PostgreSQL. Skipped unless TENANCY_TEST_DATABASE_URL is set, e.g.: +// +// docker compose up -d +// TENANCY_TEST_DATABASE_URL=postgresql://nerva:nerva_secret@localhost:5432/nerva_db pnpm test +// +// Everything runs in a throwaway namespace and role, cleaned up afterwards. +// --------------------------------------------------------------------------- + +const LIVE_URL = process.env['TENANCY_TEST_DATABASE_URL']; + +describe.runIf(Boolean(LIVE_URL))('tenant isolation (live PostgreSQL)', () => { + const ns = `tenancy_test_${Date.now().toString(36)}`; + const appRole = `${ns}_app`; + let db: postgres.Sql; + + beforeAll(async () => { + db = postgres(LIVE_URL as string, { max: 1, onnotice: () => undefined }); + + // Mirror of the row-strategy setup: tenants registry + one scoped table, + // with the policies from templates/multi-tenant/rls-policies.sql. + await db.unsafe(` + create schema ${ns}; + create table ${ns}.tenants ( + id uuid primary key, + slug text not null unique + ); + create table ${ns}.projects ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references ${ns}.tenants (id) on delete cascade, + name text not null + ); + create index on ${ns}.projects (tenant_id); + alter table ${ns}.projects enable row level security; + alter table ${ns}.projects force row level security; + create policy tenant_isolation on ${ns}.projects + for all + using (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid) + with check (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid); + `); + + // RLS is bypassed by superusers and (without FORCE) table owners, so the + // assertions below run as a dedicated non-superuser application role — + // the same shape a production deployment should use. + await db.unsafe(` + create role ${appRole} nologin; + grant usage on schema ${ns} to ${appRole}; + grant select, insert, update, delete on all tables in schema ${ns} to ${appRole}; + `); + + await db`insert into ${db(ns)}.tenants ${db([ + { id: TENANT_A.id, slug: TENANT_A.slug }, + { id: TENANT_B.id, slug: TENANT_B.slug }, + ])}`; + + // Seed one project per tenant through the tenant-scoped write path. + for (const tenant of [TENANT_A, TENANT_B]) { + await db.begin(async (tx) => { + await tx`select set_config('app.tenant_id', ${tenant.id}, true)`; + await tx.unsafe(`set local role ${appRole}`); + await tx`insert into ${tx(ns)}.projects (tenant_id, name) + values (${tenant.id}, ${`${tenant.slug}-project`})`; + }); + } + }); + + afterAll(async () => { + await db.unsafe(` + drop schema if exists ${ns} cascade; + drop schema if exists ${ns}_sa cascade; + drop schema if exists ${ns}_sb cascade; + drop owned by ${appRole}; + drop role ${appRole}; + `); + await db.end(); + }); + + /** Run `fn` as the app role with app.tenant_id bound (or not) for one transaction. */ + const asTenant = ( + tenantId: string | null, + fn: (tx: postgres.TransactionSql) => Promise, + ) => + db.begin(async (tx) => { + if (tenantId) { + await tx`select set_config('app.tenant_id', ${tenantId}, true)`; + } + await tx.unsafe(`set local role ${appRole}`); + return fn(tx); + }); + + it('RLS: each tenant sees only its own rows', async () => { + const rowsA = await asTenant(TENANT_A.id, (tx) => tx`select name from ${tx(ns)}.projects`); + expect(rowsA.map((r) => r['name'])).toEqual(['acme-project']); + + const rowsB = await asTenant(TENANT_B.id, (tx) => tx`select name from ${tx(ns)}.projects`); + expect(rowsB.map((r) => r['name'])).toEqual(['globex-project']); + }); + + it('RLS: no tenant context means no rows (fail closed)', async () => { + const rows = await asTenant(null, (tx) => tx`select * from ${tx(ns)}.projects`); + expect(rows).toHaveLength(0); + }); + + it("RLS: WITH CHECK blocks writing rows for another tenant", async () => { + await expect( + asTenant(TENANT_A.id, (tx) => + tx`insert into ${tx(ns)}.projects (tenant_id, name) values (${TENANT_B.id}, ${'smuggled'})`, + ), + ).rejects.toThrow(/row-level security/i); + }); + + it("RLS: updates cannot reach another tenant's rows", async () => { + const updated = await asTenant(TENANT_A.id, (tx) => + tx`update ${tx(ns)}.projects set name = ${'hijacked'} + where tenant_id = ${TENANT_B.id} returning id`, + ); + expect(updated).toHaveLength(0); + + const intact = await asTenant(TENANT_B.id, (tx) => tx`select name from ${tx(ns)}.projects`); + expect(intact.map((r) => r['name'])).toEqual(['globex-project']); + }); + + it("RLS: deletes cannot reach another tenant's rows", async () => { + const deleted = await asTenant(TENANT_A.id, (tx) => + tx`delete from ${tx(ns)}.projects where tenant_id = ${TENANT_B.id} returning id`, + ); + expect(deleted).toHaveLength(0); + }); + + it('schema strategy: search_path isolates per-tenant schemas', async () => { + // Two tenant schemas, same table name, different data. + for (const [schema, name] of [ + [`${ns}_sa`, 'alpha-doc'], + [`${ns}_sb`, 'bravo-doc'], + ] as const) { + await db.unsafe(` + create schema ${schema}; + create table ${schema}.documents (id serial primary key, name text not null); + `); + await db`insert into ${db(schema)}.documents (name) values (${name})`; + } + + const readVia = (schema: string) => + db.begin(async (tx) => { + await tx`select set_config('search_path', ${`${schema}, public`}, true)`; + // Unqualified table name — resolves through search_path, exactly as + // Drizzle's generated SQL does under withTenantSchema(). + return tx`select name from documents`; + }); + + expect((await readVia(`${ns}_sa`)).map((r) => r['name'])).toEqual(['alpha-doc']); + expect((await readVia(`${ns}_sb`)).map((r) => r['name'])).toEqual(['bravo-doc']); + + // Outside the transactions the search_path is back to normal, so the + // unqualified name no longer resolves — SET LOCAL did not leak. + await expect(db`select name from documents`).rejects.toThrow(/does not exist/); + }); +}); + +// Silence the "declared but never used" warning for tenants: it is imported +// to assert the registry schema shape stays stable for resolver lookups. +describe('tenants registry table', () => { + it('exposes id/name/slug/plan/created_at', () => { + expect(tenants.id.name).toBe('id'); + expect(tenants.name.notNull).toBe(true); + expect(tenants.slug.isUnique).toBe(true); + expect(tenants.plan.default).toBe('free'); + expect(tenants.createdAt.name).toBe('created_at'); + }); + + it('projects carries a non-null tenant_id FK', () => { + expect(projects.tenantId.name).toBe('tenant_id'); + expect(projects.tenantId.notNull).toBe(true); + }); +});