Work / Trunk / Wiki / Concepts
tenant-isolation
Conceptcanonicalverified 2026-10-12
CONCEPT.TENANT-ISOLATIONHow one company's data is kept from another's
Summary
Three layers, all in the database, so a mistake in application code cannot leak a company's data. Each is covered by a test in packages/db/src.
Why it matters
Trunk holds copies of its customers' products. The worst bug it can have is one company seeing another's.
Implications
- Every row a company owns carries
clientId. - A child's foreign key includes
clientId, andproductIdwhere it has one. A page cannot belong to one company and point at another's product. A journey link cannot join pages of two products. - Row-level security hides other companies' rows. The app sets the company once per transaction with
withClient(db, clientId, work). With nothing set, a query returns no rows. - The app connects as a role that owns nothing, so the rules apply to it. Migrations and system jobs connect as the owner. See
.context/ops/setup.md. - Four tables can be read before a company is known: Client, Membership, Invite and PreviewLink. Anyone may read them; only the company may write them.
- Optional links are checked by a trigger. A foreign key check ignores row-level security, so a trigger proves the linked row is in the same company.
- Prisma drops a foreign key it did not write. So every foreign key is declared in
schema.prisma. Checks, partial unique indexes, triggers and policies are hand-written at the bottom of the migration, and Prisma leaves them alone. - Not enforced by the database: that a company always has an owner. The code that removes a member must check it.
- AI actions and invoices outlive what they point at. Deleting a product keeps its AI actions, so the meter cannot be reset. A company with invoices cannot be hard-deleted by accident.
Related
Sources
- packages/db/prisma/schema.prisma
- packages/db/prisma/migrations
- packages/db/src/tenant.ts
- packages/db/src/tenant.test.ts
- packages/db/src/constraints.test.ts