From a041d8276e59c93dacbd7625d502c8d846beeb68 Mon Sep 17 00:00:00 2001 From: avallete Date: Mon, 7 Sep 2026 15:09:13 +0200 Subject: [PATCH 1/5] test(pg-delta): add failing regression for PG16 createrole ADMIN membership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CREATEROLE applier that CREATE ROLEs on PG16+ records GRANT … TO WITH ADMIN OPTION from the bootstrap superuser. Live extract kept that row, so apply to an empty branch failed with SQLSTATE 0LP01 ("ADMIN option cannot be granted back to your own grantor"). --- .../role-created-by-createrole-user/a.sql | 3 + .../role-created-by-createrole-user/b.sql | 7 + .../role-created-by-createrole-user/meta.json | 5 + packages/pg-delta/tests/corpus.ts | 4 + ...eaterole-implicit-admin-membership.test.ts | 174 ++++++++++++++++++ packages/pg-delta/tests/engine.test.ts | 98 ++++++++-- 6 files changed, 276 insertions(+), 15 deletions(-) create mode 100644 packages/pg-delta/corpus/role-created-by-createrole-user/a.sql create mode 100644 packages/pg-delta/corpus/role-created-by-createrole-user/b.sql create mode 100644 packages/pg-delta/corpus/role-created-by-createrole-user/meta.json create mode 100644 packages/pg-delta/tests/createrole-implicit-admin-membership.test.ts diff --git a/packages/pg-delta/corpus/role-created-by-createrole-user/a.sql b/packages/pg-delta/corpus/role-created-by-createrole-user/a.sql new file mode 100644 index 00000000..87f25a7d --- /dev/null +++ b/packages/pg-delta/corpus/role-created-by-createrole-user/a.sql @@ -0,0 +1,3 @@ +-- state A: no user roles. On PG16+ this scenario applies as a CREATEROLE +-- non-superuser; CREATE ROLE on B records an implicit bootstrap ADMIN grant +-- that must not be planned back onto A. diff --git a/packages/pg-delta/corpus/role-created-by-createrole-user/b.sql b/packages/pg-delta/corpus/role-created-by-createrole-user/b.sql new file mode 100644 index 00000000..9a6eb7ab --- /dev/null +++ b/packages/pg-delta/corpus/role-created-by-createrole-user/b.sql @@ -0,0 +1,7 @@ +-- state B: roles created by the CREATEROLE applier, plus an explicit +-- membership the applier granted (grantor = applier, not oid 10). +CREATE ROLE crl_parent NOLOGIN; +CREATE ROLE crl_child NOLOGIN; +GRANT crl_parent TO crl_child; +CREATE TABLE crl_t (id integer); +GRANT SELECT ON crl_t TO crl_child; diff --git a/packages/pg-delta/corpus/role-created-by-createrole-user/meta.json b/packages/pg-delta/corpus/role-created-by-createrole-user/meta.json new file mode 100644 index 00000000..1ad18594 --- /dev/null +++ b/packages/pg-delta/corpus/role-created-by-createrole-user/meta.json @@ -0,0 +1,5 @@ +{ + "isolatedCluster": true, + "minVersion": 16, + "createroleApplier": true +} diff --git a/packages/pg-delta/tests/corpus.ts b/packages/pg-delta/tests/corpus.ts index bd16c113..c320a60c 100644 --- a/packages/pg-delta/tests/corpus.ts +++ b/packages/pg-delta/tests/corpus.ts @@ -12,6 +12,10 @@ export interface ScenarioMeta { minVersion?: number; /** Rename-candidate handling for this scenario's plans. */ renames?: RenameMode; + /** Apply SQL, extract, and prove as a CREATEROLE non-superuser so PG16+ + * implicit bootstrap ADMIN memberships are in the catalog. Implies + * isolatedCluster (roles are cluster-global). */ + createroleApplier?: boolean; } export interface Scenario { diff --git a/packages/pg-delta/tests/createrole-implicit-admin-membership.test.ts b/packages/pg-delta/tests/createrole-implicit-admin-membership.test.ts new file mode 100644 index 00000000..1fcbba27 --- /dev/null +++ b/packages/pg-delta/tests/createrole-implicit-admin-membership.test.ts @@ -0,0 +1,174 @@ +/** + * On PG16+ a CREATEROLE non-superuser (Supabase `postgres`) that runs + * `CREATE ROLE x` receives `GRANT x TO WITH ADMIN OPTION` whose + * grantor is the bootstrap superuser (oid 10). Live extraction was + * grantor-blind, so a baseline from that project planned the GRANT against + * an empty branch and Postgres rejected it: + * + * ADMIN option cannot be granted back to your own grantor (SQLSTATE 0LP01) + * + * Shadow load already strips those rows (`bootstrapMembershipStrip`); this + * file pins the same contract on live extract + apply. + */ +import { afterAll, describe, expect, test } from "bun:test"; +import pg from "pg"; +import { apply } from "../src/apply/apply.ts"; +import { extract } from "../src/extract/extract.ts"; +import { plan } from "../src/plan/plan.ts"; +import { probeApplierCapability } from "../src/policy/capability.ts"; +import { + isolatedClusterPair, + type Cluster, + type TestDb, +} from "./containers.ts"; + +const PG_MAJOR = Number( + /postgres:(\d+)/.exec( + process.env["PGDELTA_TEST_IMAGE"] ?? "postgres:17-alpine", + )?.[1] ?? "17", +); + +const APPLIER_PASSWORD = "applier"; + +let cleanup: (() => Promise) | undefined; + +afterAll(async () => { + await cleanup?.(); +}); + +async function provisionApplier(cluster: Cluster, role: string): Promise { + await cluster.adminPool.query( + `CREATE ROLE "${role}" LOGIN PASSWORD '${APPLIER_PASSWORD}' CREATEROLE NOSUPERUSER INHERIT`, + ); + await cluster.adminPool.query(`GRANT "${role}" TO CURRENT_USER`); +} + +async function grantDatabase(db: TestDb, role: string): Promise { + await db.cluster.adminPool.query( + `GRANT CONNECT, CREATE ON DATABASE "${db.name}" TO "${role}"`, + ); + await db.pool.query(`GRANT ALL ON SCHEMA public TO "${role}"`); +} + +function poolAs(db: TestDb, role: string): pg.Pool { + const url = new URL(db.uri); + url.username = role; + url.password = APPLIER_PASSWORD; + const pool = new pg.Pool({ connectionString: url.toString(), max: 2 }); + pool.on("error", () => {}); + return pool; +} + +describe.skipIf(PG_MAJOR < 16)( + "PG16+ CREATEROLE implicit ADMIN membership", + () => { + test("baseline of roles created by a CREATEROLE user applies to an empty branch", async () => { + const [clusterA, clusterB] = await isolatedClusterPair(); + const suffix = `${Date.now()}`; + const applier = `crl16_app_${suffix}`; + const created = `crl16_new_${suffix}`; + const parent = `crl16_par_${suffix}`; + const child = `crl16_ch_${suffix}`; + const table = `crl16_t_${suffix}`; + + const baseA = await clusterA.listRoles(); + const baseB = await clusterB.listRoles(); + const pools: pg.Pool[] = []; + let source: TestDb | undefined; + let empty: TestDb | undefined; + cleanup = async () => { + await Promise.all(pools.map((p) => p.end().catch(() => {}))); + await Promise.all([ + source?.drop().catch(() => {}), + empty?.drop().catch(() => {}), + ]); + await clusterA.dropRolesExcept(baseA); + await clusterB.dropRolesExcept(baseB); + }; + + await provisionApplier(clusterA, applier); + await provisionApplier(clusterB, applier); + + const sourceDb = await clusterA.createDb("crl16_src"); + const emptyDb = await clusterB.createDb("crl16_empty"); + source = sourceDb; + empty = emptyDb; + await grantDatabase(sourceDb, applier); + await grantDatabase(emptyDb, applier); + + const sourcePool = poolAs(sourceDb, applier); + const emptyPool = poolAs(emptyDb, applier); + pools.push(sourcePool, emptyPool); + + await sourcePool.query(` + CREATE ROLE "${created}" NOLOGIN; + CREATE ROLE "${parent}" NOLOGIN; + CREATE ROLE "${child}" NOLOGIN; + GRANT "${parent}" TO "${child}"; + CREATE TABLE public."${table}" (id int); + GRANT SELECT ON public."${table}" TO "${child}"; + `); + + // Precondition: CREATE ROLE recorded the bootstrap-superuser ADMIN grant. + const implicit = await sourcePool.query<{ + grantor_oid: string; + admin_option: boolean; + }>( + ` + SELECT m.grantor::text AS grantor_oid, m.admin_option + FROM pg_auth_members m + JOIN pg_roles r ON r.oid = m.roleid + JOIN pg_roles mem ON mem.oid = m.member + WHERE r.rolname = $1 AND mem.rolname = $2 + `, + [created, applier], + ); + expect(implicit.rows).toEqual([ + { grantor_oid: "10", admin_option: true }, + ]); + + const [desiredState, emptyState] = [ + await extract(sourcePool), + await extract(emptyPool), + ]; + const capability = await probeApplierCapability(emptyPool); + const thePlan = plan(emptyState.factBase, desiredState.factBase, { + capability, + }); + + const report = await apply(thePlan, emptyPool); + expect({ + status: report.status, + error: report.error?.message, + sql: report.error?.sql, + }).toEqual({ status: "applied", error: undefined, sql: undefined }); + + expect( + desiredState.factBase.has({ + kind: "membership", + role: created, + member: applier, + }), + ).toBe(false); + expect( + desiredState.factBase.has({ + kind: "membership", + role: parent, + member: child, + }), + ).toBe(true); + + const grantSql = thePlan.actions.map((a) => a.sql); + expect( + grantSql.some( + (s) => + s.includes(`GRANT "${created}" TO "${applier}"`) && + s.includes("WITH ADMIN OPTION"), + ), + ).toBe(false); + expect( + grantSql.some((s) => s === `GRANT "${parent}" TO "${child}"`), + ).toBe(true); + }, 120_000); + }, +); diff --git a/packages/pg-delta/tests/engine.test.ts b/packages/pg-delta/tests/engine.test.ts index 45f877e1..f3095bd4 100644 --- a/packages/pg-delta/tests/engine.test.ts +++ b/packages/pg-delta/tests/engine.test.ts @@ -8,6 +8,7 @@ import { writeSync } from "node:fs"; import os from "node:os"; import { describe, test } from "bun:test"; +import pg from "pg"; import { apply } from "../src/apply/apply.ts"; import { encodeId } from "../src/core/stable-id.ts"; import { extract } from "../src/extract/extract.ts"; @@ -23,9 +24,41 @@ import { isolatedClusterPair, sharedCluster, type Cluster, + type TestDb, } from "./containers.ts"; import { EXPECTED_RED } from "./expected-red.ts"; +/** Login used when `meta.createroleApplier` is set. Same name on both sides + * so the role fact cancels in the diff. */ +const CREATEROLE_APPLIER = "crl_applier"; +const CREATEROLE_APPLIER_PASSWORD = "crl"; + +async function ensureCreateroleApplier(cluster: Cluster): Promise { + await cluster.adminPool.query(`DROP ROLE IF EXISTS "${CREATEROLE_APPLIER}"`); + await cluster.adminPool.query( + `CREATE ROLE "${CREATEROLE_APPLIER}" LOGIN PASSWORD '${CREATEROLE_APPLIER_PASSWORD}' CREATEROLE NOSUPERUSER INHERIT`, + ); + await cluster.adminPool.query( + `GRANT "${CREATEROLE_APPLIER}" TO CURRENT_USER`, + ); +} + +async function grantDatabaseToCreateroleApplier(db: TestDb): Promise { + await db.cluster.adminPool.query( + `GRANT CONNECT, CREATE ON DATABASE "${db.name}" TO "${CREATEROLE_APPLIER}"`, + ); + await db.pool.query(`GRANT ALL ON SCHEMA public TO "${CREATEROLE_APPLIER}"`); +} + +function poolAsCreateroleApplier(db: TestDb): pg.Pool { + const url = new URL(db.uri); + url.username = CREATEROLE_APPLIER; + url.password = CREATEROLE_APPLIER_PASSWORD; + const pool = new pg.Pool({ connectionString: url.toString(), max: 5 }); + pool.on("error", () => {}); + return pool; +} + const COMPACT_MODES = [true, false] as const; type ModeRunner = (key: string, run: () => Promise) => Promise; @@ -106,23 +139,44 @@ async function proveOn( fromSql: string, toSql: string, seed: string | undefined, + createroleApplier: boolean, ): Promise { + if (createroleApplier) { + await ensureCreateroleApplier(clusterA); + if (clusterB !== clusterA) await ensureCreateroleApplier(clusterB); + } + const source = await clusterA.createDb("src"); const desired = await clusterB.createDb("dst"); + const extraPools: pg.Pool[] = []; + const work = async (db: TestDb): Promise => { + if (!createroleApplier) return db.pool; + await grantDatabaseToCreateroleApplier(db); + const pool = poolAsCreateroleApplier(db); + extraPools.push(pool); + return pool; + }; + const endPool = async (pool: pg.Pool, dbPool: pg.Pool): Promise => { + if (pool === dbPool) return; + await pool.end().catch(() => {}); + const i = extraPools.indexOf(pool); + if (i >= 0) extraPools.splice(i, 1); + }; try { - await source.pool.query(fromSql); - await desired.pool.query(toSql); - if (seed) await source.pool.query(seed); + const sourceWork = await work(source); + const desiredWork = await work(desired); + await sourceWork.query(fromSql); + await desiredWork.query(toSql); + if (seed) await sourceWork.query(seed); const [sourceState, desiredState] = [ - await extractState(source.pool), - await extractState(desired.pool), + await extractState(sourceWork), + await extractState(desiredWork), ]; - // probe the applier (connection user `test`, a superuser here) so the corpus - // exercises the capability-gated compaction (Rule 2 owner-ALTER elision). - // Superuser → canSetOwner never fail-fasts, so this only adds the cosmetic - // elision; the proof still validates convergence, not SQL bytes. - const capability = await probeApplierCapability(source.pool); + // Probe the connection that will apply (superuser `test`, or the + // CREATEROLE login when `createroleApplier` is set) so capability-gated + // compaction sees the same role as extract/prove. + const capability = await probeApplierCapability(sourceWork); const thePlan = plan(sourceState.factBase, desiredState.factBase, { capability, compact, @@ -136,17 +190,22 @@ async function proveOn( direction, ); + // TEMPLATE clone requires zero extra connections on the source. + await endPool(sourceWork, source.pool); const clone = await source.clone(); // the original source DB would block cluster-wide DROP ROLE actions // (the role still owns its objects there); the clone is the proof target await source.drop(); try { + const cloneWork = await work(clone); // TEMPLATE cloning skips shared-catalog state (subscriptions): presync // the clone to the source's fact base before proving the real plan - const cloneState = await extractState(clone.pool); + const cloneState = await extractState(cloneWork); if (cloneState.factBase.rootHash !== sourceState.factBase.rootHash) { - const presync = plan(cloneState.factBase, sourceState.factBase); - const presyncReport = await apply(presync, clone.pool, { + const presync = plan(cloneState.factBase, sourceState.factBase, { + capability, + }); + const presyncReport = await apply(presync, cloneWork, { fingerprintGate: false, }); if (presyncReport.status !== "applied") { @@ -157,7 +216,7 @@ async function proveOn( } const verdict = await provePlan( thePlan, - clone.pool, + cloneWork, desiredState.factBase, { // corpus-only flip (P3): the library default stays opt-in. Seeding every @@ -165,6 +224,7 @@ async function proveOn( // scenarios that ship no seed.sql; the coverage contract below then // requires every non-seed to be an EXPECTED class-23 skip. autoSeed: true, + ...(createroleApplier ? { capability } : {}), }, ); enforceSeedCoverage(scenarioName, direction, name, verdict); @@ -203,9 +263,16 @@ async function proveOn( ); } } finally { + // Close the clone applier pool before DROP DATABASE. + await Promise.all( + extraPools + .filter((p) => p !== desiredWork) + .map((p) => p.end().catch(() => {})), + ); await clone.drop(); } } finally { + await Promise.all(extraPools.map((p) => p.end().catch(() => {}))); await Promise.all([source.drop(), desired.drop()]); } } @@ -245,6 +312,7 @@ async function runDirection( fromSql, toSql, seed, + scenario.meta.createroleApplier === true, ), ), ); @@ -282,7 +350,7 @@ async function runDirection( if (cleanupFailed) throw cleanupError; }; - if (scenario.meta.isolatedCluster) { + if (scenario.meta.isolatedCluster || scenario.meta.createroleApplier) { const [clusterA, clusterB] = await isolatedClusterPair(); if (scenario.meta.minVersion !== undefined) { if ((await clusterA.pgMajor()) < scenario.meta.minVersion) return; From 38f6d162732329e42b956b7ae26ae787ba0975d8 Mon Sep 17 00:00:00 2001 From: avallete Date: Mon, 7 Sep 2026 15:09:17 +0200 Subject: [PATCH 2/5] fix(pg-delta): drop implicit createrole ADMIN memberships from extraction Skip pg_auth_members rows granted by the bootstrap superuser (oid 10) to the current CREATEROLE non-superuser so live extract matches the shadow-load strip and CREATE ROLE is not followed by a failing self-GRANT. --- .changeset/createrole-implicit-admin-membership.md | 5 +++++ packages/pg-delta/src/extract/roles.ts | 11 +++++++++++ 2 files changed, 16 insertions(+) create mode 100644 .changeset/createrole-implicit-admin-membership.md diff --git a/.changeset/createrole-implicit-admin-membership.md b/.changeset/createrole-implicit-admin-membership.md new file mode 100644 index 00000000..a9c3c9b7 --- /dev/null +++ b/.changeset/createrole-implicit-admin-membership.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +Live extraction now drops PG16+ implicit CREATEROLE ADMIN memberships granted by the bootstrap superuser, matching the shadow-load strip, so a baseline of roles created by a non-superuser `postgres` applies to an empty branch without `ADMIN option cannot be granted back to your own grantor`. diff --git a/packages/pg-delta/src/extract/roles.ts b/packages/pg-delta/src/extract/roles.ts index 78d75471..1ccf465b 100644 --- a/packages/pg-delta/src/extract/roles.ts +++ b/packages/pg-delta/src/extract/roles.ts @@ -14,6 +14,11 @@ const ROLES_SQL = ` ORDER BY r.rolname`; // ── role memberships (cluster-level; multi-grantor rows deduped) ───── +// PG16+ CREATEROLE non-superusers receive GRANT TO WITH ADMIN +// OPTION from the bootstrap superuser (oid 10). Replaying that GRANT fails +// with 0LP01; CREATE ROLE already recreates it. Drop those rows so live +// extract matches load-time bootstrapMembershipStrip. Other grantors for +// the same pair still collapse via bool_or(admin). const MEMBERSHIPS_SQL = ` SELECT r1.rolname AS role, r2.rolname AS member, bool_or(m.admin_option) AS admin @@ -21,6 +26,12 @@ const MEMBERSHIPS_SQL = ` JOIN pg_roles r1 ON r1.oid = m.roleid JOIN pg_roles r2 ON r2.oid = m.member WHERE r1.rolname NOT LIKE 'pg\\_%' AND r2.rolname NOT LIKE 'pg\\_%' + AND NOT ( + m.grantor = 10 + AND r2.rolname = current_user + AND r2.rolcreaterole + AND NOT r2.rolsuper + ) GROUP BY 1, 2 ORDER BY 1, 2`; From 06c3e3d3956c2687bacc77afce57d81aa6b725c8 Mon Sep 17 00:00:00 2001 From: avallete Date: Tue, 8 Sep 2026 12:19:19 +0200 Subject: [PATCH 3/5] fix(pg-delta): project unreplayable createrole self-ADMIN via capability Live extract stays catalog-true. A PG16+ CREATEROLE applier cannot GRANT a role to itself WITH ADMIN OPTION (0LP01); capability projection drops those facts so CREATE ROLE is not followed by a failing self-GRANT. --- .../createrole-implicit-admin-membership.md | 2 +- bun.lock | 8 +- packages/pg-delta/src/extract/roles.ts | 11 -- .../pg-delta/src/integrations/profile.test.ts | 13 +++ .../pg-delta/src/policy/capability.test.ts | 100 ++++++++++++++++-- packages/pg-delta/src/policy/capability.ts | 48 +++++++-- packages/pg-delta/src/policy/policy.ts | 26 +++-- .../src/policy/projection-audit.test.ts | 35 ++++++ packages/pg-delta/tests/capability.test.ts | 2 + ...eaterole-implicit-admin-membership.test.ts | 17 ++- 10 files changed, 208 insertions(+), 54 deletions(-) diff --git a/.changeset/createrole-implicit-admin-membership.md b/.changeset/createrole-implicit-admin-membership.md index a9c3c9b7..f3fbcc46 100644 --- a/.changeset/createrole-implicit-admin-membership.md +++ b/.changeset/createrole-implicit-admin-membership.md @@ -2,4 +2,4 @@ "@supabase/pg-delta": patch --- -Live extraction now drops PG16+ implicit CREATEROLE ADMIN memberships granted by the bootstrap superuser, matching the shadow-load strip, so a baseline of roles created by a non-superuser `postgres` applies to an empty branch without `ADMIN option cannot be granted back to your own grantor`. +A PG16+ CREATEROLE non-superuser cannot replay `GRANT TO WITH ADMIN OPTION` (SQLSTATE 0LP01); `CREATE ROLE` already recreates that membership. When plan/prove receive applier capability (the same opt-in as FDW ACLs), those admin self-memberships are projected out of the managed view. Extract stays a catalog dump. diff --git a/bun.lock b/bun.lock index db4bc66c..fcf354ec 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ }, "packages/pg-delta": { "name": "@supabase/pg-delta", - "version": "1.0.0-alpha.34", + "version": "1.0.0-alpha.49", "bin": { "pgdelta": "./dist/cli/main.js", }, @@ -43,7 +43,7 @@ }, "devDependencies": { "@supabase/bun-istanbul-coverage": "workspace:*", - "@supabase/pg-topo": "^1.0.0-alpha.3", + "@supabase/pg-topo": "^1.0.0-alpha.6", "@types/bun": "^1.3.9", "@types/debug": "^4.1.12", "@types/node": "^24.10.4", @@ -53,7 +53,7 @@ "typescript": "^5.9.3", }, "peerDependencies": { - "@supabase/pg-topo": "^1.0.0-alpha.3", + "@supabase/pg-topo": "^1.0.0-alpha.6", }, "optionalPeers": [ "@supabase/pg-topo", @@ -61,7 +61,7 @@ }, "packages/pg-topo": { "name": "@supabase/pg-topo", - "version": "1.0.0-alpha.5", + "version": "1.0.0-alpha.6", "dependencies": { "@pgsql/traverse": "^17.2.4", "plpgsql-parser": "^0.5.4", diff --git a/packages/pg-delta/src/extract/roles.ts b/packages/pg-delta/src/extract/roles.ts index 1ccf465b..78d75471 100644 --- a/packages/pg-delta/src/extract/roles.ts +++ b/packages/pg-delta/src/extract/roles.ts @@ -14,11 +14,6 @@ const ROLES_SQL = ` ORDER BY r.rolname`; // ── role memberships (cluster-level; multi-grantor rows deduped) ───── -// PG16+ CREATEROLE non-superusers receive GRANT TO WITH ADMIN -// OPTION from the bootstrap superuser (oid 10). Replaying that GRANT fails -// with 0LP01; CREATE ROLE already recreates it. Drop those rows so live -// extract matches load-time bootstrapMembershipStrip. Other grantors for -// the same pair still collapse via bool_or(admin). const MEMBERSHIPS_SQL = ` SELECT r1.rolname AS role, r2.rolname AS member, bool_or(m.admin_option) AS admin @@ -26,12 +21,6 @@ const MEMBERSHIPS_SQL = ` JOIN pg_roles r1 ON r1.oid = m.roleid JOIN pg_roles r2 ON r2.oid = m.member WHERE r1.rolname NOT LIKE 'pg\\_%' AND r2.rolname NOT LIKE 'pg\\_%' - AND NOT ( - m.grantor = 10 - AND r2.rolname = current_user - AND r2.rolcreaterole - AND NOT r2.rolsuper - ) GROUP BY 1, 2 ORDER BY 1, 2`; diff --git a/packages/pg-delta/src/integrations/profile.test.ts b/packages/pg-delta/src/integrations/profile.test.ts index b8c8cba4..9a094bbf 100644 --- a/packages/pg-delta/src/integrations/profile.test.ts +++ b/packages/pg-delta/src/integrations/profile.test.ts @@ -24,6 +24,19 @@ function mockPool(opts: { return { // biome-ignore lint: minimal pg.Pool stand-in for unit tests query: async (sql: string) => { + if (sql.includes("current_user")) { + return { + rows: [ + { + role: "applier", + is_superuser: opts.superuser ?? false, + create_role: false, + pg_major: Math.floor((opts.versionNum ?? 170004) / 10000), + member_of: opts.memberOf ?? [], + }, + ], + }; + } if (sql.includes("server_version_num")) { return { rows: [{ v: opts.versionNum ?? 170004 }] }; } diff --git a/packages/pg-delta/src/policy/capability.test.ts b/packages/pg-delta/src/policy/capability.test.ts index f7d5fe5b..62f042e5 100644 --- a/packages/pg-delta/src/policy/capability.test.ts +++ b/packages/pg-delta/src/policy/capability.test.ts @@ -2,18 +2,19 @@ * Applier-capability-restricted view (docs/architecture/managed-view-architecture.md move 6). * * The managed view is a function of (facts, policy, applier capability). An - * operation the applier cannot execute is projected out — currently FDW ACLs, - * which require superuser to GRANT/REVOKE. This is additive: the Supabase - * Rule 9 (`{ acl, target fdw } → exclude`) still stands; capability derives the - * same exclusion for ANY non-superuser applier. With no capability (or a - * superuser), the view is unrestricted — the corpus path is unchanged. + * operation the applier cannot execute is projected out — FDW ACLs (superuser + * GRANT/REVOKE) and PG16+ CREATEROLE self-ADMIN memberships (0LP01). With no + * capability (or a superuser), the view is unrestricted — the corpus path is + * unchanged. */ import { describe, expect, test } from "bun:test"; import { buildFactBase, type Fact } from "../core/fact.ts"; -import type { StableId } from "../core/stable-id.ts"; +import { encodeId, type StableId } from "../core/stable-id.ts"; import { resolveView } from "./policy.ts"; import { plan } from "../plan/plan.ts"; import { + CAPABILITY_CREATEROLE_SELF_ADMIN, + CAPABILITY_FDW_ACL, capabilityExcludedRoots, type ApplierCapability, } from "./capability.ts"; @@ -59,6 +60,93 @@ describe("ApplierCapability — capability-restricted view (move 6)", () => { expect(capabilityExcludedRoots(fb, superuser).size).toBe(0); const roots = capabilityExcludedRoots(fb, nonSuper); expect(roots.size).toBe(1); + expect(roots.get(encodeId(fdwAcl))).toBe(CAPABILITY_FDW_ACL); + }); +}); + +describe("ApplierCapability — PG16+ CREATEROLE self-ADMIN membership", () => { + const created: StableId = { kind: "role", name: "created" }; + const parent: StableId = { kind: "role", name: "parent" }; + const child: StableId = { kind: "role", name: "child" }; + const selfAdmin: StableId = { + kind: "membership", + role: "created", + member: "app", + }; + const peerGrant: StableId = { + kind: "membership", + role: "parent", + member: "child", + }; + const selfPlain: StableId = { + kind: "membership", + role: "created", + member: "app", + }; + const createrolePg16: ApplierCapability = { + role: "app", + isSuperuser: false, + memberOf: [], + createRole: true, + pgMajor: 16, + }; + const membershipFb = () => + buildFactBase( + [ + f(created), + f(parent), + f(child), + { id: selfAdmin, payload: { admin: true } }, + { id: peerGrant, payload: { admin: false } }, + ], + [], + ); + + test("excludes admin self-membership for a PG16+ CREATEROLE non-superuser", () => { + const fb = membershipFb(); + const roots = capabilityExcludedRoots(fb, createrolePg16); + expect(roots.get(encodeId(selfAdmin))).toBe( + CAPABILITY_CREATEROLE_SELF_ADMIN, + ); + expect(roots.has(encodeId(peerGrant))).toBe(false); + expect( + resolveView(fb, undefined, createrolePg16).get(selfAdmin), + ).toBeUndefined(); + expect( + resolveView(fb, undefined, createrolePg16).get(peerGrant), + ).toBeDefined(); + }); + + test("keeps a non-admin self-membership", () => { + const fb = buildFactBase( + [f(created), { id: selfPlain, payload: { admin: false } }], + [], + ); + expect(capabilityExcludedRoots(fb, createrolePg16).size).toBe(0); + }); + + test("superuser, PG < 16, or omitted probe fields do not exclude", () => { + const fb = membershipFb(); + expect(capabilityExcludedRoots(fb, superuser).size).toBe(0); + expect( + capabilityExcludedRoots(fb, { + ...createrolePg16, + pgMajor: 15, + }).size, + ).toBe(0); + expect( + capabilityExcludedRoots(fb, { + role: "app", + isSuperuser: false, + memberOf: [], + }).size, + ).toBe(0); + expect( + capabilityExcludedRoots(fb, { + ...createrolePg16, + createRole: false, + }).size, + ).toBe(0); }); }); diff --git a/packages/pg-delta/src/policy/capability.ts b/packages/pg-delta/src/policy/capability.ts index eb7766d2..55290ca9 100644 --- a/packages/pg-delta/src/policy/capability.ts +++ b/packages/pg-delta/src/policy/capability.ts @@ -8,10 +8,11 @@ * probed from the applier connection and threaded into plan()/prove() as an * option. Absent, the view is unrestricted (the default — superuser/CI path). * - * v1 restriction: FDW ACLs. `GRANT`/`REVOKE ON FOREIGN DATA WRAPPER` requires - * superuser, so a non-superuser applier cannot replay them. This derives, for - * ANY non-superuser, the exclusion the Supabase policy hard-codes as Rule 9 — - * additively (Rule 9 stays until the derivation is proven at parity). + * Projected today: + * - FDW ACLs (superuser-only GRANT/REVOKE), the exclusion Supabase Rule 9 + * hard-codes — additive until that derivation is proven at parity. + * - PG16+ CREATEROLE self-ADMIN memberships: `GRANT role TO + * WITH ADMIN OPTION` is 0LP01; CREATE ROLE already recreates the row. */ import type { Pool } from "pg"; import type { FactBase } from "../core/fact.ts"; @@ -26,8 +27,17 @@ export interface ApplierCapability { * array (not a Set) so the capability persists losslessly in the Plan * artifact's JSON (follow-up 2 productization). */ memberOf: readonly string[]; + /** CREATEROLE on the applying role. Omitted on legacy artifacts / hand-built + * fixtures — membership projection stays off. */ + createRole?: boolean; + /** Server major (e.g. 16). Omitted on legacy artifacts / hand-built fixtures. */ + pgMajor?: number; } +export const CAPABILITY_FDW_ACL = "capability.fdw-acl"; +export const CAPABILITY_CREATEROLE_SELF_ADMIN = + "capability.createrole-self-admin"; + /** Probe the applier's capability from a live connection. */ export async function probeApplierCapability( pool: Pool, @@ -35,6 +45,8 @@ export async function probeApplierCapability( const res = await pool.query(` SELECT current_user AS role, (SELECT rolsuper FROM pg_catalog.pg_roles WHERE rolname = current_user) AS is_superuser, + (SELECT rolcreaterole FROM pg_catalog.pg_roles WHERE rolname = current_user) AS create_role, + (current_setting('server_version_num')::int / 10000) AS pg_major, ARRAY( SELECT r.rolname::text FROM pg_catalog.pg_roles r WHERE pg_catalog.pg_has_role(current_user, r.oid, 'MEMBER') @@ -44,30 +56,44 @@ export async function probeApplierCapability( const row = res.rows[0] as { role: string; is_superuser: boolean; + create_role: boolean; + pg_major: number; member_of: string[] | null; }; return { role: String(row.role), isSuperuser: Boolean(row.is_superuser), memberOf: row.member_of ?? [], + createRole: Boolean(row.create_role), + pgMajor: Number(row.pg_major), }; } /** - * Fact-id keys to project out for a given capability — the facts whose - * corresponding action the applier cannot execute. A superuser is unrestricted. - * Currently: FDW ACL facts (GRANT/REVOKE on a FOREIGN DATA WRAPPER is - * superuser-only). + * Fact-id keys to project out for a given capability, keyed by audit reason. + * A superuser is unrestricted. Missing `createRole` / `pgMajor` (legacy JSON) + * does not exclude memberships. */ export function capabilityExcludedRoots( fb: FactBase, cap: ApplierCapability, -): Set { - const roots = new Set(); +): Map { + const roots = new Map(); if (cap.isSuperuser) return roots; + const selfAdmin = + cap.createRole === true && cap.pgMajor !== undefined && cap.pgMajor >= 16; for (const fact of fb.facts()) { if (fact.id.kind === "acl" && fact.id.target.kind === "fdw") { - roots.add(encodeId(fact.id)); + roots.set(encodeId(fact.id), CAPABILITY_FDW_ACL); + continue; + } + if ( + selfAdmin && + fact.id.kind === "membership" && + fact.id.member === cap.role && + fact.payload["admin"] === true + ) { + roots.set(encodeId(fact.id), CAPABILITY_CREATEROLE_SELF_ADMIN); } } return roots; diff --git a/packages/pg-delta/src/policy/policy.ts b/packages/pg-delta/src/policy/policy.ts index 7013e0c7..c7ae6c12 100644 --- a/packages/pg-delta/src/policy/policy.ts +++ b/packages/pg-delta/src/policy/policy.ts @@ -1175,24 +1175,30 @@ export function resolveView( } // capability restriction (move 6): project out facts whose action the applier // cannot execute. Additive; default unrestricted. FDW ACLs are superuser-only - // GRANTs and a leaf fact, so they project out cleanly. (The owner residue is - // NOT projected — it can't be skipped without an ACL ripple — it fail-fasts - // in plan() instead; see capability.canSetOwner.) + // GRANTs and a leaf fact, so they project out cleanly. PG16+ CREATEROLE + // self-ADMIN memberships are the same class (GRANT … TO WITH ADMIN + // OPTION is 0LP01). (The owner residue is NOT projected — it can't be skipped + // without an ACL ripple — it fail-fasts in plan() instead; see + // capability.canSetOwner.) if (capability !== undefined) { const capRoots = capabilityExcludedRoots(base, capability); if (capRoots.size > 0) { const before = base; - base = excludeFactsAndDescendants(base, capRoots); + base = excludeFactsAndDescendants(base, new Set(capRoots.keys())); if (collectSuppression !== undefined) { - const attribution: ProjectionSuppressionAttribution = { - stage: "capability", - reasonCode: "capability.fdw-acl", - classification: "acknowledged", - }; collectRemovedSuppressions( before, base, - new Map([...capRoots].map((key) => [key, attribution])), + new Map( + [...capRoots].map(([key, reasonCode]) => [ + key, + { + stage: "capability" as const, + reasonCode, + classification: "acknowledged" as const, + }, + ]), + ), collectSuppression, ); } diff --git a/packages/pg-delta/src/policy/projection-audit.test.ts b/packages/pg-delta/src/policy/projection-audit.test.ts index 177cca4d..1eec46da 100644 --- a/packages/pg-delta/src/policy/projection-audit.test.ts +++ b/packages/pg-delta/src/policy/projection-audit.test.ts @@ -539,6 +539,41 @@ describe("attributed projection audit", () => { }); }); + test("capability restriction attributes a CREATEROLE self-ADMIN membership", () => { + const created: StableId = { kind: "role", name: "created" }; + const membership: StableId = { + kind: "membership", + role: "created", + member: "app", + }; + const source = buildFactBase([fact(created)], []); + const desired = buildFactBase( + [fact(created), fact(membership, { admin: true })], + [], + ); + const capability: ApplierCapability = { + role: "app", + isSuperuser: false, + memberOf: [], + createRole: true, + pgMajor: 16, + }; + + expect( + auditManagedViewProjection(source, desired, { capability }).entries[0], + ).toMatchObject({ + subject: { kind: "fact", id: membership }, + classification: "acknowledged", + suppressions: [ + { + side: "desired", + stage: "capability", + reasonCode: "capability.createrole-self-admin", + }, + ], + }); + }); + test("Supabase's intentional FDW ACL exclusion is acknowledged", () => { const wrapper: StableId = { kind: "fdw", name: "remote" }; const acl: StableId = { kind: "acl", target: wrapper, grantee: "reader" }; diff --git a/packages/pg-delta/tests/capability.test.ts b/packages/pg-delta/tests/capability.test.ts index c9f90334..c4a957b3 100644 --- a/packages/pg-delta/tests/capability.test.ts +++ b/packages/pg-delta/tests/capability.test.ts @@ -16,6 +16,8 @@ describe("probeApplierCapability (integration)", () => { // the container admin is a superuser expect(cap.role.length).toBeGreaterThan(0); expect(cap.isSuperuser).toBe(true); + expect(typeof cap.createRole).toBe("boolean"); + expect(cap.pgMajor).toBeGreaterThanOrEqual(14); // memberOf is a real parsed string[] (a role is a member of itself) — guards // against the pg driver returning the array as an unparsed "{...}" literal. expect(Array.isArray(cap.memberOf)).toBe(true); diff --git a/packages/pg-delta/tests/createrole-implicit-admin-membership.test.ts b/packages/pg-delta/tests/createrole-implicit-admin-membership.test.ts index 1fcbba27..ee3d387e 100644 --- a/packages/pg-delta/tests/createrole-implicit-admin-membership.test.ts +++ b/packages/pg-delta/tests/createrole-implicit-admin-membership.test.ts @@ -1,14 +1,9 @@ /** - * On PG16+ a CREATEROLE non-superuser (Supabase `postgres`) that runs - * `CREATE ROLE x` receives `GRANT x TO WITH ADMIN OPTION` whose - * grantor is the bootstrap superuser (oid 10). Live extraction was - * grantor-blind, so a baseline from that project planned the GRANT against - * an empty branch and Postgres rejected it: - * - * ADMIN option cannot be granted back to your own grantor (SQLSTATE 0LP01) - * - * Shadow load already strips those rows (`bootstrapMembershipStrip`); this - * file pins the same contract on live extract + apply. + * On PG16+ a CREATEROLE non-superuser that runs `CREATE ROLE x` receives + * `GRANT x TO WITH ADMIN OPTION`. Replaying that GRANT is 0LP01 + * (`ADMIN option cannot be granted back to your own grantor`). Extract keeps + * the catalog row; capability projection drops it from the managed view so + * the plan is CREATE ROLE only (which recreates the membership). */ import { afterAll, describe, expect, test } from "bun:test"; import pg from "pg"; @@ -149,7 +144,7 @@ describe.skipIf(PG_MAJOR < 16)( role: created, member: applier, }), - ).toBe(false); + ).toBe(true); expect( desiredState.factBase.has({ kind: "membership", From 2ed2d22b08ac8521c0c648496d93f81f8c5e1aaa Mon Sep 17 00:00:00 2001 From: avallete Date: Tue, 8 Sep 2026 14:08:37 +0200 Subject: [PATCH 4/5] fix(pg-delta): probe applier capability by default in resolveProfile Omitted restrictToApplier used to leave the view unrestricted, and CLI booleans leaked false when the flag was absent, so workers never got the CREATEROLE self-ADMIN projection. Probe unless explicitly false; prove keeps the plan artifact's capability instead of re-probing the clone. --- .../createrole-implicit-admin-membership.md | 2 +- docs/getting-started.md | 10 ++++- packages/pg-delta/src/cli/commands/plan.ts | 17 +++++--- .../pg-delta/src/cli/commands/prove.test.ts | 18 +++++++++ packages/pg-delta/src/cli/commands/prove.ts | 14 ++++++- packages/pg-delta/src/cli/commands/schema.ts | 16 +++++--- packages/pg-delta/src/cli/flags.test.ts | 40 +++++++++++++++++++ packages/pg-delta/src/cli/flags.ts | 19 ++++++++- .../pg-delta/src/integrations/profile.test.ts | 27 ++++++++----- packages/pg-delta/src/integrations/profile.ts | 34 +++++++++------- packages/pg-delta/src/plan/plan.ts | 9 ++--- packages/pg-delta/src/policy/capability.ts | 3 +- 12 files changed, 163 insertions(+), 46 deletions(-) create mode 100644 packages/pg-delta/src/cli/flags.test.ts diff --git a/.changeset/createrole-implicit-admin-membership.md b/.changeset/createrole-implicit-admin-membership.md index f3fbcc46..318d5dcd 100644 --- a/.changeset/createrole-implicit-admin-membership.md +++ b/.changeset/createrole-implicit-admin-membership.md @@ -2,4 +2,4 @@ "@supabase/pg-delta": patch --- -A PG16+ CREATEROLE non-superuser cannot replay `GRANT TO WITH ADMIN OPTION` (SQLSTATE 0LP01); `CREATE ROLE` already recreates that membership. When plan/prove receive applier capability (the same opt-in as FDW ACLs), those admin self-memberships are projected out of the managed view. Extract stays a catalog dump. +A PG16+ CREATEROLE non-superuser cannot replay `GRANT TO WITH ADMIN OPTION` (SQLSTATE 0LP01); `CREATE ROLE` already recreates that membership. Extract stays a catalog dump. `resolveProfile` now probes applier capability by default (omitted or `true`) and projects those self-ADMIN memberships — plus existing FDW ACLs — out of the managed view for `plan`, `schema apply`, `diff`, and `schema export`. Pass `{ restrictToApplier: false }` / `--no-restrict-to-applier` (`plan` / `schema apply`) for an unrestricted view (plan-here / apply-as-more-privileged). `prove` reconstructs the plan artifact's capability and does not re-probe the clone. A superuser probe excludes nothing. Bare `plan()` stays unrestricted when capability is omitted. diff --git a/docs/getting-started.md b/docs/getting-started.md index f55adbc3..374f8374 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -235,14 +235,14 @@ has drifted (and prints the deltas) — handy in CI. | Command | What it does | Key flags | |---|---|---| | `diff` | Print the deltas between two live DBs | `--source` `--desired` `[--strict-coverage]` | -| `plan` | Produce a plan artifact (JSON) | `--source` `--desired` `[--out]` `[--profile]` `[--renames]` `[--no-compact]` `[--accept-rename]` `[--restrict-to-applier]` `[--strict-coverage]` | +| `plan` | Produce a plan artifact (JSON) | `--source` `--desired` `[--out]` `[--profile]` `[--renames]` `[--no-compact]` `[--accept-rename]` `[--restrict-to-applier]` `[--no-restrict-to-applier]` `[--strict-coverage]` | | `render` | Write a plan out as reviewable `.sql` | `--plan` `--out` `[--allow-drops]` | | `apply` | Apply a plan to a target | `--plan` `--target` `[--profile]` `[--force]` `[--allow-data-loss]` | | `prove` | Apply a plan to a clone and verify convergence + data preservation | `--plan` `--clone` `--desired-snapshot` `[--profile]` `[--strict-audit]` `[--audit-all]` `[--trusted-local-host]` `[--allow-remote-clone]` `[--allow-unverified-source-identity]` | | `snapshot` | Save a database's fact base to a file | `--source` `--out` `[--strict-coverage]` | | `drift` | Compare a live DB against a saved snapshot | `--env` `--snapshot` `[--strict-coverage]` | | `schema export` | Export a live DB to `.sql` files | `--source` `--out-dir` `[--scope]` `[--layout]` `[--path-style]` `[--format-options]` `[--no-format]` `[--profile]` `[--strict-coverage]` | -| `schema apply` | Load `.sql` files via a shadow DB and migrate a target | `--dir` `[--shadow]` `--target` `[--scope]` `[--isolated-shadow]` `[--renames]` `[--accept-rename]` `[--force]` `[--allow-data-loss]` `[--no-reorder]` `[--trusted-local-host]` `[--allow-remote-shadow]` `[--profile]` `[--restrict-to-applier]` `[--strict-coverage]` `[--dry-run]` `[--verbose]` `[--out-plan]` | +| `schema apply` | Load `.sql` files via a shadow DB and migrate a target | `--dir` `[--shadow]` `--target` `[--scope]` `[--isolated-shadow]` `[--renames]` `[--accept-rename]` `[--force]` `[--allow-data-loss]` `[--no-reorder]` `[--trusted-local-host]` `[--allow-remote-shadow]` `[--profile]` `[--restrict-to-applier]` `[--no-restrict-to-applier]` `[--strict-coverage]` `[--dry-run]` `[--verbose]` `[--out-plan]` | | `schema lint` | Statically check `.sql` files for load-order problems (no database) | `--dir` `[--custom-migration-refs warn\|off]` | Common flags, explained: @@ -263,6 +263,12 @@ Common flags, explained: remain bounded; the plan artifact retains the complete raw audit. - **`--renames auto\|prompt\|off`** — `plan`/`schema apply` default to `prompt`, which lists rename candidates you confirm with `--accept-rename =`. +- **`--restrict-to-applier` / `--no-restrict-to-applier`** — managed-view + commands probe the connection role and drop operations that role cannot + replay (FDW ACLs, PG16+ CREATEROLE self-ADMIN memberships). That probe is + the default. `--restrict-to-applier` is an explicit yes; `--no-restrict-to-applier` + (`plan` / `schema apply`) keeps the unrestricted view for plan-here / + apply-as-more-privileged. A superuser probe excludes nothing. - **`--force`** — disables the fingerprint gate on `apply` (see [Safety](#safety-features)). Use sparingly. diff --git a/packages/pg-delta/src/cli/commands/plan.ts b/packages/pg-delta/src/cli/commands/plan.ts index 2b0027ce..66f3eb73 100644 --- a/packages/pg-delta/src/cli/commands/plan.ts +++ b/packages/pg-delta/src/cli/commands/plan.ts @@ -20,7 +20,11 @@ import { serializePlan } from "../../plan/artifact.ts"; import { encodeId, parseId, type StableId } from "../../core/stable-id.ts"; import { exitIfBlocking, printDiagnostics } from "../diagnostics.ts"; import { makePool } from "../pool.ts"; -import { parseFlags, UsageError } from "../flags.ts"; +import { + parseFlags, + restrictToApplierFromFlags, + UsageError, +} from "../flags.ts"; import { PROFILE_IDS, resolveCliProfile } from "../profile.ts"; import type { RenameMode } from "../../plan/renames.ts"; import { writeFileSync } from "node:fs"; @@ -36,7 +40,7 @@ const USAGE = "Usage: pgdelta plan --source --desired " + `[--profile ${PROFILE_IDS}] ` + "[--renames auto|prompt|off] [--no-compact] [--out ] " + - "[--accept-rename =] ... [--restrict-to-applier] [--strict-coverage] " + + "[--accept-rename =] ... [--restrict-to-applier] [--no-restrict-to-applier] [--strict-coverage] " + "[--unsafe-show-secrets]\n"; export function formatPlanIdentityWarning( @@ -70,6 +74,7 @@ export async function cmdPlan(args: string[]): Promise { out: { type: "value" }, "accept-rename": { type: "multi" }, "restrict-to-applier": { type: "boolean" }, + "no-restrict-to-applier": { type: "boolean" }, "strict-coverage": { type: "boolean" }, "unsafe-show-secrets": { type: "boolean" }, }); @@ -127,11 +132,11 @@ export async function cmdPlan(args: string[]): Promise { // would hash differently and silently stop subtracting). const redactSecrets = !flags["unsafe-show-secrets"]; // Resolve the profile against the SOURCE pool (the source is the apply - // target): this composes handler-aware extraction, the profile's policy + - // baseline, and — with --restrict-to-applier — the applier capability. All - // three flow into planOptions so plan == prove == apply (P0/P2). + // target): handler-aware extraction + policy + baseline + capability + // share one view so plan == prove == apply (P0/P2). + const restrictToApplier = restrictToApplierFromFlags(flags); const ctx = await resolveCliProfile(src.pool, flags["profile"], { - restrictToApplier: flags["restrict-to-applier"], + ...(restrictToApplier !== undefined ? { restrictToApplier } : {}), redactSecrets, }); diff --git a/packages/pg-delta/src/cli/commands/prove.test.ts b/packages/pg-delta/src/cli/commands/prove.test.ts index 58bb444c..b68295d9 100644 --- a/packages/pg-delta/src/cli/commands/prove.test.ts +++ b/packages/pg-delta/src/cli/commands/prove.test.ts @@ -19,6 +19,7 @@ import { formatProofFailure, formatProofPassCaveat, formatProofPassCoverage, + proveOptionsFromProfile, } from "./prove.ts"; import { connectionEndpointHash } from "../connection-safety.ts"; import type { ProofCoverage } from "../../proof/prove.ts"; @@ -47,6 +48,23 @@ const baseVerdict = (): ProofVerdict => ({ coverage: { tablesChecked: 0, tablesSkipped: [], perTable: [] }, }); +describe("proveOptionsFromProfile", () => { + test("drops a live-probed capability so provePlan uses the plan artifact", () => { + const fromClone = proveOptionsFromProfile({ + capability: { + role: "clone_role", + isSuperuser: false, + memberOf: [], + createRole: true, + pgMajor: 17, + }, + reextract: async () => ({ factBase: buildFactBase([], []) }), + }); + expect(fromClone.capability).toBeUndefined(); + expect(fromClone.reextract).toBeDefined(); + }); +}); + describe("assertProofCloneEndpoint", () => { const remote = "postgres://db.example.com/app"; diff --git a/packages/pg-delta/src/cli/commands/prove.ts b/packages/pg-delta/src/cli/commands/prove.ts index aa790d6d..35dc8418 100644 --- a/packages/pg-delta/src/cli/commands/prove.ts +++ b/packages/pg-delta/src/cli/commands/prove.ts @@ -12,6 +12,7 @@ import { provePlan, type ProofCoverage, type ProofVerdict, + type ProveOptions, type TableRef, } from "../../proof/prove.ts"; import type { @@ -41,6 +42,16 @@ import { resolveCliProfile, } from "../profile.ts"; +/** Drop a live-probed capability so `provePlan` falls through to + * `thePlan.capability`. The clone role can differ from the planner + * (superuser local clone of a CREATEROLE plan, or the reverse). */ +export function proveOptionsFromProfile( + profileProve: ProveOptions, +): Omit { + const { capability: _ignored, ...rest } = profileProve; + return rest; +} + /** * Render a failing `ProofVerdict` as an indented, human-readable report (the * lines printed after "Proof FAILED."). Pure + exported so the CLI output is @@ -595,6 +606,7 @@ export async function cmdProve(args: string[]): Promise { ); const ctx = await resolveCliProfile(clone.pool, profileId, { redactSecrets: planRedactSecrets, + restrictToApplier: false, }); // The baseline the profile resolves MUST match the plan's, or the proof // reconstructs a different managed view than the plan diffed. Fail loud with @@ -610,7 +622,7 @@ export async function cmdProve(args: string[]): Promise { // desired snapshot — an unredacted (`--unsafe-show-secrets`) plan must not be // proven against a default-redacted re-extract. Absent → the extract default. const verdict = await provePlan(thePlan, clone.pool, desiredFb, { - ...ctx.proveOptions, + ...proveOptionsFromProfile(ctx.proveOptions), reextract: (p) => ctx.extract(p, { redactSecrets: planRedactSecrets }), strictAudit: flags["strict-audit"], }); diff --git a/packages/pg-delta/src/cli/commands/schema.ts b/packages/pg-delta/src/cli/commands/schema.ts index 96e937c7..60a65562 100644 --- a/packages/pg-delta/src/cli/commands/schema.ts +++ b/packages/pg-delta/src/cli/commands/schema.ts @@ -181,7 +181,12 @@ import { isShadowProvisionError, provisionCoLocatedShadow, } from "../shadow.ts"; -import { CliExit, parseFlags, UsageError } from "../flags.ts"; +import { + CliExit, + parseFlags, + restrictToApplierFromFlags, + UsageError, +} from "../flags.ts"; import { effectiveProfileId, PROFILE_IDS, profileById } from "../profile.ts"; import type { RenameMode } from "../../plan/renames.ts"; import { assertDataLossAllowed } from "../data-loss-safety.ts"; @@ -719,6 +724,7 @@ export async function cmdSchemaApply(args: string[]): Promise { "accept-rename": { type: "multi" }, profile: { type: "value" }, "restrict-to-applier": { type: "boolean" }, + "no-restrict-to-applier": { type: "boolean" }, "strict-coverage": { type: "boolean" }, "strict-function-bodies": { type: "boolean" }, "strict-data-statements": { type: "boolean" }, @@ -741,7 +747,7 @@ export async function cmdSchemaApply(args: string[]): Promise { throw new UsageError( `${err.message}\nUsage: pgdelta schema apply --dir --target [--shadow ] ` + `[--renames auto|prompt|off] [--force] [--accept-rename =] ... ` + - `[--profile ${PROFILE_IDS}] [--restrict-to-applier] [--strict-coverage] [--strict-function-bodies] [--strict-data-statements] [--no-reorder] [--unsafe-show-secrets] [--isolated-shadow] [--scope database|cluster] [--skip-cluster-ddl] [--keep-shadow] [--allow-data-loss] ` + + `[--profile ${PROFILE_IDS}] [--restrict-to-applier] [--no-restrict-to-applier] [--strict-coverage] [--strict-function-bodies] [--strict-data-statements] [--no-reorder] [--unsafe-show-secrets] [--isolated-shadow] [--scope database|cluster] [--skip-cluster-ddl] [--keep-shadow] [--allow-data-loss] ` + `[--trusted-local-host ]... [--allow-remote-shadow] [--allow-same-database-identity]\n` + ` [--dry-run] (print the portable apply script to stdout; apply nothing; see pgdelta --help for execution requirements) [--verbose] (stream per-statement progress to stderr) [--out-plan ] (write the plan artifact)\n` + ` --shadow omitted: a co-located shadow database is created on the target's cluster (database scope only) and dropped after.`, @@ -759,6 +765,7 @@ export async function cmdSchemaApply(args: string[]): Promise { const dryRun = flags["dry-run"]; const verbose = flags["verbose"]; const outPlanPath = flags["out-plan"]; + const restrictToApplier = restrictToApplierFromFlags(flags); // The export directory's manifest (redaction mode, profile, scope), consulted // once and reused. Absent for hand-authored dirs / older exports. @@ -1028,9 +1035,8 @@ export async function cmdSchemaApply(args: string[]): Promise { seedAssumedSchemas: coLocated !== undefined, renames, ...(acceptRenames.length > 0 ? { acceptRenames } : {}), - resolveOptions: { - restrictToApplier: flags["restrict-to-applier"], - }, + resolveOptions: + restrictToApplier !== undefined ? { restrictToApplier } : {}, strictFunctionBodies: flags["strict-function-bodies"] === true, strictDataStatements: flags["strict-data-statements"] === true, reorder: !flags["no-reorder"], diff --git a/packages/pg-delta/src/cli/flags.test.ts b/packages/pg-delta/src/cli/flags.test.ts new file mode 100644 index 00000000..69555523 --- /dev/null +++ b/packages/pg-delta/src/cli/flags.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { restrictToApplierFromFlags, UsageError } from "./flags.ts"; + +describe("restrictToApplierFromFlags", () => { + test("omitted flags leave resolveProfile on its default probe", () => { + expect( + restrictToApplierFromFlags({ + "restrict-to-applier": false, + "no-restrict-to-applier": false, + }), + ).toBeUndefined(); + }); + + test("--restrict-to-applier is explicit true", () => { + expect( + restrictToApplierFromFlags({ + "restrict-to-applier": true, + "no-restrict-to-applier": false, + }), + ).toBe(true); + }); + + test("--no-restrict-to-applier is the unrestricted hatch", () => { + expect( + restrictToApplierFromFlags({ + "restrict-to-applier": false, + "no-restrict-to-applier": true, + }), + ).toBe(false); + }); + + test("both flags are a usage error", () => { + expect(() => + restrictToApplierFromFlags({ + "restrict-to-applier": true, + "no-restrict-to-applier": true, + }), + ).toThrow(UsageError); + }); +}); diff --git a/packages/pg-delta/src/cli/flags.ts b/packages/pg-delta/src/cli/flags.ts index 59c0e4d9..3efcb349 100644 --- a/packages/pg-delta/src/cli/flags.ts +++ b/packages/pg-delta/src/cli/flags.ts @@ -10,7 +10,7 @@ * }); * * - "value" flags consume the next argv token as their value. - * - "boolean" flags are true when present, absent = undefined. + * - "boolean" flags are true when present, absent = false. * - "multi" flags are repeatable; each occurrence appends one value; result is string[]. * - required: true on a "value" flag makes parseFlags throw a UsageError when absent. * - Unknown flags throw a UsageError (exit code 2 semantics). @@ -127,3 +127,20 @@ export function parseFlags( return { flags: result as ParsedFlags, positionals }; } + +/** Tri-state for resolveProfile: omitted → default probe; true → probe; + * false → unrestricted. CLI booleans are `false` when absent, so callers + * must not pass that through as `restrictToApplier`. */ +export function restrictToApplierFromFlags(flags: { + "restrict-to-applier": boolean; + "no-restrict-to-applier": boolean; +}): boolean | undefined { + if (flags["restrict-to-applier"] && flags["no-restrict-to-applier"]) { + throw new UsageError( + "cannot combine --restrict-to-applier and --no-restrict-to-applier", + ); + } + if (flags["no-restrict-to-applier"]) return false; + if (flags["restrict-to-applier"]) return true; + return undefined; +} diff --git a/packages/pg-delta/src/integrations/profile.test.ts b/packages/pg-delta/src/integrations/profile.test.ts index 9a094bbf..7f875c24 100644 --- a/packages/pg-delta/src/integrations/profile.test.ts +++ b/packages/pg-delta/src/integrations/profile.test.ts @@ -54,11 +54,12 @@ function mockPool(opts: { } describe("resolveProfile", () => { - test("rawProfile composes an unrestricted, policy-free view", async () => { + test("rawProfile composes a policy-free view; capability is probed by default", async () => { const ctx = await resolveProfile(mockPool({}), rawProfile); expect(ctx.id).toBe("raw"); expect(ctx.planOptions.policy).toBeUndefined(); - expect(ctx.planOptions.capability).toBeUndefined(); + expect(ctx.planOptions.capability).toBeDefined(); + expect(ctx.planOptions.capability?.isSuperuser).toBe(false); expect(ctx.planOptions.baseline).toBeUndefined(); expect(ctx.handlers.map((h) => h.extension)).toEqual(["supabase_vault"]); expect(typeof ctx.proveOptions.reextract).toBe("function"); @@ -85,22 +86,30 @@ describe("resolveProfile", () => { expect(raw.planOptions.profile).toEqual({ id: "raw" }); }); - test("restrictToApplier probes capability and threads it consistently", async () => { + test("omitted restrictToApplier probes capability and threads it consistently", async () => { const ctx = await resolveProfile( mockPool({ superuser: false }), supabaseProfile, - { - restrictToApplier: true, - }, ); expect(ctx.planOptions.capability).toBeDefined(); expect(ctx.planOptions.capability?.isSuperuser).toBe(false); - // the SAME capability object is shared with the proof bundle (plan == prove) expect(ctx.proveOptions.capability).toBe(ctx.planOptions.capability); }); - test("without restrictToApplier, capability stays unrestricted (no probe)", async () => { - const ctx = await resolveProfile(mockPool({}), supabaseProfile); + test("restrictToApplier: true is the same explicit probe", async () => { + const ctx = await resolveProfile( + mockPool({ superuser: false }), + supabaseProfile, + { restrictToApplier: true }, + ); + expect(ctx.planOptions.capability).toBeDefined(); + expect(ctx.proveOptions.capability).toBe(ctx.planOptions.capability); + }); + + test("restrictToApplier: false leaves the managed view unrestricted", async () => { + const ctx = await resolveProfile(mockPool({}), supabaseProfile, { + restrictToApplier: false, + }); expect(ctx.planOptions.capability).toBeUndefined(); expect(ctx.proveOptions.capability).toBeUndefined(); }); diff --git a/packages/pg-delta/src/integrations/profile.ts b/packages/pg-delta/src/integrations/profile.ts index 122038e3..fad39123 100644 --- a/packages/pg-delta/src/integrations/profile.ts +++ b/packages/pg-delta/src/integrations/profile.ts @@ -65,8 +65,11 @@ export interface IntegrationProfile { } export interface ResolveProfileOptions { - /** Probe the source pool's applier capability and restrict the managed view to - * operations that applier can execute (e.g. drop superuser-only FDW ACLs). */ + /** Restrict the managed view to operations the resolved connection can + * execute (FDW ACLs, PG16+ CREATEROLE self-ADMIN memberships). + * Omitted/`true`: probe and restrict. `false`: unrestricted view — for + * plan-here / apply-as-a-more-privileged-role-there. A superuser probe + * excludes nothing. */ restrictToApplier?: boolean; /** Directory to resolve a policy's declared baseline snapshot from (defaults * to the committed `src/policy/baselines/`). */ @@ -134,11 +137,12 @@ async function probePgMajor(pool: Pool): Promise { } /** - * Resolve a profile against the SOURCE pool: probe capability (if requested) and - * the declared baseline (if any) once, then hand back option bundles whose - * policy / capability / baseline are shared by reference across plan, prove, and - * apply. Re-extraction for proof and the apply fingerprint gate is the SAME - * handler-aware extractor, so the projected view never diverges. + * Resolve a profile against the SOURCE pool: probe capability (unless + * `restrictToApplier: false`) and the declared baseline (if any) once, then + * hand back option bundles whose policy / capability / baseline are shared by + * reference across plan, prove, and apply. Re-extraction for proof and the + * apply fingerprint gate is the SAME handler-aware extractor, so the projected + * view never diverges. */ export async function resolveProfile( pool: Pool, @@ -147,9 +151,10 @@ export async function resolveProfile( ): Promise { const { handlers, policy } = profile; - const capability = options.restrictToApplier - ? await probeApplierCapability(pool) - : undefined; + const capability = + options.restrictToApplier === false + ? undefined + : await probeApplierCapability(pool); // Superuser-context (SUSET) GUCs: a real Supabase-Cloud `postgres` is a // privileged NON-superuser, so a seeded routine's `SET TO …` @@ -161,11 +166,10 @@ export async function resolveProfile( // safe to strip from the seed. Only relevant when the profile's policy // actually declares `assumedSchemas` (nothing to seed otherwise), and only // when the applier is NOT a superuser (a superuser needs no stripping — the - // seed's routine replays as-is). Reuses the capability probed above when - // `restrictToApplier` was requested; otherwise probes locally without - // threading that probe into `planOptions`/`capability` (those stay governed - // strictly by `restrictToApplier`). The `pool` this resolves against is the - // same connection `schema apply` extracts the target from, which shares the + // seed's routine replays as-is). Reuses the capability probed above unless + // `restrictToApplier: false` (then probes locally without threading it into + // planOptions). The `pool` this resolves against is the same connection + // `schema apply` extracts the target from, which shares the // co-located shadow's cluster + role, so its GUC catalog and role are // authoritative for the shadow. Gated on the FLATTENED policy's // `assumedSchemas` (not the policy's own field) — a policy can inherit diff --git a/packages/pg-delta/src/plan/plan.ts b/packages/pg-delta/src/plan/plan.ts index 8c343b4e..478a4933 100644 --- a/packages/pg-delta/src/plan/plan.ts +++ b/packages/pg-delta/src/plan/plan.ts @@ -243,11 +243,10 @@ export interface PlanOptions { * stay as ALTERs (cycle-participating FKs, which the export routes to * `.fk.sql`). */ foldConstraints?: { exclude?: ReadonlySet }; - /** applier capability (move 6): operations the applier cannot execute (e.g. - * FDW ACLs for a non-superuser) are projected out of the view. Supplied by - * the resolved profile (`resolveProfile(pool, profile, { restrictToApplier: - * true })`), or probe directly with `probeApplierCapability` from - * `@supabase/pg-delta/integrations`. Default unrestricted. */ + /** applier capability (move 6): operations the applier cannot execute (FDW + * ACLs, PG16+ CREATEROLE self-ADMIN memberships) are projected out of the + * view. Omit for an unrestricted view (bare `plan()`). Probe with + * `probeApplierCapability`, or take it from `resolveProfile`. */ capability?: ApplierCapability; /** the integration profile id to stamp on the plan artifact (set by the * resolved profile's `planOptions`), so `apply`/`prove` can reconstruct the diff --git a/packages/pg-delta/src/policy/capability.ts b/packages/pg-delta/src/policy/capability.ts index 55290ca9..74ec632f 100644 --- a/packages/pg-delta/src/policy/capability.ts +++ b/packages/pg-delta/src/policy/capability.ts @@ -6,7 +6,8 @@ * silently emitted to fail at apply time. Capability is a property of WHO * applies, not of the objects — so it is not derivable from the catalog; it is * probed from the applier connection and threaded into plan()/prove() as an - * option. Absent, the view is unrestricted (the default — superuser/CI path). + * option. Absent from bare `plan()`, the view is unrestricted. `resolveProfile` + * probes by default; a superuser probe excludes nothing (local CI no-op). * * Projected today: * - FDW ACLs (superuser-only GRANT/REVOKE), the exclusion Supabase Rule 9 From f529b77554e552efd6cef3a10d478fd17684a52c Mon Sep 17 00:00:00 2001 From: avallete Date: Tue, 8 Sep 2026 14:11:05 +0200 Subject: [PATCH 5/5] fix(pg-delta): type-check proveOptionsFromProfile test Omit has no capability field; assert the key is absent instead of reading it. --- packages/pg-delta/src/cli/commands/prove.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pg-delta/src/cli/commands/prove.test.ts b/packages/pg-delta/src/cli/commands/prove.test.ts index b68295d9..37fc9339 100644 --- a/packages/pg-delta/src/cli/commands/prove.test.ts +++ b/packages/pg-delta/src/cli/commands/prove.test.ts @@ -60,7 +60,7 @@ describe("proveOptionsFromProfile", () => { }, reextract: async () => ({ factBase: buildFactBase([], []) }), }); - expect(fromClone.capability).toBeUndefined(); + expect("capability" in fromClone).toBe(false); expect(fromClone.reextract).toBeDefined(); }); });