Skip to content

fix(pg-delta): preserve REVOKE EXECUTE FROM PUBLIC#310

Open
cgaaf wants to merge 2 commits into
supabase:mainfrom
cgaaf:fix/preserve-revoke-from-public
Open

fix(pg-delta): preserve REVOKE EXECUTE FROM PUBLIC#310
cgaaf wants to merge 2 commits into
supabase:mainfrom
cgaaf:fix/preserve-revoke-from-public

Conversation

@cgaaf

@cgaaf cgaaf commented Jun 25, 2026

Copy link
Copy Markdown

Closes #308

What's wrong

CREATE FUNCTION grants EXECUTE to PUBLIC by default, and PostgreSQL's SECURITY DEFINER guidance recommends revoking that default and granting EXECUTE selectively. pg-delta dropped that REVOKE EXECUTE ... FROM PUBLIC from generated migrations, so a function the target schema locks down stayed PUBLIC-executable after the migration ran. It showed up two ways:

  • create-from-emptyCREATE FUNCTION was emitted but the REVOKE was omitted, leaving the function PUBLIC-executable.
  • function already exists — a lone new revoke produced an empty diff ("no changes").

Root cause

The raw catalogs preserve the negative state correctly (procedure.model.ts expands proacl IS NULL to the built-in PUBLIC/EXECUTE row; an explicit revoke materializes an ACL without that row). The loss happened in the diff: procedure.diff.ts applied filterPublicBuiltInDefaults("procedure", …) to both sides of the create and alter comparisons before diffPrivileges ran. Once PUBLIC/EXECUTE was stripped from both sides, an explicit REVOKE … FROM PUBLIC (represented as the absence of that default) became invisible, so no statement was emitted.

The fix

