Topic 30

Multi-Tenancy in One Codebase

Identity

Every organizer on Stagedoor is a tenant. Their events, seat maps, orders, staff and API keys must be invisible to every other organizer, and all 340 of them share one database, one schema and one codebase. Nothing in the architecture separates them; the separation is a value, the organizer id, that has to be on every query that touches tenant data, every time, with no exception that is not written down. The security review found the query that listed events for the organizer dashboard took its organizer id from the request body, which means any organizer could ask for any other organizer's events by typing a different number.

This topic draws the tenant boundary in three places and shows what happens when one of them is missing. The tenant id lives on every tenant row. It travels in the request context, from the principal, never from the client. The repository cannot be called without it. And the database itself enforces it as the last line, with row-level security, so that the query which forgets the clause returns nothing rather than everything. The topic ends with the two cases the boundary must be crossed on purpose, and the three ways to lay tenants out, of which Stagedoor uses the cheapest.

Tenant on Every Row

events carries organizer_id directly. seats reach the tenant through event_id, orders through event_id, tickets through order_id and then the event, and api_keys carry organizer_id themselves. Every table either has a path to an organizer or is global: users is global, because a buyer is not owned by an organizer and buys from many. A table with no path to the tenant and no reason to be global is a design mistake, and the review found one, a venues table that had been added for one organizer and was readable by all.

The organizer's event list: the tenant comes from the principal, and only from the principal
async def list_events(repo: EventRepo, ctx: RequestContext) -> list[Event]:
    return await repo.list_for_tenant(ctx.tenant_id)
    # SELECT id, title, starts_at, status FROM events
    #  WHERE organizer_id = $1 ORDER BY starts_at

# the old handler, for contrast: the client named the tenant, and the query obliged
#   organizer_id = body["organizer_id"]

The domain function takes the request context of Chapter 4 and reads the tenant from it; the repository puts that value in the WHERE clause; nothing in the path looks at the request body. The old handler, in the comment, read the organizer id from the body, which is the client asking to be someone, and the query obliged. The difference is one line, and it is the entire tenant boundary at the application level: the tenant is a fact about the caller, established by the auth ring, and no request may name a different one.

Tenant in the Context

The auth ring of Chapter 4 resolves the principal, and for an organizer's owner, staff member or API key the principal has an organizer_id; the ring copies it into ctx.tenant_id when it builds the replacement context. For a buyer the tenant is empty, because a buyer's requests touch buyer-owned rows and public event listings, not tenant data. The storage layer reads the tenant from the context and from nowhere else. A request whose body or path names an organizer, GET /organizers/17/events, is asking a question, and the answer is the context's tenant or a 404: if 17 is the caller's organizer the request succeeds, and if it is not, the organizer does not exist as far as this caller is concerned, per the table in Topic 29.

The Repository Enforces It

Every storage function that reads or writes tenant data takes the tenant as a required, positional argument. list_for_tenant(tenant_id) exists; list_events() does not. The type checker rejects the call that forgets, in the editor, before a test runs. This is the same structural move as Topic 29's find_for_user: make the incorrect call impossible to write rather than easy to catch. The one storage function that reads across tenants, for the admin dashboard, is named list_all_tenants_events so that a code review of any call site sees the crossing in the function name, and it lives in a module that the linter of Chapter 4 allows only the admin package to import.

Row-Level Security as the Last Line

Application enforcement is necessary and it is one forgotten clause from a leak: a new report query, a raw SQL tool on the replica, a migration script that joins two tables and forgets the third. Postgres can enforce the boundary itself. Row-level security attaches a policy to a table, and every query against that table, from any code path, sees only the rows the policy admits. Stagedoor's policy admits rows whose organizer_id equals a setting on the connection, and the application sets that setting at the start of every transaction from the context's tenant.

One policy per tenant table, and the tenant set per transaction, never per session
ALTER TABLE events ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON events
    USING (organizer_id = nullif(current_setting('app.tenant_id', true), '')::bigint);
-- the second argument makes a missing setting NULL rather than an error;
-- NULL = anything is not true, so an unset tenant sees zero rows

-- in the application, at the top of every transaction that touches tenant data:
BEGIN;
SELECT set_config('app.tenant_id', $1, true);   -- true = local to this transaction (SET LOCAL)
SELECT id, title FROM events ORDER BY starts_at;    -- no WHERE, and still only this tenant's rows
COMMIT;                                            -- the setting is gone; the pooled connection is clean

The policy compares each row's organizer to the connection's setting, and the setting is read with a flag that returns NULL when it was never set, so a query on a connection with no tenant returns no rows rather than raising. The application sets the tenant with the local flag, which scopes the value to the current transaction: at commit or rollback it disappears, and the next request that borrows the same pooled connection starts with nothing set. The third statement has no WHERE at all, and it still returns only one tenant's events. That is the property: a query that forgets returns nothing, not everything. The policy applies to stagedoor_app because that role does not own the tables; the owner and superusers bypass policies unless the table is marked to force them, and the role that runs migrations owns the tables on purpose, so that the application role never does. The replica pg-replica-a enforces the same policies, so the report that runs there, and the analyst's raw SQL tool, see one organizer per transaction and cannot be talked into more.

Three lines the tenant boundary is drawn on, and what each one catches
Context: the tenant comes from the principal
Set by the auth ring, never by the client. Catches the request body that names another organizer.
Repository: no query without the tenant
A required argument on every function that touches tenant data. Catches the handler that forgets, at type-check time.
Row-level security: the database refuses
A policy per table, the tenant set per transaction. Catches the raw SQL tool, the migration script and the report nobody reviewed: they return nothing.

Cross-Tenant Operations, Named

