Multi-Tenancy skill for Codex
One customer never sees another's data. Codex picks an isolation model from your tenant count and contracts, then makes tenant scope structural: composite keys that include tenant_id, Postgres row-level security set per transaction so pooled connections stay safe, and a request context that follows work into caches, queues, file paths and search. It finishes with a generated cross-tenant leak test over every route.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- multi-tenancy Installs to ~/.agents/skills/multi-tenancy/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install multi-tenancy The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project multi-tenancy Installs to .agents/skills/multi-tenancy/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/multi-tenancy.zipunzip -o multi-tenancy.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) multi-tenancy PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $multi-tenancy, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Designs and retrofits tenant isolation for SaaS backends - shared-schema tenant_id, schema-per-tenant and database-per-tenant models, tenant resolution from subdomain or token, Postgres row-level security with SET LOCAL under connection pooling, tenant-scoped unique keys and indexes, tenant-aware caches, jobs, files and search, and cross-tenant leak tests. Use when building a multi-tenant SaaS, adding organizations or workspaces, auditing for data leaking between customers, or moving a big tenant to its own database.
Use it when
- Building a SaaS where several customers share infrastructure
- Adding organizations, workspaces or accounts to an existing app
- A bug or audit suggests data can leak between customers
- A large customer needs dedicated storage or data residency
Use something else when
- You only need to write or debug Postgres policies: use postgres-row-level-security
- The concern is per-customer request quotas rather than data isolation: use api-rate-limiting
How to check the result
A parametrised test logs in as tenant A and requests every route with tenant B's IDs, expecting 403 or 404, and the app role sees zero rows when no tenant is set.
The mistake it prevents
Setting the tenant with a session-level SET on a pooled connection, so the next request on that connection inherits the previous customer's tenant.
Done well means: every read and write is scoped to a tenant by construction, not by
remembering to add a WHERE; the scope travels with the request into caches, jobs, files
and search; and an automated test proves tenant A cannot see tenant B through any endpoint.
Choose the isolation model
| Model | Use when | Costs |
|---|---|---|
Shared schema, tenant_id column (default) | Many small or medium tenants, one codebase, fast onboarding | Every query must be scoped; noisy neighbours share resources |
| Shared schema + Postgres RLS | Same, plus defence in depth against missed filters | Pooling and migration care (below) |
| Schema per tenant | Tens to low thousands of tenants, per-tenant customisation or restore | Migrations run N times; catalog bloat past a few thousand schemas |
| Database per tenant | Regulatory isolation, data residency, very large tenants, per-tenant backup/restore | Fleet management, connection count, cross-tenant reporting needs a warehouse |
| Hybrid | Most tenants shared, a few enterprise tenants dedicated | Routing layer that maps tenant to database |
Start shared + RLS unless a contract or regulator says otherwise. Moving one tenant out to a dedicated database later is easier than merging hundreds of schemas back.
Before changing anything
- Read
AGENTS.mdand the data-access layer (ORM base repository, query helpers). - List every table and classify it: tenant-owned, global (plans, feature catalog), or
user-level spanning tenants (a user in several orgs).
rg -n "tenant|org_id|account_id|workspace". - Find every non-HTTP entry point: job workers, cron, webhooks, CLI scripts, admin tools. These are where tenant scope usually gets lost.
Schema rules (shared model)
CREATE TABLE invoices (
tenant_id uuid NOT NULL REFERENCES tenants(id),
id uuid NOT NULL DEFAULT gen_random_uuid(),
customer_id uuid NOT NULL,
number text NOT NULL,
PRIMARY KEY (tenant_id, id),
UNIQUE (tenant_id, number), -- uniqueness is per tenant
FOREIGN KEY (tenant_id, customer_id) REFERENCES customers (tenant_id, id) -- cannot point across tenants
);
CREATE INDEX ON invoices (tenant_id, created_at DESC); -- tenant_id leads every index
tenant_id NOT NULLon every tenant-owned table, including join tables.- Composite foreign keys that include
tenant_idmake cross-tenant references impossible. - Globally unique columns (email for login) live on global tables only.
Postgres row-level security
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY; -- applies to the table owner too
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
Set the tenant per transaction, never per connection, because pooled connections are reused:
await db.transaction(async (tx) => {
await tx.execute(sql`SELECT set_config('app.tenant_id', ${tenantId}, true)`); // true = transaction-local
return handler(tx);
});
- The app role must not be a superuser, must not own the tables (or tables must use
FORCE), and must not haveBYPASSRLS. Migrations and admin jobs use a separate role. - With
current_setting(..., true)an unset tenant yields NULL and matches no rows: fail closed. - PgBouncer in transaction mode is safe with
set_config(..., true); session-levelSETis not. - Check plans: the policy predicate must use the
tenant_idindex. RunEXPLAINas the app role.
For policy design in depth, use postgres-row-level-security.
Tenant resolution
| Source | Notes |
|---|---|
Subdomain acme.app.com | Map host to tenant in a cached lookup; reject unknown hosts |
Path /t/acme/... | Easy for APIs; still verify membership |
Token claim tenant_id / org_id | Set by your auth server after membership check |
Header X-Tenant-ID | Only as a selector among tenants the user already belongs to |
Whatever the source, verify membership: user_id belongs to tenant_id with a role. A
tenant ID from the client is a request, not a fact. Resolve once in middleware and put it in
a request context object (AsyncLocalStorage, contextvars, request-scoped DI), not a global.
Scope everything else
| Surface | Rule |
|---|---|
| Caches | Key prefix t:{tenant_id}:...; never cache tenant data under a global key |
| Background jobs | Payload carries tenant_id; worker sets context (and RLS setting) before any query |
| Cron / batch | Iterate tenants explicitly; one failing tenant must not abort the rest |
| File storage | Prefix tenants/{tenant_id}/...; presigned URLs generated only after a scoped lookup |
| Search indexes | Filter by tenant_id on the server or with scoped keys; never trust a client filter |
| Logs and traces | Add tenant_id as a field for debugging and per-tenant usage |
| Rate limits and quotas | Per tenant, so one customer cannot starve others (api-rate-limiting) |
| Analytics exports | Explicit tenant filter; review any query without one |
Retrofitting tenancy into a single-tenant app
- Add
tenantstable and nullabletenant_idcolumns; backfill with the default tenant (data-backfills), then setNOT NULLand add composite keys (zero-downtime-migrations). - Introduce the request context and a scoped repository layer; replace raw queries.
- Add RLS in permissive audit mode first: log queries without tenant context, fix them, then enforce.
- Run the leak test suite (below) before onboarding a second real tenant.
Production migrations and backfills require explicit user confirmation and a tested rollback.
Leak tests
Create two tenants with identical fixtures, then for every endpoint and job:
@pytest.mark.parametrize("route", ALL_TENANT_ROUTES)
def test_no_cross_tenant_access(client, tenant_a_user, tenant_b_resource_ids, route):
resp = client.as_user(tenant_a_user).get(route.format(id=tenant_b_resource_ids[route]))
assert resp.status_code in (403, 404)
- Generate the route list from the router so new endpoints are covered automatically.
- Add a DB-level test: connect as the app role without setting
app.tenant_idand assert every tenant table returns zero rows. - Prefer 404 over 403 for other tenants’ resources so IDs are not confirmed to exist.
Gotchas
- Admin “impersonate tenant” tools that bypass scoping become the leak. Log every use.
SELECT DISTINCT/ aggregate endpoints (counts, dashboards) often forget the tenant filter.- Sequential integer IDs leak tenant sizes and invite enumeration; use UUIDs or ULIDs.
- ORMs’ global scopes (Rails
default_scope, Django managers, Prisma extensions) are bypassed by raw SQL and some bulk operations. RLS catches what they miss. - Database-per-tenant multiplies connections; pool per tenant with small limits (
connection-pooling). - Deleting a tenant (offboarding) must cover DB rows, files, search docs, cache keys and backups policy. Never run it without explicit confirmation.
Output
Report: chosen model and why, tables classified, how tenant context is set and propagated, RLS status per table, surfaces scoped (cache, jobs, files, search), and leak-test coverage.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Multi-Tenancy"
short_description: "One customer never sees another's data"
default_prompt: "$multi-tenancy Turn this single-tenant app into a multi-tenant SaaS with organizations" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.