Stop filtering the built-in out of both sides; compare the real ACL the routine will have against the desired ACL:

  • Existing routines (alter path): pass the raw source/target privilege arrays to diffPrivileges. Default-on-both cancels; default→revoked emits a REVOKE; revoked→default emits a GRANT.
  • New routines (create path): compare the desired ACL against the ACL the routine actually has right after creation:
    • normal plan — use the effective default-privilege state. Per-schema ALTER DEFAULT PRIVILEGES entries are additive to the global routine defaults in PostgreSQL (a per-schema entry can't subtract the global built-in PUBLIC EXECUTE), so global and schema defaults are unioned rather than letting a schema entry mask the global built-in.
    • self-contained / skipDefaultPrivilegeSubtraction — declarative output intentionally ignores role-configured defaults, but PostgreSQL still creates routines with PUBLIC EXECUTE, so a minimal built-in baseline (PUBLIC/EXECUTE/not grantable) is used as the comparison floor.
    • the existing owner-privilege filtering (creator privileges on ownership transfer) is preserved.

The change is local to procedure.diff.ts (plus a small typed PUBLIC_EXECUTE_DEFAULT constant and a private de-dup helper); no changes to base.privilege-diff.ts or other object diffs.

Scope

pg-delta's procedure diff path covers both functions and procedures, so the fix applies to both even though the issue's reproducer is a function. Aggregates have a separate diff and are unchanged. The same filterPublicBuiltInDefaults helper also strips PUBLIC USAGE for types and languages; those paths are not touched here — they're unverified and out of scope for #308.

Tests

Authored test-first (RED → GREEN); the two commits are split so the test: commit can be checked out and watched to fail.

  • Unit (procedure.diff.test.ts): create→revoke-when-target-absent; create-for-procedures (not just functions); no-redundant-grant when the built-in is kept; global+schema default union; alter revoke; alter restore (grant); create-after-global-ADP-revoke emits nothing redundant; declarative create (both keep-default and revoke-default).
  • Integration (tests/integration/revoke-execute-from-public.test.ts, PG15/17/18): create preserves the revoke and has_function_privilege confirms an unprivileged role can't execute on either DB; alter-only revoke; planned global default revoke avoids a redundant per-function revoke; self-contained plan emits only the needed explicit revoke.
  • A changeset (patch, @supabase/pg-delta) is included.

Verification

Reproduces from the CLI with pgdelta plan --target <url> --format sql (create-from-empty omits --source); also via pgdelta sync / declarative-apply and Supabase's db schema declarative sync. Environment: macOS 26.5.1 (Darwin, Apple Silicon), Bun 1.3.14, Docker 29.5.3, PostgreSQL 15/17/18 via Alpine testcontainers.

RED — at the test: commit (production unchanged from base):

unit:                     18 pass, 5 fail   (the PUBLIC create/alter/declarative cases; "expected revoke length 1, received 0")
integration (PG17):        1 pass, 3 fail   (create / alter / self-contained)

GREEN — after the fix: commit:

unit:                     23 pass, 0 fail
integration (PG17):        4 pass, 0 fail
default-privileges-edge-case (PG17, adjacent regression): 17 pass, 0 fail
bun run test:pg-delta:    2663 pass, 10 skip, 0 fail   (full suite, PG15/17/18)
bun run build / check-types / format-and-lint / knip:    all clean

Disclosure

  • This is my first contribution to pg-toolbelt.
  • It was implemented with assistance from Claude Opus 4.8 and GPT-5.5. I'm flagging this so reviewers can scrutinize it accordingly — I verified the reproduction and every claim against a real PostgreSQL instance, and the diff is small enough to review line-by-line.

cgaaf added 2 commits June 24, 2026 22:21
…outines

CREATE FUNCTION grants EXECUTE to PUBLIC by default. An explicit
REVOKE EXECUTE ... FROM PUBLIC (represented as the absence of that
built-in default) was dropped from generated migrations, leaving the
routine PUBLIC-executable after the migration was applied.

Adds unit coverage in procedure.diff.test.ts and an end-to-end
integration regression (tests/integration/revoke-execute-from-public.test.ts)
across PG15/17/18: create-from-empty, alter-existing, planned global
default revoke, and the self-contained/declarative plan path.

RED at this commit (production unchanged from 9284412):

  unit (src/core/objects/procedure/procedure.diff.test.ts): 18 pass, 5 fail
    - create emits PUBLIC EXECUTE revoke when the default is absent in the target
    - create emits PUBLIC EXECUTE revoke for procedures as well as functions
    - alter emits PUBLIC EXECUTE revoke when an existing routine removes the built-in default
    - alter emits PUBLIC EXECUTE grant when an existing routine restores the built-in default
    - declarative create emits PUBLIC EXECUTE revoke when the target removes the built-in default

  integration PG17 (tests/integration/revoke-execute-from-public.test.ts): 1 pass, 3 fail
    - create preserves REVOKE EXECUTE FROM PUBLIC and applies the locked-down behavior
    - alter emits REVOKE EXECUTE FROM PUBLIC for an existing function
    - self-contained plan emits only the explicit PUBLIC revoke needed for locked-down functions

Failures are the missing REVOKE/GRANT, e.g. "expected revoke length 1, received 0".

Refs supabase#308
CREATE FUNCTION grants EXECUTE to PUBLIC by default, and the procedure
diff filtered that built-in out of BOTH sides before diffPrivileges ran
(filterPublicBuiltInDefaults on the create and alter paths). An explicit
REVOKE ... FROM PUBLIC is the absence of that default, so once both sides
were filtered the revoke became invisible and was dropped.

Compare the real ACLs instead:
- existing routines: pass the raw source/target privilege arrays to
  diffPrivileges, so default<->revoked emits REVOKE/GRANT;
- new routines: compare the desired ACL against the ACL the routine has
  right after creation -- the effective default-privilege state normally
  (unioning global + schema routine defaults, since per-schema ALTER
  DEFAULT PRIVILEGES is additive and cannot subtract the global built-in),
  or a minimal PUBLIC/EXECUTE baseline under skipDefaultPrivilegeSubtraction
  so self-contained/declarative plans still emit the explicit target ACL.

Owner-privilege filtering on ownership transfer is unchanged. Scope is the
procedure path (functions and procedures); aggregates and PUBLIC USAGE on
types/languages are untouched.

GREEN after this commit:
  unit: 23 pass, 0 fail
  integration PG15/17/18 (revoke-execute-from-public.test.ts): all pass
  full suite (bun run test:pg-delta): 2663 pass, 10 skip, 0 fail

Fixes supabase#308
@changeset-bot

changeset-bot Bot commented Jun 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: abf6534

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@supabase/pg-delta Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@cgaaf
cgaaf marked this pull request as ready for review June 25, 2026 13:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pg-delta: REVOKE EXECUTE ... FROM PUBLIC on a function is dropped from the diff

1 participant