Skip to main content

How tenants are kept apart

Isolation is enforced in the application, not by the database. Row-level security policies exist on fifteen tables and are inert, and a reviewer is entitled to know which is which.

Written for an audit response11 min readUpdated

Every customer questionnaire asks some version of how do you stop one of your customers seeing another's data, and the temptation is to answer with the strongest-sounding mechanism in the codebase. This article answers with the mechanism that is actually load-bearing today, and names the one that is present and not yet doing anything.

The shape of the isolation

Four layers, all of them in the application, and each one has caught a real defect at some point in this product's history.

An organisation identifier on every table
This is a standing engineering constraint rather than a convention: every table carries the tenant. A handful of tables genuinely cannot — see the exceptions below — and those are named rather than glossed over.
A path guard on every tenant-scoped route
Routes of the form /organizations/{orgID}/… are guarded so that the organisation in the path must be one the authenticated caller belongs to. A caller substituting another tenant's identifier is refused before the handler runs.
The tenant comes from the credential, not the request, where it can
Several surfaces have no organisation in the path at all and derive it from the authenticated session or API key. The MCP endpoint goes further and refuses nine argument names that could try to name a tenant, rather than ignoring them — see Connecting your own AI client over MCP.
A tenant predicate in the query itself
The filter is in the WHERE clause rather than applied to the results afterwards. That distinction matters: a check performed after a query has returned rows is a check that can be forgotten in one code path out of eighty.

Two examples of the fourth layer being deliberately doubled up, because they show the standard being applied rather than described. An AI conversation is filtered by both the organisation and the owning user, in the query, precisely because the organisation filter alone would let a colleague enumerate identifiers and read someone else's transcript. And a sensor completing a response ticket is bound to the sensor that authenticated, with the identifier in the path treated only as a value to check against — the fix for a defect in which a sensor could drain another tenant's ticket queue.

Row-level security: present, and inert

PostgreSQL can enforce a tenant predicate itself, so that a query with no tenant filter returns nothing rather than everything. That is a genuinely stronger control than an application predicate, because it holds even when a developer forgets. Policies for it have been written.

Fifteen tables carry them, chosen as the ones where a leak would be worst rather than the ones that were easiest:

  • Credentials — the privileged-access vault and its envelopes, integration credentials, application-password credentials, and stored MCP server credentials.
  • Privileged sessions — the session records, their recordings, their transcripts and their channel events.
  • Audit chains — the privileged-access, compliance and Sentinel-Q hash chains.
  • AI — the audit trail, conversations, and conversation messages.

The policies are enabled and forced, which matters: forcing means the table's owner is subject to them too, and an owner otherwise bypasses row-level security silently. The application does set a database session variable naming the current organisation on every connection it acquires, and if setting it fails the connection is discarded rather than handed out with an unknown scope. There is a build-time guard that fails the build if any file outside one designated place tries to set or reset that variable.

There is a documented cutover plan. Its opening line is nothing in this document has been performed. The step that makes the policies live is changing the application's connection string, and that step can take the whole platform down if the new role's grants are wrong, which is why it needs an operator watching rather than an automated release. A rehearsal has been run against the non-bypassing role and reported no permission failures anywhere in the test suite, which is encouraging and is not the same as having done it.

One further honesty point, because it changes the number: the production database is several migrations behind the repository, and the migrations that create these policies have not been applied there. So the count of tables with row-level security policies on production today is zero, not fifteen. Fifteen is what the repository's migrations produce when they are run.

The three tables that cannot carry a tenant, and why

These are excluded deliberately and each exclusion has a reason a reviewer can evaluate. Naming them is more useful than a blanket claim that every table is scoped.

Tables outside the tenant-scoping rule, with the reason
TableWhy
UsersIt has no organisation column and cannot acquire one. Signing in looks a person up by email address before any tenant is known — that is what a login is. A tenant predicate here would make authentication impossible.
Organisation membershipsRead before a tenant context exists, in order to establish one. The partner portfolio also reads it to work out which tenants the signed-in person belongs to at all.
Audit logsIts organisation column is nullable, and a large proportion of existing rows have it empty. This is the table whose missing predicate caused a real cross-tenant read, through an audit-log query filtered by user identifier and not by tenant. That specific hole is closed in the application; the column is not yet reliable enough to enforce on.

The audit-log row is the uncomfortable one and it is the one worth stating plainly. A nullable tenant column on an audit table is a weakness, the historical rows cannot be retrospectively attributed with confidence, and until they can, a database-level policy on that table would either reject legitimate reads or let unattributed rows through. Neither is acceptable, so the fix is data before it is policy.

What we can see, which is a separate question

Isolation between customers is one question. What the vendor can see is another, and a reviewer will ask both. The short answer is that support access is a deliberate, recorded act rather than standing access — Support access and what we can see is the full account, and it is the article to hand a reviewer alongside this one.

One mechanism relevant here: crossing a tenant boundary internally requires declaring a reason on the connection, and the set of places allowed to do that is an explicit allow-list enforced by a test. Background jobs that legitimately work across tenants — a retention purge, a baseline recompute — go through that path. It is not a wide-open capability, and it is also not a control your auditor can verify from outside, so present it as an engineering practice rather than as an assurance.

How to answer the questionnaire

Wording matters here more than usual, because the difference between two defensible answers and one indefensible one is a few words.

Accurate phrasings, and the ones to avoid
Say thisNot this
"A shared database with logical separation, enforced in the application by a tenant predicate on every query, a route-level tenant guard and a per-action permission check.""Isolated databases per customer." It is one database.
"Database-level row-level security is implemented for the highest-risk tables and is pending an operator cutover; it is not the control in force today.""Tenant isolation is enforced by PostgreSQL row-level security." It is written and inert.
"Credentials and session recordings are additionally protected by envelope encryption with per-record data keys.""Encrypted at rest" alone, which is true of everything and answers nothing.
"Cross-tenant access by platform components requires a declared reason and is restricted to an enforced allow-list.""No cross-tenant access exists." Background jobs need it, and pretending otherwise fails the first follow-up question.

What to ask us for

  1. The date of the row-level security cutover, if your own controls depend on database-level enforcement. It is an operational decision with a written plan behind it, so a question about timing has an answer.
  2. The data processing agreement in writing, before you cite anything from this article or from Where your data lives in a filing. This is our engineering description of the system, not a contractual statement.
  3. A penetration-test summary covering tenant isolation specifically. Cross-tenant read attempts are the tests worth asking about, because they are the ones that have found real defects here.

What to read next

  1. Where your data lives — placement, sub-processors, and what we do not claim.
  2. Support access and what we can see — the vendor-access half of the question.
  3. What an auditor will ask for — the rest of the questionnaire, with the evidence for each answer.

Was this article wrong?

If a procedure here does not match what you see, or a limit we described has changed, tell us and we will fix the page. Email us about this article, or see how to get help if you need an answer rather than a correction.

Everything in compliance evidence