diff --git a/alembic/versions/20260803_b71d9e4c2a60_v111_runtime_role_grants.py b/alembic/versions/20260803_b71d9e4c2a60_v111_runtime_role_grants.py index 8ae192e18..02ecab6df 100644 --- a/alembic/versions/20260803_b71d9e4c2a60_v111_runtime_role_grants.py +++ b/alembic/versions/20260803_b71d9e4c2a60_v111_runtime_role_grants.py @@ -499,6 +499,7 @@ def _build_migration_owner_posture() -> str: "compliance_notifications", "compliance_status_log", "controls", + "data_gap_events", "food_traceability_list", "funnel_events", "graph_outbox", @@ -568,6 +569,10 @@ def _build_migration_owner_posture() -> str: "public.audit_logs": ("SELECT", "INSERT"), "public.compliance_alerts": ("SELECT", "INSERT"), "public.controls": ("SELECT",), + # v142 -- ingest opens and closes data gaps. No DELETE: gap history is + # evidence. UPDATE is column-scoped to the close fields (see + # REGENGINE_COLUMN_GRANTS). + "public.data_gap_events": ("SELECT", "INSERT"), "public.food_traceability_list": ("SELECT",), "public.funnel_events": ("SELECT", "INSERT"), "public.obligation_cte_rules": ("SELECT",), @@ -659,6 +664,14 @@ def _build_migration_owner_posture() -> str: # without exposing names/settings. v139 applies the same column grant to # databases already past v111; this inventory keeps it across cutover. "public.tenants": {"SELECT": ("id", "status")}, + # v142 -- opening a gap attributes it to the lot's facility. Only the + # columns that lookup reads; the rest stays control-plane. + "public.supplier_traceability_lots": {"SELECT": ("tenant_id", "tlc_code", "facility_id")}, + # v142 -- closing a gap sets only the close receipt. Opened rows, gap + # identity and tenant cannot be rewritten. + "public.data_gap_events": { + "UPDATE": ("closed_at", "closing_record_id", "closing_record_hash"), + }, # v126 -- POST /api/v1/ingest/csv (#3122) retains each upload verbatim # in the ingest transaction (webhook_compat._open_ingestion_run). # v138 extends INSERT with storage_url for partner-uploads pointer rows; @@ -727,6 +740,7 @@ def _build_migration_owner_posture() -> str: "public.audit_logs": ("SELECT", "INSERT"), "public.auth_challenges": ("SELECT", "INSERT", "UPDATE", "DELETE"), "public.compliance_alerts": ("SELECT", "INSERT", "UPDATE"), + "public.data_gap_events": ("SELECT",), "public.funnel_events": ("SELECT",), "public.graph_outbox": ("SELECT", "INSERT", "UPDATE"), "public.invites": ("SELECT", "INSERT", "UPDATE"), @@ -840,13 +854,29 @@ def _grant_sql(role: str, object_name: str, privileges: tuple[str, ...], kind: s """ +def _column_grant_sql(role: str, object_name: str, privilege: str, columns: tuple[str, ...]) -> str: + # Same to_regclass guard as _grant_sql: a column grant can name a table a + # later revision creates (public.data_gap_events comes from v135), and a + # bare GRANT would abort a fresh `alembic upgrade head` at this revision. + grant = f"GRANT {privilege} ({', '.join(columns)}) ON TABLE {object_name} TO {role};" + return f""" +DO $$ +BEGIN + IF to_regclass('{object_name}') IS NOT NULL THEN + EXECUTE '{grant}'; + END IF; +END +$$; +""" + + def _build_object_grants() -> str: statements = [_OBJECT_GRANT_BASE] for object_name, privileges in sorted(REGENGINE_TABLE_GRANTS.items()): statements.append(_grant_sql("regengine", object_name, privileges, "TABLE")) for object_name, privilege_map in sorted(REGENGINE_COLUMN_GRANTS.items()): for privilege, columns in sorted(privilege_map.items()): - statements.append(f"GRANT {privilege} ({', '.join(columns)}) ON TABLE {object_name} TO regengine;") + statements.append(_column_grant_sql("regengine", object_name, privilege, columns)) for object_name, privileges in sorted(REGENGINE_SEQUENCE_GRANTS.items()): statements.append(_grant_sql("regengine", object_name, privileges, "SEQUENCE")) @@ -854,7 +884,7 @@ def _build_object_grants() -> str: statements.append(_grant_sql("regengine_sysadmin", object_name, privileges, "TABLE")) for object_name, privilege_map in sorted(REGENGINE_SYSADMIN_COLUMN_GRANTS.items()): for privilege, columns in sorted(privilege_map.items()): - statements.append(f"GRANT {privilege} ({', '.join(columns)}) ON TABLE {object_name} TO regengine_sysadmin;") + statements.append(_column_grant_sql("regengine_sysadmin", object_name, privilege, columns)) for object_name, privileges in sorted(REGENGINE_SYSADMIN_SEQUENCE_GRANTS.items()): statements.append(_grant_sql("regengine_sysadmin", object_name, privileges, "SEQUENCE")) return "\n".join(statements) diff --git a/alembic/versions/20261104_9c0bfe465816_v142_data_gap_runtime_grants.py b/alembic/versions/20261104_9c0bfe465816_v142_data_gap_runtime_grants.py new file mode 100644 index 000000000..cde618406 --- /dev/null +++ b/alembic/versions/20261104_9c0bfe465816_v142_data_gap_runtime_grants.py @@ -0,0 +1,73 @@ +"""Grant the runtime role what data gap tracking needs (v142). + +v135 granted ``public.data_gap_events`` to ``regengine``, but only in that +migration. ``apply_runtime_role_cutover`` rebuilds runtime grants from v111's +closed inventory on every deploy, so the grant was revoked at once. The gap +insert also reads ``public.supplier_traceability_lots``, which ``regengine`` +never had. Every gap write failed with ``permission denied``, and because gap +logging is fail-open the only trace was a ``data_gap_write_failed`` log line. + +This revision applies the grants, and the matching v111 inventory entries keep +them across deploys. ``data_gap_events`` UPDATE is column-scoped to the close +fields (``closed_at``, ``closing_record_id``, ``closing_record_hash``), so the +runtime cannot rewrite an opened gap. ``supplier_traceability_lots`` gets only +the three columns the lookup reads. Both tables are tenant-scoped by forced RLS. + +Mirrors ``supabase/migrations/20261104120000_v142_data_gap_runtime_grants.sql``. + +Revision ID: 9c0bfe465816 +Revises: 08c7cf36ddbe +Create Date: 2026-11-04 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "9c0bfe465816" # pragma: allowlist secret +down_revision: Union[str, Sequence[str], None] = "08c7cf36ddbe" # pragma: allowlist secret +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + DO $grant$ + BEGIN + IF to_regclass('public.data_gap_events') IS NOT NULL + AND to_regclass('public.supplier_traceability_lots') IS NOT NULL + AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'regengine') + AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'regengine_sysadmin') THEN + REVOKE UPDATE ON TABLE public.data_gap_events FROM regengine; + GRANT SELECT, INSERT ON TABLE public.data_gap_events TO regengine; + GRANT UPDATE (closed_at, closing_record_id, closing_record_hash) + ON TABLE public.data_gap_events TO regengine; + GRANT SELECT ON TABLE public.data_gap_events TO regengine_sysadmin; + GRANT SELECT (tenant_id, tlc_code, facility_id) ON TABLE public.supplier_traceability_lots TO regengine; + END IF; + END + $grant$; + """ + ) + + +def downgrade() -> None: + op.execute( + """ + DO $revoke$ + BEGIN + IF to_regclass('public.data_gap_events') IS NOT NULL + AND to_regclass('public.supplier_traceability_lots') IS NOT NULL + AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'regengine') + AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'regengine_sysadmin') THEN + REVOKE UPDATE (closed_at, closing_record_id, closing_record_hash) + ON TABLE public.data_gap_events FROM regengine; + GRANT SELECT, INSERT, UPDATE ON TABLE public.data_gap_events TO regengine; + REVOKE SELECT ON TABLE public.data_gap_events FROM regengine_sysadmin; + REVOKE SELECT (tenant_id, tlc_code, facility_id) ON TABLE public.supplier_traceability_lots FROM regengine; + END IF; + END + $revoke$; + """ + ) diff --git a/supabase/migrations/20261104120000_v142_data_gap_runtime_grants.sql b/supabase/migrations/20261104120000_v142_data_gap_runtime_grants.sql new file mode 100644 index 000000000..af0067b68 --- /dev/null +++ b/supabase/migrations/20261104120000_v142_data_gap_runtime_grants.sql @@ -0,0 +1,25 @@ +-- v142: runtime grants for data gap tracking. +-- +-- Mirrors alembic/versions/20261104_9c0bfe465816_v142_data_gap_runtime_grants.py. +-- The v135 grant was revoked by the per-deploy runtime-role cutover, and the +-- gap insert reads supplier_traceability_lots, which regengine never had, so +-- every gap write failed. v111's inventory keeps these grants across deploys. +-- data_gap_events UPDATE is column-scoped to the close fields. +-- +-- Idempotent. + +DO $grant$ +BEGIN + IF to_regclass('public.data_gap_events') IS NOT NULL + AND to_regclass('public.supplier_traceability_lots') IS NOT NULL + AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'regengine') + AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'regengine_sysadmin') THEN + REVOKE UPDATE ON TABLE public.data_gap_events FROM regengine; + GRANT SELECT, INSERT ON TABLE public.data_gap_events TO regengine; + GRANT UPDATE (closed_at, closing_record_id, closing_record_hash) + ON TABLE public.data_gap_events TO regengine; + GRANT SELECT ON TABLE public.data_gap_events TO regengine_sysadmin; + GRANT SELECT (tenant_id, tlc_code, facility_id) ON TABLE public.supplier_traceability_lots TO regengine; + END IF; +END +$grant$; diff --git a/tests/migrations/test_v142_data_gap_runtime_grants.py b/tests/migrations/test_v142_data_gap_runtime_grants.py new file mode 100644 index 000000000..deb49dbca --- /dev/null +++ b/tests/migrations/test_v142_data_gap_runtime_grants.py @@ -0,0 +1,81 @@ +"""v142: data gap writes must keep working after the per-deploy cutover. + +A grant applied only by a migration is revoked by +``apply_runtime_role_cutover``, so these checks pin the v111 inventory, which +is what survives a deploy. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +V111 = REPO_ROOT / "alembic/versions/20260803_b71d9e4c2a60_v111_runtime_role_grants.py" +V142 = REPO_ROOT / "alembic/versions/20261104_9c0bfe465816_v142_data_gap_runtime_grants.py" +SUPABASE = REPO_ROOT / "supabase/migrations/20261104120000_v142_data_gap_runtime_grants.sql" +OPEN_GAP_COLUMNS = ("tenant_id", "tlc_code", "facility_id") +CLOSE_GAP_COLUMNS = ("closed_at", "closing_record_id", "closing_record_hash") + + +def _v111(): + spec = importlib.util.spec_from_file_location("regengine_v111_for_v142", V111) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_inventory_keeps_gap_grants_across_cutover() -> None: + v111 = _v111() + + assert "data_gap_events" in v111.APPLICATION_TABLES["public"] + assert set(v111.REGENGINE_TABLE_GRANTS["public.data_gap_events"]) == {"SELECT", "INSERT"} + assert v111.REGENGINE_COLUMN_GRANTS["public.data_gap_events"]["UPDATE"] == CLOSE_GAP_COLUMNS + assert v111.REGENGINE_SYSADMIN_TABLE_GRANTS["public.data_gap_events"] == ("SELECT",) + + +def test_supplier_lots_stay_column_scoped_for_runtime() -> None: + v111 = _v111() + + assert "public.supplier_traceability_lots" not in v111.REGENGINE_TABLE_GRANTS + assert v111.REGENGINE_COLUMN_GRANTS["public.supplier_traceability_lots"] == {"SELECT": OPEN_GAP_COLUMNS} + + +def test_runtime_gap_update_is_column_scoped_only() -> None: + v111 = _v111() + + assert "UPDATE" not in v111.REGENGINE_TABLE_GRANTS["public.data_gap_events"] + assert v111.REGENGINE_COLUMN_GRANTS["public.data_gap_events"] == {"UPDATE": CLOSE_GAP_COLUMNS} + for path in (V142, SUPABASE): + upgrade_sql = path.read_text(encoding="utf-8").split("def downgrade")[0] + assert "GRANT SELECT, INSERT, UPDATE ON TABLE public.data_gap_events" not in upgrade_sql, path.name + + +def test_gap_history_cannot_be_deleted_by_runtime() -> None: + assert "DELETE" not in _v111().REGENGINE_TABLE_GRANTS["public.data_gap_events"] + + +def test_alembic_and_supabase_apply_the_same_grants() -> None: + expected = ( + "REVOKE UPDATE ON TABLE public.data_gap_events FROM regengine;", + "GRANT SELECT, INSERT ON TABLE public.data_gap_events TO regengine;", + "GRANT UPDATE (closed_at, closing_record_id, closing_record_hash)\n", + "ON TABLE public.data_gap_events TO regengine;", + "GRANT SELECT ON TABLE public.data_gap_events TO regengine_sysadmin;", + "GRANT SELECT (tenant_id, tlc_code, facility_id) ON TABLE public.supplier_traceability_lots TO regengine;", + ) + for path in (V142, SUPABASE): + sql = path.read_text(encoding="utf-8") + for statement in expected: + assert statement in sql, f"{path.name} is missing {statement}" + + +def test_v111_column_grants_are_guarded_for_fresh_upgrade() -> None: + # data_gap_events is created by v135, after v111 runs in a fresh + # `alembic upgrade head`, so its column grant must skip a missing table. + sql = _v111()._build_object_grants() + grant = "GRANT UPDATE (closed_at, closing_record_id, closing_record_hash) ON TABLE public.data_gap_events TO regengine;" + guarded = f"IF to_regclass('public.data_gap_events') IS NOT NULL THEN\n EXECUTE '{grant}';" + assert guarded in sql + assert f"\n{grant}" not in sql