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, never everything. Every rule below exists to make the safe behaviour automatic and the unsafe behaviour loud.

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

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

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.

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

Isolation policy

Database backstop

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 application connects as a role that is subject to RLS — never a superuser, which bypasses it.

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.

RLS policy — the backstop under the application scope
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);

-- 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;

Invariants this spec guarantees

  • A query issued with no resolved tenant context returns zero rows — never all rows.
  • No row can be written without a tenant_id; the value is stamped on create, not supplied by the client.
  • Every cross-tenant read is an entry in the bypass registry, admin-gated, and audit-logged.
  • A forgotten application-level filter is still contained by the database RLS policy.

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