Skip to content

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.

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.

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.

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.

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 alias
uris_development_t3 tenant 3's filtered alias
uris_development_v2026… the concrete versioned index

test/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.

Requests resolve their tenant from the subdomain (Tenant.resolve). A token is checked twice before any scope applies:

  • aud must match this tenant’s own /mcp URL, verified by masks.
  • iss must match MASKS_ISSUER_TEMPLATE formatted 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.