Tenant isolation
How one tenant's data is kept from another — and how you can tell that it is, rather than being asked to take it on trust.
The model, stated plainly
RapidValue's SaaS control plane is multi-tenant with logical isolation: one
database, shared schema, every tenant-scoped row carrying a tenant_id. There is
no per-tenant database and no per-tenant schema. That choice has a real cost —
isolation is enforced by software, not by physical separation — so this page is
about what enforces it, where it can fail, and what catches it when it does.
Two independent layers enforce the boundary:
- Application scoping — every query filters on the caller's tenant. This is the primary control: it is what makes the application correct.
- Postgres row-level security (RLS) — the database refuses to return another tenant's rows regardless of what the query asked for. This is the backstop: it is what makes a mistake in layer 1 survivable.
The distinction matters, so we state it rather than blurring it. RLS does not replace application authorization: it knows nothing about roles, ownership or who may see which entitlement — only which tenant. A missing authorization check is still a bug it will not catch. What it converts is the one catastrophic class where a service method forgets its tenant filter: with the backstop in place, that returns too few rows instead of someone else's.
Layer 1 — application scoping
Every service query filters on the tenant resolved from the caller's token, and list views narrow further by role — so "tenant-wide" is itself the exception:
| Regime | Roles | Sees |
|---|---|---|
| Unrestricted | admin, auditor, helpdesk, business admin | everything in the tenant |
| Team | manager | themselves plus their reports, resolved transitively down the management chain |
| Owned resources | object owner | themselves plus the entitlements and systems they own |
| Self | everyone else | their own profile only |
Alongside these fixed roles are scoped roles: a capability set bound to a scope that resolves owner-anchored and fail-closed. The objects in scope are exactly the objects the identity owns, so owning nothing means seeing nothing, never a blanket grant. Delegated onboarding — an external organisation's own administrator managing their own people and no one else's — is the worked example, and the scope is derived rather than assigned.
Layer 2 — row-level security
Every table carrying a tenant_id has a policy of this shape, applied to reads
and writes:
USING (tenant_id = current_setting('app.tenant_id', true)
OR current_setting('app.rls_bypass', true) = 'on')
The policies are generated from the live schema, not maintained as a hand-written list — the set of tenant-scoped tables changes with the data model, and a list would drift silently. Two tables sit outside the scheme because they carry no tenant at all (migration bookkeeping and the cross-tenant vendor connector catalogue); one — configuration promotion, which by definition moves config from a source tenant to a target tenant — carries a bespoke two-sided policy instead.
Four properties are load-bearing:
Transaction-local scope. app.tenant_id is stamped as the first statement of
every transaction in the SET LOCAL form, so it dies with the transaction.
Session-scoped configuration would be catastrophic on a pooled connection:
tenant A's scope would still be set when tenant B's request picked that
connection up. The stamp is installed on the session class itself, so it covers
every session — request handlers, scheduler sweeps, startup — rather than only
the ones someone remembered to route through a helper.
Fail-closed. With no tenant established the setting is an empty string, which
matches no row, because tenant_id is never empty. A path that forgets to
establish scope therefore sees nothing. The empty string is explicit rather
than unset for a reason: an unset setting reads as NULL, and resting a security
boundary on three-valued logic is a footgun even when it happens to behave.
Nothing that connects can escape. FORCE ROW LEVEL SECURITY is set on every
policy-carrying table, so the role that owns them is subject to policy like
anyone else — without it the owner bypasses everything silently, and the
application originally connected as exactly that role. The application now
connects as a dedicated iga_app: NOSUPERUSER, NOBYPASSRLS, not the owner,
no DDL rights. Migrations connect separately as the owner, because they need the
DDL the runtime role deliberately lacks.
The bypass is a policy predicate, not a role attribute. Genuinely cross-tenant work exists (see below), but the runtime role can never escape isolation merely by connecting — it has to execute code that opts in explicitly, and the set of code that does is pinned by tests.
The failure mode we designed against
RLS has an unusually nasty property: it fails open and silent. Ship every policy, then connect as a superuser, and all of them are ignored while the database still lists them cheerfully. Nothing errors, health checks stay green, and isolation is simply absent.
So the platform does not check that policies exist. On every deep health check it scopes to a tenant id that cannot exist and asks a populated, policy-carrying table for rows; the answer must be zero. Alongside that functional probe it asserts the ways enforcement can be lost wholesale:
| Way to lose enforcement | What rules it out |
|---|---|
| Connected role is a superuser | canary reads the role's attributes and reports superuser |
Connected role holds BYPASSRLS |
same check, reports bypassrls |
| Table owner bypass | FORCE ROW LEVEL SECURITY on every table |
| A table shipped with no policy | canary counts unprotected tenant-scoped tables |
A failure here makes the deep health endpoint return 503, and the deploy workflows gate on that endpoint — so the failure mode is a deploy that stops, not an environment that silently ships unprotected.
⚠ What the canary does and does not prove
It proves that, for one populated table, a scope that should see nothing sees nothing — and that the connected role holds no escape hatch. It does not verify that every individual policy predicate is semantically right, and it is not a substitute for review or for an independent penetration test. When every policy-carrying table happens to be empty it reports
indeterminate, which is deliberately not treated as a failure (a freshly provisioned database would otherwise fail its own deploy gate) — and equally should not be read as a pass.
Callers that do not have a tenant yet
Some requests cannot be tenant-scoped at the moment they arrive, because working out which tenant they belong to is the request's entire job. A scoped lookup there would find nothing and make login impossible. Two shapes exist, both narrow and both pinned by tests:
- Pre-authentication endpoints — login, the public password policy, the MFA
challenge, activation and password reset, the OIDC and SAML entry points, and
the pre-login branding lookup that resolves
<slug>.app.rapidvalue.euto a tenant so the login page can be branded. These use a separate, explicitly named dependency rather than a flag on the normal one, keeping the list short and reviewable. Adding to it is a deliberate security decision that shows up as a diff. - Credential resolution — an API key, an agent token, a signed invite token or a signed download link is the tenant identifier. Each opens the bypass for the lookup alone, then binds the request to the tenant it resolved, so the endpoint that follows runs scoped like any other. Both halves pull in opposite directions: scoped, the lookup finds nothing and every valid credential is rejected; bypassed for the whole request, one customer's key would read every customer's rows.
A small number of surfaces are cross-tenant by definition — the vendor's own tenant administration, the vendor support view, and configuration promotion or cloning between tenants. There the bypass is bound to the whole router rather than to individual routes, and the set of such routers is asserted by a test: adding one declares that an entire surface may read every customer's data.
Known edges, published rather than discovered
- Isolation is logical, not physical. Tenants share a database instance and a schema. Everything above is what makes that safe; none of it is the same thing as separate infrastructure.
- RLS is Postgres-only. The unit suite runs on SQLite, where policies do not exist and only the application filters apply. Enforcement is covered instead by a dedicated suite that builds the real schema, applies the real policy generator and connects as a real non-privileged role — with control tests that fail loudly if that harness ever stops enforcing, since otherwise every assertion in the file would quietly become vacuous.
- The policy generator ran once. A table added later needs its own policy. A test and the canary's unprotected-table count both catch the omission, but nothing writes it for you.
- Fail-closed failures look like missing data. Shortly after rollout, two vendor endpoints lost their cross-tenant bypass when their authentication gate was swapped, and a tenant list reported zero tenants against a database holding four. Nothing errored — a missing scope returns nothing, not everything. That is the failure direction we want, and it is why the bypass is now bound to a surface rather than to one authentication dependency, and why a test now enumerates every route and requires each to establish scope. When it was first written it found 68 routes running fail-closed that the previous source-text assertions could not see; those are resolved, and the test now fails the build if a new route appears without a scope or an explicit, reasoned exemption.
- No external certification of any of this. Nothing here has been certified or attested by a third party; what exists instead is mechanism you can inspect. Enforcement status is a field on the public health canary, while the detailed role, probe and coverage output is deliberately not published there, so the endpoint cannot be used to fingerprint an environment.
Further reading: