Three isolation models, in ascending order of both strength and setup cost. Choose deliberately: the difference between them is where the guarantee lives.
Mode A — application-scoped
%Foresight.Context{tenant_id: "default", mode: :mode_a, bank: "notes"}One schema. Every query is scoped by bank in application code. Fine for a single tenant, or where "tenants" are your own subsystems and a bug leaking between them is embarrassing rather than serious.
The guarantee lives entirely in Foresight's query construction. If that is not enough for your data, it is not enough.
Mode B — row-level
Rows carry a tenant column and queries are filtered on it. Still application code, but the boundary is explicit and auditable in the data.
Mode C — schema-per-tenant with enforced RLS
%Foresight.Context{tenant_id: "acme", mode: :mode_c, bank: "notes"}Each tenant gets its own PostgreSQL schema. Connections SET ROLE to a
tenant-specific role for the duration of the work and RESET ROLE afterwards,
re-entrantly. Tables carry ROW LEVEL SECURITY and FORCE ROW LEVEL SECURITY.
Here the guarantee lives in the database. A bug in Foresight's query building does not breach it, because the database refuses to return rows the connection's role cannot see.
The part that is easy to get wrong
RLS does not apply to superusers, and it does not apply to a table's owner.
If your application connects as postgres, Mode C is decorative — you have
configured a guarantee that is not in force, and every test will still pass.
The application role must be non-superuser and must not own the tenant tables.
Getting there requires five distinct things, documented in
MODE_C_LEAST_PRIVILEGE_RUNBOOK.md in the order they actually fail, with the
real error strings. Setting it up is genuinely fiddly and the runbook exists
because we did it wrong five times first.
Verifying rather than assuming
VICTIM_API_KEY=... elixir scripts/mode_c_isolation_proof.exs
Five forged-header cross-tenant attacks against a running instance over real HTTP. It checks the attacker's responses for the victim's canary string rather than trusting status codes — a 200 with empty results and a 403 both look like "blocked" from the outside, but only one of them means the data stayed put.
- exit 0 — attacks blocked, and the script confirmed it could have detected a breach
- exit 2 — INCONCLUSIVE. Not a pass. The self-check did not confirm, so the run proves nothing.
Last verified run: rolsuper = false, RLS forced, 5/5 blocked.
A continuous check runs in mix ci too: mix foresight.isolation_coverage fails
the build if the isolation tests are tagged out of the default lane or the suite
is empty. A silently-skipped isolation probe reads as coverage while providing
none, which is worse than having no probe at all.
Choosing
| Mode A | Mode B | Mode C | |
|---|---|---|---|
| Guarantee enforced by | app code | app code | PostgreSQL |
| Setup cost | none | low | real |
| Survives a Foresight bug | no | no | yes |
| Suitable for others' personal data | no | depends | yes |