41

Tenant Isolation in a Shared Database

A shared database is not automatically a shared trust boundary. Safe multi-tenancy means tenant ownership is part of every row's identity, every parent-child reference preserves that ownership, and the database enforces the scope even when application code forgets.

The enterprise problem and today's slice

Enterprise problem: A B2B product serves many customers from one PostgreSQL cluster, but one missing WHERE tenant_id = ... or one cross-tenant foreign key can quietly turn a normal bug into a customer-data breach.

Whole-course context: The System Design course has already covered data stores, replication, sharding, and reliability tradeoffs; today narrows to the application-database boundary where multi-tenant correctness is either enforced or merely hoped for.

Today's slice: Design tenant isolation for a shared-schema relational database using composite keys, tenant-aware foreign keys, row-level security (RLS), transaction-scoped tenant context, and tenant-first indexes.

End-of-day evidence: A reviewer can inspect one schema, one request flow, one RLS policy, and one connection-pooling pattern and explain why tenant A cannot read or attach to tenant B's rows through either an application mistake or an operational shortcut.

Still unsolved: Per-tenant encryption key hierarchies, cross-region data residency, and the threshold for promoting a very large tenant into its own database remain later scaling decisions.

Thesis: In a shared database, tenant isolation is strongest when it is expressed three times at once: in the row identity, in the relational constraints, and in the query execution boundary.

Customer use cases

Multi-tenancy is not an abstract platform concern; it exists to let real actors perform normal work without seeing or corrupting another customer's data. These two use cases define the safe positive path and the dangerous denied path.

Use case IDActorCustomer jobSuccess outcomeDenial or recovery evidence
D41-UC-01Workspace member of tenant ARead and update documents in their own workspaceQueries return and mutate only rows owned by tenant A, even if the application query forgets an explicit tenant filterA forged tenant selector or stale pooled connection cannot expose tenant B rows; audit evidence shows the denied or empty result
D41-UC-02Platform engineerAdd a child table or background worker without weakening tenant isolationNew tables and jobs preserve tenant ownership through schema, runtime context, and reviewable constraintsA missing composite foreign key, missing RLS policy, or unscoped job is blocked in review or fails safely before cross-tenant writes occur

Actor-centred user stories

User stories force us to name the accountable person and the proof they need. Without that, "multi-tenant safe" becomes a slogan rather than a falsifiable design claim.

Story IDUse case IDsUser storyObservable acceptance conditions
D41-US-01D41-UC-01As a workspace member, I want my requests bound to my tenant before the query runs, so that another tenant's rows are not reachable even if an endpoint is implemented carelesslyThe authenticated request resolves one trusted tenant_id, the transaction sets it locally, RLS filters every read, and inserts or updates with another tenant ID are rejected
D41-US-02D41-UC-02As a platform engineer, I want every tenant-owned child row to reference its parent through the same tenant boundary, so that a row from tenant A cannot point at a parent owned by tenant BComposite primary keys and composite foreign keys exist, migration review catches missing tenant columns, and cross-tenant references fail at the database layer

End-to-end product flows

The point of tenant isolation is not the schema alone; it is the full path from authentication to transaction to storage. These flows make the ownership boundary visible.

Flow IDUse case IDsPathTriggerNumbered stepsTerminal evidence
D41-FLOW-01D41-UC-01HappyTenant A user opens a document in the product UI1. Identity service verifies the session or JWT.<br>2. Authorization resolves the user's allowed tenant memberships.<br>3. The request chooses one tenant and the server verifies access.<br>4. A database transaction begins and sets SET LOCAL app.tenant_id = ....<br>5. The query runs with ordinary SQL and RLS automatically scopes rows.<br>6. The response returns only tenant A data.Request ID, authenticated actor, trusted tenant_id, transaction log, query result, and absence of rows from other tenants
D41-FLOW-02D41-UC-01DeniedA caller tampers with tenant_id in a request parameter or body1. The API receives the untrusted tenant selector.<br>2. Authorization compares it with verified membership.<br>3. The server rejects or overrides the untrusted value.<br>4. No transaction is opened with an unauthorized tenant context.<br>5. The database is never asked to serve the forged tenant's rows.Access denial or empty result, actor, requested tenant, authorized tenant set, and no successful cross-tenant query
D41-FLOW-03D41-UC-02HappyA platform engineer adds a document_chunks table1. Schema defines tenant_id on the child row.<br>2. Primary key includes tenant_id plus child identifier.<br>3. Foreign key references (tenant_id, document_id) on the parent.<br>4. Matching RLS policy and indexes are added.<br>5. A cross-tenant insert attempt is exercised in review or testing.DDL, composite PK, composite FK, RLS policy, index definition, and rejected invalid insert
D41-FLOW-04D41-UC-02RecoveryA background worker is found using pooled connections without transaction-scoped tenant binding1. The worker is paused or guarded.<br>2. The connection layer is changed so every tenant query runs inside a transaction.<br>3. SET LOCAL replaces any session-scoped tenant setting.<br>4. A positive-control tenant job succeeds.<br>5. A negative-control cross-tenant read returns nothing.Fixed worker revision, transaction wrapper evidence, positive-control job output, negative-control query result, and incident close note

System design derived from the flows

The design is simplest when ownership is carried in the schema itself. Tenant identity should not be inferred from a join, a naming convention, or an application memory structure.

Use case IDEntry pointResponsible servicesAuthoritative storeFailure evidence
D41-UC-01Authenticated product requestIdentity service, authorization layer, transaction wrapper, PostgreSQL RLS engineShared PostgreSQL schema whose tenant-owned tables include tenant-scoped primary keys and policiesUnauthorized tenant selector, rejected insert/update, empty read under wrong tenant, or audit log proving transaction tenant binding
D41-UC-02Schema migration or background worker executionSchema author, migration runner, data-access layer, worker runtimeSame shared PostgreSQL schema plus worker transaction boundaryMissing composite FK, missing RLS policy, session-scoped tenant leak, or cross-tenant write rejected by constraint

Data model and ownership

Tenant safety gets much stronger when the tenant is part of the record identity rather than just one filter column among many. This is the practical default for shared-schema multi-tenancy.

Generated-application database: Required in this slice. The shared database stores many tenants' rows, but every tenant-owned table includes tenant_id as a first-class key field.

Record or entityStore and ownerPrimary keyForeign key or opaque referenceTenant keyMaterial constraintLifecycle and deletionUse case IDs
TenantPostgreSQL tenants; platform-ownedtenant_id UUIDNoneN/AStable tenant identity and statusCreated on customer onboarding; retained for audit even if downstream rows are archivedD41-UC-01, D41-UC-02
DocumentPostgreSQL documents; application-owned(tenant_id, document_id)Parent tenant reference to tenants(tenant_id)tenant_idNo document exists without a tenant; row identity is compositeCreated and deleted within tenant lifecycle; RLS prevents other tenants from reading or writing itD41-UC-01
DocumentChunkPostgreSQL document_chunks; application-owned(tenant_id, chunk_id)(tenant_id, document_id) -> documents(tenant_id, document_id)tenant_idChild row cannot point at a parent in another tenantCreated with its parent or later enrichment; delete cascades or is managed explicitly inside the same tenant boundaryD41-UC-01, D41-UC-02
AuditLogPostgreSQL audit_logs; platform-owned(tenant_id, audit_log_id) or separate global key with tenant field, depending on retention designReferences opaque actor/request IDs; tenant-owned event rows stay tenant-scopedtenant_idAudit visibility must obey tenant scope except for approved platform rolesRetained longer than product data; platform accesses require explicit elevated rulesD41-UC-01, D41-UC-02
CREATE TABLE documents (
  tenant_id UUID NOT NULL,
  document_id UUID NOT NULL,
  title TEXT NOT NULL,
  content TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (tenant_id, document_id)
);
CREATE TABLE document_chunks (
  tenant_id UUID NOT NULL,
  chunk_id UUID NOT NULL,
  document_id UUID NOT NULL,
  content TEXT NOT NULL,
  PRIMARY KEY (tenant_id, chunk_id),
  FOREIGN KEY (tenant_id, document_id)
    REFERENCES documents (tenant_id, document_id)
);

The critical point is not "we happen to store tenant_id." It is that the relational model itself says a document only exists as part of one tenant's namespace, and every child row must stay inside that same namespace.

Trusted tenant resolution

The database should not decide which tenant a user belongs to. That comes from a verified identity and authorization step before the transaction starts.

The user may send a tenant selector, but the server must treat it as a request, not as truth. The trusted value comes from membership verification:

  1. Verify the user identity.
  2. Resolve allowed tenants or roles.
  3. Confirm the requested tenant is one of those allowed tenants.
  4. Bind the verified tenant_id into the transaction context.

