feat(db): fail-closed RLS backstop on quote tables and tenant-RLS coverage guard (PMS-683) #464
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/PMS-683-rls-backstop"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
Restores the fail-closed
tenant_isolationRLS backstop on the tenant-scoped tables that slipped through the 024/038/039 catch-up loops, and adds a durable CI guard so it cannot regress. Scoped to the GUC-safe subset after a per-table query-path audit; the rest is deferred with an explicit, tested allowlist. This does NOT enable RLS on any table whose current query path would break under it.Why
Migration
038_rls_fail_closed.sqlmakes tenant isolation fail closed at the database: every tenant table has RLS with atenant_isolationpolicy keyed on theapp.current_tenantGUC, and the request-serving pool (mokosh_app) is NOBYPASSRLS. Thirteen tables added after the loops never got that policy, so a forgottenWHERE tenant_idon them (including financialquotesandchange_requests) has no DB backstop.The critical constraint (why this is scoped, not a blanket loop)
Because
mokosh_appis NOBYPASSRLS, enabling RLS on a table ACTIVELY constrains app queries: any query that does not set the tenant GUC viaDatabase::begin_with_tenantfail-closes to zero rows. A blanket loop over all 13 would therefore break the features whose services do not set that GUC.I audited every query path of all 13 tables:
begin_with_tenant):quotes,quote_sequences. All SQL is insrc/modules/quotes/service.rs; the portal delegates to the sameQuotesService, anddata_transferusesbegin_with_tenant. Verified there is no other SQL touching these two tables anywhere insrc/.db.pool()and querying it with no GUC): the other 11, acrossdashboards,approvals,email_intake,saved_reports,workflows, andticket_templatesservices. Enabling RLS on them now would fail-close their reads and writes in production. Their feature tests useboot()(the BYPASSRLS pool) so they would not even catch the breakage.Converting those six services to
begin_with_tenant(and routingtenant_intake_tokens's cross-tenant secret-hash resolve through the migrator pool) is thebegin_with_tenantread-path migration (PMS-256 / PMS-285 lineage) and is out of scope for a defense-in-depth migration; doing it un-validated across financial and token paths would be higher risk than the gap it closes. It is left as follow-up.Changes
migrations/094_rls_quotes_backstop.sql: enables + forces ROW LEVEL SECURITY and creates the fail-closedtenant_isolationpolicy onquotesandquote_sequences, mirroring the exact policy shape of038/090/091. The header documents the scope decision and lists each of the 11 excluded tables with the service that blocks it.tests/rls_coverage.rs: a new#[sqlx::test]guard that queries the migrated schema and fails if any public table with atenant_idcolumn lacks enabled+forced RLS plus atenant_isolationpolicy, except a documentedALLOWED_WITHOUT_RLSallowlist holding exactly the 11 deferred tables. It asserts in both directions (no un-allowlisted table lacks RLS; no allowlisted table already has it), so the list can only shrink to empty and a newly added tenant table cannot silently skip RLS the way these 13 did. A schema-querying#[sqlx::test]was chosen over a file-parsing nushell check because the RLS is applied by dynamicinformation_schemaloops that a file parser cannot evaluate, and it runs in the existing integration workflow which provides a fully migrated database.Follow-up (not in this PR)
Enable RLS on the remaining 11 tables as each owning service is migrated to
begin_with_tenant, removing the table fromALLOWED_WITHOUT_RLSin the same change: saved_dashboards, scheduled_dashboards (dashboards); ticket_approvals, change_requests (approvals); tenant_intake_tokens, email_intake_log (email_intake, plus the migrator-pool resolve for the cross-tenant token lookup); saved_reports, scheduled_reports (saved_reports); workflow_rules, workflow_rule_runs (workflows); ticket_templates.Tests
There is no host cargo, so checks run through Docker (the CI mirrors).
just check-migrations: green (94 prefix unique across 93 files).just pre-commit(mirrors CI check.yml): green. cargo fmt --check, clippy --all-targets -D warnings (compiles the new test), cargo check --all-targets, 293 unit tests passed / 0 failed, doc tests passed.quotesandquote_sequencesgain RLS and precisely the 11 documented tables remain uncovered.tests/rls_coverage.rs(new guard): passed.tests/rls_isolation.rs: passed.tests/per_user_isolation.rs: 4 passed. These RLS-role tests run when#[sqlx::test]connects as the superuser (matching CI's DATABASE_URL). Thequotes/quote_convert/quote_signoffsuites passed (17/6/6) with RLS enabled.tests/readiness.rs::ready_returns_ok_when_db_reachable_and_infisical_unconfigured503s because the readiness probe's hard 1s DB-ping timeout is exceeded (~1003 ms) on the dockerized dev Postgres. It fails the same way single-threaded in isolation, touches code this PR does not modify, and is independent of RLS on the quote tables.