Security
How multi-tenant data isolation actually works
Why filtering by organization id in application code is not isolation, and what row-level security changes about the guarantee you can offer.
Every SaaS product serving more than one company has the same problem: many organizations, one database, and an absolute requirement that no organization ever sees another’s data.
The way that requirement is met varies enormously, and the difference is invisible from the outside. It is worth understanding before you trust anyone with your customer list.
The common approach, and why it is fragile
The usual implementation adds an organization_id column to every table and filters on it in
application code:
SELECT * FROM customers WHERE organization_id = $1;
This works. It also fails the first time somebody forgets.
The problem is that correctness depends on every single query, written by every developer, forever.
One missing WHERE clause in one endpoint — a new report, a background job, an admin tool, a CSV
export written in a hurry — and that endpoint returns everybody’s data. The bug is silent. Nothing
crashes. The tests pass, because the test database has one organization in it.
Application-level filtering makes isolation a matter of discipline. Discipline degrades.
Pushing the guarantee into the database
PostgreSQL row-level security moves the rule underneath the query. You enable it per table and define a policy:
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON customers
USING (organization_id = current_setting('app.current_organization')::uuid);
Now the database itself refuses to return rows outside the current organization. The application sets the organization once per request, and every query in that request — including the ones nobody remembered to filter — is constrained.
The forgotten WHERE clause stops being a data breach and becomes an empty result set.
What this does not solve
Row-level security is a floor, not a ceiling. It is worth being precise about what it does not do:
- It does not replace authorization. RLS separates organizations from each other. It says nothing about whether this user, in this organization should see this record. Roles and permissions still do that work.
- It can be bypassed by a superuser connection. If your application connects as a role that bypasses RLS, the policies do nothing. The connection role matters as much as the policy.
- It does not cover what leaves the database. Caches, search indexes, exports, logs and analytics pipelines all need the same scoping applied deliberately.
That third one catches people. A search index built from all rows and queried without a tenant filter reintroduces exactly the leak you closed at the database.
Why this matters for AI
This is where isolation stops being an infrastructure detail.
An AI assistant is, functionally, a very effective query generator. If it runs against a connection that can see everything, it will eventually surface something it should not — not maliciously, just by doing its job well against a context that was too wide.
So the AI has to inherit the boundary rather than sit beside it. The chain runs organization → user → role → permissions → data access → AI context, and the assistant’s data access is the user’s data access, not the application’s.
The rule we hold to: if a user cannot open a record in the interface, the assistant cannot surface it either.
The question to ask a vendor
Not “is our data isolated” — everyone says yes.
Ask: “where is isolation enforced, and what happens if a query forgets to filter?”
If the answer is “our code always filters”, you are trusting their discipline. If the answer names the database layer, you are trusting a mechanism.
Our approach is on the security page.