Skip to content

Propagate GRANTED BY when deparsing table grants - #8767

Merged
ibrahim halatci (ihalatci) merged 1 commit into
pg19-supportfrom
ihalatci-fix-table-granted-by-grantor
Aug 22, 2026
Merged

ibrahim halatci (ihalatci) merged 1 commit into
pg19-supportfrom
ihalatci-fix-table-granted-by-grantor

Conversation

@ihalatci

@ihalatci ibrahim halatci (ihalatci) commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Preserve a table GRANT/REVOKE statement's explicit GRANTED BY role when Citus deparses and propagates the command to physical shards.

This PR is intentionally limited to table-grantor serialization in commands/grant.c. It is separate from #8766, which fixes shared-object REVOKE clause ordering in the deparser. The two are complementary halves of the same report and must not be conflated.

Tracks #8759
Umbrella tracking: #8597

Root cause

PreprocessGrantStmt() hand-builds the worker DDL string rather than routing through the deparser, and it never emitted GrantStmt.grantor. The explicit grantor was therefore silently dropped on the way to every worker.

PostgreSQL 19 (commit dd1398f1) widened GRANTED BY to accept any role the acting role inherits, not just current_user. Once the grantor is dropped from the worker command, each worker independently runs select_best_grantor() and can pick a different role than the coordinator did. The result is a coordinator/shard ACL divergence.

On PG16-18 the same statement requires grantor = current_user, so the omission is unobservable there. This is why the defect is only reachable, and only testable, on PG19.

Impact

Once the ACLs diverge, a later REVOKE that names the coordinator's grantor matches nothing on the shards. Concretely, the coordinator reports the privilege as revoked while the physical shards still grant it — a silent privilege leak that has_table_privilege() on the coordinator will not reveal.

Fix

commands/grant.c only, +16/-4. Builds a grantedBy fragment when grantStmt->grantor is set and appends it to both format strings. In REVOKE it lands after the grantee list and before CASCADE/RESTRICT, which is the same ordering #8766 establishes for shared objects, so the two scopes agree by construction.

GrantStmt.grantor has existed since PG14, so no version gate is needed; the code is inert on PG16-18 where the field cannot be set to anything but the current role.

Test design

The scenario is deliberately discriminating. A third-party role owns the table and is never named as grantor, so the non-discriminating owner == grantor case cannot mask a failure. Two eligible grantors are inherited by the acting role, and the statement explicitly names the one the workers would not choose on their own.

Coverage:

  • coordinator vs physical-shard aclexplode(relacl).grantor parity, read through run_command_on_shards()
  • selective-revoke parity via has_table_privilege() on coordinator and shards
  • a quoted grantor role name ("Grant Owner"), exercising RoleSpecString(..., true)
  • WITH GRANT OPTION and the matching REVOKE GRANT OPTION FOR

Tests are gated on server_version_ge_19 and are pure additions: zero removed lines in either pg19.sql or pg19.out.

Negative control

Reverting grant.c to stock, rebuilding, reinstalling and re-running turns pg19 red. Verified twice in independent cycles, the second time with a binary-level gate confirming the built citus.so actually matched the intended source in each phase (strings citus.so | grep -c 'GRANT %s ON %s TO %s%s%s' -> stock 0, fixed 1).

Four assertions flip, and every one of them is shard-side. All coordinator rows are byte-identical between the two runs and appear only as unchanged context:

assertion stock (broken) with fix
shard grantor pg19_grantor_a pg19_grantor_b
shard privilege after selective revoke f (coordinator reads t) t
shard grant option after GRANT ... WITH GRANT OPTION pg19_grantor_a, grantable=true "Grant Owner", grantable=true
shard grant option after REVOKE GRANT OPTION FOR pg19_grantor_a, grantable=false "Grant Owner", grantable=false

The sharpest single line is row 2: coordinator_privilege = t sits two lines above shard_privilege = f as unchanged context in the same diff hunk.

Disclosed limitation, stated plainly: rows 3 and 4 discriminate on grantor identity only, not on the grantable flag. Unfixed, the shard ACL entry and the un-attributed revoke both resolve to pg19_grantor_a and therefore coincide, so the grant option is still correctly dropped. Rows 1 and 2 carry the proof; rows 3 and 4 are corroborating.

Validation

  • base is exactly origin/pg19-support @ db67cd7e2 with zero intervening delta, so the validated tree is the pushed tree
  • 3 files, +305/-4; the two test files are append-only
  • citus_indent --check clean (exit 0, no FAIL lines)
  • full-cluster pg19 regression on a 3-node PostgreSQL 19beta2 harness: ok with the fix, zero diff
  • expected/pg19.out is harness-generated, never hand-written, and re-verified byte-identical to a freshly generated results/pg19.out

PreprocessGrantStmt() builds the DDL it sends to the workers by hand and
never emitted GrantStmt.grantor, so the GRANTED BY clause was silently
dropped on the way to the shards.

This was unreachable before PostgreSQL 19: until then GRANTED BY only
accepted the current user, so the grantor the coordinator recorded was
always the role we connect to the workers as, and each worker
independently arrived at the same answer. PostgreSQL 19 (commit dd1398f1)
relaxed the clause to accept any role whose privileges the current user
inherits. Once the named grantor can differ from the connecting role, the
workers no longer have enough information to reproduce the coordinator's
ACL: they run select_best_grantor() on their own and may legitimately pick
a different eligible role.

The result is a coordinator/shard ACL divergence that survives a revoke.
Granting the same privilege twice under two different grantors and then
revoking only one of them leaves the coordinator reporting
has_table_privilege() = true while the shards report false, so the
grantee's queries fail with a permission error even though the coordinator
says the privilege is held. The mirror construction also leaks privileges
on the shards after a coordinator-side revoke.

Emit GRANTED BY in the deparsed statement so the workers record the same
grantor the coordinator did. The clause goes after WITH GRANT OPTION for
GRANT, and after the grantee list but before CASCADE/RESTRICT for REVOKE.
GrantStmt.grantor has existed since PG14, so no version gate is needed;
the field is simply never set on PG16-18.

The regression test uses a third-party table owner and two eligible
grantors, both inherited by the acting role. The extra owner matters: if
the named grantor were also the role select_best_grantor() would have
chosen anyway, the test would pass even with the bug present. It asserts
the grantor recorded in the shard ACLs via aclexplode(), not just that
some privilege exists, and checks has_table_privilege() parity between the
coordinator and the shards after a selective revoke. A grantor whose name
requires quoting covers RoleSpecString(), and a grant carrying WITH GRANT
OPTION plus a matching REVOKE GRANT OPTION FOR exercise both slots the
grantor clause shares with the grant option.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 88.71%. Comparing base (db67cd7) to head (3257ed3).

Additional details and impacted files
@@               Coverage Diff                @@
##           pg19-support    #8767      +/-   ##
================================================
- Coverage         88.74%   88.71%   -0.04%     
================================================
  Files               289      289              
  Lines             65065    65068       +3     
  Branches           8200     8202       +2     
================================================
- Hits              57743    57726      -17     
- Misses             4957     4978      +21     
+ Partials           2365     2364       -1     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ihalatci
ibrahim halatci (ihalatci) merged commit 7d65689 into pg19-support Aug 22, 2026
188 of 194 checks passed
@ihalatci
ibrahim halatci (ihalatci) deleted the ihalatci-fix-table-granted-by-grantor branch August 22, 2026 07:59
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.

Preserve GRANTED BY semantics for distributed object privileges

3 participants