Tenancy
Every catalog table carries tenant_id from migration #1. Two tenants exist from the first seed so
that every scenario can be exercised against both.
The layers
Section titled “The layers”| Layer | Enforced by | Fails how |
|---|---|---|
| application | TenantScoped default scope |
a forgotten scope |
| database | Postgres RLS, FORCE + tenant_isolation policy |
silently, if the app role is a superuser |
| search | a per-tenant filtered alias | invisibly — RLS cannot reach OpenSearch |
| cable | subscription_scope :tenant_id |
two tenants sharing one stream name |
Each is asserted separately. A layer covered only transitively can regress without anything going red.
Application
Section titled “Application”app/models/concerns/tenant_scoped.rb is three lines:
belongs_to :tenant, default: -> { Current.tenant }default_scope { where(tenant_id: Current.tenant&.id) }Included by Item, Reference, Resource, Run, Gate, AuditEvent, MergeProposal and
ResourceBlob.
Database
Section titled “Database”Seven tables carry ENABLE + FORCE ROW LEVEL SECURITY and a tenant_isolation policy in
db/structure.sql:
CREATE POLICY tenant_isolation ON public.items USING (tenant_id = NULLIF(current_setting('items.tenant_id', true), '')::bigint) WITH CHECK (tenant_id = NULLIF(current_setting('items.tenant_id', true), '')::bigint);Tenant.switch sets that setting with set_config(..., true) inside a transaction and restores the
previous value on the way out — the previous value, not empty, so a nested switch does not blind
the outer one.
Two traps:
- A table’s owner bypasses RLS unless the table is
FORCEd. - A superuser bypasses it regardless.
Either makes every isolation test pass without proving anything, which is why
db/docker-entrypoint-initdb.d creates a separate non-superuser role for the application rather
than letting Rails connect as POSTGRES_USER. CI does the same.
test/models/tenant_isolation_test.rb asserts RLS holds with Item.unscoped, that a cross-tenant
insert raises ActiveRecord::StatementInvalid, that no tenant in scope reads nothing at all, and
that a nested switch restores the outer tenant.
The schema is dumped as SQL (config.active_record.schema_format = :sql) because schema.rb cannot
represent a policy.
Search
Section titled “Search”Postgres policies stop at Postgres. The application never queries the underlying OpenSearch index —
only SearchIndex.alias_for(tenant), an alias carrying a tenant_id filter term applied by the
engine.
uris_development the shared aliasuris_development_t3 tenant 3's filtered aliasuris_development_v2026… the concrete versioned indextest/models/search_index_test.rb covers it. See Search.
Subscriptions::BaseSubscription declares subscription_scope :tenant_id, so two tenants
subscribing to the same field do not share a stream name.
Tokens
Section titled “Tokens”Requests resolve their tenant from the subdomain (Tenant.resolve). A token is checked twice
before any scope applies:
audmust match this tenant’s own/mcpURL, verified bymasks.issmust matchMASKS_ISSUER_TEMPLATEformatted with this subdomain.
Grant#verify_tenant! then compares the token’s tenant claim subdomain against the tenant that
was resolved, and raises Grant::Denied on a mismatch.
test/integration/graphql_tenancy_test.rb asserts that an item id from another tenant resolves to
nil, and that an unknown subdomain is a 404.