Skip to content

feat(store): RLS enforcement + per-transaction tenant scoping (RIG-3106) - #830

Open
rigel-mintaka wants to merge 3 commits into
mainfrom
compass-managed/rig-3106-rls-tenant-scoping
Open

feat(store): RLS enforcement + per-transaction tenant scoping (RIG-3106)#830
rigel-mintaka wants to merge 3 commits into
mainfrom
compass-managed/rig-3106-rls-tenant-scoping

Conversation

@rigel-mintaka

@rigel-mintaka rigel-mintaka commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

T2 of the frozen RIG-2861 managed-multitenancy record: the enforcement half
of tenant isolation. T1 gave accounts a tenant_id and the bootstrap tenant;
T2 propagates tenant_id onto every tenant-owned table, turns on Postgres RLS
with FORCE, and threads a per-transaction compass.tenant_id GUC through the
whole store so a request-path query reads and writes only its own tenant's rows.

Migration 0002_rls.sql:

  • Two cluster roles: compass_app (non-owner, non-BYPASSRLS — the request-path
    role the policies constrain) and compass_system (BYPASSRLS — the N5/OQ-4
    cross-tenant background role). Idempotent, serialized by the migration lock.
  • 26 tenant-owned tables (bucket B account-FK-rooted + accounts + C2
    linear_agent_sessions) gain tenant_id, backfilled via the FK chain to
    accounts.tenant_id, ENABLE+FORCE ROW LEVEL SECURITY, and a fail-closed
    scalar-subquery policy: current_setting('compass.tenant_id', true) <> '' AND tenant_id = (SELECT current_setting(...)) as USING + WITH CHECK.
  • Forge-board tables (issues, forge_repo_subscriptions,
    forge_artifact_cursors) fold tenant_id INTO the coordinate unique key/PK so
    two tenants may watch the same coordinate without colliding.
  • N7 (RIG-2921): account_handles uniqueness becomes org-scoped
    ((tenant_id, handle) WHERE owner_user_id IS NULL, and the owner tier).
  • Bucket A (tenants, tokens, agent_config_bundle) stays RLS-exempt: token
    resolution establishes the tenant before any GUC exists, and the config bundle
    is a fleet singleton.

Threading (tenant_tx.go): the sqlc *db.Queries binds to a scopedDBTX that
arms SET LOCAL ROLE + set_config('compass.tenant_id', <tenant>, true) into
one pgx batch ahead of every pool-path statement (single round-trip). Multi-
statement methods use beginTenantTx, which arms once at BEGIN. SET LOCAL is
transaction-scoped, so a transaction-mode pooler carries no leftover scope. The
four N5 background loops (delivery-cursor sweep, deliver-ack + forge-notify-ack
advance) run under WithSystemRole (the BYPASSRLS role, no GUC).

Tests (rls_pgtest_test.go, pgtest): cross-tenant read=0 rows, cross-tenant
write lands under the writer's tenant, unset-GUC fails closed, pooled-conn reuse
carries no scope, non-owner role is RLS-scoped, forge-board two-tenants-same-
coordinate, N7 @handle under two tenants, and a catalog check asserting
ENABLE+FORCE on all 26 tables and the bucket-A exemption. Full store pgtest
suite green.

Refs RIG-3106 RIG-2861

Co-authored-by: Matt Wilkinson matt@rigel.build


Review outcome (formal review pass, second commit)

