Skip to content
Isolation Policystable

Row-level tenant isolation policy

The single-database isolation model: a mandatory tenant key, a default-deny global scope, an explicit bypass registry, and a database-enforced backstop. Where the request scope dies and what carries the tenant across it.

Single-database multi-tenancy is safe only when tenant scoping is the default and every exception to it is explicit, reviewed, and enforced in more than one layer. This spec defines that model: the schema requirement, the application-level default scope, the registry of sanctioned bypasses, and the database-level backstop that holds when the application forgets.

The governing principle is default-deny: a query with no tenant context returns nothing, not everything. Every rule below exists to make the safe behaviour automatic and the unsafe behaviour loud.

Conformance language follows RFC 2119: MUST / MUST NOT are enforceable requirements, SHOULD / SHOULD NOT are strong recommendations with legitimate exceptions, and MAY marks a genuine choice.

Definition

Schema requirement

Every tenant-bound table carries a non-nullable `tenant_id` foreign key, indexed as the leading column of every composite index that supports a tenant-scoped query. Junction and derived tables carry it directly rather than inferring it through a join — inference is where isolation is lost.

ColumnTypeConstraintRationale
tenant_iduuid / bigintNOT NULL, FK → tenants(id)The isolation key; never nullable
iduuid / bigintPRIMARY KEYPrefer non-guessable IDs to reduce enumeration risk
(tenant_id, …)indexcomposite, tenant_id firstEvery scoped query hits the index
Required columns on every tenant-bound table

Contract

Tenant resolution

The acting tenant is resolved from the request in a fixed priority order, first match wins: a verified token / session claim (the tenant the authenticated principal belongs to), then an explicit `X-Tenant-Id` header (for internal, authenticated service-to-service calls), then the subdomain / host. The order is load-bearing — a token claim is authoritative and a client-supplied header or host MUST NOT override it.

A resolved tenant MUST be validated before it is trusted: the tenant exists and is active (not suspended or deleted), and — where the request carries an authenticated user — that user actually holds a role in that tenant. Resolution and authorization are distinct; resolving a tenant does not by itself grant the caller access to it.

Failure fails closed with a specific status, never a fall-through to an unscoped query: no resolvable tenant on a tenant-scoped route is a 400 (malformed for that route); a tenant that resolves but that the authenticated user has no role in is a 401/403 (authenticated, not authorised for this tenant); a suspended or non-existent tenant is a 403/404 per policy.

PrioritySourceTrust / failure
1Verified token / session claimAuthoritative — the tenant the principal belongs to
2X-Tenant-Id headerAuthenticated internal callers only; never overrides a token claim
3Subdomain / hostPublic entry; still validated against the tenant registry
—None resolvable400 — malformed for a tenant-scoped route
—Resolved, user has no role401 / 403 — authenticated, not authorised for this tenant
Resolution priority and failure

Resolution priority is token claim > header > subdomain; a client-supplied header/host MUST NOT override an authenticated token claim, the tenant MUST be validated (exists, active, user has a role), and an unresolved or unauthorised tenant fails closed with a specific status — never an unscoped query.

Isolation policy

Application default scope

A global scope on every tenant-bound model applies the `tenant_id` filter automatically and fails closed: if no tenant is resolved, the scope must produce an impossible predicate, not an absent one. The same trait stamps `tenant_id` on create, so a row can never be written without an owner.

BelongsToTenant — default-deny global scope
trait BelongsToTenant
{
    protected static function bootBelongsToTenant(): void
    {
        static::addGlobalScope('tenant', function (Builder $q) {
            $id = app(TenantContext::class)->id();
            // Fail CLOSED: no context => match nothing, never everything.
            $q->where($q->getModel()->getTable().'.tenant_id', $id ?? '00000000-0000-0000-0000-000000000000');
        });

        static::creating(function ($model) {
            $model->tenant_id ??= app(TenantContext::class)->requireId();
        });
    }
}

Isolation policy

Where the request scope dies

The tenant is resolved from the request (subdomain, header, verified token) and disappears the moment the request ends. Every asynchronous or out-of-band boundary must re-establish it explicitly. Reading it from `Auth::user()` is prohibited outside the request lifecycle — it is null there.

BoundaryFailure if unhandledCarrier
Queued jobRuns with no / wrong tenantTenant ID in constructor, re-bound in handle()
Scheduled command / cronGlobal run leaks all tenantsExplicit per-tenant loop; no ambient default
Model observerFires with null auth contextRead tenant from the model, never the request
Cache keyOne tenant serves another's valueTenant ID prefixed into every key
Broadcast / webhookCross-tenant payloadTenant ID on the event, re-hydrated on use
Boundaries that lose request scope, and the carrier

Contract

Tenant context across boundaries

Anything that outlives the request MUST carry the tenant explicitly and re-establish it — this is the concrete form of the carriers above. A cache key MUST be prefixed with the tenant, so one tenant can never read another's cached value; the prefix is applied by a wrapper, not left to each call site to remember.

A queued job MUST carry the tenant id in its payload (not a hydrated model, not an ambient default) and re-bind the tenant context at the start of `handle()` before it touches any tenant-scoped data — a worker has no request, so `Auth::user()` and any request-bound tenant are null. A scheduled/cron task iterates tenants explicitly and binds each in turn; it never runs one query across all tenants by omission.

The same rule governs broadcasts, webhooks, and model observers: the tenant travels on the event or is read from the model, and is re-bound before any scoped access. Each of these is a place the default scope silently disappears, which is why re-establishing the tenant is a MUST, not a convention.

Cache prefix by wrapper; job carries + re-binds the tenant
// Cache: tenant-prefixed by a wrapper, never by hand.
Cache::remember("t:{$tenantId}:{$key}", $ttl, $cb);

// Queue: tenant in the payload, re-bound before any scoped access.
class SendInvoice implements ShouldQueue, TenantAware
{
    public function __construct(public int $tenantId, public int $invoiceId) {}

    public function handle(): void
    {
        app(TenantContext::class)->bind($this->tenantId);   // re-establish first
        $invoice = Invoice::findOrFail($this->invoiceId);   // now correctly scoped
    }
}

Cache keys MUST be tenant-prefixed by a wrapper; jobs MUST carry the tenant id and re-bind context in handle() before scoped access; scheduled tasks iterate tenants explicitly. Every boundary that outlives the request re-establishes the tenant.

Isolation policy

Sanctioned bypass registry

`withoutGlobalScope('tenant')` is the only permitted way to cross the boundary, and every call site is listed here. An unlisted bypass fails code review. Each entry names the reason and the compensating control that keeps it safe.

A bypass applies to a tenant-scoped model — one that has a `tenant_id` — read deliberately across tenants. A model that is inherently un-scoped (shared reference data, see Cross-tenant and shared data) has no tenant boundary to cross, so it is not a bypass and needs no registry entry.

Call siteReasonCompensating control
Platform admin consoleCross-tenant support viewAdmin-only middleware + audit log per read
POPIA/erasure checksDetect records across tenantsRead-only; returns existence, not data
Billing rollup jobAggregate across tenantsWrites to a separate reporting schema only
The complete list of permitted tenant-scope bypasses

Definition

Cross-tenant and shared data

Not all data is tenant-owned. Shared reference data — country codes, currency lists, a global product catalogue, system roles — is not tenant-scoped and MUST NOT carry a `tenant_id`. Forcing a tenant key onto genuinely-shared data either duplicates it per tenant or invites a nullable `tenant_id`, and a nullable tenant key is precisely the hole this model exists to close. Shared data lives in its own tables (or a separate schema), explicitly outside the tenant-scoping and RLS machinery, and read-only to tenants.

A relationship that genuinely spans tenants — a marketplace order referencing another tenant's catalogue item, a parent/child tenant hierarchy — is modelled explicitly, not by relaxing isolation. The join row carries both tenant ids and is itself access-controlled, so crossing the boundary is a named, audited operation (a bypass-registry entry), never an ambient consequence of a foreign key. If two tenants must share mutable data, that shared surface is its own bounded context with its own rules, not a leak in either tenant's scope.

This is distinct from the sanctioned bypass registry, and the test is the column. Shared data is inherently un-scoped — it has no `tenant_id` by design and needs no registry entry. A bypass is a tenant-scoped model (one that has a `tenant_id`) deliberately read across tenants. If a model has a `tenant_id`, crossing it is a bypass; if it has none by design, it is shared data.

Genuinely-shared reference data is NOT tenant-scoped and MUST NOT carry a nullable tenant_id — it lives in separate, read-only tables outside the RLS machinery, and is distinct from a bypass (which crosses a tenant-scoped model deliberately). The test is the column: has tenant_id → bypass; no tenant_id by design → shared data.

Isolation policy

Database backstop (RLS)

Application scoping is necessary but not sufficient; a forgotten `where` clause must still fail closed. Postgres Row-Level Security enforces the same predicate at the engine, keyed on a session GUC set per transaction. The setting is applied inside the transaction that uses it and reset on connection release, so a pooled connection never carries one tenant's context into another's checkout.

The role requirements are specific: the application MUST connect as a role that is not a superuser and is not the table owner — or the tables use `FORCE ROW LEVEL SECURITY`, which applies the policy even to the owner — because both superusers and table owners bypass RLS by default. Policies are written per command: a `USING` clause filters the rows a SELECT / UPDATE / DELETE can see, and a `WITH CHECK` clause constrains what an INSERT / UPDATE may write, so a tenant can neither read nor write across the boundary.

