# Multi-tenancy

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

```elixir
%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

```elixir
%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.

### Provisioning from an in-BEAM consumer

Use the public role-neutral provisioning seam:

```elixir
{:ok, %{tenant_id: tenant_id, prefix: prefix, migrated_versions: versions}} =
  Foresight.Tenancy.provision_mode_c_schema(
    MyApp.Repo,
    "acme",
    "tenant_acme",
    rls_enabled: true
  )
```

The call first installs or upgrades Foresight's shared control plane, then
creates or rechecks the tenant schema from packaged tenant migrations. It is
idempotent. `:rls_enabled` is mandatory and must be `true` or `false`; there is
no application-environment fallback at this boundary.

Historical public-schema data repairs are classified separately and are not
replayed into newly provisioned tenant schemas.
The `public` schema is reserved for the host application and cannot be used as
a Mode C tenant prefix.

This seam is deliberately authority-neutral: it starts no tenant runtime,
creates no PostgreSQL role, and writes no tenant API key. A host that owns role
or credential lifecycle must do that itself. Foresight's HTTP tenant lifecycle
remains available for deployments that intentionally want those side effects.

The same explicit `rls_enabled:` option may be passed to public memory
operations. A per-call `false` overrides an application default of `true`,
including the parallel recall strategies and nested reflect calls. Async
storage, migration, and webhook jobs persist only this boolean and restore it in
the worker; no Repo credentials or other caller options are serialized.

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

```bash
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** |
