Repository navigation
feat(evidence): authorize the fsma.allow_mutation break-glass by role and arm the retention purge narrowly (v142) - #3764
Conversation
… and arm the retention purge narrowly (v142) The append-only guards on fsma.cte_events / fsma.hash_chain (v078, v107), fsma.correction_patches (v095) and fsma.traceability_events (v141) let any statement through once the transaction-local GUC fsma.allow_mutation read 'true'. PostgreSQL lets every role SET an unreserved two-part GUC, so a session holding the runtime role (which v111 grants UPDATE or DELETE on these tables) could arm the break-glass itself and rewrite evidence. The GUC was an intent flag, never an authorization (CodeRabbit on #3717). Migration v142 (17fc5f9d1e69, revises 08c7cf36ddbe) keeps the GUC as the intent flag and adds the authorization: - fsma.break_glass_authorized(oid), SECURITY INVOKER: true only when the GUC is set and the invoking role is a superuser or a member of the guarded table's owner (the migration role). Operator and migration credential sessions keep the documented SET LOCAL contract; regengine and regengine_sysadmin lose it. - The CTE retention purge in services/scheduler/service.py was the one runtime mutation. It now calls fsma.arm_retention_purge(tenant, min_age) (SECURITY DEFINER, owner-only FORCE-RLS table fsma.break_glass_arms, floor 730 days), and fsma.prevent_cte_mutation permits a DELETE only for that tenant's fsma.cte_events rows older than the armed age inside the arming transaction (fsma.retention_purge_permits). The arm cannot widen into an UPDATE, another table or a younger row. The sweep's DELETE now names the tenant explicitly so a BYPASSRLS session cannot sweep other tenants under one arm. - All five trigger bodies are replaced with CREATE OR REPLACE (no trigger dropped); the v141 bodies are derived from the v141 module by replacing the single GUC test, so the allowlist logic is byte-identical. - v111's closed grant inventory carries EXECUTE on the three helpers for regengine and regengine_sysadmin behind to_regprocedure guards, so scripts/apply_runtime_role_cutover.py keeps them after every deploy. - Downgrade restores the exact v107, v095 and v141 bodies and drops the helpers and the arm table. Tests: tests/migrations/test_v142_break_glass_authorization.py (5 static, 8 behavioural against a live PostgreSQL): superuser keeps the GUC-only contract; SET LOCAL ROLE regengine with the GUC is refused on traceability_events (23514) and cte_events, and its DELETE on the canonical table is refused by the grant layer (42501); the sanctioned verdict UPDATE still works for regengine (the EXECUTE grants hold); the arm permits only the tenant's expired cte_events rows, inside the arming transaction, never an UPDATE; floor and tenant-context checks raise; the arm table is owner only; downgrade restores the pre-v142 behaviour and upgrade re-applies. The v141 suite, chain health, RLS coverage, revision guard, tenant_id UUID guard and the scheduler archival live test pass against the scratch database at v142. docs/engineering/SCHEMA_CHANGE_POLICY.md 5.1 records the authorization rule (appendix 1.3.0). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GyhDYG3ddQvVKyu2qtYfCj
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
|
PR Blocker ChecklistHead SHA: Status: Ready
|
The archive_expired_records DELETE on fsma.cte_events now carries an explicit tenant predicate (the arm is per tenant), so the sql-without-tenant-predicate allowlist entry for that site no longer matches anything. scripts/security/scan_tenant_scope.py and tests/security/test_tenant_scope_gate.py fail a stale entry on purpose, which is what turned the SAST and Tenant Isolation jobs red on 088cc23. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GyhDYG3ddQvVKyu2qtYfCj
Status (2026-10-31 05:15 UTC, head 8a93c7e)Head: 8a93c7e = 088cc23 (v142) + one CI fix. Base is Why 088cc23 was red (both failures, one cause, mine). Reviews: none yet (draft; CodeRabbit skips drafts). The break-glass thread on #3717 (4408700257) is what this PR answers. Local verification (scratch PostgreSQL 16 at v142): Waiting on: CI on 8a93c7e; #3717 to merge first (then this retargets to main); a Generated by Claude Code |
Summary
Follow-up to #3717 (CodeRabbit's open security thread there: the break-glass GUC alone authorized the bypass). Stacked on that branch so the chain is v141 -> v142; GitHub retargets this PR to
mainwhen #3717 merges. Lane: backend (persist evidence).Problem. The append-only guards on
fsma.cte_events/fsma.hash_chain(v078, v107),fsma.correction_patches(v095) andfsma.traceability_events(v141) let any statement through once the transaction-local GUCfsma.allow_mutationread'true'. PostgreSQL lets every roleSETan unreserved two-part GUC, so a session holding the runtime role (which v111 grants UPDATE or DELETE on these tables) could arm the break-glass itself and rewrite evidence. The GUC was an intent flag, never an authorization.Migration v142 (
17fc5f9d1e69, revises08c7cf36ddbe;alembic/versions/20261030_17fc5f9d1e69_v142_break_glass_authorization.py):fsma.break_glass_authorized(oid)(SECURITY INVOKER): true only when the GUC is set and the invoking role is a superuser or a member of the guarded table's owner (the migration role). Operator and migration-credential sessions keep the documentedSET LOCAL fsma.allow_mutation = 'true'contract;regengineandregengine_sysadminlose it.services/scheduler/service.py) was the one runtime mutation. It now callsfsma.arm_retention_purge(tenant_id, min_age)(SECURITY DEFINER; owner-only FORCE-RLS tablefsma.break_glass_arms; floorinterval '730 days'), andfsma.prevent_cte_mutationpermits a DELETE only for that tenant'sfsma.cte_eventsrows older than the armed age inside the arming transaction (fsma.retention_purge_permits). The arm cannot widen into an UPDATE, another table or a younger row. The sweep's DELETE now names the tenant explicitly so a BYPASSRLS session (the CI databases) cannot sweep other tenants under one arm.CREATE OR REPLACE(no trigger dropped, no table lock beyond the function swap,lock_timeout10s). The v141 bodies are derived from the v141 module by substituting the single GUC test, so the allowlist logic is byte-identical.alembic/versions/20260803_b71d9e4c2a60_v111_runtime_role_grants.py) carries EXECUTE on the three helpers forregengineandregengine_sysadminbehindto_regprocedureguards, soscripts/apply_runtime_role_cutover.pykeeps them after every deploy. The triggers call the two read-only helpers as the invoking role, so without those grants every sanctioned UPDATE would fail; a test pins that the runtime role's verdict UPDATE still works.docs/engineering/SCHEMA_CHANGE_POLICY.md5.1 records the authorization rule; appendix 1.3.0.Out of scope, on purpose:
audit.allow_break_glass(v066,audit_logs) is a separate GUC on a table the runtime role cannot write; column-grant narrowing on v111 stays a later item.Threat model after this change
regengineorregengine_sysadmin: cannot UPDATE or DELETE evidence, with or without the GUC. Its only mutation path is the arm: purge one tenant's CTE rows past the retention floor, which is the sanctioned sweep.Type of Change
Test Plan
New
tests/migrations/test_v142_break_glass_authorization.py(5 static, 8 behavioural on a live PostgreSQL, the runtime role simulated withSET LOCAL ROLE regengine):fsma.traceability_eventsrefused (23514), DELETE onfsma.cte_eventsrefused, DELETE on the canonical table refused by the grant layer (42501);cte_eventsrows, inside the arming transaction, never an UPDATE; a committed arm does not carry into a later transaction;Run locally against a scratch PostgreSQL 16 at v142 (all green):
REGENGINE_MIGRATION_TEST_DB=postgresql://... python3 -m pytest tests/migrations/test_v141_traceability_events_append_only.py tests/migrations/test_v142_break_glass_authorization.py tests/migrations/test_alembic_chain_health.py tests/migrations/test_rls_coverage.py --no-cov -o "testpaths=tests"Plus
scripts/check_docs_links.py,scripts/check_trust_safety_copy.py,scripts/ci/check_secrets_baseline.shon the changed files,ruff check --select I,E9,F63,F7,F82,git diff --check.Known pre-existing:
tests/migrations/test_v128_idempotency_pg.py::test_fresh_upgrade_reaches_single_v128_headasserts the fresh-upgrade head equals93eb33f262a1and fails on the #3717 branch and on this one the same way (it is skipped in CI without its scratch-cluster DSN).Checklist
sys.modules[X] = stub, I justified it in the PR description (none)Related Issues
Relates to #3717 (S4; the open break-glass thread), #3701 (gap analysis row 9), SEL-589.
Generated by Claude Code