Architecture & trust rapidvalue.eu →

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:

  1. Application scoping — every query filters on the caller's tenant. This is the primary control: it is what makes the application correct.
  2. 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:

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


Further reading: