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 ID | Actor | Customer job | Success outcome | Denial or recovery evidence |
|---|---|---|---|---|
| D41-UC-01 | Workspace member of tenant A | Read and update documents in their own workspace | Queries return and mutate only rows owned by tenant A, even if the application query forgets an explicit tenant filter | A forged tenant selector or stale pooled connection cannot expose tenant B rows; audit evidence shows the denied or empty result |
| D41-UC-02 | Platform engineer | Add a child table or background worker without weakening tenant isolation | New tables and jobs preserve tenant ownership through schema, runtime context, and reviewable constraints | A 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 ID | Use case IDs | User story | Observable acceptance conditions |
|---|---|---|---|
| D41-US-01 | D41-UC-01 | As 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 carelessly | The 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-02 | D41-UC-02 | As 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 B | Composite 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 ID | Use case IDs | Path | Trigger | Numbered steps | Terminal evidence |
|---|---|---|---|---|---|
| D41-FLOW-01 | D41-UC-01 | Happy | Tenant A user opens a document in the product UI | 1. 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-02 | D41-UC-01 | Denied | A caller tampers with tenant_id in a request parameter or body | 1. 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-03 | D41-UC-02 | Happy | A platform engineer adds a document_chunks table | 1. 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-04 | D41-UC-02 | Recovery | A background worker is found using pooled connections without transaction-scoped tenant binding | 1. 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 ID | Entry point | Responsible services | Authoritative store | Failure evidence |
|---|---|---|---|---|
| D41-UC-01 | Authenticated product request | Identity service, authorization layer, transaction wrapper, PostgreSQL RLS engine | Shared PostgreSQL schema whose tenant-owned tables include tenant-scoped primary keys and policies | Unauthorized tenant selector, rejected insert/update, empty read under wrong tenant, or audit log proving transaction tenant binding |
| D41-UC-02 | Schema migration or background worker execution | Schema author, migration runner, data-access layer, worker runtime | Same shared PostgreSQL schema plus worker transaction boundary | Missing 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 entity | Store and owner | Primary key | Foreign key or opaque reference | Tenant key | Material constraint | Lifecycle and deletion | Use case IDs |
|---|---|---|---|---|---|---|---|
| Tenant | PostgreSQL tenants; platform-owned | tenant_id UUID | None | N/A | Stable tenant identity and status | Created on customer onboarding; retained for audit even if downstream rows are archived | D41-UC-01, D41-UC-02 |
| Document | PostgreSQL documents; application-owned | (tenant_id, document_id) | Parent tenant reference to tenants(tenant_id) | tenant_id | No document exists without a tenant; row identity is composite | Created and deleted within tenant lifecycle; RLS prevents other tenants from reading or writing it | D41-UC-01 |
| DocumentChunk | PostgreSQL document_chunks; application-owned | (tenant_id, chunk_id) | (tenant_id, document_id) -> documents(tenant_id, document_id) | tenant_id | Child row cannot point at a parent in another tenant | Created with its parent or later enrichment; delete cascades or is managed explicitly inside the same tenant boundary | D41-UC-01, D41-UC-02 |
| AuditLog | PostgreSQL audit_logs; platform-owned | (tenant_id, audit_log_id) or separate global key with tenant field, depending on retention design | References opaque actor/request IDs; tenant-owned event rows stay tenant-scoped | tenant_id | Audit visibility must obey tenant scope except for approved platform roles | Retained longer than product data; platform accesses require explicit elevated rules | D41-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:
- Verify the user identity.
- Resolve allowed tenants or roles.
- Confirm the requested tenant is one of those allowed tenants.
- Bind the verified
tenant_idinto 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:
- No tenant query outside a transaction.
- 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:
| Pressure | Usual next step | What it buys | What it costs |
|---|---|---|---|
| One tenant dominates storage or QPS | Hash partition by tenant_id | Better pruning and operational separation | More operational complexity |
| One tenant has legal or residency requirements | Dedicated database or cluster | Stronger isolation and governance | Higher cost and more deployment paths |
| All tenants fit but one query pattern is slow | Add measured tenant-first index | Faster reads with local scope | Write 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_idon every tenant-owned row and make it part of the primary key for the shared-schema default. - Include
tenant_idin 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 LOCALwith pooled connections; session-scoped tenant settings are a common cross-tenant leak. - Put
tenant_idfirst in most indexes, then consider partitioning or dedicated databases only when measured tenant skew or governance demands it.
Checklist
- [ ] I can explain why
tenant_idshould usually be part of the primary key in a shared-schema multi-tenant design. - [ ] I can show why a foreign key on only
document_idis 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
USINGandWITH CHECKeach contribute to a PostgreSQL RLS policy. - [ ] I can explain why
SET LOCALis safer than session-scopedSETunder connection pooling. - [ ] I can justify why most indexes in this model begin with
tenant_id, and when that still is not enough.