Three things in Stagedoor legitimately read across organizers: the admin dashboard, the nightly reconciliation of Chapter 10 that compares every organizer's payments with Payrail's ledger, and the aggregate metrics job. Each of them runs as an explicit all-tenants principal, connects through a second, smaller pool as the role stagedoor_ops, which carries the bypass attribute for row-level security, and logs every query it runs under a marker the audit dashboard filters on. The list of operations allowed to use that pool is a Python tuple of three names in one module, reviewed when it changes, and the pool refuses a caller whose operation is not in it.

What is not allowed is a cross-tenant job that runs as an ordinary principal with the tenant switched per iteration, or, worse, with the tenant unset and the WHERE clauses removed by hand. The first is a job that sees one organizer at a time and can be reasoned about; the second is the design in which the reconciliation ran with a tenant of the first organizer it looped over, saw one organizer's payments, reported 339 organizers as fully reconciled, and was believed for a month.

Shared vs Separate

One database, one schema, a tenant column and a policy per table is the cheapest layout: one migration per release, one connection pool, one backup, and isolation that is a policy rather than a process. Schema per tenant gives each organizer its own tables under one database, which isolates their rows structurally and turns every migration into 340 migrations, one of which will fail at 3 in the morning. Database per tenant is the strongest isolation, with separate backups, separate resource limits and separate everything, and it is the most operations by a factor of the tenant count. The tradeoff is isolation against operational multiplicity, and it is decided by a requirement that is written down, a contract that says an organizer's data must be physically separate, not by an assumption that separate feels safer. Stagedoor has no such contract, and stays with the column.

Application-Enforced vs Database-Enforced Tenancy

Application enforcement, the tenant in the context and the WHERE clause in every repository function, is necessary: it is where the tenant is decided, and it is what makes the ordinary path correct. It is also one forgotten clause from a leak, and the clause is forgotten in the places nobody reviews: the raw SQL tool, the migration script, the report added on a Friday.

Database enforcement, a row-level security policy per table with the tenant set per transaction, is the safety net that turns a forgotten clause into "returns nothing." It costs one policy per tenant table and one set_config per transaction. Stagedoor does both, because the cost of the second is a migration and the cost of not having it is every organizer's data in one report.

Common Mistakes
  • The tenant id from the request body — the client names another organizer, the query obliges, and the organizer dashboard is every organizer's dashboard for anyone who edits one number.
  • A storage function without a tenant parameter — written for the admin dashboard, then called by accident from an organizer route six months later, and the type checker had nothing to object to.
  • RLS without setting the tenant on the connection — the policy sees NULL, every query returns zero rows, and the organizer dashboard is empty in production after a pool library upgrade changed how the connection was set up.
  • The pooled connection with the previous request's tenant still set — a session-level SET survives the transaction, the next request on that connection runs as the last organizer, and the leak is intermittent and unreproducible; use the transaction-local form, never the session one.
  • Cross-tenant jobs that run as a normal principal — the reconciliation that sees one organizer's payments and reports the other 339 as reconciled, believed for a month.
  • The migration role running application queries — it owns the tables, so it bypasses every policy, and a script that "just checks something" on the replica reads all 340 organizers at once.
Best Practices
  • Put a tenant column, or a path to one, on every tenant table, take the tenant from the principal in the context, and make it a required parameter on every repository function that touches tenant data.
  • Enable row-level security on every tenant table with a policy that reads the tenant from a transaction-local setting, and set that setting with set_config and the local flag at the top of every transaction.
  • Run the tables under a migration role that owns them and the application under stagedoor_app, which does not, so the policies apply to every application query.
  • Keep an explicit, logged, reviewed list of cross-tenant operations, each with its own principal, its own pool and its own database role.
  • Stay with one database and a tenant column until a written isolation requirement says otherwise; never move to schema or database per tenant on a feeling.
Comparable toolsPostgreSQL row-level security, the mechanism above; PostgreSQL Deep Dive Chapter 14 for the engine's side of itdjango-tenants schema per tenant, and acts_as_tenant (Rails) the tenant column with a default scopeCitus sharding by tenant when one primary is no longer enoughAWS SaaS tenancy patterns, the written tradeoff between pooled, bridged and siloed layouts

Knowledge Check

The organizer dashboard route receives {"organizer_id": 17} in its body. Where does the tenant for the events query come from, and what happens to the 17?

  • From the body, after checking that 17 exists in the organizers table and is active
  • From the role table, which maps the principal's role to the organizers it may act as
  • From the context, set by the auth ring; the 17 is compared to it and mismatches answer 404
  • From the row-level security policy, which reads the caller's organizer from the connection itself

A new report query on pg-replica-a is written without a WHERE organizer_id clause. With row-level security in place and the tenant set for the transaction, what does it return?

  • An error, because the policy detects the missing clause and refuses to run the query
  • Every organizer's rows, because policies are enforced on the primary and not on a replica
  • No rows at all, because a query without the clause has no tenant for the policy to match
  • Only the rows of the tenant that was set, exactly as if the clause had been written

Why does Stagedoor set the tenant with the transaction-local form rather than a plain session-level SET?

  • A pooled connection is reused, and a session-level value would leak into the next request
  • Policies can only read settings made inside a transaction, not session-level ones
  • A session-level value is not replicated to pg-replica-a, so reports there would see nothing
  • The transaction-local form is faster, because it avoids writing the setting to the catalog

An organizer signs a contract requiring its data to be physically separate from other organizers'. Which layout change does the book consider justified, and at what cost?

  • Keep one database and tighten the row-level security policy, at no cost beyond a migration
  • Move that tenant to its own database, paying separate backups, limits and migrations for it
  • Move every organizer to its own schema, since schemas give the same isolation more cheaply
  • Shard the whole database by tenant with Citus, so each organizer lands on a different node

You got correct