Architecture العربية

ADR-0007 — Multi-Tenancy Model & Isolation Enforcement

Status: Accepted · Date: 2026-07-20 · Deciders: Amir Haroun (Tech Lead) Related: QAS-SEC-01 (top-priority scenario), R-05 (critical risk), Flow 6, Security §2


Context#

Company accounts (ACT-CO) own employee sub-accounts (ACT-EMP). One employer's records about their employees must not be visible to another employer. QAS-SEC-01 is the highest-priority quality scenario in the system, and a cross-tenant leak is an existential, PDPL-reportable event.

The critical property of this failure mode: it is silent. No error, no crash — just Company A reading Company B's staff records, potentially for months before discovery.

The data shape argues against a tenancy package#

Data Tenancy
Marketplace listings, consultants, templates, learning content, assessments Shared / platform-global — the majority of the data, and the entire point of the product
Company↔employee relationships, company documents, subscriptions, employee progress and results Tenant-scoped — a minority

Decision#

Shared database, shared schema, company_id discriminator, enforced primarily by PostgreSQL Row-Level Security. No tenancy package.

Why not a tenancy package#

The mainstream Laravel tenancy packages are built around "switch the whole application context to one tenant per request." That model actively fights this system:

  • A user browsing the marketplace is in no tenant context
  • An employee viewing company training and public listings on one screen is in two contexts
  • Platform admins are in none, and reviewers routinely work across many

Adopting one would mean spending the project escaping its abstraction.

Why not schema- or database-per-tenant#

Stronger isolation, but: migrations across thousands of schemas, connection-pool exhaustion, backup/restore fan-out, and — decisively — cross-tenant marketplace queries become impossible or require a second denormalised store. The marketplace is the product; making it hard is disqualifying.

Cost of being wrong in this direction is low. If a specific enterprise customer later demands physical isolation, extract that customer to a dedicated deployment — an infrastructure decision at the time, not an architecture pre-paid on day one.


The enforcement mechanism — this is the actual decision#

A company_id column is not a control. What follows is.

Five layers, ordered by how late each catches#

# Layer Fails closed? Role
1 PostgreSQL RLS, FORCE ROW LEVEL SECURITY The backstop — returns zero rows regardless of application bugs
2 TenantContext that throws when unset Never null, never "no filter"
3 Eloquent global scope Convenience and index usage — explicitly not a security boundary
4 Architecture test: every company_id table uses the trait ✅ at build Catches the model added next year
5 Adversarial CI suite ✅ at build Tests observable behaviour, not internals

Layers 1 and 5 survive staff turnover. Layers 2–4 make the correct thing easy.

Why layer 3 alone is insufficient#

An Eloquent global scope is bypassed by withoutGlobalScopes(), raw queries, DB::table(), some relationship paths, and by any developer unaware it exists. Treating it as the control is the single most common serious flaw in multi-tenant Laravel applications.

Layer 5 in detail#

The suite that actually keeps the system safe over time:

  1. Seed two companies with full, identical-looking object graphs
  2. Authenticate as Company A
  3. Enumerate every authenticated route, requesting each with Company B's IDs
  4. Assert 403/404 on every one — and that no response body contains any Company B identifier
  5. Fail the build when a new route appears without coverage

New endpoints then cannot silently introduce a leak.


⚠ Two failure modes that must be designed for now#

Both are cases where the RLS policy is written correctly and data still leaks.

1. Connection reuse#

With transaction-mode connection pooling — or persistent workers under a long-lived runtime — a session variable set during one request can survive into the next, serving one tenant's data under another's context.

Mitigation: set the tenant variable with transaction-scoped semantics inside an explicit transaction, and reset it in terminating middleware.

This is the actual leak vector. The policy works perfectly and still returns the wrong rows.

2. Queued jobs have no request#

A background job runs without an HTTP request, so the tenant variable is unset and every request-scoped safety mechanism evaporates.

Mitigation: serialise the tenant identifier into the job payload; establish context in job middleware; a job that cannot establish context must throw, not default.


Consequences#

Positive#

  • Marketplace queries span tenants naturally — the product works
  • One schema, one migration path, one backup
  • Database-enforced isolation documents far better in a regulatory audit than "we have a trait developers are supposed to use." "Failure to implement technical and organisational safeguards" is among SDAIA's reported enforcement categories.
  • No dependency on a tenancy package's abstractions or release cadence

Negative / accepted#

  • RLS adds a small per-query planning cost — offset by keeping layer 3 so the planner still uses the company_id index
  • Every developer must understand why layer 3 is not the control
  • Adding RLS to a populated table under load requires a maintenance window → create policies alongside the first tenant-scoped migration, not later
  • Weaker isolation than physical separation — accepted, with the extraction path above as the escape hatch

Consequence for the runtime choice#

This ADR is a primary reason Laravel Octane is deferred. Octane's persistent workers make failure mode 1 materially more likely. Adopting it before tenant isolation is proven and test-covered would trade the system's top quality attribute for performance the launch scale does not require. Revisit once layers 1–5 are in place and the adversarial suite is green.

The second isolation axis#

ACT-EMP introduces a dimension beyond company↔company: an employee must not read a colleague's assessment scores, and a company admin's access to employee records should be scoped and audited rather than unlimited. Do not model tenancy as a single flat axis — company scoping is necessary and not sufficient. See Security §2.4.


Open dependency#

OQ-07 — when a company is suspended or rejected, what happens to its employees' in-flight assessments and already-issued certificates? The mechanism above ensures the propagation point is one place (tenant context resolution) rather than scattered across modules, but the policy requires a sponsor ruling. The certificate verification endpoint returns current status computed live, so it must have an answer.

Tech Lead · Amir Haroun Draft v0.1 · research & design only