Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",),
Expand Down Expand Up @@ -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"),
},
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
# 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;
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -840,21 +854,37 @@ 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"))

for object_name, privileges in sorted(REGENGINE_SYSADMIN_TABLE_GRANTS.items()):
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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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$;
"""
)
Original file line number Diff line number Diff line change
@@ -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$;
81 changes: 81 additions & 0 deletions tests/migrations/test_v142_data_gap_runtime_grants.py
Original file line number Diff line number Diff line change
@@ -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
Loading