Repository navigation
feat(evidence): enforce the traceability_events mutation policy and derive waiver approvers from the principal #3717
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
ed324f3
feat(evidence): enforce the traceability_events mutation policy and d…
PetrefiedThunder d19bc71
Merge remote-tracking branch 'origin/main' into claude/gracious-ediso…
PetrefiedThunder 2bb51fe
ci(evidence): satisfy the secrets and tenant_id guards on the v138 files
PetrefiedThunder ecf94c0
fix(scripts): spell the key-dir mode as stat.S_IRWXU for the Semgrep …
claude 16a0f1f
fix(evidence): make the v138 status-transition guard null-safe
claude d14ba01
Merge main into #3717: bring v139+v140 so traceability mutation can r…
PetrefiedThunder d4becbe
fix(db): rechain #3717 mutation policy as v141 after main's v140
PetrefiedThunder ca410da
test(evidence): bound v141 trigger DDL lock wait, assert SQLSTATE dir…
PetrefiedThunder 468081c
Merge remote-tracking branch 'origin/main' into claude/gracious-ediso…
claude 6c27050
test(evidence): pin that clearing a protected column to NULL is rejec…
PetrefiedThunder 6887ca5
merge(main): bring #3717 up to date with origin/main
PetrefiedThunder a53128f
fix(deps): port #3773 pypdf, pyasn1 and follow-redirects bumps
PetrefiedThunder 4630484
merge(main): sync #3717 with origin/main (#3773 + #3766)
PetrefiedThunder File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
198 changes: 198 additions & 0 deletions
198
alembic/versions/20261028_08c7cf36ddbe_v141_traceability_events_append_only.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,198 @@ | ||
| """v141 — fsma.traceability_events mutation policy (column allowlist trigger). | ||
|
|
||
| ``fsma.traceability_events`` is the canonical FSMA 204 evidence table and | ||
| the chain hash is computed over its payload columns, yet until now nothing | ||
| in the database stopped an UPDATE or DELETE on it. ``fsma.cte_events`` has | ||
| had an append-only trigger since v107; the canonical table had none, so a | ||
| ``UPDATE fsma.traceability_events SET quantity = ...`` from any role with | ||
| UPDATE privilege silently broke the evidence the FDA package is built on. | ||
|
|
||
| This revision installs a BEFORE UPDATE OR DELETE row trigger plus a BEFORE | ||
| TRUNCATE statement trigger that together enforce the sanctioned mutation | ||
| surface, which is exactly what the three production writers do today: | ||
|
|
||
| * ``services/shared/canonical_persistence/writer.py`` marks the row an | ||
| amendment supersedes: ``status = 'superseded', amended_at = NOW()``. | ||
| * ``services/shared/rules/engine.py`` records the event-level verdict: | ||
| ``evaluation_verdict``, ``no_verdict_reason``, ``evaluated_at``, and the | ||
| evaluation error marker: ``evaluation_error``, ``evaluated_at``. | ||
|
|
||
| Allowed on UPDATE: changes confined to the allowlisted columns | ||
| (``status``, ``amended_at``, ``evaluation_verdict``, ``no_verdict_reason``, | ||
| ``evaluation_error``, ``evaluated_at``), and ``status`` may only move from | ||
| ``active`` to ``superseded``. Any other column change, any other status | ||
| transition, every DELETE and every TRUNCATE raise ``check_violation``. | ||
|
|
||
| Break-glass: ``SET LOCAL fsma.allow_mutation = 'true'`` lets the statement | ||
| through with a WARNING, the same GUC v095 and v107 use. The runtime resets | ||
| it per transaction (``services/shared/tenant_context.py``) and per pooled | ||
| connection (``services/admin/app/database.py``); the retention purge is | ||
| its only production user. | ||
|
|
||
| The comparison is ``to_jsonb(NEW) - allowlist = to_jsonb(OLD) - allowlist`` | ||
| rather than a list of frozen column names, so the trigger is correct on | ||
| every catalog variant of the table (the Supabase twin types the tenant | ||
| column as text and has no ``ingested_at``) and keeps protecting columns | ||
| added later. | ||
|
|
||
| Idempotent: ``CREATE OR REPLACE`` functions, ``DROP TRIGGER IF EXISTS`` | ||
| before ``CREATE TRIGGER``, ``to_regclass`` guard. No grant changes (v111 | ||
| still grants UPDATE to the runtime role; the trigger is the enforcement). | ||
|
|
||
| Revision ID: 08c7cf36ddbe | ||
| Revises: b8c1e4f2a901 | ||
| Create Date: 2026-10-28 | ||
| """ | ||
|
|
||
| from typing import Sequence, Union | ||
|
|
||
| from alembic import op | ||
|
|
||
| revision: str = "08c7cf36ddbe" # pragma: allowlist secret | ||
| down_revision: Union[str, Sequence[str], None] = "b8c1e4f2a901" # pragma: allowlist secret | ||
| branch_labels = None | ||
| depends_on = None | ||
|
|
||
| TABLE = "fsma.traceability_events" | ||
| ROW_TRIGGER = "traceability_events_mutation_policy" | ||
| TRUNCATE_TRIGGER = "traceability_events_no_truncate" | ||
| ROW_FUNCTION = "fsma.prevent_traceability_event_mutation" | ||
| TRUNCATE_FUNCTION = "fsma.prevent_traceability_event_truncate" | ||
|
|
||
| # Columns the sanctioned writers may change. Keep in step with the module | ||
| # docstring and docs/engineering/SCHEMA_CHANGE_POLICY.md §5. | ||
| ALLOWLIST: tuple[str, ...] = ( | ||
| "status", | ||
| "amended_at", | ||
| "evaluation_verdict", | ||
| "no_verdict_reason", | ||
| "evaluation_error", | ||
| "evaluated_at", | ||
| ) | ||
|
|
||
| _ALLOWLIST_SQL = ", ".join(f"'{c}'" for c in ALLOWLIST) | ||
|
|
||
| _ROW_FUNCTION_SQL = f""" | ||
| CREATE OR REPLACE FUNCTION {ROW_FUNCTION}() | ||
| RETURNS TRIGGER LANGUAGE plpgsql AS $$ | ||
| DECLARE | ||
| allowlist text[] := ARRAY[{_ALLOWLIST_SQL}]; | ||
| changed text[]; | ||
| BEGIN | ||
| IF current_setting('fsma.allow_mutation', true) = 'true' THEN | ||
| RAISE WARNING | ||
| 'traceability_events modification via break-glass. op=% user=% event_id=%', | ||
| TG_OP, current_user, COALESCE(NEW.event_id::text, OLD.event_id::text); | ||
| IF TG_OP = 'UPDATE' THEN | ||
| RETURN NEW; | ||
| END IF; | ||
| RETURN OLD; | ||
| END IF; | ||
|
|
||
| IF TG_OP = 'DELETE' THEN | ||
| RAISE EXCEPTION | ||
| 'traceability_events is append-only (FDA 21 CFR 1.1455). DELETE rejected for event_id=%. Set fsma.allow_mutation=true for authorized break-glass.', | ||
| OLD.event_id | ||
| USING ERRCODE = 'check_violation'; | ||
| END IF; | ||
|
|
||
| -- UPDATE: every changed column must be on the allowlist. | ||
| SELECT COALESCE(array_agg(key ORDER BY key), ARRAY[]::text[]) | ||
| INTO changed | ||
| FROM ( | ||
| SELECT key FROM jsonb_each(to_jsonb(NEW) - allowlist) | ||
| EXCEPT | ||
| SELECT key FROM jsonb_each(to_jsonb(OLD) - allowlist) | ||
| UNION | ||
| SELECT n.key | ||
| FROM jsonb_each(to_jsonb(NEW) - allowlist) n | ||
| JOIN jsonb_each(to_jsonb(OLD) - allowlist) o ON o.key = n.key | ||
| WHERE n.value IS DISTINCT FROM o.value | ||
| ) diff; | ||
|
PetrefiedThunder marked this conversation as resolved.
Resolved
|
||
| IF array_length(changed, 1) IS NOT NULL THEN | ||
| RAISE EXCEPTION | ||
| 'traceability_events is append-only (FDA 21 CFR 1.1455). UPDATE rejected for event_id=%: column(s) % are not amendable. Corrections are new events that supersede the old one. Set fsma.allow_mutation=true for authorized break-glass.', | ||
| OLD.event_id, array_to_string(changed, ', ') | ||
| USING ERRCODE = 'check_violation'; | ||
| END IF; | ||
|
|
||
| -- status may only move active -> superseded (an amendment landed). | ||
| -- IS NOT TRUE, not NOT (...): the Supabase twin leaves status nullable, | ||
| -- and a NULL on either side would turn NOT (...) into NULL and let the | ||
| -- change through. | ||
| IF NEW.status IS DISTINCT FROM OLD.status | ||
| AND (OLD.status = 'active' AND NEW.status = 'superseded') IS NOT TRUE THEN | ||
| RAISE EXCEPTION | ||
| 'traceability_events status may only move from active to superseded; % -> % rejected for event_id=%. Set fsma.allow_mutation=true for authorized break-glass.', | ||
| OLD.status, NEW.status, OLD.event_id | ||
| USING ERRCODE = 'check_violation'; | ||
| END IF; | ||
|
|
||
| RETURN NEW; | ||
| END; | ||
| $$; | ||
| """ | ||
|
|
||
| _TRUNCATE_FUNCTION_SQL = f""" | ||
| CREATE OR REPLACE FUNCTION {TRUNCATE_FUNCTION}() | ||
| RETURNS TRIGGER LANGUAGE plpgsql AS $$ | ||
| BEGIN | ||
| IF current_setting('fsma.allow_mutation', true) = 'true' THEN | ||
| RAISE WARNING 'traceability_events TRUNCATE via break-glass. user=%', current_user; | ||
| RETURN NULL; | ||
| END IF; | ||
| RAISE EXCEPTION | ||
| 'traceability_events is append-only (FDA 21 CFR 1.1455). TRUNCATE rejected. Set fsma.allow_mutation=true for authorized break-glass.' | ||
| USING ERRCODE = 'check_violation'; | ||
| END; | ||
| $$; | ||
| """ | ||
|
|
||
| _TRIGGERS_SQL = f""" | ||
| DO $$ | ||
| BEGIN | ||
| IF to_regclass('{TABLE}') IS NULL THEN | ||
| RETURN; | ||
| END IF; | ||
| DROP TRIGGER IF EXISTS {ROW_TRIGGER} ON {TABLE}; | ||
| CREATE TRIGGER {ROW_TRIGGER} | ||
| BEFORE UPDATE OR DELETE ON {TABLE} | ||
| FOR EACH ROW EXECUTE FUNCTION {ROW_FUNCTION}(); | ||
| DROP TRIGGER IF EXISTS {TRUNCATE_TRIGGER} ON {TABLE}; | ||
| CREATE TRIGGER {TRUNCATE_TRIGGER} | ||
| BEFORE TRUNCATE ON {TABLE} | ||
| FOR EACH STATEMENT EXECUTE FUNCTION {TRUNCATE_FUNCTION}(); | ||
| END $$; | ||
| """ | ||
|
|
||
| _DOWNGRADE_SQL = f""" | ||
| DO $$ | ||
| BEGIN | ||
| IF to_regclass('{TABLE}') IS NOT NULL THEN | ||
| DROP TRIGGER IF EXISTS {ROW_TRIGGER} ON {TABLE}; | ||
| DROP TRIGGER IF EXISTS {TRUNCATE_TRIGGER} ON {TABLE}; | ||
| END IF; | ||
| END $$; | ||
| DROP FUNCTION IF EXISTS {ROW_FUNCTION}(); | ||
| DROP FUNCTION IF EXISTS {TRUNCATE_FUNCTION}(); | ||
| """ | ||
|
|
||
|
|
||
| # CREATE/DROP TRIGGER takes SHARE ROW EXCLUSIVE on the table and would queue | ||
| # behind a long ingest or export transaction, holding every later writer | ||
| # behind it. Bound the wait (same 10s as v134) so the migration fails fast | ||
| # and can be retried instead of stalling production. Alembic runs each | ||
| # path in one transaction, so SET LOCAL covers every statement below. | ||
| _LOCK_TIMEOUT_SQL = "SET LOCAL lock_timeout = '10s'" | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| op.execute(_LOCK_TIMEOUT_SQL) | ||
| op.execute(_ROW_FUNCTION_SQL) | ||
| op.execute(_TRUNCATE_FUNCTION_SQL) | ||
| op.execute(_TRIGGERS_SQL) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| op.execute(_LOCK_TIMEOUT_SQL) | ||
| op.execute(_DOWNGRADE_SQL) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: regengine/RegEngine
Length of output: 8128
Script executed:
Repository: regengine/RegEngine
Length of output: 42592
Script executed:
Repository: regengine/RegEngine
Length of output: 41944
Script executed:
Repository: regengine/RegEngine
Length of output: 15556
Protect the break-glass override with authorization.
regenginehasUPDATEprivilege onfsma.traceability_events, and the canonical writer reaches this table through an ordinaryUPDATE. PostgreSQL allows that role to set a custom two-part GUC withSET LOCAL. The trigger then returnsNEWbefore checking the operation or changed columns.A non-break-glass
regenginesession can therefore setfsma.allow_mutation = 'true'and update protected evidence columns. Gate the override through a dedicated trusted role orSECURITY DEFINERfunction. Do not use the GUC value alone as authorization.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Verified, and the path is real: v111 grants
regengineUPDATE onfsma.traceability_events, PostgreSQL lets any roleSET LOCALan unreserved two-part GUC, and the trigger returnsNEWas soon asfsma.allow_mutation = 'true'. So a session that already holds the application role can rewrite evidence columns without any further privilege.What that path requires is raw SQL on the application role: leaked
regenginecredentials, or an injection that yields statement execution. Either already gives full read and write of every tenant's data, so the trigger is not the boundary that holds against it today. What the trigger does hold against is the realistic case this PR targets: an ORM or hand-written UPDATE in application code, a migration, or an operator session that forgets the policy. The repository's existing mutation guards (v095 on supplier remediation, v107 onfsma.cte_events) use this same GUC with the same semantics, and this migration deliberately matches them so the retention purge (the only production caller) keeps one break-glass contract. Two mitigations stay in place on the bypass path: the trigger logsRAISE WARNINGwithTG_OP,current_userand the event id to the PostgreSQL log, andsha256_hashplus the chain verification in/export/verifystill detect an edited payload.Gating the override behind a dedicated role or a
SECURITY DEFINERfunction is the right hardening, but it is not a change I can make in proportion inside this PR: it needs a new role and grant in v111 (whichscripts/apply_runtime_role_cutover.pyrebuilds after every deploy), the scheduler's purge path moved onto that role or wrapped in the definer function, and the same treatment for v095 and v107 so the three guards do not diverge. That is the column-grant narrowing follow-up the PR description already names. I am leaving this thread open for the founder's call on whether to take it as a follow-up or fold it in here.Generated by Claude Code
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@PetrefiedThunder Understood. The stated scope is protection against accidental mutations, not against a session that can execute arbitrary SQL as
regengine. Matching v095 and v107 explains the compatibility constraint.The authorization finding remains valid.
RAISE WARNINGprovides logging, and hash-chain verification provides detection; neither authorizes the override or prevents the mutation. This does not negate the protection v141 adds against accidental updates.I agree that the founder should decide whether to include the hardening here or accept the risk for a follow-up. I recommend recording that decision before merging. The thread should remain open pending that decision.
For the follow-up, the key verification is that
regenginecannot modify protected columns even after settingfsma.allow_mutation, while the authorized retention purge still works after runtime-role cutover.Would you like me to open a GitHub issue for that coordinated hardening?
You are interacting with an AI system.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The hardening now exists as #3764 (stacked on this branch, v142):
fsma.break_glass_authorized(oid)honours the GUC only for a superuser or a member of the table owner, so the runtime roles cannot arm the break-glass with raw SQL; the retention purge moves to a SECURITY DEFINER arm scoped to one tenant,fsma.cte_events, DELETE only, rows past the FSMA floor; all three guard families (v095, v107, v141) get the same check; v111's inventory carries the grants; a live test runs the runtime role viaSET LOCAL ROLE regengineand asserts the bypass is refused while the sanctioned verdict UPDATE still works. This thread stays open for the founder to choose: merge #3717 as is and take #3764 right after, or hold #3717 until #3764 is reviewed.Generated by Claude Code
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@PetrefiedThunder Understood.
#3764is the reported fix for this finding, but it does not change the bypass in#3717alone. I have not independently verified#3764.This thread should remain open for the founder’s merge-order decision. If the founder chooses to merge
#3717first, record explicit acceptance of the remaining bypass until v142 is deployed.Before closing the finding, verify that runtime roles cannot bypass the guards after runtime-role cutover, sanctioned verdict updates still work, and the retention purge enforces its tenant, table, operation, and retention limits.
You are interacting with an AI system.