This is why a URL such as GET /documents?tenant_id=some-customer is not itself a security boundary. The safe boundary is the server's authorization decision plus the transaction-scoped database context it establishes.

Database-enforced isolation with row-level security

Application filters are good hygiene, but they are not enough on their own. One forgotten predicate can become a breach. PostgreSQL RLS lets the database apply the tenant boundary even when the query text is broader than intended.

ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation_documents
ON documents
USING (
  tenant_id = current_setting('app.tenant_id', true)::uuid
)
WITH CHECK (
  tenant_id = current_setting('app.tenant_id', true)::uuid
);

At the start of each transaction:

BEGIN;
SET LOCAL app.tenant_id = '8fe841bc-47b7-4dd5-b54d-54d16fd3cd8c';
SELECT *
FROM documents;
COMMIT;

USING filters which existing rows are visible. WITH CHECK prevents writes that try to create or modify rows under another tenant's ID. Together they turn tenant isolation from a code convention into a database rule.

You should still write tenant-aware queries when practical:

SELECT *
FROM documents
WHERE tenant_id = $1
  AND document_id = $2;

That keeps index usage clearer and makes review easier. The point is defense in depth: application filtering plus database enforcement plus relational constraints.

The connection-pooling trap

Many RLS designs fail not because the policy is wrong, but because the tenant context leaks across pooled connections. Session-scoped tenant state can survive longer than one request.

Unsafe pattern:

SET app.tenant_id = 'tenant-a';

If that connection later serves tenant B, the old setting can still be present. The safer default is transaction-scoped binding:

BEGIN;
SET LOCAL app.tenant_id = 'tenant-a';
-- tenant query
COMMIT;

SET LOCAL disappears when the transaction ends. This creates two strong rules for the data-access layer:

  1. No tenant query outside a transaction.
  2. No transaction begins before tenant context is established.

Background jobs need the same discipline. A worker that iterates over tenants should open a fresh transaction per tenant work unit, bind that tenant locally, perform the work, then commit before moving to the next tenant.

Indexing, hot tenants, and when to partition

Because tenant ownership is present in almost every predicate, most indexes should begin with tenant_id. This lets PostgreSQL prune work early and prevents one large tenant from dominating everybody else's query path.

CREATE INDEX idx_documents_tenant_created
ON documents (tenant_id, created_at DESC);

CREATE INDEX idx_documents_tenant_title
ON documents (tenant_id, title);

Useful query shape:

WHERE tenant_id = ? AND created_at > ?

Less useful query shape for a shared-schema application:

WHERE created_at > ?

Tenant-first indexing does not solve every scaling problem. If one tenant becomes extremely large or operationally special, the next steps may include:

PressureUsual next stepWhat it buysWhat it costs
One tenant dominates storage or QPSHash partition by tenant_idBetter pruning and operational separationMore operational complexity
One tenant has legal or residency requirementsDedicated database or clusterStronger isolation and governanceHigher cost and more deployment paths
All tenants fit but one query pattern is slowAdd measured tenant-first indexFaster reads with local scopeWrite amplification and storage

The design principle is simple: start with one shared schema plus strict tenant boundaries, then split only when measured pressure or governance requires it.

Key takeaways

  • Put tenant_id on every tenant-owned row and make it part of the primary key for the shared-schema default.
  • Include tenant_id in every foreign key so a child row cannot reference a parent owned by another tenant.
  • Resolve the active tenant from trusted authentication and authorization, not from an unverified request field.
  • Enforce isolation in PostgreSQL with RLS and WITH CHECK, then still keep explicit tenant filters in application queries for clarity and performance.
  • Use transaction-scoped SET LOCAL with pooled connections; session-scoped tenant settings are a common cross-tenant leak.
  • Put tenant_id first in most indexes, then consider partitioning or dedicated databases only when measured tenant skew or governance demands it.

Checklist

  • [ ] I can explain why tenant_id should usually be part of the primary key in a shared-schema multi-tenant design.
  • [ ] I can show why a foreign key on only document_id is weaker than a foreign key on (tenant_id, document_id).
  • [ ] I can distinguish an untrusted tenant selector from a trusted tenant context derived from verified identity and membership.
  • [ ] I can describe what USING and WITH CHECK each contribute to a PostgreSQL RLS policy.
  • [ ] I can explain why SET LOCAL is safer than session-scoped SET under connection pooling.
  • [ ] I can justify why most indexes in this model begin with tenant_id, and when that still is not enough.