Multitenancy & Audit
Purpose
Keep each tenant's data provably isolated and every sensitive change recorded. Pick the isolation model that matches the compliance/scale need, and make tenant-context injection bulletproof — because a single missing predicate leaks one customer's data to another.
Universal — the three isolation models, per-request tenant-context injection, and append-only audit logging are SaaS-architecture principles; Postgres RLS is the default enforcement.
Procedure
Choose the isolation model by isolation/scale/compliance needs
- Shared schema + RLS (default) — one DB,
tenant_id column, RLS enforces isolation; cheapest, scales to many tenants; the default for most SaaS
- Schema-per-tenant — stronger isolation, per-tenant migration cost; moderate tenant count
- Database-per-tenant — strongest isolation (regulatory), highest operational cost; few large/regulated tenants
- Don't jump to DB-per-tenant without a compliance/scale driver
Make tenant-context injection reliable (the load-bearing detail)
- Set
app.current_tenant per request via SET LOCAL INSIDE the request transaction
- RLS policies reference
current_setting('app.current_tenant')
SET LOCAL (not SET) so it's scoped to the transaction and can't leak across pooled connections — this is the #1 multitenancy footgun
Write RLS policies on every tenant-scoped table
USING (tenant_id = current_setting('app.current_tenant')::uuid)
- Enable RLS on the table (a table without RLS enabled bypasses all policies)
- Never
USING (true) (effectively disabled)
Append-only audit log for sensitive changes
- Capture
tenant_id, actor, action, entity, old_value, new_value, timestamp
- Append-only: no updates/deletes on the audit table (DB-enforced — revoke UPDATE/DELETE)
- Write the audit row in the SAME transaction as the change (see
transaction-management)
Never trust the app layer alone for isolation
- App-layer
WHERE tenant_id = ? is the first line; RLS is the defense-in-depth that catches the forgotten filter
- Connection pooling can leak context if you use session-level
SET — always SET LOCAL
5b. Propagate tenant context to background jobs and async consumers
- Tenant context set in HTTP middleware does NOT automatically reach BullMQ workers, outbox relays, or scheduled jobs — a missing propagation here is the same leak as a missing app filter
- Stamp
tenant_id into the job/event payload at enqueue; at the worker start of every job, SET LOCAL app.current_tenant = <from payload> inside the worker's DB transaction
5c. Per-tenant quotas and rate limits
- One tenant's spike must not degrade the others (noisy neighbor) — apply rate limits / connection-pool slices / job concurrency caps per tenant key
- Track usage per tenant (request count, DB time, queue depth) — surface in observability with
tenant_id as a low-cardinality label
5d. Tenant data deletion (right-to-be-forgotten)
- Compliance (GDPR / similar) requires deleting a tenant's data on request
- Plan the cascade up-front: which tables, soft-delete vs hard-delete, retention of the audit log itself, deletion of derived artifacts (caches, search indexes, BI exports)
- Verify deletion completes across all stores (DB + cache + search + backups within policy)
- Validate (validation loop)
- As tenant A, attempt to read tenant B's rows by ID → verify denied (RLS blocks)
- Disable the app-layer filter in a test → verify RLS STILL denies (defense in depth works)
- Verify a pooled connection doesn't carry tenant A's context into tenant B's request (SET LOCAL scoping)
- If cross-tenant access succeeds → RLS/context-injection broken; fix and re-test
Anti-patterns
| ❌ Anti-pattern |
✅ Correct |
App-layer WHERE tenant_id only |
RLS as defense-in-depth at the DB |
SET app.current_tenant (session-level) with pooling |
SET LOCAL inside the transaction |
Table with tenant_id but RLS not enabled |
ENABLE ROW LEVEL SECURITY + policy |
USING (true) policy |
Real tenant_id = current_setting(...) predicate |
| Mutable/deletable audit log |
Append-only (revoke UPDATE/DELETE) |
| Tenant context set only in HTTP middleware (not in workers / outbox) |
Stamp tenant_id into job/event payload; SET LOCAL at every worker start |
| No per-tenant rate limit (one tenant degrades all) |
Per-tenant quotas / concurrency caps; observability labels by tenant |
| No documented data-deletion cascade for tenant offboarding |
Pre-planned cascade across DB / cache / search / exports for GDPR-style erasure |
Severity tiers
| Tier |
Examples |
Action SLA |
| Critical |
Cross-tenant data leak (tenant A sees tenant B); RLS not enabled on a tenant table; session-level SET leaking context across pooled connections |
Block release; fix immediately |
| Major |
Audit log mutable; tenant context not propagated to background jobs |
Fix this sprint |
| Minor |
Audit log missing some non-sensitive actions; isolation model over-provisioned |
Schedule within 2 sprints |
Stop & Ask (AI must pause for user approval)
- Before changing tenant-context propagation (middleware,
SET LOCAL, connection-pool defaults) — a bug here means cross-tenant data leakage
- Before changing the isolation model (shared-schema ↔ schema-per-tenant ↔ db-per-tenant) — schema moves are largely irreversible once data lands
- Before disabling the audit log or relaxing append-only constraints — compliance impact
Completion Criteria
Output
- Isolation decision ADR: model + rationale
- RLS policies per tenant table + tenant-context middleware
- Audit log table (append-only) + write hooks
- Commit format:
feat(tenancy): RLS isolation for <table> / feat(audit): append-only log for <entity>
Implementation
TypeScript + Supabase / Postgres + Prisma (default)
- Supabase: RLS is first-class;
auth.jwt() ->> 'tenant_id' in policies, or SET LOCAL for service-role flows
- NestJS middleware: extract tenant from token →
SET LOCAL app.current_tenant at transaction start
- Audit:
audit_log table; REVOKE UPDATE, DELETE ... FROM app_role; trigger or app-write in the same $transaction
- Prisma: raw
SET LOCAL via $executeRaw at the start of the request transaction
Other stacks
- Python / FastAPI: SQLAlchemy
SET LOCAL per session-in-request; RLS policies identical
- Go:
SET LOCAL via the connection in the request context
- Universal: RLS is Postgres (and other RDBMS); the three isolation models + append-only audit + SET-LOCAL-with-pooling concern are SaaS-architecture universals
Related skills
authorization — tenant isolation is RLS applied to a tenant_id predicate
transaction-management — SET LOCAL tenant context lives inside the request transaction
backend-security-audit — cross-tenant leakage is a Critical security finding
Reference
- Key insight encoded: Set
app.current_tenant per request with SET LOCAL (transaction-scoped, pooling-safe) so RLS enforces isolation; back it with append-only audit rows capturing tenant_id + old/new data — never trust the app layer alone.
1---2name: multitenancy-audit3description: Choose a tenant isolation strategy (shared-schema+RLS / schema-per-tenant / db-per-tenant), propagate tenant context reliably per request, and keep an append-only audit log. Use when building multi-tenant SaaS, when tenants could see each other's data, or when compliance needs an audit trail. Not for per-user (non-tenant) access control (use authorization) or general OWASP review (use backend-security-audit).4license: MIT5---67# Multitenancy & Audit89## Purpose10Keep each tenant's data provably isolated and every sensitive change recorded. Pick the isolation model that matches the compliance/scale need, and make tenant-context injection bulletproof — because a single missing predicate leaks one customer's data to another.1112**Universal** — the three isolation models, per-request tenant-context injection, and append-only audit logging are SaaS-architecture principles; Postgres RLS is the default enforcement.1314## Procedure15161. **Choose the isolation model by isolation/scale/compliance needs**17 - **Shared schema + RLS** (default) — one DB, `tenant_id` column, RLS enforces isolation; cheapest, scales to many tenants; the default for most SaaS18 - **Schema-per-tenant** — stronger isolation, per-tenant migration cost; moderate tenant count19 - **Database-per-tenant** — strongest isolation (regulatory), highest operational cost; few large/regulated tenants20 - Don't jump to DB-per-tenant without a compliance/scale driver21222. **Make tenant-context injection reliable (the load-bearing detail)**23 - Set `app.current_tenant` per request via `SET LOCAL` INSIDE the request transaction24 - RLS policies reference `current_setting('app.current_tenant')`25 - `SET LOCAL` (not `SET`) so it's scoped to the transaction and can't leak across pooled connections — this is the #1 multitenancy footgun26273. **Write RLS policies on every tenant-scoped table**28 - `USING (tenant_id = current_setting('app.current_tenant')::uuid)`29 - Enable RLS on the table (a table without RLS enabled bypasses all policies)30 - Never `USING (true)` (effectively disabled)31324. **Append-only audit log for sensitive changes**33 - Capture `tenant_id, actor, action, entity, old_value, new_value, timestamp`34 - Append-only: no updates/deletes on the audit table (DB-enforced — revoke UPDATE/DELETE)35 - Write the audit row in the SAME transaction as the change (see `transaction-management`)36375. **Never trust the app layer alone for isolation**38 - App-layer `WHERE tenant_id = ?` is the first line; RLS is the defense-in-depth that catches the forgotten filter39 - Connection pooling can leak context if you use session-level `SET` — always `SET LOCAL`40415b. **Propagate tenant context to background jobs and async consumers**42 - Tenant context set in HTTP middleware does NOT automatically reach BullMQ workers, outbox relays, or scheduled jobs — a missing propagation here is the same leak as a missing app filter43 - Stamp `tenant_id` into the job/event payload at enqueue; at the worker start of every job, `SET LOCAL app.current_tenant = <from payload>` inside the worker's DB transaction44455c. **Per-tenant quotas and rate limits**46 - One tenant's spike must not degrade the others (noisy neighbor) — apply rate limits / connection-pool slices / job concurrency caps per tenant key47 - Track usage per tenant (request count, DB time, queue depth) — surface in observability with `tenant_id` as a low-cardinality label48495d. **Tenant data deletion (right-to-be-forgotten)**50 - Compliance (GDPR / similar) requires deleting a tenant's data on request51 - Plan the cascade up-front: which tables, soft-delete vs hard-delete, retention of the audit log itself, deletion of derived artifacts (caches, search indexes, BI exports)52 - Verify deletion completes across all stores (DB + cache + search + backups within policy)53546. **Validate (validation loop)**55 - As tenant A, attempt to read tenant B's rows by ID → verify denied (RLS blocks)56 - Disable the app-layer filter in a test → verify RLS STILL denies (defense in depth works)57 - Verify a pooled connection doesn't carry tenant A's context into tenant B's request (SET LOCAL scoping)58 - If cross-tenant access succeeds → RLS/context-injection broken; fix and re-test5960## Anti-patterns6162| ❌ Anti-pattern | ✅ Correct |63|---|---|64| App-layer `WHERE tenant_id` only | RLS as defense-in-depth at the DB |65| `SET app.current_tenant` (session-level) with pooling | `SET LOCAL` inside the transaction |66| Table with `tenant_id` but RLS not enabled | `ENABLE ROW LEVEL SECURITY` + policy |67| `USING (true)` policy | Real `tenant_id = current_setting(...)` predicate |68| Mutable/deletable audit log | Append-only (revoke UPDATE/DELETE) |69| Tenant context set only in HTTP middleware (not in workers / outbox) | Stamp tenant_id into job/event payload; `SET LOCAL` at every worker start |70| No per-tenant rate limit (one tenant degrades all) | Per-tenant quotas / concurrency caps; observability labels by tenant |71| No documented data-deletion cascade for tenant offboarding | Pre-planned cascade across DB / cache / search / exports for GDPR-style erasure |7273## Severity tiers7475| Tier | Examples | Action SLA |76|---|---|---|77| **Critical** | Cross-tenant data leak (tenant A sees tenant B); RLS not enabled on a tenant table; session-level SET leaking context across pooled connections | Block release; fix immediately |78| **Major** | Audit log mutable; tenant context not propagated to background jobs | Fix this sprint |79| **Minor** | Audit log missing some non-sensitive actions; isolation model over-provisioned | Schedule within 2 sprints |8081## Stop & Ask (AI must pause for user approval)8283- **Before changing tenant-context propagation** (middleware, `SET LOCAL`, connection-pool defaults) — a bug here means cross-tenant data leakage84- **Before changing the isolation model** (shared-schema ↔ schema-per-tenant ↔ db-per-tenant) — schema moves are largely irreversible once data lands85- **Before disabling the audit log or relaxing append-only constraints** — compliance impact8687## Completion Criteria88- [ ] Isolation model chosen with documented rationale89- [ ] Tenant context via `SET LOCAL` inside the transaction (pooling-safe)90- [ ] RLS enabled + correct policy on every tenant-scoped table91- [ ] Append-only audit log (DB-enforced) for sensitive changes92- [ ] Cross-tenant access test denied (with app filter disabled too)9394## Output95- **Isolation decision ADR**: model + rationale96- **RLS policies** per tenant table + tenant-context middleware97- **Audit log table** (append-only) + write hooks98- **Commit format**: `feat(tenancy): RLS isolation for <table>` / `feat(audit): append-only log for <entity>`99100## Implementation101102### TypeScript + Supabase / Postgres + Prisma (default)103- Supabase: RLS is first-class; `auth.jwt() ->> 'tenant_id'` in policies, or `SET LOCAL` for service-role flows104- NestJS middleware: extract tenant from token → `SET LOCAL app.current_tenant` at transaction start105- Audit: `audit_log` table; `REVOKE UPDATE, DELETE ... FROM app_role`; trigger or app-write in the same `$transaction`106- Prisma: raw `SET LOCAL` via `$executeRaw` at the start of the request transaction107108### Other stacks109- **Python / FastAPI**: SQLAlchemy `SET LOCAL` per session-in-request; RLS policies identical110- **Go**: `SET LOCAL` via the connection in the request context111- **Universal**: RLS is Postgres (and other RDBMS); the three isolation models + append-only audit + SET-LOCAL-with-pooling concern are SaaS-architecture universals112113## Related skills114- `authorization` — tenant isolation is RLS applied to a tenant_id predicate115- `transaction-management` — SET LOCAL tenant context lives inside the request transaction116- `backend-security-audit` — cross-tenant leakage is a Critical security finding117118## Reference119- **Key insight encoded**: Set `app.current_tenant` per request with `SET LOCAL` (transaction-scoped, pooling-safe) so RLS enforces isolation; back it with append-only audit rows capturing tenant_id + old/new data — never trust the app layer alone.