From d3a99e1eea95be066a7b11bfc0567dead9eaedcf Mon Sep 17 00:00:00 2001 From: Programmable <309941960+0xprogrammable@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:01:57 +0200 Subject: [PATCH 1/3] fix(data-pipeline): admit exact Supabase restore grant --- scripts/data-pipeline/candidate-restore.mjs | 89 +++++++++++++------ .../data-pipeline/candidate-restore.test.mjs | 80 +++++++++++++++++ 2 files changed, 144 insertions(+), 25 deletions(-) diff --git a/scripts/data-pipeline/candidate-restore.mjs b/scripts/data-pipeline/candidate-restore.mjs index 42f7874..9d569cf 100644 --- a/scripts/data-pipeline/candidate-restore.mjs +++ b/scripts/data-pipeline/candidate-restore.mjs @@ -1587,6 +1587,65 @@ function partitionClosure(closure, label) { }); } +export function assertRestoreRolePostureEvidence(identity, roles, memberships) { + const postgresRole = roles.find(({ rolname }) => rolname === "postgres"); + const migrator = roles.find(({ rolname }) => rolname === "programmable_migrator"); + const operatorMemberships = memberships.filter( + ({ + member_role: memberRole, + granted_role: grantedRole, + grantor_role: grantorRole, + inherit_option: inheritOption, + set_option: setOption, + admin_option: adminOption, + }) => + memberRole === "postgres" && + grantedRole === "programmable_migrator" && + grantorRole === "postgres" && + inheritOption === false && + setOption === true && + adminOption === false, + ); + const supabaseMemberships = memberships.filter( + ({ + member_role: memberRole, + granted_role: grantedRole, + grantor_role: grantorRole, + inherit_option: inheritOption, + set_option: setOption, + admin_option: adminOption, + }) => + memberRole === "postgres" && + grantedRole === "programmable_migrator" && + grantorRole === "supabase_admin" && + inheritOption === false && + setOption === false && + adminOption === true, + ); + if ( + !["postgres", "cli_login_postgres"].includes(identity?.session_user) || + identity?.current_user !== "postgres" || + identity?.current_role !== "postgres" || + identity?.can_set_migrator !== true || + roles.length !== 2 || + postgresRole?.rolsuper !== false || + migrator?.rolsuper !== false || + migrator?.rolinherit !== false || + migrator?.rolcreaterole !== false || + migrator?.rolcreatedb !== false || + migrator?.rolcanlogin !== false || + migrator?.rolreplication !== false || + migrator?.rolbypassrls !== false || + operatorMemberships.length !== 1 || + supabaseMemberships.length !== + (identity?.session_user === "cli_login_postgres" ? 1 : 0) || + memberships.length !== + operatorMemberships.length + supabaseMemberships.length + ) { + throw new Error("Candidate restore role posture is not exact"); + } +} + async function assertRestoreRolePosture(sql) { const [identity] = await sql.unsafe(` select session_user::text as session_user, @@ -1606,6 +1665,7 @@ async function assertRestoreRolePosture(sql) { const memberships = await sql.unsafe(` select member_role.rolname as member_role, granted_role.rolname as granted_role, + grantor_role.rolname as grantor_role, membership.inherit_option, membership.set_option, membership.admin_option @@ -1614,34 +1674,13 @@ async function assertRestoreRolePosture(sql) { on member_role.oid = membership.member join pg_catalog.pg_roles as granted_role on granted_role.oid = membership.roleid + join pg_catalog.pg_roles as grantor_role + on grantor_role.oid = membership.grantor where member_role.rolname = 'postgres' and granted_role.rolname = 'programmable_migrator' + order by grantor_role.rolname `); - const postgresRole = roles.find(({ rolname }) => rolname === "postgres"); - const migrator = roles.find(({ rolname }) => rolname === "programmable_migrator"); - if ( - !["postgres", "cli_login_postgres"].includes(identity?.session_user) || - identity?.current_user !== "postgres" || - identity?.current_role !== "postgres" || - identity?.can_set_migrator !== true || - roles.length !== 2 || - postgresRole?.rolsuper !== false || - migrator?.rolsuper !== false || - migrator?.rolinherit !== false || - migrator?.rolcreaterole !== false || - migrator?.rolcreatedb !== false || - migrator?.rolcanlogin !== false || - migrator?.rolreplication !== false || - migrator?.rolbypassrls !== false || - memberships.length !== 1 || - memberships[0]?.member_role !== "postgres" || - memberships[0]?.granted_role !== "programmable_migrator" || - memberships[0]?.inherit_option !== false || - memberships[0]?.set_option !== true || - memberships[0]?.admin_option !== false - ) { - throw new Error("Candidate restore role posture is not exact"); - } + assertRestoreRolePostureEvidence(identity, roles, memberships); } async function assertRestoreSchemasAbsent(sql) { diff --git a/scripts/data-pipeline/candidate-restore.test.mjs b/scripts/data-pipeline/candidate-restore.test.mjs index 282b057..76ed7a7 100644 --- a/scripts/data-pipeline/candidate-restore.test.mjs +++ b/scripts/data-pipeline/candidate-restore.test.mjs @@ -19,6 +19,7 @@ import { applyCandidateRestore, applyCandidateRuntimeEnable, applyCandidateSafetyRecovery, + assertRestoreRolePostureEvidence, buildCandidateSafetyBackupEvidence, createCandidateRestorePlan, createCandidateRuntimeEnablePlan, @@ -69,6 +70,85 @@ const SAFETY_MANIFEST = `0x${"e".repeat(64)}`; const SAFETY_STRUCTURAL_MANIFEST = `0x${"f".repeat(64)}`; const SAFETY_PORTABLE_STRUCTURAL_MANIFEST = `0x${"d".repeat(64)}`; +const RESTORE_ROLE_IDENTITY = Object.freeze({ + session_user: "cli_login_postgres", + current_user: "postgres", + current_role: "postgres", + can_set_migrator: true, +}); +const RESTORE_ROLES = Object.freeze([ + Object.freeze({ rolname: "postgres", rolsuper: false }), + Object.freeze({ + rolname: "programmable_migrator", + rolsuper: false, + rolinherit: false, + rolcreaterole: false, + rolcreatedb: false, + rolcanlogin: false, + rolreplication: false, + rolbypassrls: false, + }), +]); +const RESTORE_OPERATOR_MEMBERSHIP = Object.freeze({ + member_role: "postgres", + granted_role: "programmable_migrator", + grantor_role: "postgres", + inherit_option: false, + set_option: true, + admin_option: false, +}); +const RESTORE_SUPABASE_MEMBERSHIP = Object.freeze({ + member_role: "postgres", + granted_role: "programmable_migrator", + grantor_role: "supabase_admin", + inherit_option: false, + set_option: false, + admin_option: true, +}); + +test("restore posture accepts the exact Supabase admin and operator grants", () => { + assert.doesNotThrow(() => + assertRestoreRolePostureEvidence( + RESTORE_ROLE_IDENTITY, + RESTORE_ROLES, + [RESTORE_SUPABASE_MEMBERSHIP, RESTORE_OPERATOR_MEMBERSHIP], + ), + ); +}); + +test("restore posture also accepts a standalone postgres operator grant", () => { + assert.doesNotThrow(() => + assertRestoreRolePostureEvidence( + { ...RESTORE_ROLE_IDENTITY, session_user: "postgres" }, + RESTORE_ROLES, + [RESTORE_OPERATOR_MEMBERSHIP], + ), + ); +}); + +test("restore posture rejects unknown or duplicated memberships", () => { + const invalid = Object.freeze({ + ...RESTORE_SUPABASE_MEMBERSHIP, + grantor_role: "unknown_admin", + }); + for (const memberships of [ + [RESTORE_SUPABASE_MEMBERSHIP], + [RESTORE_OPERATOR_MEMBERSHIP], + [RESTORE_OPERATOR_MEMBERSHIP, RESTORE_OPERATOR_MEMBERSHIP], + [RESTORE_OPERATOR_MEMBERSHIP, invalid], + ]) { + assert.throws( + () => + assertRestoreRolePostureEvidence( + RESTORE_ROLE_IDENTITY, + RESTORE_ROLES, + memberships, + ), + /Candidate restore role posture is not exact/u, + ); + } +}); + const PINNED_SNAPSHOT_EVIDENCE = Object.freeze({ kind: "programmable-database-backup-restore-evidence", schemaVersion: 1, From 8ab4837d6f74eedc668ae556561c30a2fd1f24d2 Mon Sep 17 00:00:00 2001 From: Programmable <309941960+0xprogrammable@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:04:26 +0200 Subject: [PATCH 2/3] test(data-pipeline): distinguish hosted restore posture --- scripts/data-pipeline/candidate-restore.mjs | 10 +++++-- .../data-pipeline/candidate-restore.test.mjs | 26 ++++++++++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/scripts/data-pipeline/candidate-restore.mjs b/scripts/data-pipeline/candidate-restore.mjs index 9d569cf..af81622 100644 --- a/scripts/data-pipeline/candidate-restore.mjs +++ b/scripts/data-pipeline/candidate-restore.mjs @@ -1627,6 +1627,7 @@ export function assertRestoreRolePostureEvidence(identity, roles, memberships) { identity?.current_user !== "postgres" || identity?.current_role !== "postgres" || identity?.can_set_migrator !== true || + typeof identity?.supabase_admin_exists !== "boolean" || roles.length !== 2 || postgresRole?.rolsuper !== false || migrator?.rolsuper !== false || @@ -1638,7 +1639,7 @@ export function assertRestoreRolePostureEvidence(identity, roles, memberships) { migrator?.rolbypassrls !== false || operatorMemberships.length !== 1 || supabaseMemberships.length !== - (identity?.session_user === "cli_login_postgres" ? 1 : 0) || + (identity?.supabase_admin_exists === true ? 1 : 0) || memberships.length !== operatorMemberships.length + supabaseMemberships.length ) { @@ -1653,7 +1654,12 @@ async function assertRestoreRolePosture(sql) { current_role::text as current_role, pg_catalog.pg_has_role( current_user, 'programmable_migrator', 'SET' - ) as can_set_migrator + ) as can_set_migrator, + exists ( + select 1 + from pg_catalog.pg_roles + where rolname = 'supabase_admin' + ) as supabase_admin_exists `); const roles = await sql.unsafe(` select rolname, rolsuper, rolinherit, rolcreaterole, rolcreatedb, diff --git a/scripts/data-pipeline/candidate-restore.test.mjs b/scripts/data-pipeline/candidate-restore.test.mjs index 76ed7a7..ea3f68d 100644 --- a/scripts/data-pipeline/candidate-restore.test.mjs +++ b/scripts/data-pipeline/candidate-restore.test.mjs @@ -75,6 +75,7 @@ const RESTORE_ROLE_IDENTITY = Object.freeze({ current_user: "postgres", current_role: "postgres", can_set_migrator: true, + supabase_admin_exists: true, }); const RESTORE_ROLES = Object.freeze([ Object.freeze({ rolname: "postgres", rolsuper: false }), @@ -116,11 +117,25 @@ test("restore posture accepts the exact Supabase admin and operator grants", () ); }); -test("restore posture also accepts a standalone postgres operator grant", () => { +test("restore posture accepts the exact grants for a hosted postgres login", () => { assert.doesNotThrow(() => assertRestoreRolePostureEvidence( { ...RESTORE_ROLE_IDENTITY, session_user: "postgres" }, RESTORE_ROLES, + [RESTORE_SUPABASE_MEMBERSHIP, RESTORE_OPERATOR_MEMBERSHIP], + ), + ); +}); + +test("restore posture accepts an isolated postgres operator grant", () => { + assert.doesNotThrow(() => + assertRestoreRolePostureEvidence( + { + ...RESTORE_ROLE_IDENTITY, + session_user: "postgres", + supabase_admin_exists: false, + }, + RESTORE_ROLES, [RESTORE_OPERATOR_MEMBERSHIP], ), ); @@ -147,6 +162,15 @@ test("restore posture rejects unknown or duplicated memberships", () => { /Candidate restore role posture is not exact/u, ); } + assert.throws( + () => + assertRestoreRolePostureEvidence( + { ...RESTORE_ROLE_IDENTITY, supabase_admin_exists: false }, + RESTORE_ROLES, + [RESTORE_SUPABASE_MEMBERSHIP, RESTORE_OPERATOR_MEMBERSHIP], + ), + /Candidate restore role posture is not exact/u, + ); }); const PINNED_SNAPSHOT_EVIDENCE = Object.freeze({ From 192e1faffe1e4ca13fc8a91cf55a4c9881b9f6bd Mon Sep 17 00:00:00 2001 From: Programmable <309941960+0xprogrammable@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:07:08 +0200 Subject: [PATCH 3/3] fix(data-pipeline): bind hosted restore posture --- scripts/data-pipeline/candidate-restore.mjs | 32 +++++++++++++------ .../candidate-restore.pg17.test.mjs | 5 ++- .../data-pipeline/candidate-restore.test.mjs | 5 +++ 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/scripts/data-pipeline/candidate-restore.mjs b/scripts/data-pipeline/candidate-restore.mjs index af81622..3c27ab3 100644 --- a/scripts/data-pipeline/candidate-restore.mjs +++ b/scripts/data-pipeline/candidate-restore.mjs @@ -1587,7 +1587,12 @@ function partitionClosure(closure, label) { }); } -export function assertRestoreRolePostureEvidence(identity, roles, memberships) { +export function assertRestoreRolePostureEvidence( + identity, + roles, + memberships, + { supabaseHosted = true } = {}, +) { const postgresRole = roles.find(({ rolname }) => rolname === "postgres"); const migrator = roles.find(({ rolname }) => rolname === "programmable_migrator"); const operatorMemberships = memberships.filter( @@ -1628,6 +1633,8 @@ export function assertRestoreRolePostureEvidence(identity, roles, memberships) { identity?.current_role !== "postgres" || identity?.can_set_migrator !== true || typeof identity?.supabase_admin_exists !== "boolean" || + typeof supabaseHosted !== "boolean" || + identity.supabase_admin_exists !== supabaseHosted || roles.length !== 2 || postgresRole?.rolsuper !== false || migrator?.rolsuper !== false || @@ -1639,7 +1646,7 @@ export function assertRestoreRolePostureEvidence(identity, roles, memberships) { migrator?.rolbypassrls !== false || operatorMemberships.length !== 1 || supabaseMemberships.length !== - (identity?.supabase_admin_exists === true ? 1 : 0) || + (supabaseHosted ? 1 : 0) || memberships.length !== operatorMemberships.length + supabaseMemberships.length ) { @@ -1647,7 +1654,7 @@ export function assertRestoreRolePostureEvidence(identity, roles, memberships) { } } -async function assertRestoreRolePosture(sql) { +async function assertRestoreRolePosture(sql, posture) { const [identity] = await sql.unsafe(` select session_user::text as session_user, current_user::text as current_user, @@ -1686,7 +1693,7 @@ async function assertRestoreRolePosture(sql) { and granted_role.rolname = 'programmable_migrator' order by grantor_role.rolname `); - assertRestoreRolePostureEvidence(identity, roles, memberships); + assertRestoreRolePostureEvidence(identity, roles, memberships, posture); } async function assertRestoreSchemasAbsent(sql) { @@ -1731,9 +1738,9 @@ export async function assertCandidateSchemaStage(sql, expectedSchemas) { } } -export async function cleanupCandidateSchemas(sql, closure) { +export async function cleanupCandidateSchemas(sql, closure, posture) { const statements = partitionClosure(closure, "Candidate cleanup closure"); - await assertRestoreRolePosture(sql); + await assertRestoreRolePosture(sql, posture); await sql.begin(async (transaction) => { await transaction.unsafe("set local role programmable_migrator").simple(); await transaction.unsafe(LATER_ONLY_RESTRICT_CLEANUP_SQL).simple(); @@ -1750,10 +1757,15 @@ export async function cleanupCandidateSchemas(sql, closure) { } }); await assertRestoreSchemasAbsent(sql); - await assertRestoreRolePosture(sql); + await assertRestoreRolePosture(sql, posture); } -export async function applyOwnerAndSecurityClosure(sql, owners, security) { +export async function applyOwnerAndSecurityClosure( + sql, + owners, + security, + posture, +) { const ownerLines = owners.sql.trimEnd().split("\n"); const objectOwners = ownerLines.filter((line) => !line.startsWith("ALTER SCHEMA ")); const schemaOwners = ownerLines.filter((line) => line.startsWith("ALTER SCHEMA ")); @@ -1761,7 +1773,7 @@ export async function applyOwnerAndSecurityClosure(sql, owners, security) { throw new Error("Candidate schema owner closure is incomplete"); } const acl = partitionClosure(security, "Candidate security closure"); - await assertRestoreRolePosture(sql); + await assertRestoreRolePosture(sql, posture); await sql.begin(async (transaction) => { await transaction.unsafe(` grant create on schema programmable_private, @@ -1774,7 +1786,7 @@ export async function applyOwnerAndSecurityClosure(sql, owners, security) { await transaction.unsafe("set local role postgres").simple(); if (acl.postgresOwned) await transaction.unsafe(acl.postgresOwned).simple(); }); - await assertRestoreRolePosture(sql); + await assertRestoreRolePosture(sql, posture); } export async function preparePinnedRestoreClosures({ diff --git a/scripts/data-pipeline/candidate-restore.pg17.test.mjs b/scripts/data-pipeline/candidate-restore.pg17.test.mjs index de8273c..89d684d 100644 --- a/scripts/data-pipeline/candidate-restore.pg17.test.mjs +++ b/scripts/data-pipeline/candidate-restore.pg17.test.mjs @@ -277,7 +277,9 @@ test( environment: Object.freeze({ LANG: "C", LC_ALL: "C" }), secrets: [], }); - await cleanupCandidateSchemas(sql, closures.cleanup); + await cleanupCandidateSchemas(sql, closures.cleanup, { + supabaseHosted: false, + }); await runTool(config.pgRestore, [ ...CANDIDATE_SAFETY_RECOVERY_FLAGS, "--host", @@ -294,6 +296,7 @@ test( sql, closures.owners, closures.security, + { supabaseHosted: false }, ); await assertCandidateSchemaStage(sql, CANDIDATE_RESTORE_SCHEMAS); const recovered = await captureDatabaseManifest(sql); diff --git a/scripts/data-pipeline/candidate-restore.test.mjs b/scripts/data-pipeline/candidate-restore.test.mjs index ea3f68d..3d2cddd 100644 --- a/scripts/data-pipeline/candidate-restore.test.mjs +++ b/scripts/data-pipeline/candidate-restore.test.mjs @@ -113,6 +113,7 @@ test("restore posture accepts the exact Supabase admin and operator grants", () RESTORE_ROLE_IDENTITY, RESTORE_ROLES, [RESTORE_SUPABASE_MEMBERSHIP, RESTORE_OPERATOR_MEMBERSHIP], + { supabaseHosted: true }, ), ); }); @@ -123,6 +124,7 @@ test("restore posture accepts the exact grants for a hosted postgres login", () { ...RESTORE_ROLE_IDENTITY, session_user: "postgres" }, RESTORE_ROLES, [RESTORE_SUPABASE_MEMBERSHIP, RESTORE_OPERATOR_MEMBERSHIP], + { supabaseHosted: true }, ), ); }); @@ -137,6 +139,7 @@ test("restore posture accepts an isolated postgres operator grant", () => { }, RESTORE_ROLES, [RESTORE_OPERATOR_MEMBERSHIP], + { supabaseHosted: false }, ), ); }); @@ -158,6 +161,7 @@ test("restore posture rejects unknown or duplicated memberships", () => { RESTORE_ROLE_IDENTITY, RESTORE_ROLES, memberships, + { supabaseHosted: true }, ), /Candidate restore role posture is not exact/u, ); @@ -168,6 +172,7 @@ test("restore posture rejects unknown or duplicated memberships", () => { { ...RESTORE_ROLE_IDENTITY, supabase_admin_exists: false }, RESTORE_ROLES, [RESTORE_SUPABASE_MEMBERSHIP, RESTORE_OPERATOR_MEMBERSHIP], + { supabaseHosted: false }, ), /Candidate restore role posture is not exact/u, );