RLS has real limits to design around. It does not govern `TRUNCATE` (which ignores row policies entirely — revoke it from the app role); it is bypassed by superusers and, without FORCE, by the table owner; and some referential-integrity and bulk paths run with elevated privileges. Treat RLS as the backstop that catches a forgotten application filter, not the sole control — the application scope, the non-superuser role, revoked TRUNCATE, and the per-transaction GUC all have to hold together.

RLS policy — reads (USING) and writes (WITH CHECK), TRUNCATE revoked
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;   -- applies even to the table owner

CREATE POLICY tenant_isolation ON invoices
  USING      (tenant_id = current_setting('app.current_tenant', true)::uuid)  -- reads
  WITH CHECK (tenant_id = current_setting('app.current_tenant', true)::uuid); -- writes

REVOKE TRUNCATE ON invoices FROM app_role;   -- TRUNCATE ignores row policies

-- Per transaction, the app sets the tenant and asserts it took effect:
--   BEGIN;
--   SET LOCAL app.current_tenant = '...';
--   -- current_setting(...) NULL => matches no rows => fails closed
--   COMMIT;

The app role MUST NOT be a superuser or the table owner (or the table uses FORCE ROW LEVEL SECURITY); policies use USING for reads and WITH CHECK for writes; TRUNCATE ignores RLS and MUST be revoked. RLS is the backstop, not the sole control.

Definition

Performance and indexing

`tenant_id` MUST be the leading column of every index that supports a tenant-scoped query, because every such query filters on it first: a composite `(tenant_id, …)` index turns the tenant filter into an index seek and keeps each tenant's working set contiguous. An index that omits `tenant_id`, or puts it second, forces the planner to scan across tenants and then filter — slow, and a noisy-neighbour risk where one large tenant degrades queries for everyone.

The RLS policy adds its predicate to every query, ANDed with the query's own filters. Its cost is negligible when the predicate is `tenant_id = current_setting(...)` and that column leads an index — the same index the application filter already uses. It becomes expensive only when the policy references a subquery or a function the planner can't inline, so the policy is kept to a simple, indexable equality on the tenant column, never a join or a lookup.

For very large tenants or hard isolation guarantees, list- or hash-partition the biggest tables by `tenant_id` so each tenant's rows live in their own partition — the planner prunes to one partition, and a partition is a natural unit for archival or a per-tenant restore. Partitioning is an optimisation on top of the row-level model, not a replacement for it; the tenant_id column, the scope, and the RLS policy all remain.

ConcernRule
Index shapetenant_id is the LEADING column of every scoped index
RLS predicateSimple indexable equality on tenant_id — no subquery / function
Noisy neighbourComposite index keeps each tenant's working set contiguous
Very large tenantsPartition big tables by tenant_id; the planner prunes to one partition
Indexing and performance rules

Invariants this spec guarantees

  • A query with no resolved tenant context MUST return zero rows, not all rows — the application scope and the RLS policy both fail closed.
  • A row MUST NOT be written without a tenant_id; the value is stamped on create, never supplied by the client.
  • Tenant resolution follows a fixed priority (token claim > header > subdomain); a client-supplied header/host MUST NOT override a token claim, the tenant MUST be validated (exists, active, user has a role), and an unresolved or unauthorised tenant fails closed with a specific status.
  • Every boundary that outlives the request MUST carry the tenant explicitly and re-establish it — cache keys tenant-prefixed, jobs carrying the tenant id and re-binding in handle(), cron iterating tenants.
  • Genuinely-shared reference data is NOT tenant-scoped and MUST NOT carry a nullable tenant_id; a cross-tenant relationship is modelled explicitly as a named, audited bypass.
  • Every cross-tenant read MUST be an entry in the bypass registry, admin-gated, and audit-logged.
  • The RLS backstop holds only when the app connects as a non-superuser, non-owner (or FORCE) role with the tenant GUC set and TRUNCATE revoked; a superuser, an unset GUC, or TRUNCATE is outside it.
  • tenant_id MUST be the leading column of every index supporting a scoped query, and the RLS predicate is a simple indexable equality.

Revision history: revised 24 August 2026 for consistency (RFC 2119; RLS backstop stated with its preconditions). Revised 25 August 2026 to add tenant resolution (priority token > header > subdomain, validation, fail-closed statuses), tenant context across boundaries (cache prefixing, job payload, worker re-bind), cross-tenant & shared data (no nullable tenant_id; explicitly-modelled bypass), expanded RLS implementation (role requirements, USING / WITH CHECK, per-transaction GUC, TRUNCATE and superuser limits), and performance & indexing (tenant_id leading column, RLS predicate cost, partitioning).

Want this specified for your system?

We turn definitions like these into the actual schema, policies, and contracts your system runs on. Fixed scope, fixed price, defined delivery date.

Request a Fixed-Scope Architecture Blueprint