From 015be6a48e430eb64b3fd13e18e38f64aead7c88 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:20:32 -0800 Subject: [PATCH 1/3] fix(db): keep runtime grants for data gap tracking across deploys Every data gap write has failed in production with permission denied. v135 granted data_gap_events to regengine only in the migration, and the per-deploy runtime-role cutover rebuilds grants from v111's closed inventory, so the grant was revoked. The insert also reads supplier_traceability_lots, which regengine never had. Gap logging is fail-open, so the only trace was a data_gap_write_failed log line. Add both to the v111 inventory and apply them in v142. supplier_traceability_lots gets only the three columns the lookup reads, and data_gap_events gets no DELETE. Both tables have forced tenant RLS. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- ...3_b71d9e4c2a60_v111_runtime_role_grants.py | 8 +++ ...0bfe465816_v142_data_gap_runtime_grants.py | 66 +++++++++++++++++++ ...104120000_v142_data_gap_runtime_grants.sql | 21 ++++++ .../test_v142_data_gap_runtime_grants.py | 56 ++++++++++++++++ 4 files changed, 151 insertions(+) create mode 100644 alembic/versions/20261104_9c0bfe465816_v142_data_gap_runtime_grants.py create mode 100644 supabase/migrations/20261104120000_v142_data_gap_runtime_grants.sql create mode 100644 tests/migrations/test_v142_data_gap_runtime_grants.py diff --git a/alembic/versions/20260803_b71d9e4c2a60_v111_runtime_role_grants.py b/alembic/versions/20260803_b71d9e4c2a60_v111_runtime_role_grants.py index 8ae192e18..e1e433432 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,9 @@ 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. + "public.data_gap_events": ("SELECT", "INSERT", "UPDATE"), "public.food_traceability_list": ("SELECT",), "public.funnel_events": ("SELECT", "INSERT"), "public.obligation_cte_rules": ("SELECT",), @@ -659,6 +663,9 @@ 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")}, # 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 +734,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"), 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..deb140484 --- /dev/null +++ b/alembic/versions/20261104_9c0bfe465816_v142_data_gap_runtime_grants.py @@ -0,0 +1,66 @@ +"""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. ``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 + GRANT SELECT, INSERT, UPDATE 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 SELECT, INSERT, UPDATE ON TABLE public.data_gap_events FROM 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..9be62e91a --- /dev/null +++ b/supabase/migrations/20261104120000_v142_data_gap_runtime_grants.sql @@ -0,0 +1,21 @@ +-- 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. +-- +-- 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 + GRANT SELECT, INSERT, UPDATE 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..997a8cfee --- /dev/null +++ b/tests/migrations/test_v142_data_gap_runtime_grants.py @@ -0,0 +1,56 @@ +"""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") + + +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", "UPDATE"} + 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_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 = ( + "GRANT SELECT, INSERT, UPDATE 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}" From 30152cd4745906e6b1c665c43e3527ff98ed5db4 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:47:58 -0800 Subject: [PATCH 2/3] fix(db): column-scope data_gap_events UPDATE grants The runtime role only closes gaps: _CLOSE_GAPS_SQL sets closed_at, closing_record_id and closing_record_hash, and the open path inserts with ON CONFLICT DO NOTHING. A table-level UPDATE let regengine rewrite any column of an opened gap (tenant_id, lot_code, gap_kind, opened_at), which is more than the close path needs. Gap history is evidence. v111's inventory now grants SELECT, INSERT at table level plus column UPDATE on the three close fields, so the per-deploy cutover keeps the narrower grant. v142 (alembic and supabase) revokes the table-level UPDATE v135 left behind and applies the column grant. Downgrade drops the column grant and restores v135's table-level grant. Sysadmin stays SELECT-only. Signed-off-by: C.Lee Sellers Co-Authored-By: Claude Opus 5.5 --- ...3_b71d9e4c2a60_v111_runtime_role_grants.py | 10 ++++++++-- ...0bfe465816_v142_data_gap_runtime_grants.py | 15 +++++++++++---- ...104120000_v142_data_gap_runtime_grants.sql | 6 +++++- .../test_v142_data_gap_runtime_grants.py | 19 +++++++++++++++++-- 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/alembic/versions/20260803_b71d9e4c2a60_v111_runtime_role_grants.py b/alembic/versions/20260803_b71d9e4c2a60_v111_runtime_role_grants.py index e1e433432..b8842562b 100644 --- a/alembic/versions/20260803_b71d9e4c2a60_v111_runtime_role_grants.py +++ b/alembic/versions/20260803_b71d9e4c2a60_v111_runtime_role_grants.py @@ -570,8 +570,9 @@ def _build_migration_owner_posture() -> str: "public.compliance_alerts": ("SELECT", "INSERT"), "public.controls": ("SELECT",), # v142 -- ingest opens and closes data gaps. No DELETE: gap history is - # evidence. - "public.data_gap_events": ("SELECT", "INSERT", "UPDATE"), + # 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",), @@ -666,6 +667,11 @@ def _build_migration_owner_posture() -> str: # 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; diff --git a/alembic/versions/20261104_9c0bfe465816_v142_data_gap_runtime_grants.py b/alembic/versions/20261104_9c0bfe465816_v142_data_gap_runtime_grants.py index deb140484..cde618406 100644 --- a/alembic/versions/20261104_9c0bfe465816_v142_data_gap_runtime_grants.py +++ b/alembic/versions/20261104_9c0bfe465816_v142_data_gap_runtime_grants.py @@ -8,8 +8,10 @@ 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. ``supplier_traceability_lots`` gets only the three columns -the lookup reads. Both tables are tenant-scoped by forced RLS. +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``. @@ -37,7 +39,10 @@ def upgrade() -> None: 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 - GRANT SELECT, INSERT, UPDATE ON TABLE public.data_gap_events TO regengine; + 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; @@ -56,7 +61,9 @@ def downgrade() -> None: 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 SELECT, INSERT, UPDATE ON TABLE public.data_gap_events FROM regengine; + 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; diff --git a/supabase/migrations/20261104120000_v142_data_gap_runtime_grants.sql b/supabase/migrations/20261104120000_v142_data_gap_runtime_grants.sql index 9be62e91a..af0067b68 100644 --- a/supabase/migrations/20261104120000_v142_data_gap_runtime_grants.sql +++ b/supabase/migrations/20261104120000_v142_data_gap_runtime_grants.sql @@ -4,6 +4,7 @@ -- 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. @@ -13,7 +14,10 @@ BEGIN 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 - GRANT SELECT, INSERT, UPDATE ON TABLE public.data_gap_events TO regengine; + 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; diff --git a/tests/migrations/test_v142_data_gap_runtime_grants.py b/tests/migrations/test_v142_data_gap_runtime_grants.py index 997a8cfee..62dfea5b5 100644 --- a/tests/migrations/test_v142_data_gap_runtime_grants.py +++ b/tests/migrations/test_v142_data_gap_runtime_grants.py @@ -15,6 +15,7 @@ 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(): @@ -29,7 +30,8 @@ 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", "UPDATE"} + 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",) @@ -40,13 +42,26 @@ def test_supplier_lots_stay_column_scoped_for_runtime() -> None: 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 = ( - "GRANT SELECT, INSERT, UPDATE ON TABLE public.data_gap_events TO regengine;", + "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;", ) From 5e536ef7a2ca0bdd7a17010d249a965daf8b4b6a Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:55:39 -0800 Subject: [PATCH 3/3] fix(db): guard v111 column grants with to_regclass _build_object_grants() emitted bare column-level GRANTs for REGENGINE_COLUMN_GRANTS and REGENGINE_SYSADMIN_COLUMN_GRANTS, while table and sequence grants already went through the to_regclass guard in _grant_sql. public.data_gap_events is created in v135, so a fresh `alembic upgrade head` ran v111 before the table existed and aborted on the new column UPDATE grant with UndefinedTable. Column grants now go through _column_grant_sql, which uses the same DO/IF to_regclass/EXECUTE guard. On a fresh upgrade, v111 skips tables that are not created yet. The per-deploy cutover (scripts/apply_runtime_role_cutover.py) runs this module again against a fully migrated DB, where every table exists, so it still applies every column grant. The inventory itself is unchanged. Signed-off-by: C.Lee Sellers Co-Authored-By: Claude Opus 5.5 --- ...3_b71d9e4c2a60_v111_runtime_role_grants.py | 20 +++++++++++++++++-- .../test_v142_data_gap_runtime_grants.py | 10 ++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/alembic/versions/20260803_b71d9e4c2a60_v111_runtime_role_grants.py b/alembic/versions/20260803_b71d9e4c2a60_v111_runtime_role_grants.py index b8842562b..02ecab6df 100644 --- a/alembic/versions/20260803_b71d9e4c2a60_v111_runtime_role_grants.py +++ b/alembic/versions/20260803_b71d9e4c2a60_v111_runtime_role_grants.py @@ -854,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")) @@ -868,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/tests/migrations/test_v142_data_gap_runtime_grants.py b/tests/migrations/test_v142_data_gap_runtime_grants.py index 62dfea5b5..deb49dbca 100644 --- a/tests/migrations/test_v142_data_gap_runtime_grants.py +++ b/tests/migrations/test_v142_data_gap_runtime_grants.py @@ -69,3 +69,13 @@ def test_alembic_and_supabase_apply_the_same_grants() -> None: 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