diff --git a/.changeset/createrole-implicit-admin-membership.md b/.changeset/createrole-implicit-admin-membership.md new file mode 100644 index 00000000..318d5dcd --- /dev/null +++ b/.changeset/createrole-implicit-admin-membership.md @@ -0,0 +1,5 @@ +--- +"@supabase/pg-delta": patch +--- + +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/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/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/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/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..37fc9339 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("capability" in fromClone).toBe(false); + 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 b8c8cba4..7f875c24 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 }] }; } @@ -41,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"); @@ -72,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.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..74ec632f 100644 --- a/packages/pg-delta/src/policy/capability.ts +++ b/packages/pg-delta/src/policy/capability.ts @@ -6,12 +6,14 @@ * 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). * - * 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 +28,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 +46,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 +57,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/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..ee3d387e --- /dev/null +++ b/packages/pg-delta/tests/createrole-implicit-admin-membership.test.ts @@ -0,0 +1,169 @@ +/** + * 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"; +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(true); + 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;