The test that reads another tenant's rows
Tenant isolation in a shared Postgres schema is only as trustworthy as the test that tries to break it. A generated probe turns a convention into a property CI enforces.
Every multi-tenant application has a sentence in its design document that says every query is scoped to the tenant. The sentence is true on the day it is written. It stops being true the first time someone writes a raw query for a report, or adds a table without the scoping column, or runs a session-level setting through a connection pool that reuses sessions. None of those mistakes fails a test, because there is no test. The CRM I built at CustomGlide serves companies that must never see each other's pipelines, and the thing that let me sleep was not the WHERE clause. It was a test that logs in as one tenant and tries, table by table, to read another.
Every implementation has a way to stop applying
There are three common ways to scope a shared-table schema, and each has a failure mode that is silent.
The WHERE clause: every query includes tenant_id = $1. It stops applying whenever someone writes a query that does not include it, which in a codebase of any age is a matter of when. A Prisma client extension or a query middleware that appends the clause automatically is better, and it stops applying for raw queries, for queries through a second client, and for the one model that was added after the extension's list was written.
Row-level security: Postgres policies on each table that compare a row's tenant column with a setting on the connection, set with SET LOCAL inside the transaction. The Postgres documentation is clear that policies are bypassed by superusers and by roles with BYPASSRLS, and that a table's owner is exempt unless the table is set to force it. So RLS stops applying whenever the application connects as the wrong role, and it stops applying in a different way when the setting is made at session level instead of transaction level and the connection goes back to a pool.
That last one is the subtle one. A transaction-mode pooler such as PgBouncer hands each transaction to whichever server connection is free; its documentation lists session-level features that do not work in that mode, and SET outside a transaction is one of them. A SET app.tenant = 'A' that is not LOCAL survives the transaction, follows the server connection back to the pool, and is inherited by the next client, who may be tenant B. Nothing errors. B's first query runs as A.
The cross-tenant probe
So the test is generated, not written, because a hand-written test covers the tables someone remembered. The probe reads the schema, finds every table that carries a tenant column, and produces one case per table. Each case seeds a row for tenant B, authenticates as tenant A through the application's normal path, and asserts that a query for that table returns zero rows belonging to B. It does this through every read path the application has: the ORM, the raw query helper, the reporting endpoint, and anything else that talks to the database.
Two details of the seeding decide whether the probe means anything. The rows for tenant B must be identical to tenant A's in every column except the tenant column, so that a leak cannot hide behind a filter that happens to exclude B's data for some other reason, a status, a date range, a soft-delete flag. And the probe must run through the same connection topology as production, pooler included, because the failure modes worth finding live in the pooler and a probe that connects directly to Postgres will never see them.
Then one more case per table, the pool-hangover case. Run a request as tenant A that sets the tenant, commit, return the connection, then run a request as tenant B on the same pool and assert that B's first query does not see A's rows. On a transaction pooler this case fails within seconds if the setting is session-level. On a direct connection it passes, which is exactly why the bug survives in development and appears in production.
The probe reports two numbers: tables covered and tables leaking. The first number is how much of the schema the probe understands. The second must be zero, and a leaking count above zero fails the build. That is the entire contract. The isolation guarantee is no longer a sentence in a design document; it is a CI job with a number in it.
What the schema tells you before the probe runs
Generating the probe from the schema has a side effect: it makes you look at which tables carry a tenant column and which do not. I ran that count against a large public schema, the Prisma schema of cal.com, on the main branch on 17 September 2026. Of 100 models, 61 carry a team, user or organisation column directly, and 39 do not.
That second group is not a list of bugs. It is a list of decisions. Some of those models are global by design: feature flags, app metadata, deployment settings. Some reach a tenant through a parent: an attendee belongs to a booking, and a booking belongs to a user or a team, so the scoping is one join away. The probe has to know which is which, and the honest way to give it that knowledge is a small annotation per table: global, direct, or through-parent-X. A table with no annotation fails the build too, because an unclassified table is the one that will be added next month with the scoping forgotten.
Four ways it stops applying, four cases that catch each
The role check is worth spelling out because it is the cheapest and the most often skipped. Before the suite runs, it asks the database which role the application is connected as, and whether that role is a superuser, has BYPASSRLS, or owns the tables it queries. If any answer is yes, the suite fails before a single tenant case runs, because every RLS-based case would pass for the wrong reason. A probe that reports zero leaks while connected as a role that policies do not apply to is worse than no probe.
What it costs and what it buys
The generator is a few hundred lines. It reads the schema, emits one test file per table, and runs in the same CI job as everything else against a database seeded with two tenants. The suite is slow in the way any database suite is slow, minutes rather than seconds, and the way to keep it from being skipped is to make it the job that gates deployment rather than the job that runs nightly. A leak found nightly is a leak that shipped.
What it buys is a different relationship with the sentence in the design document. The sentence still says every query is scoped to the tenant. Now it is followed by a number, the count of tables the probe checks, and by a job that turns red on the day the sentence stops being true. That is the whole difference between a convention and a property, and for a CRM where two companies' pipelines share a table, it is the difference that matters.
Get new posts by email
Occasional essays on engineering, AI, and building for the people technology leaves behind.
Subscribe with RSS