Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/createrole-implicit-admin-membership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@supabase/pg-delta": patch
---

A PG16+ CREATEROLE non-superuser cannot replay `GRANT <role> TO <self> 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.
8 changes: 4 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 <from>=<to>`.
- **`--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.

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"isolatedCluster": true,
"minVersion": 16,
"createroleApplier": true
}
17 changes: 11 additions & 6 deletions packages/pg-delta/src/cli/commands/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -36,7 +40,7 @@ const USAGE =
"Usage: pgdelta plan --source <pg-url> --desired <pg-url> " +
`[--profile ${PROFILE_IDS}] ` +
"[--renames auto|prompt|off] [--no-compact] [--out <plan.json>] " +
"[--accept-rename <from>=<to>] ... [--restrict-to-applier] [--strict-coverage] " +
"[--accept-rename <from>=<to>] ... [--restrict-to-applier] [--no-restrict-to-applier] [--strict-coverage] " +
"[--unsafe-show-secrets]\n";

export function formatPlanIdentityWarning(
Expand Down Expand Up @@ -70,6 +74,7 @@ export async function cmdPlan(args: string[]): Promise<void> {
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" },
});
Expand Down Expand Up @@ -127,11 +132,11 @@ export async function cmdPlan(args: string[]): Promise<void> {
// 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,
});

Expand Down
18 changes: 18 additions & 0 deletions packages/pg-delta/src/cli/commands/prove.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";

Expand Down
14 changes: 13 additions & 1 deletion packages/pg-delta/src/cli/commands/prove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
provePlan,
type ProofCoverage,
type ProofVerdict,
type ProveOptions,
type TableRef,
} from "../../proof/prove.ts";
import type {
Expand Down Expand Up @@ -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<ProveOptions, "capability"> {
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
Expand Down Expand Up @@ -595,6 +606,7 @@ export async function cmdProve(args: string[]): Promise<void> {
);
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
Expand All @@ -610,7 +622,7 @@ export async function cmdProve(args: string[]): Promise<void> {
// 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"],
});
Expand Down
16 changes: 11 additions & 5 deletions packages/pg-delta/src/cli/commands/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -719,6 +724,7 @@ export async function cmdSchemaApply(args: string[]): Promise<void> {
"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" },
Expand All @@ -741,7 +747,7 @@ export async function cmdSchemaApply(args: string[]): Promise<void> {
throw new UsageError(
`${err.message}\nUsage: pgdelta schema apply --dir <dir> --target <pg-url> [--shadow <pg-url>] ` +
`[--renames auto|prompt|off] [--force] [--accept-rename <from>=<to>] ... ` +
`[--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 <hostname>]... [--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 <plan.json>] (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.`,
Expand All @@ -759,6 +765,7 @@ export async function cmdSchemaApply(args: string[]): Promise<void> {
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.
Expand Down Expand Up @@ -1028,9 +1035,8 @@ export async function cmdSchemaApply(args: string[]): Promise<void> {
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"],
Expand Down
40 changes: 40 additions & 0 deletions packages/pg-delta/src/cli/flags.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
19 changes: 18 additions & 1 deletion packages/pg-delta/src/cli/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -127,3 +127,20 @@ export function parseFlags<T extends FlagsDef>(

return { flags: result as ParsedFlags<T>, 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;
}
Loading
Loading