Playbook library
Multi-tenant data isolation
Tenant isolation is the property that is never visible in manual testing: everything looks right when you are logged into one account. It also degrades quietly — a new query without the filter, a cache key without the tenant prefix, a worker that inherited the wrong context. These rules exist to make each of those a diff-level failure.
- Written for
- SaaS teams where one database serves many customers — including agencies running client workspaces.
- Not covered
- Row-level security in the database is a stronger guarantee than any of this and is worth adopting if you can. These rules are what protects the code paths that RLS does not cover, and what you need in the meantime.
10 rules7 blocking3 warning
01 The tenant id comes from the session
errorThe tenant/organization id is read from the authenticated session or a verified token, and from nowhere else. Taking it from the request body, the query string, a path parameter or a client-supplied header is rejected, even when the code also checks membership afterwards. Any tenant id the client can send is a tenant id the client can change.
Blocks: this is the root of the entire isolation model — one exception voids all the others.
Scopewhole repository
Tagsmulti-tenancysecurityauthorization
02 Every query filters on the tenant
errorEvery select, update and delete against a tenant-scoped table includes the tenant predicate — including lookups by primary key. "The id is a UUID so it's unguessable" is not an authorization check: ids leak through exports, logs, support tickets and shared URLs.
Blocks: a single missing predicate is a cross-customer data leak.
Scopewhole repository
Tagsmulti-tenancysecuritydatabase
03 Cache keys carry the tenant id
errorAny cache key derived from an entity id, a user input, or a computed result includes the tenant id. A key without it serves one customer's data to another, and the poisoned entry outlives the deploy that introduced the bug — so the fix does not fix it.
Blocks: cache leaks are cross-customer, persistent, and invisible in logs.
Scopewhole repository
Tagsmulti-tenancysecuritycaching
04 Storage paths are tenant-prefixed
errorUploads and generated files are written under a tenant-scoped prefix, and any signed URL is minted with a short expiry (15 minutes or less). A flat bucket namespace means a guessable or enumerable key is a direct read of another customer's document, with no application code involved.
Blocks: object storage bypasses every check that lives in your application.
Scopesrc/**/storage/**src/**/*upload*src/**/*file*
Tagsmulti-tenancysecuritystorage
05 Jobs carry the tenant explicitly
errorA queued job or scheduled task includes the tenant id in its payload, and the handler re-establishes the scope from it before touching data. Relying on ambient or thread-local context inside a worker is rejected — the context that produced the job is long gone by the time it runs.
Blocks: a worker with the wrong scope writes to the wrong customer, silently.
Scopesrc/**/jobs/**src/**/workers/**src/**/*worker*
Tagsmulti-tenancysecurityqueue
06 New tenant-scoped tables carry the column and the index
errorA new table holding customer data declares a non-null tenant column with a foreign key to the tenant table, and an index leading with that column. Adding it later means a migration on a populated table plus an audit of every query written in the meantime.
Blocks: the cost of getting this wrong grows every day the table exists.
Scopemigrations/**db/migrations/****/schema*.ts
Tagsmulti-tenancydatabase
07 Role grants verify the target tenant
errorBefore creating an invitation or changing a role, verify that the actor is a member of the session's tenant with permission to grant, and that the target user and target role belong to that same tenant. Cross-tenant privilege escalation is the highest-severity bug this product category has, and the code path looks entirely ordinary.
Blocks: it grants an attacker durable, legitimate-looking access to another customer.
Scopesrc/**/members/**src/**/invitations/**src/**/*role*
Tagsmulti-tenancysecurityauthorization
08 Cross-tenant reads are explicit and audited
warningA query that intentionally spans tenants — admin tooling, billing rollups, internal analytics — goes through a named unscoped helper and logs the actor who ran it. Without a marked escape hatch, people quietly drop the filter in ordinary code and the two cases become indistinguishable in review.
Warns: legitimate uses exist, so the goal is visibility — the reviewer confirms the escape hatch was intended.
Scopewhole repository
Tagsmulti-tenancyaudit
09 Foreign resources return 404, not 403
warningA resource belonging to another tenant returns exactly what a nonexistent resource returns. A 403 confirms the id exists, which turns any endpoint into an enumeration oracle for another customer's data volume, naming scheme and activity.
Warns: it leaks metadata rather than content, but it is what makes targeted attacks cheap.
Scopesrc/routes/**apps/api/**
Tagsmulti-tenancysecurityapi
10 Ship the cross-tenant test with the endpoint
warningEvery new tenant-scoped endpoint ships a test asserting that a member of tenant B receives a 404 on tenant A's resource. Isolation is the property manual testing never exercises, because a human is only ever logged into one account at a time.
Warns: missing tests do not break production today — they just guarantee nobody notices when it does.
Scope**/*.test.***/*.spec.*tests/**
Tagsmulti-tenancytesting
Before you import all of it
Severity is the lever that decides whether people keep the gate on. Only error blocks a merge — bring these in as warnings first, look at what actually fires on a few weeks of real pull requests, and promote the rules that earned it. And rewrite the scope globs: the paths above are examples, and a rule only runs against files its globs match.
Rules go into Normik one at a time — through the playbook form, or by letting a coding agent propose them over MCP from an existing repository. There is no bulk markdown import: the copy button above is for your AGENTS.md, for a PR where the team argues about the list, and to keep beside you while you enter the rules you actually kept.
Every rule here is judged the way Normik judges it: on the lines the pull request adds. Nothing in this playbook depends on the PR description, on a file the diff does not touch, or on the history of a file — those are real review standards, but a gate that cannot see the evidence would either stay silent or guess.
The anatomy of a rule, how severity and globs behave, versioning and the AGENTS.md export are all in the playbook documentation.