Skip to content

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.

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.

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.

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.