One adversarial review pass on the diff: 0 high, 2 medium, 4 low. No cross-tenant leak and no fail-open found — all tenant-owned tables carry ENABLE+FORCE+the fail-closed policy, every request-path query routes through the armed non-owner role, and the policy fails closed on unset/empty GUC. The second commit (fix(store): address T2 RLS review findings) resolves every finding:

  • M1 (Matt-ruled) — forge_authored_artifacts PK: folded tenant_id into the PRIMARY KEY (tenant_id, forge_provider, forge_host, repo, kind, number), matching the sibling forge-board tables. Two tenants may now hold authorship at the same forge coordinate without an ON-CONFLICT collision against an RLS-invisible row — needed for the future case of pulling in externally-authored PRs for review. New TestForgeAuthoredTwoTenantsSameCoordinate.
  • M2 (Matt-ruled) — pooler verification: rewrote TestPooledConnReuseCarriesNoScope to drive the production single-statement path (scopedDBTX's pgx SendBatch implicit-transaction) across a pinned physical-connection reuse boundary, instead of explicit Begin/Commit. A real transaction-mode-pooler CI service is tracked as follow-up RIG-3138 (SET LOCAL is pooler-safe by construction; the specific pooler is a separable deployment choice).
  • Low — RecordOwedMention: documented the INSERT..SELECT zero-row precondition (resolved-member caller; the ON CONFLICT DO NOTHING is the intended idempotent re-record, so a rows-affected assertion would wrongly fail a replay).
  • Low — migration header: softened the backfill-correctness claim (FK-rooted backfills correct for real data; account-less LIMIT 1 backfills correct only in the single-tenant case).
  • Low — catalog test: made TestRLSCatalogEnabledAndForced self-auditing — it enumerates every tenant_id-bearing table from the catalog and asserts ENABLE+FORCE, so a future tenant-owned table that forgets RLS fails automatically rather than needing a hand-maintained list edit.
  • Low — messages.seq global sequence side channel: pre-existing, inherent to the shared monotonic seq the delivery cursor depends on; no T2 change. Noted for the tenancy record.

Full store pgtest suite green on the rebased tree.

@linear-code

linear-code Bot commented Sep 1, 2026

Copy link
Copy Markdown

RIG-3106

RIG-2861

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Compass engineering docs preview: https://compass-managed-rig-3106-rls.compass-eng-docs.pages.dev

Deployed from compass-managed/rig-3106-rls-tenant-scoping at 99e19be.

rigel-mintaka and others added 2 commits September 1, 2026 00:18
T2 of the frozen RIG-2861 managed-multitenancy record: the enforcement half
of tenant isolation. T1 gave `accounts` a `tenant_id` and the bootstrap tenant;
T2 propagates `tenant_id` onto every tenant-owned table, turns on Postgres RLS
with FORCE, and threads a per-transaction `compass.tenant_id` GUC through the
whole store so a request-path query reads and writes only its own tenant's rows.

Migration `0002_rls.sql`:
- Two cluster roles: `compass_app` (non-owner, non-BYPASSRLS — the request-path
  role the policies constrain) and `compass_system` (BYPASSRLS — the N5/OQ-4
  cross-tenant background role). Idempotent, serialized by the migration lock.
- 26 tenant-owned tables (bucket B account-FK-rooted + `accounts` + C2
  `linear_agent_sessions`) gain `tenant_id`, backfilled via the FK chain to
  `accounts.tenant_id`, `ENABLE`+`FORCE ROW LEVEL SECURITY`, and a fail-closed
  scalar-subquery policy: `current_setting('compass.tenant_id', true) <> '' AND
  tenant_id = (SELECT current_setting(...))` as USING + WITH CHECK.
- Forge-board tables (`issues`, `forge_repo_subscriptions`,
  `forge_artifact_cursors`) fold `tenant_id` INTO the coordinate unique key/PK so
  two tenants may watch the same coordinate without colliding.
- N7 (RIG-2921): `account_handles` uniqueness becomes org-scoped
  (`(tenant_id, handle) WHERE owner_user_id IS NULL`, and the owner tier).
- Bucket A (`tenants`, `tokens`, `agent_config_bundle`) stays RLS-exempt: token
  resolution establishes the tenant before any GUC exists, and the config bundle
  is a fleet singleton.

Threading (`tenant_tx.go`): the sqlc `*db.Queries` binds to a `scopedDBTX` that
arms `SET LOCAL ROLE` + `set_config('compass.tenant_id', <tenant>, true)` into
one pgx batch ahead of every pool-path statement (single round-trip). Multi-
statement methods use `beginTenantTx`, which arms once at BEGIN. `SET LOCAL` is
transaction-scoped, so a transaction-mode pooler carries no leftover scope. The
four N5 background loops (delivery-cursor sweep, deliver-ack + forge-notify-ack
advance) run under `WithSystemRole` (the BYPASSRLS role, no GUC).

Tests (`rls_pgtest_test.go`, pgtest): cross-tenant read=0 rows, cross-tenant
write lands under the writer's tenant, unset-GUC fails closed, pooled-conn reuse
carries no scope, non-owner role is RLS-scoped, forge-board two-tenants-same-
coordinate, N7 @handle under two tenants, and a catalog check asserting
ENABLE+FORCE on all 26 tables and the bucket-A exemption. Full store pgtest
suite green.

Refs RIG-3106 RIG-2861

Co-authored-by: Matt Wilkinson <matt@rigel.build>
Review of PR #830 (0 high, 2 medium, 4 low). Additive commit atop the pushed
T2 commit so the PR shows an interdiff.

M1 (Matt-ruled): fold tenant_id into forge_authored_artifacts' PRIMARY KEY
(tenant_id, forge_provider, forge_host, repo, kind, number), matching the sibling
forge-board tables. Two tenants may now hold authorship at the same forge
coordinate without an ON-CONFLICT collision against an RLS-invisible row — needed
for the future case of pulling in externally-authored PRs for review. Updates the
ON CONFLICT arbiter + regenerates sqlc; adds TestForgeAuthoredTwoTenantsSameCoordinate.

M2 (Matt-ruled): rewrite TestPooledConnReuseCarriesNoScope to drive the PRODUCTION
single-statement path (scopedDBTX's pgx SendBatch implicit-transaction) across a
pinned physical-connection reuse boundary, instead of explicit Begin/Commit — the
path whose transaction-mode-pooler safety the design requires. A real
transaction-mode-pooler CI service is tracked as follow-up RIG-3138 (SET LOCAL is
pooler-safe by construction; the specific pooler is a separable deployment choice).

Low findings:
- Document RecordOwedMention's INSERT..SELECT zero-row precondition (resolved-member
  caller; ON CONFLICT DO NOTHING is the intended idempotent re-record, so a
  rows-affected assertion would wrongly fail a replay).
- Soften the 0002_rls.sql header: FK-rooted backfills are correct for real data;
  the account-less (LIMIT 1) backfills are correct only in the single-tenant case.
- Make TestRLSCatalogEnabledAndForced self-auditing: enumerate every tenant_id-bearing
  table from the catalog and assert ENABLE+FORCE, so a future tenant-owned table that
  forgets RLS fails automatically rather than needing a hand-maintained list edit.

Co-authored-by: Matt Wilkinson <matt@rigel.build>
@rigel-mintaka
rigel-mintaka force-pushed the compass-managed/rig-3106-rls-tenant-scoping branch from a8ae080 to 4ffa71c Compare September 1, 2026 04:20
@rigel-mintaka
rigel-mintaka marked this pull request as ready for review September 1, 2026 04:21
…-3106)

CI pgtest caught a real gap the store-only local run missed: T2 added
tenant_id NOT NULL to agent_placements, and TestStartAgentSessionWithBackfilledPlacementRecordsSession
seeds the migration-0004 backfill row shape via a raw owner-connection INSERT
(execSQL) that bypasses the store's tenant-scoping, so the column DEFAULT never
stamps tenant_id and the NOT NULL constraint fires (SQLSTATE 23502).

The seed now resolves tenant_id from the agent's account FK
(SELECT ... a.tenant_id FROM accounts a WHERE a.id = $1), exactly as the
0002_rls backfill does for account-rooted tables — keeping the test faithful to
what a real backfilled pre-upgrade row looks like under RLS.

Verified: full go/... pgtest suite green locally (all 7 pgtest packages:
store, server, auth, board, comms, delivery, runnerhub).

Co-authored-by: Matt Wilkinson <matt@rigel.build>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant