Skip to content

Tenant isolation

Every record belongs to a tenant, and a query without tenant context fails rather than returning everything.

In the database. Every tenant-owned table carries tenant_id, and PostgreSQL row-level security policies gate access to it. The API sets app.current_tenant per request or transaction, and the policies do the rest.

This is the layer that matters most, because it holds even if application code is wrong. A query that forgets its tenant filter returns nothing rather than everything.

In the application. Queries run through a tenant-scoped client that refuses to execute without a tenant. There is no way to get an unscoped handle without deliberately writing one.

At the edge. The permission guard checks the tenant on the request against the tenant on the token.

Each layer would be adequate alone. Having three means a mistake in one is contained rather than exploited.

A request is matched in this order:

  1. An explicit x-tenant-slug header, for API clients and local development.
  2. The Host header against the tenant’s primaryDomain, which is what makes a custom domain work.
  3. The first label of the hostname as a tenant slug.
  4. DEFAULT_TENANT_SLUG, for a single-tenant self-hosted install.

Health and metrics endpoints skip all of it, so probes keep working when nothing else does.

The same code path runs whether there is one tenant or fifty. There is no separate single-tenant mode with its own bugs, and no untested branch that only runs at one customer.

The practical consequence is that isolation is exercised continuously, by every deployment, rather than only by the multi-tenant ones.

Physical separation. One stack per customer, with its own containers, its own PostgreSQL database, its own secrets and its own backups.

So the answer to “is our data segregated” is “you have your own database, and here is its backup”, rather than an explanation of row-level security. Both are true; only one of them ends the conversation.

What a procurement officer is actually asking

Section titled “What a procurement officer is actually asking”

Usually one of these, and they want different answers:

“Can another school see our data?” No. Three layers, and on managed hosting a separate database.

“Can your staff see our data?” On managed hosting, only with vendor access enabled, time-bound, and every action in your own audit log. You can disable it outright. Self-hosted, there is no path at all.

“What happens if there is a bug?” The database-level policy is the answer. Application code being wrong does not by itself leak data across tenants.

“Where is the data?” A different question, answered in regions and data residency.

A group with several campuses can run several tenants on one deployment, which is what the included tenant count on a plan refers to.

Each campus gets its own people, devices, workflow, branding and roles. Isolation between them is the same isolation as between two unrelated schools, which is usually what a group with distinct campuses actually wants.

If you want campuses to share data, they should be one tenant with locations, not two tenants.

Live updates are filtered by institution before anything is sent, so you only ever see your own.

Tenant isolation is verified in CI on every push, alongside the backup restore drill. That is not a substitute for the design being right, but it does mean a regression fails the build rather than shipping.