Tenancy
Tenancy is present from migration #1: tenant_id everywhere, row-level security as the backstop,
and tokens bound to the tenant’s own URL. Retrofitting it is not a thing that happens, because
single-tenant assumptions do not announce themselves — they leak through a scope someone forgot,
months later.
So there are two tenants from the first seed, and every scenario in the suite should be exercised against both.
Five layers, asserted separately
Section titled “Five layers, asserted separately”| Layer | Enforced by | Fails how |
|---|---|---|
| application | TenantScoped default scope |
a forgotten scope |
| database | Postgres RLS, FORCE + policy |
silently, if the app role is a superuser |
| search | a per-tenant filtered alias | invisibly — RLS cannot reach the index |
| cable | subscription_scope :tenant_id |
two tenants sharing one stream name |
| API | tenant-checked object_from_id |
node(id:) walks out of the tenant |
The tests assert each one separately, because a layer that is only covered transitively is a layer that can regress without anything going red.
The two traps
Section titled “The two traps”A table’s owner bypasses RLS unless the table is marked FORCE ROW LEVEL SECURITY, and a
superuser bypasses it regardless. Either one makes every isolation test pass without proving
anything at all.
That is why compose.yml creates a separate non-superuser role for the application rather than
letting Rails connect as POSTGRES_USER, and why CI does the same before running the suite. Both
traps were caught by test/models/tenant_isolation_test.rb failing, which is what that file is for.
The search index is the path RLS cannot protect
Section titled “The search index is the path RLS cannot protect”Postgres policies stop at the edge of Postgres. OpenSearch has its own copy of enough of the catalog to answer a query, and nothing in the database can stop a query from reading another tenant’s copy.
Isolation there is a per-tenant filtered alias: the application never queries the underlying index, only an alias that carries the tenant filter with it. A leak at this layer is invisible — there is no error, just results that should not be there — which is why it is asserted directly rather than inferred from the application layer being correct.
Tokens are bound to the tenant
Section titled “Tokens are bound to the tenant”Each tenant’s issuer signs with its own key, and aud is bound to the tenant’s canonical URL. A
token minted for one tenant is rejected against another on its audience, before any tenant scoping
runs — so the isolation layers above are a backstop for a request that should never have been
authenticated in the first place.
Tenants are addressed by subdomain. MASKS_ISSUER_TEMPLATE is a template over that subdomain, and a
token is rejected unless iss matches it.