RapidValueDocs RapidValue.eu
Docs/Architecture & trust

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.

Application scoping and row-level security independently enforce the tenant boundary; a missing application filter is emptied by the database backstop.

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.eu to 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.

Scoping inside a tenant

Tenant isolation answers "can another customer see this". A second question sits inside it: within one tenant, who sees what.

Every list view filters on the viewer. A manager's lists resolve to the people who report to them; an owner's resolve to the objects they own; an ordinary user sees their own record and nothing else; an administrator or auditor sees the tenant. The filter is applied server-side as part of the query rather than by hiding rows after the fact, for the same reason the tenant scope is: a filter applied after the data has been fetched is a presentation choice, not a control.

Scoped roles extend this to delegated administration. A scoped role is a set of capabilities bound to a scope, and the scope is owner-anchored: the objects in it are exactly the objects that identity owns. Both halves are checked — holding the capability is not enough if the target is outside the scope, and being inside the scope is not enough without the capability. It resolves fail-closed, so an identity that owns nothing has an empty scope rather than a blanket one. The typical use is letting an external organisation's own administrator onboard their people without giving them anything beyond their organisation.

Known edges, published rather than discovered

  • Region selection and tenant isolation are separate controls. Hosting data in a selected EU region establishes location; it does not give each tenant separate infrastructure or prove a regulatory outcome. See Hosting, regions and deployment status for the current provider and availability matrix.
  • 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:

Did this answer your question?One click records the page; add detail by email if something is missing.

Try “tenant isolation”, “role mining” or “Entra”.