Multi-tenancy and isolation
Traced at commit ffe4e48 ·
libs/temporal/src/activities/db.ts ·
libs/db/migrations/*.sql (49 policies) ·
libs/temporal/src/activities/dial-gate.ts
Summary
- One database holds the data of every client.
- The database enforces the separation. The application does not.
- Each database call runs in a transaction. The transaction first sets three session values.
- 49 row-level security policies read those values.
- The application connects with a role that is not the table owner. A policy therefore always applies.
- Two crossings are deliberate. This page lists both.
- A missing filter in a query returns fewer rows. It does not return the rows of another client.
How the platform isolates one call
- A request arrives. It carries a user and a client.
withRlsContextopens a transaction.- The transaction sets
app.user_id,app.tenant_idandapp.is_super_admin. - The system sets these values with
set_config(..., true). The values are transaction-local. - A leaked connection therefore cannot pass the context of one client to the next request.
- The connection uses the role
app_api. This role does not own the tables. - The role has no
BYPASSRLSattribute. Nothing can skip a policy. - Each policy has the same shape:
is_super_admin = true OR tenant_id = app.tenant_id. - The query returns the rows of one client. The rows of other clients do not exist to that query.
Note. Isolation does not depend on the query. A developer who forgets a
WHERE tenant_id clause gets fewer rows than expected. That developer does not
get the rows of another client.
Crossing 1 — background work
- Background work has no signed-in user.
- The worker calls
systemRlsContext(tenantId). - This function sets
is_super_admintotrue. - The policies then permit every row.
- The work still names one client and stays inside that client.
- This escape is the mechanism for background work. It is not a defect.
- Read any call to
systemRlsContextas a claim that the code that follows is client-safe by construction.
Crossing 2 — the stop list
- A stop record can have the scope
GLOBAL. - The safety gate matches a
GLOBALrecord from any client. - Without this rule, a person who asks one client to stop still receives calls from the other clients.
- The person does not know that the platform serves several clients.
- See Opt-outs for the records and The dial gate for the check.
The connection pool is not an isolation boundary
- The worker pool holds 36 connections.
- A rolling deploy runs the old worker and the new worker at the same time.
- Two pools must therefore fit inside the database limit of 100 connections.
- An earlier value of 48 exhausted the limit.
- The database then refused
psqlitself. - The failure looked like a database outage. The cause was a pool size.
- Set
WORKER_DB_POOL_MAXonly after you confirm the connection limit of the server.
Opt-outs — the one record type that crosses clients on purpose.