Skip to content

feat(evidence): authorize the fsma.allow_mutation break-glass by role and arm the retention purge narrowly (v142) - #3764

Draft
PetrefiedThunder wants to merge 2 commits into
claude/gracious-edison-8fijxh-s4-s5from
claude/gracious-edison-8fijxh-break-glass
Draft

PetrefiedThunder wants to merge 2 commits into
claude/gracious-edison-8fijxh-s4-s5from
claude/gracious-edison-8fijxh-break-glass

Conversation

@PetrefiedThunder

Copy link
Copy Markdown
Collaborator

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 main when #3717 merges. Lane: backend (persist evidence).

Problem. 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.

Migration v142 (17fc5f9d1e69, revises 08c7cf36ddbe; 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 documented SET LOCAL fsma.allow_mutation = 'true' contract; regengine and regengine_sysadmin lose it.
  • The CTE retention purge (services/scheduler/service.py) was the one runtime mutation. It now calls fsma.arm_retention_purge(tenant_id, min_age) (SECURITY DEFINER; owner-only FORCE-RLS table fsma.break_glass_arms; floor interval '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 (the CI databases) cannot sweep other tenants under one arm.
  • All five trigger bodies are replaced with CREATE OR REPLACE (no trigger dropped, no table lock beyond the function swap, lock_timeout 10s). The v141 bodies are derived from the v141 module by substituting the single GUC test, so the allowlist logic is byte-identical.
  • v111's closed grant inventory (alembic/versions/20260803_b71d9e4c2a60_v111_runtime_role_grants.py) 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. 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.
  • Downgrade restores the exact v107, v095 and v141 bodies and drops the helpers and the arm table. A database downgraded past v142 needs the previous scheduler build (it would call a missing function).
  • docs/engineering/SCHEMA_CHANGE_POLICY.md 5.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

  • Raw SQL as regengine or regengine_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.
  • Migration credential or superuser: unchanged break-glass with the GUC (these credentials already own the schema).

Type of Change

  • Bug fix
  • New feature
  • Enhancement to existing feature
  • Documentation
  • CI/CD or infrastructure
  • Security fix

Test Plan

  • Unit tests added/updated
  • Integration tests pass
  • Manual testing performed
  • N/A (docs, config, etc.)

New tests/migrations/test_v142_break_glass_authorization.py (5 static, 8 behavioural on a live PostgreSQL, the runtime role simulated with SET LOCAL ROLE regengine):

  • superuser keeps the GUC-only contract;
  • runtime role + GUC: UPDATE on fsma.traceability_events refused (23514), DELETE on fsma.cte_events refused, DELETE on the canonical table refused by the grant layer (42501);
  • runtime role's sanctioned verdict UPDATE still allowed (EXECUTE grants hold);
  • arm permits only the tenant's expired cte_events rows, inside the arming transaction, never an UPDATE; a committed arm does not carry into a later transaction;
  • floor (22023) and tenant-context (42501) checks raise; the arm table is owner-only;
  • downgrade restores the pre-v142 behaviour (runtime role + GUC bypasses again), upgrade re-applies.

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"
ADMIN_DATABASE_URL=postgresql://regengine_sysadmin@... python3 -m pytest tests/shared/test_data_archival_job_live.py --no-cov -o "testpaths=tests"   # the scheduler sweep through the arm
python3 -m pytest services/scheduler/tests/test_task_queue_retention.py --no-cov
python3 scripts/check_alembic_revisions.py
python3 scripts/check_tenant_id_uuid_only.py

Plus scripts/check_docs_links.py, scripts/check_trust_safety_copy.py, scripts/ci/check_secrets_baseline.sh on 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_head asserts the fresh-upgrade head equals 93eb33f262a1 and fails on the #3717 branch and on this one the same way (it is skipped in CI without its scratch-cluster DSN).

Checklist

  • My code follows the existing patterns in the codebase
  • I have not introduced hardcoded secrets or credentials
  • If this PR adds a test that uses sys.modules[X] = stub, I justified it in the PR description (none)
  • I have updated relevant documentation (if applicable)
  • My changes generate no new warnings or lint errors

Related Issues

Relates to #3717 (S4; the open break-glass thread), #3701 (gap analysis row 9), SEL-589.


Generated by Claude Code

… 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
@coderabbitai

coderabbitai Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

PR Blocker Checklist

Head SHA: 8a93c7e64a7ff8fef35f95e7175b1cf66e63f348

Status: Ready

  • All review threads resolved
  • No reviews requesting changes
  • No blocked review engines

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

Copy link
Copy Markdown
Collaborator Author

Status (2026-10-31 05:15 UTC, head 8a93c7e)

Head: 8a93c7e = 088cc23 (v142) + one CI fix. Base is claude/gracious-edison-8fijxh-s4-s5 (#3717, head 6c27050) so the chain reads v141 -> v142; GitHub retargets to main when #3717 merges.

Why 088cc23 was red (both failures, one cause, mine). SAST (Semgrep) and Tenant Isolation & Auth Tests both failed on the tenant-scope query gate (scripts/security/scan_tenant_scope.py and tests/security/test_tenant_scope_gate.py), not on Semgrep itself (0 findings in all three Semgrep passes): the .semgrep/tenant-scope-allowlist.yml entry for the archive_expired_records DELETE on fsma.cte_events (sql-without-tenant-predicate) became stale because this PR gives that DELETE an explicit tenant predicate, and the gate fails a stale entry on purpose. 8a93c7e removes the entry; the scanner and the two gate test modules pass locally.

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): test_v142_break_glass_authorization.py (13), test_v141_traceability_events_append_only.py, test_alembic_chain_health.py, test_rls_coverage.py, test_data_archival_job_live.py (scheduler sweep through the arm, with the admin DSN as regengine_sysadmin), services/scheduler/tests/test_task_queue_retention.py; check_alembic_revisions.py, check_tenant_id_uuid_only.py, scan_tenant_scope.py, docs link and trust-copy gates, secrets baseline, ruff, git diff --check.

Waiting on: CI on 8a93c7e; #3717 to merge first (then this retargets to main); a REVIEW: PASS <engine> 8a93c7e… marker; the founder's decision on whether to mark the PR ready for review.


Generated by Claude Code

This branch has not been deployed

No deployments
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