Multitenancy with RLS and a Hard Control Plane Boundary
Isolation between customers is a solved problem; isolation between platform staff and customer data almost never is — and it is built with the same tool and one test.
- Published
- Author
- Konis Software
Buyers of a SaaS ERP tend to ask one question far too late: who inside the vendor can, on a quiet Tuesday afternoon, open our general ledger, our customer list with payment terms, and the payroll filing carrying every employee's national ID and net pay. A signed NDA is not an answer; it is a hope with a legal header.
Two boundaries get conflated here. The first is isolation between customers: tenant A must not see tenant B's data — well understood, usually solved. The second is isolation between the vendor's own staff and customer data. Far fewer solve it: not because it is hard, but because someone has to build a wall against themselves.
Three ways to isolate a tenant
A database per tenant, a schema per tenant, or a shared database with row level security. The choice looks like taste until the first schema change across three hundred customers — or the first query written without a tenant filter.
| Model | Isolation | Schema change | Where it breaks |
|---|---|---|---|
| Database per tenant | Strongest, physical | N migrations, N outcomes | Backups, monitoring, connections grow linearly; versions drift |
| Schema per tenant | Medium, logical | One migration × N schemas | search_path is runtime state; who sets it decides isolation |
| Shared database + RLS | Logical, in the database | One migration, one outcome | A policy on EVERY table, plus context discipline |
A shared database with RLS is the only one of the three where a mistake in application code does not end as a leak: a query with no tenant predicate returns the current tenant's rows, not everyone's. The price: the boundary moves somewhere easy to forget — every table's definition.
RLS that does not depend on developer discipline
- ENABLE is not enough; you also need FORCE. Without FORCE ROW LEVEL SECURITY the policy does not apply to the table owner — typically the role your migration tool runs as. A seed script run under that role walks past it as if it were not there.
- A runtime role without BYPASSRLS. The application runs as an ordinary role with per-table grants. If a superuser connection exists anywhere for background jobs, RLS is decoration.
- A policy that fails in the right direction. USING (tenant_id = current_setting('app.tenant_id')::uuid) looks correct until it runs with no context: it raises an error instead of returning an empty set. The form with current_setting('app.tenant_id', true) yields NULL, the comparison yields NULL, the result is zero rows. Fail-closed.
A context that dies with the transaction
app.tenant_id has to travel from a verified token to the connection, and disappear before that connection returns to the pool. The session-scoped variant, with a RESET on return, works exactly as long as nobody forgets: one exception in the wrong place and the next request reads someone else's data over the same connection. The transaction-scoped variant has no such bug class — SET LOCAL, cleared by commit and rollback alike.
The policy missing on one table out of four hundred
Isolation is a property of every individual table, not of the system, and there are several hundred tables. The odds that review catches a missing policy in a twelve-file pull request are low; the cost is total. The only mechanism that works is a meta-test: it reads the catalog and fails the build unless every table with a tenant_id column carries ENABLE, FORCE and a policy of the expected shape. It must compare the expression, not the name: the right name over a wrong expression passes review.
A rule that no test verifies is not a rule; it is a recommendation. Tenant isolation does not tolerate recommendations.
The second boundary: control plane and data plane
RLS governs the customer-to-customer relationship and says nothing about the vendor-to-customer one. Platform staff have legitimate work: provisioning, licences, quotas, schema version, backup drills, p95 latency, incidents. None of it requires reading an invoice line, a bank statement entry or a row of a VAT ledger. So the two share neither a space nor an identity.
| Question | Control plane | Tenant data plane |
|---|---|---|
| What it holds | Tenants, packages, price books, subscriptions, licences, usage, health, audit | Business data: documents, partners, general ledger, stock, payroll |
| Schema and DB role | Separate schema, no tenant RLS, its own role | Tenant tables with FORCE RLS, runtime role without BYPASSRLS |
| Who gets in | Platform staff, separate realm, MFA required | Tenant users |
| Access to the other side | Never by default; only via a support access grant | Never |
| Interface | A separate portal, outside the ERP | The ERP |
The last row is a matter of grants, not policy documents. The runtime role holds no grant on the control plane schema; the control plane's role holds none on tenant tables. A boundary that exists as an absent grant survives refactoring, a new hire and a Friday afternoon; an isPlatformStaff() check does not. The control plane never reads business tables either: usage comes from events the modules emit, content-revealing dimensions hashed. The platform knows a tenant created 14,000 documents in March. It knows none of them.
Why a super tenant is a risk, not a solution
The usual answer to the support problem is a super tenant: technically a tenant, but it sees everything. Same interface, same schema, one flag in the token. Elegant on a diagram; wrong in practice, for four reasons.
- It requires a hole in the policy. An account that sees everything must get past RLS: either BYPASSRLS, or a policy branch checking an extra setting. From then on, every customer's isolation hangs on one role check.
- It spreads quietly. Every new table inherits the policy template together with the exception branch. An exception made once becomes default behaviour, and a year later nobody reads it as one.
- It destroys the audit trail. When access is the default, the audit records everyday work. An entry appearing forty thousand times a month answers nothing. An audit trail counts only when it records the exception.
- It has no answer for the customer. Asked who looked at our payroll in March, it says only: everyone who was allowed to, and we cannot tell whether they did.
Support access: access as an event, not a state
- 1
Diagnostics before access
The first move is not a request for data but a diagnostic bundle: application and schema version, last migration, document type config, decision logs, stack traces — no personal data, no amounts. Most tickets close here.
- 2
A request with scope and an expiry
If the bundle is not enough, the engineer states the request: which tenant, which incident, which scope (read-only, which modules), for how long — eight hours at most.
- 3
Approval inside the customer's system
The customer's administrator approves in their own ERP, not over email. Email is not an access control: it does not live in the system that enforces the decision, and cannot be revoked.
- 4
Work with a witness
The grant issues a token with exactly that scope, and the session carries a persistent marker. Every action lands in two audit trails: the platform's and the tenant's. The tenant's matters more — a customer must not depend on our copy.
- 5
Automatic expiry and a report
The grant expires on its own, with no human action. The customer gets a session report: who, when, how long, which screens, which records. Access switched off by hand is not switched off.
On a real ticket: an allocation following the FEFO rule picks a lot with a later expiry date than it should. The engineer needs the ordered lots with their dates, the rule, and the decision log — not the customer's name, the price, or the order. So diagnostics come first; the grant, if needed at all, stays read-only and scoped to one module. The emergency path must exist, but at a price agreed in advance, or it quietly becomes the normal route.
What this costs
- Indexes. Every composite index starts with tenant_id, the predicate on every query. An index that ignores this is used worse than expected by the planner — and you find out on the tenant with a million rows.
- A predicate on every plan. The policy is an extra condition in every query: small but not zero, and it shows most on queries pulling few rows from a large table.
- Confusing development. Code running outside a transaction gets an empty result, not an error: what you want in production, and what costs half an hour in development. Fail-closed errs the right way, but silently.
- Cross-tenant batches. A job over several customers must not rebind the context inside a live transaction; it opens one transaction per tenant. A rebind inside an open one quietly hits the wrong tenant.
How NG One sets both boundaries
Those costs are predictable and measurable. The cost of the alternative — one leak between two customers — is neither predictable nor fixable in the next release. So both boundaries sit underneath everything else in NG One: underneath all nine business spaces and all sixty-odd domains inside them. Isolation is not a module you switch on, it is a property of every table and every grant in the system — and retrofitted, it is a migration of several hundred tables under production load.
- Shared database with RLS: ENABLE and FORCE on every tenant table, the policy in the same migration as the table.
- The context is bound per request and set at the start of every transaction, so it dies with it; no BYPASSRLS anywhere.
- A meta-test over the catalog fails the build when a table departs from the template.
- A separate control plane schema, without tenant RLS, whose database role holds no grant on business tables — and vice versa.
- The Platform Control Center is a separate portal with its own auth client and MFA — not a screen inside the ERP.
- Support access: scope, approval by the customer's administrator, dual audit, automatic expiry, session report.
- Maker-checker on suspension, tenant termination, entitlement overrides and emergency access.