Repository navigation
fix(db): keep runtime grants for data gap tracking across deploys #3816
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
4 commits
Select commit
Hold shift + click to select a range
015be6a
fix(db): keep runtime grants for data gap tracking across deploys
PetrefiedThunder 1930abf
merge: bring main into data-gap-runtime-grants
PetrefiedThunder 30152cd
fix(db): column-scope data_gap_events UPDATE grants
PetrefiedThunder 5e536ef
fix(db): guard v111 column grants with to_regclass
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
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
73 changes: 73 additions & 0 deletions
73
alembic/versions/20261104_9c0bfe465816_v142_data_gap_runtime_grants.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,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$; | ||
| """ | ||
| ) |
25 changes: 25 additions & 0 deletions
25
supabase/migrations/20261104120000_v142_data_gap_runtime_grants.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
| 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$; |
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,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 |
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.