plugins/backend-toolkit/skills/multitenancy-audit/SKILL.md
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).
npx skillsauth add jaykim88/claude-ai-engineering multitenancy-auditInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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.
Choose the isolation model by isolation/scale/compliance needs
tenant_id column, RLS enforces isolation; cheapest, scales to many tenants; the default for most SaaSMake tenant-context injection reliable (the load-bearing detail)
app.current_tenant per request via SET LOCAL INSIDE the request transactioncurrent_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 footgunWrite RLS policies on every tenant-scoped table
USING (tenant_id = current_setting('app.current_tenant')::uuid)USING (true) (effectively disabled)Append-only audit log for sensitive changes
tenant_id, actor, action, entity, old_value, new_value, timestamptransaction-management)Never trust the app layer alone for isolation
WHERE tenant_id = ? is the first line; RLS is the defense-in-depth that catches the forgotten filterSET — always SET LOCAL5b. Propagate tenant context to background jobs and async consumers
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 transaction5c. Per-tenant quotas and rate limits
tenant_id as a low-cardinality label5d. Tenant data deletion (right-to-be-forgotten)
| ❌ 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 |
| 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 |
SET LOCAL, connection-pool defaults) — a bug here means cross-tenant data leakageSET LOCAL inside the transaction (pooling-safe)feat(tenancy): RLS isolation for <table> / feat(audit): append-only log for <entity>auth.jwt() ->> 'tenant_id' in policies, or SET LOCAL for service-role flowsSET LOCAL app.current_tenant at transaction startaudit_log table; REVOKE UPDATE, DELETE ... FROM app_role; trigger or app-write in the same $transactionSET LOCAL via $executeRaw at the start of the request transactionSET LOCAL per session-in-request; RLS policies identicalSET LOCAL via the connection in the request contextauthorization — tenant isolation is RLS applied to a tenant_id predicatetransaction-management — SET LOCAL tenant context lives inside the request transactionbackend-security-audit — cross-tenant leakage is a Critical security findingapp.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.development
Design webhooks correctly on both sides — sending (HMAC signing, retries with backoff, at-least-once) and receiving (verify signature on raw body, enqueue + 200 fast, dedupe on event id). Use when adding webhook delivery or consuming a provider's webhooks. Not for internal service-to-service events (use async-messaging) or general outbound-call retry policy (use resilience-patterns).
testing
Use transactions and isolation levels correctly — keep them short, no network calls inside, explicit isolation, retry on serialization conflicts, and choose optimistic vs pessimistic locking. Use when a write spans multiple tables, when concurrent updates corrupt data, or when designing money/inventory flows. Not for cross-service event delivery (use async-messaging Outbox) or schema-level constraints (use schema-design).
development
Backend testing pyramid — unit for pure logic, integration against a real DB (Testcontainers), and consumer-driven contract testing (Pact) for service boundaries. Use before a feature, after a bug fix, or when services break each other on deploy. Not for load testing (use performance-profiling) or security testing (use backend-security-audit).
data-ai
Design a relational schema — normalize to 3NF then denormalize with justification, choose the right Postgres index type per data shape, enforce constraints at the DB. Use when modeling a new domain, when queries are slow, or before a migration. Not for diagnosing slow queries (use query-optimization) or shipping the change without downtime (use migration-strategy).