Skip to content

40% off the solution priceReserve early access

NG One

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.

Architecture
Published
Author
Konis Software
9 min read

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.

ModelIsolationSchema changeWhere it breaks
Database per tenantStrongest, physicalN migrations, N outcomesBackups, monitoring, connections grow linearly; versions drift
Schema per tenantMedium, logicalOne migration × N schemassearch_path is runtime state; who sets it decides isolation
Shared database + RLSLogical, in the databaseOne migration, one outcomeA 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

  1. 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.
  2. 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.
  3. 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.
NG One — SQL conventions and templates

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.

QuestionControl planeTenant data plane
What it holdsTenants, packages, price books, subscriptions, licences, usage, health, auditBusiness data: documents, partners, general ledger, stock, payroll
Schema and DB roleSeparate schema, no tenant RLS, its own roleTenant tables with FORCE RLS, runtime role without BYPASSRLS
Who gets inPlatform staff, separate realm, MFA requiredTenant users
Access to the other sideNever by default; only via a support access grantNever
InterfaceA separate portal, outside the ERPThe 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. 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. 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. 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. 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. 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.

The same question, against your own numbers

We run the walkthrough on your documents and your approval chain, not on demo data. Your line, your dimensions, your posting — on the screen, not in a deck.