From 1ed27103e26dc0b35a8e0eebd6c1e59236862ef1 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:24:38 +0800 Subject: [PATCH 1/2] fix(db): make hosted default ACL migrations postgres-safe --- .github/workflows/ci.yml | 2 +- docs/process-hardening.md | 2 +- scripts/check-default-acl.ts | 2 +- scripts/generate-drift-manifest.ts | 4 +- supabase/drift-manifest.json | 81 ++++--- ...sert_supabase_admin_default_privileges.sql | 179 --------------- ...41_assert_postgres_default_privileges.sql} | 84 +++---- ...5_reassert_postgres_default_privileges.sql | 37 ++++ ...609_repair_postgres_default_privileges.sql | 158 +++++++++++++ ...55623_enforce_public_title_word_scope.sql} | 18 +- supabase/roles.sql | 67 +++--- supabase/schema.sql | 208 +++++++----------- tests/supabase-schema.test.ts | 84 ++++--- 13 files changed, 422 insertions(+), 504 deletions(-) delete mode 100644 supabase/migrations/20260717161000_assert_supabase_admin_default_privileges.sql rename supabase/migrations/{20260717173000_reassert_supabase_admin_default_privileges.sql => 20260719055541_assert_postgres_default_privileges.sql} (51%) create mode 100644 supabase/migrations/20260719055555_reassert_postgres_default_privileges.sql create mode 100644 supabase/migrations/20260719055609_repair_postgres_default_privileges.sql rename supabase/migrations/{20260718223000_enforce_public_title_word_scope.sql => 20260719055623_enforce_public_title_word_scope.sql} (88%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee23dd4d2..b23439155 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -348,7 +348,7 @@ jobs: db_container="$(docker ps --format '{{.Names}}' | grep '^supabase_db_' | head -n 1)" test -n "${db_container}" docker exec -i "${db_container}" \ - psql -U supabase_admin -d postgres -v ON_ERROR_STOP=1 \ + psql -U postgres -d postgres -v ON_ERROR_STOP=1 \ < supabase/roles.sql - name: Verify Migration Replay diff --git a/docs/process-hardening.md b/docs/process-hardening.md index c02853b1e..082fe0cd4 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -326,7 +326,7 @@ hybrid:10}`, all 10 forced-embedding vector cases passed (`force_embedding_failu ## 2026-07-13 audit remediation batch (branch claude/audit-remediation-2026-07-13) - **Lexical retrieval rewrite (audit finding 1):** `20260713100000_index_friendly_lexical_retrieval.sql` splits `match_document_chunks_text`'s OR-across-relations candidate search into two GIN-index probes unioned by chunk id (same contract, same scores; the `_v2` wrapper inherits the speedup). Parity + plan + timing harness: `scripts/sql/lexical-rpc-parity-check.sql` (scratch databases only; run it against the drift-manifest container kept with `--keep`). Re-run it whenever either lexical body changes. -- **supabase_admin default privileges (audit finding 7):** `supabase/roles.sql` establishes and verifies the intended defaults before migrations on fresh databases. Supabase CLI reads that file as the unprivileged local `postgres` role, so the migration-replay job starts an empty emulator without migrations, applies the file once as `supabase_admin`, and then applies every migration with `supabase migration up --local` in the same fresh database. This preserves the database-scoped defaults without granting reserved role membership. `20260717161000_assert_supabase_admin_default_privileges.sql` evaluates effective defaults from `pg_default_acl` plus `acldefault`, so a missing catalog row cannot hide PostgreSQL's built-in `PUBLIC EXECUTE` on functions. `20260717173000_reassert_supabase_admin_default_privileges.sql` repeats that fail-closed postcondition after the later performance migrations and must remain the final migration. Existing hosted databases do not receive `roles.sql` through ordinary migration deployment; after an authorized hosted apply, run the read-only provider check with `npm run check:default-acl -- --confirm-provider-read`. This command contacts the live Supabase project and therefore requires explicit provider approval. The future-object probe in `20260713102000_revoke_supabase_admin_default_privileges.sql` remains useful in environments that can assume `supabase_admin`. +- **Hosted migration-role default privileges (audit finding 7):** `supabase/roles.sql` establishes and verifies secure `postgres` defaults before fresh local migration replay. `20260719055541_assert_postgres_default_privileges.sql` evaluates effective defaults from `pg_default_acl` plus `acldefault`, so a missing catalog row cannot hide PostgreSQL's built-in `PUBLIC EXECUTE` on functions. `20260719055555_reassert_postgres_default_privileges.sql` repeats the fail-closed postcondition, and `20260719055609_repair_postgres_default_privileges.sql` is the forward-only hosted repair immediately before `20260719055623_enforce_public_title_word_scope.sql`. These filenames match the hosted migration history exactly. All active revokes and assertions target objects created by `postgres` in `public`; service-role grants remain least-privilege. Older applied migration history is immutable. After an authorized hosted apply, run the read-only provider check with `npm run check:default-acl -- --confirm-provider-read`. This command contacts the live Supabase project and therefore requires explicit provider approval. - **Legacy rag query text scrub (audit finding 5):** `20260713103000_scrub_legacy_rag_query_text.sql` performs four redaction/deletion operations for pre-HMAC plaintext query text: (1) scrubs `rag_queries.query` rows not matching `redacted-query:%`, replacing them with salted `redacted-query:legacy:` placeholders; (2) scrubs both `rag_query_misses.query` and `rag_query_misses.normalized_query` not matching `redacted-query:%`; (3) scrubs both `rag_retrieval_logs.query` and `rag_retrieval_logs.normalized_query` not matching `redacted-query:%` (nullable); (4) deletes `rag_response_cache` rows where `normalized_query` does not match `redacted-cache:%` (cache entries, not re-keyed). **Operator verification after live apply:** for each affected table/operation, count rows not matching the expected redacted pattern (expect 0 unless `RAG_PERSIST_RAW_QUERY_TEXT` is deliberately enabled): `select count(*) from rag_queries where query not like 'redacted-query:%';` (expect 0), `select count(*) from rag_query_misses where query not like 'redacted-query:%' or normalized_query not like 'redacted-query:%';` (expect 0), `select count(*) from rag_retrieval_logs where query not like 'redacted-query:%' or (normalized_query is not null and normalized_query not like 'redacted-query:%');` (expect 0), `select count(*) from rag_response_cache where normalized_query not like 'redacted-cache:%';` (expect 0). The migration includes a post-apply assertion block that enforces these exact checks and fails the migration if any unscrubbed/undeleted rows remain. - Remaining operator items from the 2026-07-13 audit (confirmation-required, not automated): apply this batch's migrations live, re-run Supabase advisors, trigger the Eval Canary and require two consecutive green scheduled runs, repair the invalid `storage.idx_objects_bucket_id_name_lower` index via dashboard/support, and dedupe response-cache purge cron jobs if `cron.job` shows duplicates. diff --git a/scripts/check-default-acl.ts b/scripts/check-default-acl.ts index 7edff312b..2d68019f9 100644 --- a/scripts/check-default-acl.ts +++ b/scripts/check-default-acl.ts @@ -5,7 +5,7 @@ function argument(name: string, fallback: string) { return index >= 0 && process.argv[index + 1] ? process.argv[index + 1]! : fallback; } -const roleName = argument("--role", "supabase_admin"); +const roleName = argument("--role", "postgres"); const schemaName = argument("--schema", "public"); if (!process.argv.includes("--confirm-provider-read")) { throw new Error("Refusing provider-backed ACL verification without --confirm-provider-read."); diff --git a/scripts/generate-drift-manifest.ts b/scripts/generate-drift-manifest.ts index 74f0d2448..632c4f72c 100644 --- a/scripts/generate-drift-manifest.ts +++ b/scripts/generate-drift-manifest.ts @@ -95,8 +95,8 @@ async function main() { sql, ); - console.log("Applying role bootstrap (supabase_admin)…"); - psql("supabase_admin", rolesSql); + console.log("Applying role bootstrap (postgres)…"); + psql("postgres", rolesSql); console.log("Applying storage scaffold (supabase_admin)…"); psql("supabase_admin", scaffoldSql); console.log("Replaying supabase/schema.sql from scratch (postgres)…"); diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index bbc83f94e..847477990 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,9 +1,9 @@ { - "generated_at": "2026-07-18T14:23:15.039Z", + "generated_at": "2026-07-19T06:37:36.934Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127", - "schema_sha256": "3a2bd3f0de8dd1d16857100556c9bee604b063eedcb9eb5e7ab959e4799d28f0", - "replay_seconds": 20, + "schema_sha256": "f7c2f22f13422e0d884e796c29a1aa7644b6350b073f95a7fcae0801dcce8a2f", + "replay_seconds": 28, "snapshot": { "views": [ { @@ -15,7 +15,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "api_rate_limit_subjects", "columns": [ @@ -67,7 +67,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "api_rate_limits", "columns": [ @@ -119,7 +119,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "audit_logs", "columns": [ @@ -187,7 +187,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "clinical_registry_record_sources", "columns": [ @@ -247,7 +247,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "clinical_registry_records", "columns": [ @@ -507,7 +507,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "differential_records", "columns": [ @@ -655,7 +655,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_chunks", "columns": [ @@ -824,7 +824,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_embedding_fields", "columns": [ @@ -935,7 +935,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_images", "columns": [ @@ -1168,7 +1168,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_index_quality", "columns": [ @@ -1284,7 +1284,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_index_units", "columns": [ @@ -1480,7 +1480,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_labels", "columns": [ @@ -1577,7 +1577,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_memory_cards", "columns": [ @@ -1749,7 +1749,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_pages", "columns": [ @@ -1822,7 +1822,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_publication_approvals", "columns": [ @@ -1906,7 +1906,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_sections", "columns": [ @@ -2062,7 +2062,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_summaries", "columns": [ @@ -2170,7 +2170,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_table_facts", "columns": [ @@ -2315,7 +2315,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "document_title_words", "columns": [ @@ -2343,7 +2343,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "documents", "columns": [ @@ -2531,7 +2531,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "image_caption_cache", "columns": [ @@ -2615,7 +2615,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "import_batches", "columns": [ @@ -2747,7 +2747,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "indexing_v3_agent_jobs", "columns": [ @@ -2871,7 +2871,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "ingestion_job_stages", "columns": [ @@ -2987,7 +2987,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "ingestion_jobs", "columns": [ @@ -3127,7 +3127,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "medication_records", "columns": [ @@ -3291,7 +3291,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "rag_aliases", "columns": [ @@ -3383,7 +3383,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "rag_answer_feedback", "columns": [ @@ -3483,7 +3483,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "rag_queries", "columns": [ @@ -3559,7 +3559,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "rag_query_misses", "columns": [ @@ -3779,7 +3779,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "rag_response_cache", "columns": [ @@ -3879,7 +3879,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "rag_retrieval_logs", "columns": [ @@ -4123,7 +4123,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "rag_visual_eval_cases", "columns": [ @@ -4231,7 +4231,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "rag_visual_eval_runs", "columns": [ @@ -4315,7 +4315,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "source_review_events", "columns": [ @@ -4431,7 +4431,7 @@ { "acl": [ "postgres=arwdDxtm/postgres", - "service_role=arwdDxtm/postgres" + "service_role=arwd/postgres" ], "name": "storage_cleanup_jobs", "columns": [ @@ -6567,7 +6567,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "a6f0e7c27da08e47e2c01dce4ab5d58d", + "def_hash": "f0835bb6f4314fb9704af9ff56950d58", "signature": "public.default_privileges_status(text,text)" }, { @@ -6899,7 +6899,6 @@ }, { "acl": [ - "=X/postgres", "postgres=X/postgres", "service_role=X/postgres" ], diff --git a/supabase/migrations/20260717161000_assert_supabase_admin_default_privileges.sql b/supabase/migrations/20260717161000_assert_supabase_admin_default_privileges.sql deleted file mode 100644 index ea4e91025..000000000 --- a/supabase/migrations/20260717161000_assert_supabase_admin_default_privileges.sql +++ /dev/null @@ -1,179 +0,0 @@ --- Catalog-level, fail-closed verification for future objects created by --- supabase_admin. A missing pg_default_acl row must be interpreted through --- acldefault(), including PostgreSQL's built-in PUBLIC EXECUTE on functions. - -create or replace function public.default_privileges_status( - p_role_name text default 'supabase_admin', - p_schema_name text default 'public' -) -returns jsonb -language plpgsql -stable -security definer -set search_path = '' -as $$ -declare - v_role_oid oid; - v_namespace_oid oid; - v_entries text[] := '{}'::text[]; - v_safe boolean := false; - v_has_unexpected_grantee boolean := false; -begin - select oid into v_role_oid from pg_catalog.pg_roles where rolname = p_role_name; - select oid into v_namespace_oid from pg_catalog.pg_namespace where nspname = p_schema_name; - - if v_role_oid is null or v_namespace_oid is null then - return jsonb_build_object( - 'role_exists', v_role_oid is not null, - 'schema_exists', v_namespace_oid is not null, - 'safe', false, - 'entries', '[]'::jsonb - ); - end if; - - with object_types(object_type, object_code) as ( - values ('table'::text, 'r'::"char"), ('sequence'::text, 'S'::"char"), ('function'::text, 'f'::"char") - ), effective_acls as ( - select - ot.object_type, - coalesce(global_acl.defaclacl, pg_catalog.acldefault(ot.object_code, v_role_oid)) - || coalesce(schema_acl.defaclacl, '{}'::aclitem[]) as acl - from object_types ot - left join pg_catalog.pg_default_acl global_acl - on global_acl.defaclrole = v_role_oid - and global_acl.defaclnamespace = 0 - and global_acl.defaclobjtype = ot.object_code - left join pg_catalog.pg_default_acl schema_acl - on schema_acl.defaclrole = v_role_oid - and schema_acl.defaclnamespace = v_namespace_oid - and schema_acl.defaclobjtype = ot.object_code - ), exploded as ( - select distinct - ea.object_type, - case when privilege.grantee = 0 then 'PUBLIC' else grantee.rolname end as grantee, - lower(privilege.privilege_type) as privilege_type - from effective_acls ea - cross join lateral pg_catalog.aclexplode(ea.acl) privilege - left join pg_catalog.pg_roles grantee on grantee.oid = privilege.grantee - ) - select - coalesce( - array_agg(format('%s:%s:%s', object_type, grantee, privilege_type) - order by object_type, grantee, privilege_type), - '{}'::text[] - ), - coalesce(bool_or(grantee not in (p_role_name, 'postgres', 'service_role')), false) - into v_entries, v_has_unexpected_grantee - from exploded; - - v_safe := - not v_has_unexpected_grantee - and not exists ( - select 1 from unnest(v_entries) entry - where entry like 'table:PUBLIC:%' - or entry like 'table:anon:%' - or entry like 'table:authenticated:%' - or entry like 'sequence:PUBLIC:%' - or entry like 'sequence:anon:%' - or entry like 'sequence:authenticated:%' - or entry = 'function:PUBLIC:execute' - or entry like 'function:anon:%' - or entry like 'function:authenticated:%' - ) - and 'table:service_role:select' = any(v_entries) - and 'table:service_role:insert' = any(v_entries) - and 'table:service_role:update' = any(v_entries) - and 'table:service_role:delete' = any(v_entries) - and 'sequence:service_role:usage' = any(v_entries) - and 'sequence:service_role:select' = any(v_entries) - and 'function:service_role:execute' = any(v_entries) - and not exists ( - select 1 from unnest(v_entries) entry - where entry like 'table:service_role:%' - and entry <> all(array[ - 'table:service_role:select', 'table:service_role:insert', - 'table:service_role:update', 'table:service_role:delete' - ]) - ) - and not exists ( - select 1 from unnest(v_entries) entry - where entry like 'sequence:service_role:%' - and entry <> all(array['sequence:service_role:usage', 'sequence:service_role:select']) - ) - and not exists ( - select 1 from unnest(v_entries) entry - where entry like 'function:service_role:%' - and entry <> 'function:service_role:execute' - ); - - return jsonb_build_object( - 'role_exists', true, - 'schema_exists', true, - 'safe', v_safe, - 'entries', to_jsonb(v_entries) - ); -end; -$$; - -revoke all on function public.default_privileges_status(text, text) - from public, anon, authenticated; -grant execute on function public.default_privileges_status(text, text) - to service_role; - -do $$ -declare - v_status jsonb; -begin - if not exists (select 1 from pg_catalog.pg_roles where rolname = 'supabase_admin') then - raise notice 'role supabase_admin does not exist; default-privilege assertion is not applicable'; - return; - end if; - - begin - -- Local/Superuser-capable environments can assume the target role even - -- when the migration role cannot use ALTER DEFAULT PRIVILEGES FOR ROLE - -- directly. Hosted environments that cannot assume it fall through to the - -- catalog assertion and block with operator instructions. - execute 'set local role supabase_admin'; - -- Revokes must be global: per-schema ACLs cannot subtract privileges from - -- built-in or previously granted global defaults. - alter default privileges for role supabase_admin - revoke all privileges on tables from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke all privileges on tables from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin - revoke all privileges on sequences from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke all privileges on sequences from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin - revoke execute on functions from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke execute on functions from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - grant select, insert, update, delete on tables to service_role; - alter default privileges for role supabase_admin in schema public - grant usage, select on sequences to service_role; - alter default privileges for role supabase_admin in schema public - grant execute on functions to service_role; - execute 'reset role'; - exception when insufficient_privilege then - begin execute 'reset role'; exception when others then null; end; - raise notice 'current role % cannot remediate supabase_admin default privileges; asserting the catalog postcondition', current_user; - end; - - v_status := public.default_privileges_status('supabase_admin', 'public'); - if not coalesce((v_status->>'safe')::boolean, false) then - raise exception using - errcode = '42501', - message = 'Unsafe supabase_admin default privileges; migration blocked.', - detail = v_status::text, - hint = E'Run these six statements as supabase_admin, then retry the migration:\n' - 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE ALL PRIVILEGES ON TABLES FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL PRIVILEGES ON TABLES FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n' - 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE ALL PRIVILEGES ON SEQUENCES FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL PRIVILEGES ON SEQUENCES FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n' - 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n' - 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO service_role;\n' - 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO service_role;\n' - 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO service_role;'; - end if; -end; -$$; diff --git a/supabase/migrations/20260717173000_reassert_supabase_admin_default_privileges.sql b/supabase/migrations/20260719055541_assert_postgres_default_privileges.sql similarity index 51% rename from supabase/migrations/20260717173000_reassert_supabase_admin_default_privileges.sql rename to supabase/migrations/20260719055541_assert_postgres_default_privileges.sql index b8ee1c8c8..09e8c9bc0 100644 --- a/supabase/migrations/20260717173000_reassert_supabase_admin_default_privileges.sql +++ b/supabase/migrations/20260719055541_assert_postgres_default_privileges.sql @@ -1,11 +1,10 @@ --- Reassert the fail-closed default-ACL postcondition after the later --- performance migrations so this safety check remains the final migration. --- Catalog-level, fail-closed verification for future objects created by --- supabase_admin. A missing pg_default_acl row must be interpreted through --- acldefault(), including PostgreSQL's built-in PUBLIC EXECUTE on functions. +-- Catalog-level, fail-closed verification for future objects created by the +-- hosted migration role. A missing pg_default_acl row must be interpreted +-- through acldefault(), including PostgreSQL's built-in PUBLIC EXECUTE on +-- functions. create or replace function public.default_privileges_status( - p_role_name text default 'supabase_admin', + p_role_name text default 'postgres', p_schema_name text default 'public' ) returns jsonb @@ -66,7 +65,7 @@ begin order by object_type, grantee, privilege_type), '{}'::text[] ), - coalesce(bool_or(grantee not in (p_role_name, 'postgres', 'service_role')), false), + coalesce(bool_or(grantee not in (p_role_name, 'service_role')), false), coalesce(bool_or(is_grantable), false) into v_entries, v_has_unexpected_grantee, v_has_grantable from exploded; @@ -120,66 +119,43 @@ begin ); end; $$; - revoke all on function public.default_privileges_status(text, text) from public, anon, authenticated; grant execute on function public.default_privileges_status(text, text) to service_role; +-- Global revokes remove built-in/default grants that per-schema ACLs cannot +-- subtract. Explicit application grants remain scoped to schema public. +alter default privileges for role postgres + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + grant select, insert, update, delete on tables to service_role; +alter default privileges for role postgres in schema public + grant usage, select on sequences to service_role; +alter default privileges for role postgres in schema public + grant execute on functions to service_role; + do $$ declare v_status jsonb; begin - if not exists (select 1 from pg_catalog.pg_roles where rolname = 'supabase_admin') then - raise notice 'role supabase_admin does not exist; default-privilege assertion is not applicable'; - return; - end if; - - begin - -- Local/Superuser-capable environments can assume the target role even - -- when the migration role cannot use ALTER DEFAULT PRIVILEGES FOR ROLE - -- directly. Hosted environments that cannot assume it fall through to the - -- catalog assertion and block with operator instructions. - execute 'set local role supabase_admin'; - -- Revokes must be global: per-schema ACLs cannot subtract privileges from - -- built-in or previously granted global defaults. - alter default privileges for role supabase_admin - revoke all privileges on tables from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke all privileges on tables from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin - revoke all privileges on sequences from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke all privileges on sequences from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin - revoke execute on functions from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke execute on functions from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - grant select, insert, update, delete on tables to service_role; - alter default privileges for role supabase_admin in schema public - grant usage, select on sequences to service_role; - alter default privileges for role supabase_admin in schema public - grant execute on functions to service_role; - execute 'reset role'; - exception when insufficient_privilege then - begin execute 'reset role'; exception when others then null; end; - raise notice 'current role % cannot remediate supabase_admin default privileges; asserting the catalog postcondition', current_user; - end; - - v_status := public.default_privileges_status('supabase_admin', 'public'); + v_status := public.default_privileges_status('postgres', 'public'); if not coalesce((v_status->>'safe')::boolean, false) then raise exception using errcode = '42501', - message = 'Unsafe supabase_admin default privileges; migration blocked.', + message = 'Unsafe postgres default privileges in schema public; migration blocked.', detail = v_status::text, - hint = E'Run these six statements as supabase_admin, then retry the migration:\n' - 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE ALL PRIVILEGES ON TABLES FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL PRIVILEGES ON TABLES FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n' - 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE ALL PRIVILEGES ON SEQUENCES FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL PRIVILEGES ON SEQUENCES FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n' - 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n' - 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO service_role;\n' - 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO service_role;\n' - 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO service_role;'; + hint = 'Reapply the postgres default-privilege repair and retry the migration.'; end if; end; $$; diff --git a/supabase/migrations/20260719055555_reassert_postgres_default_privileges.sql b/supabase/migrations/20260719055555_reassert_postgres_default_privileges.sql new file mode 100644 index 000000000..f0c04bacf --- /dev/null +++ b/supabase/migrations/20260719055555_reassert_postgres_default_privileges.sql @@ -0,0 +1,37 @@ +-- Reassert the fail-closed default-ACL postcondition after later migrations. +-- Every statement is safe to rerun and is executable by hosted migrations, +-- which create public-schema objects as postgres. + +alter default privileges for role postgres + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + grant select, insert, update, delete on tables to service_role; +alter default privileges for role postgres in schema public + grant usage, select on sequences to service_role; +alter default privileges for role postgres in schema public + grant execute on functions to service_role; + +do $$ +declare + v_status jsonb; +begin + v_status := public.default_privileges_status('postgres', 'public'); + if not coalesce((v_status->>'safe')::boolean, false) then + raise exception using + errcode = '42501', + message = 'Unsafe postgres default privileges in schema public; reassertion blocked.', + detail = v_status::text, + hint = 'Reapply the postgres default-privilege repair and retry the migration.'; + end if; +end; +$$; diff --git a/supabase/migrations/20260719055609_repair_postgres_default_privileges.sql b/supabase/migrations/20260719055609_repair_postgres_default_privileges.sql new file mode 100644 index 000000000..73452e002 --- /dev/null +++ b/supabase/migrations/20260719055609_repair_postgres_default_privileges.sql @@ -0,0 +1,158 @@ +-- Forward-only hosted repair for secure future-object defaults. This migration +-- is intentionally self-contained because the earlier assertion failed before +-- its function or ACL changes could commit in production. + +create or replace function public.default_privileges_status( + p_role_name text default 'postgres', + p_schema_name text default 'public' +) +returns jsonb +language plpgsql +stable +security definer +set search_path = '' +as $$ +declare + v_role_oid oid; + v_namespace_oid oid; + v_entries text[] := '{}'::text[]; + v_safe boolean := false; + v_has_unexpected_grantee boolean := false; + v_has_grantable boolean := false; +begin + select oid into v_role_oid from pg_catalog.pg_roles where rolname = p_role_name; + select oid into v_namespace_oid from pg_catalog.pg_namespace where nspname = p_schema_name; + + if v_role_oid is null or v_namespace_oid is null then + return jsonb_build_object( + 'role_exists', v_role_oid is not null, + 'schema_exists', v_namespace_oid is not null, + 'safe', false, + 'entries', '[]'::jsonb + ); + end if; + + with object_types(object_type, object_code) as ( + values ('table'::text, 'r'::"char"), ('sequence'::text, 'S'::"char"), ('function'::text, 'f'::"char") + ), effective_acls as ( + select + ot.object_type, + coalesce(global_acl.defaclacl, pg_catalog.acldefault(ot.object_code, v_role_oid)) + || coalesce(schema_acl.defaclacl, '{}'::aclitem[]) as acl + from object_types ot + left join pg_catalog.pg_default_acl global_acl + on global_acl.defaclrole = v_role_oid + and global_acl.defaclnamespace = 0 + and global_acl.defaclobjtype = ot.object_code + left join pg_catalog.pg_default_acl schema_acl + on schema_acl.defaclrole = v_role_oid + and schema_acl.defaclnamespace = v_namespace_oid + and schema_acl.defaclobjtype = ot.object_code + ), exploded as ( + select distinct + ea.object_type, + case when privilege.grantee = 0 then 'PUBLIC' else grantee.rolname end as grantee, + lower(privilege.privilege_type) as privilege_type, + privilege.is_grantable + from effective_acls ea + cross join lateral pg_catalog.aclexplode(ea.acl) privilege + left join pg_catalog.pg_roles grantee on grantee.oid = privilege.grantee + ) + select + coalesce( + array_agg(format('%s:%s:%s', object_type, grantee, privilege_type) + order by object_type, grantee, privilege_type), + '{}'::text[] + ), + coalesce(bool_or(grantee not in (p_role_name, 'service_role')), false), + coalesce(bool_or(is_grantable), false) + into v_entries, v_has_unexpected_grantee, v_has_grantable + from exploded; + + v_safe := + not v_has_unexpected_grantee + and not v_has_grantable + and not exists ( + select 1 from unnest(v_entries) entry + where entry like 'table:PUBLIC:%' + or entry like 'table:anon:%' + or entry like 'table:authenticated:%' + or entry like 'sequence:PUBLIC:%' + or entry like 'sequence:anon:%' + or entry like 'sequence:authenticated:%' + or entry = 'function:PUBLIC:execute' + or entry like 'function:anon:%' + or entry like 'function:authenticated:%' + ) + and 'table:service_role:select' = any(v_entries) + and 'table:service_role:insert' = any(v_entries) + and 'table:service_role:update' = any(v_entries) + and 'table:service_role:delete' = any(v_entries) + and 'sequence:service_role:usage' = any(v_entries) + and 'sequence:service_role:select' = any(v_entries) + and 'function:service_role:execute' = any(v_entries) + and not exists ( + select 1 from unnest(v_entries) entry + where entry like 'table:service_role:%' + and entry <> all(array[ + 'table:service_role:select', 'table:service_role:insert', + 'table:service_role:update', 'table:service_role:delete' + ]) + ) + and not exists ( + select 1 from unnest(v_entries) entry + where entry like 'sequence:service_role:%' + and entry <> all(array['sequence:service_role:usage', 'sequence:service_role:select']) + ) + and not exists ( + select 1 from unnest(v_entries) entry + where entry like 'function:service_role:%' + and entry <> 'function:service_role:execute' + ); + + return jsonb_build_object( + 'role_exists', true, + 'schema_exists', true, + 'safe', v_safe, + 'entries', to_jsonb(v_entries) + ); +end; +$$; +revoke all on function public.default_privileges_status(text, text) + from public, anon, authenticated; +grant execute on function public.default_privileges_status(text, text) + to service_role; + +alter default privileges for role postgres + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + grant select, insert, update, delete on tables to service_role; +alter default privileges for role postgres in schema public + grant usage, select on sequences to service_role; +alter default privileges for role postgres in schema public + grant execute on functions to service_role; + +do $$ +declare + v_status jsonb; +begin + v_status := public.default_privileges_status('postgres', 'public'); + if not coalesce((v_status->>'safe')::boolean, false) then + raise exception using + errcode = '42501', + message = 'Unsafe postgres default privileges in schema public; repair blocked.', + detail = v_status::text, + hint = 'Rerun this idempotent repair after correcting postgres default privileges.'; + end if; +end; +$$; diff --git a/supabase/migrations/20260718223000_enforce_public_title_word_scope.sql b/supabase/migrations/20260719055623_enforce_public_title_word_scope.sql similarity index 88% rename from supabase/migrations/20260718223000_enforce_public_title_word_scope.sql rename to supabase/migrations/20260719055623_enforce_public_title_word_scope.sql index 3d4afb75f..73bd96621 100644 --- a/supabase/migrations/20260718223000_enforce_public_title_word_scope.sql +++ b/supabase/migrations/20260719055623_enforce_public_title_word_scope.sql @@ -39,7 +39,6 @@ begin end if; end; $$; - create or replace function public.enforce_document_title_word_scope() returns trigger language plpgsql @@ -66,7 +65,6 @@ begin return new; end; $$; - revoke execute on function public.enforce_document_title_word_scope() from public, anon, authenticated, service_role; @@ -142,25 +140,19 @@ revoke execute on function public.correct_clinical_query_terms(text, real) grant execute on function public.correct_clinical_query_terms(text, real) to service_role; --- 20260717173000 establishes this as the fail-closed default-ACL invariant. --- This newer migration creates a function, so reassert that the invariant still --- holds before the transaction can commit. +-- The immediately preceding repair establishes this fail-closed default-ACL +-- invariant. This migration creates a function, so reassert it before commit. do $$ declare v_status jsonb; begin - if not exists (select 1 from pg_catalog.pg_roles where rolname = 'supabase_admin') then - raise notice 'role supabase_admin does not exist; default-privilege assertion is not applicable'; - return; - end if; - - v_status := public.default_privileges_status('supabase_admin', 'public'); + v_status := public.default_privileges_status('postgres', 'public'); if not coalesce((v_status->>'safe')::boolean, false) then raise exception using errcode = '42501', - message = 'Unsafe supabase_admin default privileges; title-word privacy migration blocked.', + message = 'Unsafe postgres default privileges in schema public; title-word privacy migration blocked.', detail = v_status::text, - hint = 'Reapply the default-privilege remediation in migration 20260717173000, then retry.'; + hint = 'Reapply migration 20260719055609_repair_postgres_default_privileges, then retry.'; end if; end; $$; diff --git a/supabase/roles.sql b/supabase/roles.sql index c153381ec..ff730d548 100644 --- a/supabase/roles.sql +++ b/supabase/roles.sql @@ -1,8 +1,6 @@ --- Bootstrap safe supabase_admin future-object defaults before migrations in --- fresh local databases. The Supabase CLI reads this file as `postgres`, which --- cannot alter the reserved supabase_admin role. Local replay therefore applies --- this file once as supabase_admin before reset; subsequent CLI reads verify the --- durable catalog postcondition and become no-ops. +-- Bootstrap secure future-object defaults for migrations and dashboard SQL +-- that create objects as postgres. The Supabase CLI and hosted migration +-- runner can execute this file without assuming another role. do $$ declare @@ -11,39 +9,33 @@ declare v_entries text[] := '{}'::text[]; v_safe boolean := false; v_has_unexpected_grantee boolean := false; + v_has_grantable boolean := false; begin - select oid into v_role_oid from pg_catalog.pg_roles where rolname = 'supabase_admin'; + select oid into v_role_oid from pg_catalog.pg_roles where rolname = 'postgres'; select oid into v_namespace_oid from pg_catalog.pg_namespace where nspname = 'public'; if v_role_oid is null or v_namespace_oid is null then - raise exception 'supabase_admin and public schema are required for default-privilege bootstrap'; + raise exception 'postgres role and public schema are required for default-privilege bootstrap'; end if; - begin - alter default privileges for role supabase_admin - revoke all privileges on tables from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke all privileges on tables from public, anon, authenticated, service_role; - - alter default privileges for role supabase_admin - revoke all privileges on sequences from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke all privileges on sequences from public, anon, authenticated, service_role; - - alter default privileges for role supabase_admin - revoke execute on functions from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke execute on functions from public, anon, authenticated, service_role; - - alter default privileges for role supabase_admin in schema public - grant select, insert, update, delete on tables to service_role; - alter default privileges for role supabase_admin in schema public - grant usage, select on sequences to service_role; - alter default privileges for role supabase_admin in schema public - grant execute on functions to service_role; - exception when insufficient_privilege then - null; - end; + alter default privileges for role postgres + revoke all privileges on tables from public, anon, authenticated, service_role; + alter default privileges for role postgres in schema public + revoke all privileges on tables from public, anon, authenticated, service_role; + alter default privileges for role postgres + revoke all privileges on sequences from public, anon, authenticated, service_role; + alter default privileges for role postgres in schema public + revoke all privileges on sequences from public, anon, authenticated, service_role; + alter default privileges for role postgres + revoke execute on functions from public, anon, authenticated, service_role; + alter default privileges for role postgres in schema public + revoke execute on functions from public, anon, authenticated, service_role; + alter default privileges for role postgres in schema public + grant select, insert, update, delete on tables to service_role; + alter default privileges for role postgres in schema public + grant usage, select on sequences to service_role; + alter default privileges for role postgres in schema public + grant execute on functions to service_role; with object_types(object_type, object_code) as ( values ('table'::text, 'r'::"char"), ('sequence'::text, 'S'::"char"), ('function'::text, 'f'::"char") @@ -65,7 +57,8 @@ begin select distinct ea.object_type, case when privilege.grantee = 0 then 'PUBLIC' else grantee.rolname end as grantee, - lower(privilege.privilege_type) as privilege_type + lower(privilege.privilege_type) as privilege_type, + privilege.is_grantable from effective_acls ea cross join lateral pg_catalog.aclexplode(ea.acl) privilege left join pg_catalog.pg_roles grantee on grantee.oid = privilege.grantee @@ -76,12 +69,14 @@ begin order by object_type, grantee, privilege_type), '{}'::text[] ), - coalesce(bool_or(grantee not in ('supabase_admin', 'postgres', 'service_role')), false) - into v_entries, v_has_unexpected_grantee + coalesce(bool_or(grantee not in ('postgres', 'service_role')), false), + coalesce(bool_or(is_grantable), false) + into v_entries, v_has_unexpected_grantee, v_has_grantable from exploded; v_safe := not v_has_unexpected_grantee + and not v_has_grantable and not exists ( select 1 from unnest(v_entries) entry where entry like 'table:PUBLIC:%' @@ -123,7 +118,7 @@ begin if not v_safe then raise exception using errcode = '42501', - message = 'Unsafe supabase_admin default privileges; bootstrap must run as supabase_admin.', + message = 'Unsafe postgres default privileges in schema public.', detail = to_jsonb(v_entries)::text; end if; end; diff --git a/supabase/schema.sql b/supabase/schema.sql index 86c77674c..afae18911 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -4987,37 +4987,26 @@ alter default privileges for role postgres in schema public alter default privileges for role postgres in schema public grant execute on functions to service_role; --- Default privileges are keyed to the creating role, so the postgres block --- above does not cover objects created by supabase_admin (dashboard SQL --- editor, platform tooling). Guarded because only a superuser or a member of --- supabase_admin may alter its defaults (2026-07-13 audit, finding 7). -do $$ -begin - if not exists (select 1 from pg_roles where rolname = 'supabase_admin') then - raise warning 'role supabase_admin does not exist; default-privilege hardening skipped'; - return; - end if; - - begin - alter default privileges for role supabase_admin in schema public - revoke all privileges on tables from anon, authenticated; - alter default privileges for role supabase_admin in schema public - revoke usage, select on sequences from anon, authenticated; - alter default privileges for role supabase_admin in schema public - revoke execute on functions from public, anon, authenticated; - alter default privileges for role supabase_admin in schema public - grant select, insert, update, delete on tables to service_role; - alter default privileges for role supabase_admin in schema public - grant usage, select on sequences to service_role; - alter default privileges for role supabase_admin in schema public - grant execute on functions to service_role; - exception - when insufficient_privilege then - raise warning 'cannot alter default privileges for supabase_admin as %; ' - 'operator follow-up required: run the six ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin ' - 'statements from migration 20260713102000 via the Supabase dashboard SQL editor', current_user; - end; -end $$; +-- Reassert the creating role's future-object defaults. Global revokes remove +-- built-in/default grants; application grants stay scoped to schema public. +alter default privileges for role postgres + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + grant select, insert, update, delete on tables to service_role; +alter default privileges for role postgres in schema public + grant usage, select on sequences to service_role; +alter default privileges for role postgres in schema public + grant execute on functions to service_role; -- Performance remediation: registry projection lifecycle cleanup. create index if not exists documents_registry_projection_lookup_idx @@ -8229,11 +8218,11 @@ revoke all on function public.retry_ingestion_job_if_idle(uuid, uuid, timestampt grant execute on function public.retry_ingestion_job_if_idle(uuid, uuid, timestamptz, integer, timestamptz, timestamptz) to service_role; -- Catalog-level, fail-closed verification for future objects created by --- supabase_admin. A missing pg_default_acl row must be interpreted through +-- postgres. A missing pg_default_acl row must be interpreted through -- acldefault(), including PostgreSQL's built-in PUBLIC EXECUTE on functions. create or replace function public.default_privileges_status( - p_role_name text default 'supabase_admin', + p_role_name text default 'postgres', p_schema_name text default 'public' ) returns jsonb @@ -8248,6 +8237,7 @@ declare v_entries text[] := '{}'::text[]; v_safe boolean := false; v_has_unexpected_grantee boolean := false; + v_has_grantable boolean := false; begin select oid into v_role_oid from pg_catalog.pg_roles where rolname = p_role_name; select oid into v_namespace_oid from pg_catalog.pg_namespace where nspname = p_schema_name; @@ -8281,7 +8271,8 @@ begin select distinct ea.object_type, case when privilege.grantee = 0 then 'PUBLIC' else grantee.rolname end as grantee, - lower(privilege.privilege_type) as privilege_type + lower(privilege.privilege_type) as privilege_type, + privilege.is_grantable from effective_acls ea cross join lateral pg_catalog.aclexplode(ea.acl) privilege left join pg_catalog.pg_roles grantee on grantee.oid = privilege.grantee @@ -8292,12 +8283,14 @@ begin order by object_type, grantee, privilege_type), '{}'::text[] ), - coalesce(bool_or(grantee not in (p_role_name, 'postgres', 'service_role')), false) - into v_entries, v_has_unexpected_grantee + coalesce(bool_or(grantee not in (p_role_name, 'service_role')), false), + coalesce(bool_or(is_grantable), false) + into v_entries, v_has_unexpected_grantee, v_has_grantable from exploded; v_safe := not v_has_unexpected_grantee + and not v_has_grantable and not exists ( select 1 from unnest(v_entries) entry where entry like 'table:PUBLIC:%' @@ -8354,59 +8347,36 @@ do $$ declare v_status jsonb; begin - if not exists (select 1 from pg_catalog.pg_roles where rolname = 'supabase_admin') then - raise notice 'role supabase_admin does not exist; default-privilege assertion is not applicable'; - return; - end if; - - begin - -- Local/Superuser-capable environments can assume the target role even - -- when the migration role cannot use ALTER DEFAULT PRIVILEGES FOR ROLE - -- directly. Hosted environments that cannot assume it fall through to the - -- catalog assertion and block with operator instructions. - execute 'set local role supabase_admin'; - -- Revokes must be global: per-schema ACLs cannot subtract privileges from - -- built-in or previously granted global defaults. - alter default privileges for role supabase_admin - revoke all privileges on tables from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke all privileges on tables from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin - revoke all privileges on sequences from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke all privileges on sequences from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin - revoke execute on functions from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke execute on functions from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - grant select, insert, update, delete on tables to service_role; - alter default privileges for role supabase_admin in schema public - grant usage, select on sequences to service_role; - alter default privileges for role supabase_admin in schema public - grant execute on functions to service_role; - execute 'reset role'; - exception when insufficient_privilege then - begin execute 'reset role'; exception when others then null; end; - raise notice 'current role % cannot remediate supabase_admin default privileges; asserting the catalog postcondition', current_user; - end; - - v_status := public.default_privileges_status('supabase_admin', 'public'); + alter default privileges for role postgres + revoke all privileges on tables from public, anon, authenticated, service_role; + alter default privileges for role postgres in schema public + revoke all privileges on tables from public, anon, authenticated, service_role; + alter default privileges for role postgres + revoke all privileges on sequences from public, anon, authenticated, service_role; + alter default privileges for role postgres in schema public + revoke all privileges on sequences from public, anon, authenticated, service_role; + alter default privileges for role postgres + revoke execute on functions from public, anon, authenticated, service_role; + alter default privileges for role postgres in schema public + revoke execute on functions from public, anon, authenticated, service_role; + alter default privileges for role postgres in schema public + grant select, insert, update, delete on tables to service_role; + alter default privileges for role postgres in schema public + grant usage, select on sequences to service_role; + alter default privileges for role postgres in schema public + grant execute on functions to service_role; + + v_status := public.default_privileges_status('postgres', 'public'); if not coalesce((v_status->>'safe')::boolean, false) then raise exception using errcode = '42501', - message = 'Unsafe supabase_admin default privileges; migration blocked.', + message = 'Unsafe postgres default privileges in schema public; migration blocked.', detail = v_status::text, - hint = E'Run these six statements as supabase_admin, then retry the migration:\n' - 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE ALL PRIVILEGES ON TABLES FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL PRIVILEGES ON TABLES FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n' - 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE ALL PRIVILEGES ON SEQUENCES FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL PRIVILEGES ON SEQUENCES FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n' - 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n' - 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO service_role;\n' - 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO service_role;\n' - 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO service_role;'; + hint = 'Reapply the postgres default-privilege repair and retry the migration.'; end if; end; $$; + drop trigger if exists clinical_registry_records_delete_cleanup on public.clinical_registry_records; create trigger clinical_registry_records_delete_cleanup after delete on public.clinical_registry_records @@ -8589,11 +8559,11 @@ grant execute on function public.consume_summary_rate_limits_atomic( ) to service_role; -- Catalog-level, fail-closed verification for future objects created by --- supabase_admin. A missing pg_default_acl row must be interpreted through +-- postgres. A missing pg_default_acl row must be interpreted through -- acldefault(), including PostgreSQL's built-in PUBLIC EXECUTE on functions. create or replace function public.default_privileges_status( - p_role_name text default 'supabase_admin', + p_role_name text default 'postgres', p_schema_name text default 'public' ) returns jsonb @@ -8654,7 +8624,7 @@ begin order by object_type, grantee, privilege_type), '{}'::text[] ), - coalesce(bool_or(grantee not in (p_role_name, 'postgres', 'service_role')), false), + coalesce(bool_or(grantee not in (p_role_name, 'service_role')), false), coalesce(bool_or(is_grantable), false) into v_entries, v_has_unexpected_grantee, v_has_grantable from exploded; @@ -8718,56 +8688,32 @@ do $$ declare v_status jsonb; begin - if not exists (select 1 from pg_catalog.pg_roles where rolname = 'supabase_admin') then - raise notice 'role supabase_admin does not exist; default-privilege assertion is not applicable'; - return; - end if; - - begin - -- Local/Superuser-capable environments can assume the target role even - -- when the migration role cannot use ALTER DEFAULT PRIVILEGES FOR ROLE - -- directly. Hosted environments that cannot assume it fall through to the - -- catalog assertion and block with operator instructions. - execute 'set local role supabase_admin'; - -- Revokes must be global: per-schema ACLs cannot subtract privileges from - -- built-in or previously granted global defaults. - alter default privileges for role supabase_admin - revoke all privileges on tables from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke all privileges on tables from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin - revoke all privileges on sequences from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke all privileges on sequences from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin - revoke execute on functions from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - revoke execute on functions from public, anon, authenticated, service_role; - alter default privileges for role supabase_admin in schema public - grant select, insert, update, delete on tables to service_role; - alter default privileges for role supabase_admin in schema public - grant usage, select on sequences to service_role; - alter default privileges for role supabase_admin in schema public - grant execute on functions to service_role; - execute 'reset role'; - exception when insufficient_privilege then - begin execute 'reset role'; exception when others then null; end; - raise notice 'current role % cannot remediate supabase_admin default privileges; asserting the catalog postcondition', current_user; - end; - - v_status := public.default_privileges_status('supabase_admin', 'public'); + alter default privileges for role postgres + revoke all privileges on tables from public, anon, authenticated, service_role; + alter default privileges for role postgres in schema public + revoke all privileges on tables from public, anon, authenticated, service_role; + alter default privileges for role postgres + revoke all privileges on sequences from public, anon, authenticated, service_role; + alter default privileges for role postgres in schema public + revoke all privileges on sequences from public, anon, authenticated, service_role; + alter default privileges for role postgres + revoke execute on functions from public, anon, authenticated, service_role; + alter default privileges for role postgres in schema public + revoke execute on functions from public, anon, authenticated, service_role; + alter default privileges for role postgres in schema public + grant select, insert, update, delete on tables to service_role; + alter default privileges for role postgres in schema public + grant usage, select on sequences to service_role; + alter default privileges for role postgres in schema public + grant execute on functions to service_role; + + v_status := public.default_privileges_status('postgres', 'public'); if not coalesce((v_status->>'safe')::boolean, false) then raise exception using errcode = '42501', - message = 'Unsafe supabase_admin default privileges; migration blocked.', + message = 'Unsafe postgres default privileges in schema public; reassertion blocked.', detail = v_status::text, - hint = E'Run these six statements as supabase_admin, then retry the migration:\n' - 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE ALL PRIVILEGES ON TABLES FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL PRIVILEGES ON TABLES FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n' - 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE ALL PRIVILEGES ON SEQUENCES FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL PRIVILEGES ON SEQUENCES FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n' - 'DO $remediate$ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC, anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC, anon, authenticated, service_role; END $remediate$;\n' - 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO service_role;\n' - 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO service_role;\n' - 'ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO service_role;'; + hint = 'Reapply the postgres default-privilege repair and retry the migration.'; end if; end; $$; diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index 96ba27a1d..d943090f8 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -116,10 +116,6 @@ const pinOwnerMatchesV2SearchPathMigration = readFileSync( new URL("../supabase/migrations/20260713101000_pin_retrieval_owner_matches_v2_search_path.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); -const supabaseAdminDefaultPrivilegesMigration = readFileSync( - new URL("../supabase/migrations/20260713102000_revoke_supabase_admin_default_privileges.sql", import.meta.url), - "utf8", -).replace(/\s+/g, " "); const publicationApprovalMigration = readFileSync( new URL("../supabase/migrations/20260717131000_guard_document_publication_approval.sql", import.meta.url), "utf8", @@ -129,7 +125,15 @@ const deleteDocumentIfIdleMigration = readFileSync( "utf8", ).replace(/\s+/g, " "); const defaultAclAssertionMigration = readFileSync( - new URL("../supabase/migrations/20260717173000_reassert_supabase_admin_default_privileges.sql", import.meta.url), + new URL("../supabase/migrations/20260719055541_assert_postgres_default_privileges.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); +const defaultAclReassertionMigration = readFileSync( + new URL("../supabase/migrations/20260719055555_reassert_postgres_default_privileges.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); +const defaultAclRepairMigration = readFileSync( + new URL("../supabase/migrations/20260719055609_repair_postgres_default_privileges.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); const defaultAclRoleBootstrap = readFileSync(new URL("../supabase/roles.sql", import.meta.url), "utf8").replace( @@ -216,7 +220,7 @@ const hardenRagScalabilityPatchMigration = readFileSync( "utf8", ).replace(/\s+/g, " "); const documentTitleWordScopeMigration = readFileSync( - new URL("../supabase/migrations/20260718223000_enforce_public_title_word_scope.sql", import.meta.url), + new URL("../supabase/migrations/20260719055623_enforce_public_title_word_scope.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); const publicTitleCorrectorMigration = readFileSync( @@ -1131,39 +1135,28 @@ describe("Supabase Preview replay guards", () => { ); }); - it("locks down supabase_admin future-object default privileges", () => { - // 2026-07-13 audit finding 7: the 20260528007000 hardening covered role - // postgres only; default privileges are keyed to the creating role. - for (const sql of [schema, supabaseAdminDefaultPrivilegesMigration]) { + it("locks down postgres future-object default privileges", () => { + for (const sql of [ + schema, + defaultAclAssertionMigration, + defaultAclReassertionMigration, + defaultAclRepairMigration, + ]) { expect(sql).toContain( - "alter default privileges for role supabase_admin in schema public revoke all privileges on tables from anon, authenticated;", + "alter default privileges for role postgres in schema public revoke all privileges on tables from public, anon, authenticated, service_role;", ); expect(sql).toContain( - "alter default privileges for role supabase_admin in schema public revoke usage, select on sequences from anon, authenticated;", + "alter default privileges for role postgres in schema public revoke all privileges on sequences from public, anon, authenticated, service_role;", ); expect(sql).toContain( - "alter default privileges for role supabase_admin in schema public revoke execute on functions from public, anon, authenticated;", + "alter default privileges for role postgres in schema public revoke execute on functions from public, anon, authenticated, service_role;", ); expect(sql).toContain( - "alter default privileges for role supabase_admin in schema public grant execute on functions to service_role;", + "alter default privileges for role postgres in schema public grant execute on functions to service_role;", ); - // Guarded: only a superuser or member of supabase_admin may alter its - // defaults. The guard must DEGRADE to a warning, never re-raise — a - // re-raise would abort the whole migration chain on hosted Supabase. - expect(sql).toContain("when insufficient_privilege then"); - expect(sql).not.toContain("raise;"); + expect(sql).not.toContain("set local role"); + expect(sql).toContain("public.default_privileges_status('postgres', 'public')"); } - // The migration also proves the lockdown with future-object probes. - expect(supabaseAdminDefaultPrivilegesMigration).toContain("_defacl_probe_table"); - expect(supabaseAdminDefaultPrivilegesMigration).toContain( - "has_table_privilege('anon', 'public._defacl_probe_table', 'select')", - ); - expect(supabaseAdminDefaultPrivilegesMigration).toContain( - "has_function_privilege('anon', 'public._defacl_probe_fn()', 'execute')", - ); - expect(supabaseAdminDefaultPrivilegesMigration).toContain( - "has_sequence_privilege('anon', probe_seq, 'usage, select')", - ); }); it("requires append-only operator evidence for owned-to-public transitions", () => { @@ -1211,51 +1204,52 @@ describe("Supabase Preview replay guards", () => { } }); - it("fails closed on effective supabase_admin default ACLs", () => { - for (const sql of [schema, defaultAclAssertionMigration]) { + it("fails closed on effective postgres default ACLs", () => { + for (const sql of [schema, defaultAclAssertionMigration, defaultAclRepairMigration]) { expect(sql).toContain("create or replace function public.default_privileges_status("); expect(sql).toContain("pg_catalog.acldefault(ot.object_code, v_role_oid)"); expect(sql).toContain("pg_catalog.aclexplode(ea.acl)"); - expect(sql).toContain("bool_or(grantee not in (p_role_name, 'postgres', 'service_role'))"); + expect(sql).toContain("bool_or(grantee not in (p_role_name, 'service_role'))"); + expect(sql).toContain("bool_or(is_grantable)"); expect(sql).toContain("entry like 'table:PUBLIC:%'"); expect(sql).toContain("entry like 'sequence:PUBLIC:%'"); expect(sql).toContain("entry = 'function:PUBLIC:execute'"); - expect(sql).toContain("message = 'Unsafe supabase_admin default privileges; migration blocked.'"); - expect(sql).toContain("Run these six statements as supabase_admin, then retry the migration:"); + expect(sql).toContain("public.default_privileges_status('postgres', 'public')"); + expect(sql).toContain("Unsafe postgres default privileges in schema public"); } const migrationFiles = readdirSync(migrationDirectoryUrl) .filter((fileName) => /^\d+_.+\.sql$/.test(fileName)) .sort(); - expect(migrationFiles.at(-1)).toBe("20260718223000_enforce_public_title_word_scope.sql"); + expect(migrationFiles.at(-1)).toBe("20260719055623_enforce_public_title_word_scope.sql"); expect(documentTitleWordScopeMigration).toContain( - "v_status := public.default_privileges_status('supabase_admin', 'public')", + "v_status := public.default_privileges_status('postgres', 'public')", ); expect(documentTitleWordScopeMigration).toContain( - "message = 'Unsafe supabase_admin default privileges; title-word privacy migration blocked.'", + "message = 'Unsafe postgres default privileges in schema public; title-word privacy migration blocked.'", ); }); it("bootstraps safe default ACLs before fresh local and preview migration replay", () => { expect(defaultAclRoleBootstrap).toContain( - "alter default privileges for role supabase_admin revoke all privileges on tables from public, anon, authenticated, service_role;", + "alter default privileges for role postgres revoke all privileges on tables from public, anon, authenticated, service_role;", ); expect(defaultAclRoleBootstrap).toContain( - "alter default privileges for role supabase_admin revoke all privileges on sequences from public, anon, authenticated, service_role;", + "alter default privileges for role postgres revoke all privileges on sequences from public, anon, authenticated, service_role;", ); expect(defaultAclRoleBootstrap).toContain( - "alter default privileges for role supabase_admin revoke execute on functions from public, anon, authenticated, service_role;", + "alter default privileges for role postgres revoke execute on functions from public, anon, authenticated, service_role;", ); expect(defaultAclRoleBootstrap).toContain( - "alter default privileges for role supabase_admin in schema public grant select, insert, update, delete on tables to service_role;", + "alter default privileges for role postgres in schema public grant select, insert, update, delete on tables to service_role;", ); expect(defaultAclRoleBootstrap).toContain( - "alter default privileges for role supabase_admin in schema public grant usage, select on sequences to service_role;", + "alter default privileges for role postgres in schema public grant usage, select on sequences to service_role;", ); expect(defaultAclRoleBootstrap).toContain( - "alter default privileges for role supabase_admin in schema public grant execute on functions to service_role;", + "alter default privileges for role postgres in schema public grant execute on functions to service_role;", ); - expect(defaultAclRoleBootstrap).toContain("bool_or(grantee not in ('supabase_admin', 'postgres', 'service_role'))"); + expect(defaultAclRoleBootstrap).toContain("bool_or(grantee not in ('postgres', 'service_role'))"); }); it("scrubs legacy plaintext query text with salted irreversible placeholders", () => { From d31fca51648eb684790b4900b4dce4fa57cd377d Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:47:07 +0800 Subject: [PATCH 2/2] fix(ci): free Supabase image cache during replay --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b23439155..6f00889a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -329,7 +329,10 @@ jobs: - name: Load cached Supabase Docker images run: | if [ -f /tmp/supabase-docker-cache/images.tar ]; then - docker load -i /tmp/supabase-docker-cache/images.tar + if ! docker load -i /tmp/supabase-docker-cache/images.tar; then + echo "::warning::Cached Supabase images exceeded runner disk capacity; discarding the restored tar so supabase start can pull only missing images." + fi + rm -f /tmp/supabase-docker-cache/images.tar fi - name: Start Supabase Local Emulator and bootstrap default ACLs