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.
| Column | Type | Constraint | Rationale |
|---|---|---|---|
| tenant_id | uuid / bigint | NOT NULL, FK → tenants(id) | The isolation key; never nullable |
| id | uuid / bigint | PRIMARY KEY | Prefer non-guessable IDs to reduce enumeration risk |
| (tenant_id, …) | index | composite, tenant_id first | Every scoped query hits the index |
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.
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.
| Boundary | Failure if unhandled | Carrier |
|---|---|---|
| Queued job | Runs with no / wrong tenant | Tenant ID in constructor, re-bound in handle() |
| Scheduled command / cron | Global run leaks all tenants | Explicit per-tenant loop; no ambient default |
| Model observer | Fires with null auth context | Read tenant from the model, never the request |
| Cache key | One tenant serves another's value | Tenant ID prefixed into every key |
| Broadcast / webhook | Cross-tenant payload | Tenant ID on the event, re-hydrated on use |
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 site | Reason | Compensating control |
|---|---|---|
| Platform admin console | Cross-tenant support view | Admin-only middleware + audit log per read |
| POPIA/erasure checks | Detect records across tenants | Read-only; returns existence, not data |
| Billing rollup job | Aggregate across tenants | Writes to a separate reporting schema only |
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.
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.