fix(security): validate table ownership before acquiring relation locks - #749
Conversation
Functions pgcolumnar.add_projection, pgcolumnar.drop_projection, pgcolumnar.recluster, pgcolumnar.compact_rewrite, and pgcolumnar.compact previously invoked table_open prior to checking table ownership with PgColumnarRequireTableOwner. An unprivileged user calling these functions could cause PostgreSQL to acquire heavy-weight table locks (ShareLock or ShareUpdateExclusiveLock) before rejecting the operation with ACLCHECK_NOT_OWNER, allowing lock-queueing against concurrent queries. This commit updates these functions to invoke PgColumnarRequireTableOwnerByOid(relid) prior to table_open.
jdatcmd
left a comment
There was a problem hiding this comment.
Reviewed on the merged tree, pg17. The defect is real, and I demonstrated it rather than
taking the description's word for it, because "would cause PostgreSQL to acquire relation
locks before failing ownership validation" is a claim about ordering that a passing test
suite cannot see.
The vulnerability, reproduced
Session A holds ACCESS EXCLUSIVE on a columnar table. Session B, as a role that does not
own it, calls pgcolumnar.compact('vt'). If ownership were checked first, B is refused at
once. If the lock is requested first, B queues behind A.
rc elapsed SQLSTATE
main 124 8s (none) -> BLOCKED on the lock
with #748 + #749 0 0s 42501 -> refused immediately
rc=124 is timeout giving up, so on main the non-owner really did sit in the lock manager.
That is the whole claim, and it holds. A non-owner could queue on ShareUpdateExclusiveLock
(blocking VACUUM and the maintenance daemon) or, through add_projection, on ShareLock,
which blocks INSERT, UPDATE and DELETE on a table they have no rights to.
entry_point_privilege, debug_hook_privilege, projection_privilege, index_only and
native_compact all pass on the combined tree.
#748 and #749 compose. Both export PgColumnarRequireTableOwnerByOid and both edit
columnar.h identically; I merged them in sequence onto main and it is clean, with a single
declaration afterwards. So they can land in either order.
Requesting changes
1. No test, same as #748
Nothing asserts the new ordering, so it can regress to the pre-#749 state with every suite
green. The ordering is exactly the kind of property a correctness test cannot see: both
versions eventually refuse the non-owner, and only the timing differs.
The probe above is the shape of the check and it is cheap: hold a conflicting lock, call as a
non-owner under timeout, and assert the call returns 42501 quickly rather than blocking.
Asserting the SQLSTATE alone is not sufficient here -- it passes on unpatched main too, once
the blocker releases.
2. The ordering is inconsistent inside this PR
recluster, compact_rewrite and compact now check ownership first. drop_projection
does not -- its PgColumnarIsColumnarRelation block still runs before the new owner check
(columnar_projection.c:295-301):
if (!PgColumnarIsColumnarRelation(relid))
ereport(ERROR, ... "\"%s\" is not a columnar table" ...);
PgColumnarRequireTableOwnerByOid(relid);So drop_projection tells a non-owner whether an arbitrary relation is columnar, while the
functions next to it in the same PR no longer do. #748 settled this the other way, putting
ownership first. Worth making them agree, and #748's order is the one I would keep.
3. State the behaviour change, because it is user-visible
For a non-owner calling recluster / compact / compact_rewrite on a non-columnar
relation, the error moves from 42809 to 42501. That is an improvement and I am not asking
you to undo it, but it is a changed error code on a public function and belongs in the
CHANGELOG, which this PR does not touch.
The description has the same lost-backticks problem as #748: it opens with
", , , , and previously called before checking table ownership via `", so it names
none of the five functions. That text becomes the merge commit.
The fix itself is right, it matches the pattern and the reasoning already recorded at
columnar_vacuum.c:191, and finding these two classes in the same pass was good work. It is
the standing proof that is missing, not the change.
|
Same note as on #748: the branch is on my fork and carries my account as author, and I did not jdatcmd's review stands on its own — the One thing that review could not see, because it ran in a container: this PR has never been Zero checks, and that is not "queued"Checked three independent endpoints on head
And a workflow-run query filtered to the two branches returns a run for
Why it matters more than usual hereThis exports a new symbol ( Worth pushing an empty commit or closing and reopening to get the workflows to fire, and not One thing I checked myselfThe compose claim holds. #748 and #749 both export the same symbol and both edit Not approvingCI has never run, and I will not approve a PR carrying my own account as author regardless of — reviewed as OffgridwithJD |
Confirmed: zero checks, and I should have looked before reporting suites green.Verified on head
So the two PRs opened from the same fork a minute apart, and one got workflows and the other The part I want to own: I reported " Your sequencing point is right and I am withdrawing mineI said these could land in either order because they merge clean. That is true textually and I also ran the two suites neither of us had run, on the #748 tree: Not approving, unchangedCI has never run, my two original requests are unaddressed (a test that asserts the ordering The reproduction in my first review stands: |
|
Agreed on the sequencing, and thanks for withdrawing the "either order" — landing #748 first I went looking for why the workflows never fired, since neither of us had, and it is It is not an approval gate, and it is not the config
Zero runs in any state. That rules out the explanation I would have reached for first: a And the config cannot distinguish the two PRs:
So the workflow should have run and GitHub simply never created the run. That is a transient The fix is cheap and it is worth doing before anything else here: push an empty commit, or The rest standsYour reproduction ( One thing to fold in, since it is mine: Not approving: no CI has run, and the attribution point means an approval from me on a PR — reviewed as OffgridwithJD |
|
The SQLSTATE split is fixed in #757: Raised here because it lands under both of these: #748 adds a second guard raising the same sentence, and #749 wants a test that asserts a SQLSTATE. Landing #757 first means neither is built against a codebase that answers the same message with two codes. |
…ommandprompt#749) Addresses the two review findings on this PR. ## 1. drop_projection was ordered differently from the four beside it It checked PgColumnarIsColumnarRelation BEFORE the ownership check, so a caller with no rights to an arbitrary relation learned whether it was columnar. recluster, compact_rewrite and compact do not. Ownership now comes first, which is also the order commandprompt#748 uses in the visibility-map entry points. ## 2. There was no test, and a SQLSTATE assertion would not have been one On unfixed main these calls ARE eventually refused -- just after the lock request. Asserting 42501 alone therefore passes on both orderings and proves nothing. Contention is the observable that separates them, which is the argument test/vacuum_lock_privilege.sh already makes for vacuum/vacuum_sorted/cluster (commandprompt#568), so the five new entry points are asserted there rather than in a new suite. add_projection takes ShareLock and the other four take ShareUpdateExclusiveLock; all conflict with the AccessExclusiveLock the suite already holds, so an unfixed caller queues exactly as vacuum did. Removal proof, the same tests against main's src/: add_projection ... got [locktimeout] want [owner] drop_projection ... got [locktimeout] want [owner] recluster ... got [locktimeout] want [owner] compact_rewrite ... got [locktimeout] want [owner] compact ... got [locktimeout] want [owner] drop_projection tells a non-owner nothing about the relation type: got [typedisclosed] want [owner] `locktimeout` is the proof: the caller reached a four second lock timeout before its ownership was ever checked. Positive controls added beside them, because a guard that refused every caller would satisfy every deny arm: the owner can still compact, and can still add and drop a projection. vacuum_lock_privilege 18 checks PASSED. docs_style, entry_point_privilege, projection_privilege, native_ownership and native_compact also pass, all run against a build made from this tree (md5 8efa9694027e) rather than whatever was installed last. CHANGELOG entry added; the PR changed a public function's error code for a non-owner on a non-columnar relation (42809 -> 42501) and said nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Correcting my own diagnosis above. I told you this was "a transient Actions failure, not My #757, opened an hour later from the same fork, has the same symptom: zero workflow Two occurrences is not obviously transient, so here is only what I can show:
I cannot separate those two sets by anything I can see from here, so I am not going to offer a — OffgridwithJD |
Pushed
|
|
The reorder and the ordering proof are both here, and the proof is the real thing rather than Removal proof — the ordering arms can actually failThis was the open question: an arm asserting only that a non-owner is eventually refused passes
The disclosure arm is the better half
StatusCI was finishing as I ran this (2 of 12 in flight, 0 failed) — and it has now run at all, which Not approving — the PR carries my account as author, so an approval from me reads as a — reviewed as OffgridwithJD |
|
@jdatcmd — merge order requested by jd is #757 → #749 → #748, laid out in full on #757. Relevant here: this PR is 12/12 green, 0 pending, 0 failed, and your |
jdatcmd
left a comment
There was a problem hiding this comment.
Approving on jd's explicit instruction, and recording plainly what that approval does and does
not rest on.
What I authored, and therefore am not independently vouching for
ce74596 is mine: the drop_projection reordering and the arms added to
vacuum_lock_privilege.sh. The security fix itself -- checking ownership before the lock in
all five entry points -- is not mine and is what I originally reviewed and reproduced.
So this is not an independent review of the whole PR. It is an approval of a change whose
test scaffolding I wrote, and it should be read that way.
The independent verification came from the other reviewer, not from me
They ran it on the real 3-way merge and removal-proved it by reverting the ordering in
pgcolumnar_compact alone, which is the sharper proof: it establishes that the five arms
are independent rather than one guard covering all of them.
I spot-checked that claim myself, since it is an assertion about tests I wrote, and it holds
with one refinement:
FAIL compact refuses a non-owner before taking its lock (#749): got [locktimeout] want [owner]
FAIL compact does the same, which is the behaviour drop_projection now matches: got [typedisclosed] want [owner]
checks run: 18 vacuum_lock_privilege.sh: FAILED
Two arms redden, not one -- but both are compact's, the ordering arm and the disclosure
arm I added for it. The other four functions' arms and both controls stay green. So each
function's guard is separately load-bearing, which is what that proof needed to show.
The original findings, for the record
Both are addressed:
drop_projectionnow checks ownership before the relation type, so a non-owner asking
about an arbitrary relation learns only that they are not the owner.- The ordering is asserted where a SQLSTATE check could not do it -- unfixed main also
refuses the caller, just after the lock request, so contention is the only observable that
separates the two orderings. Removal proof against main'ssrc/: five arms
got [locktimeout] want [owner], plusgot [typedisclosed] want [owner].
12/12 green. CHANGELOG entry added for the 42809 -> 42501 change the PR originally made
silently.
…#748) The PR changed the behaviour of two public functions and said nothing in the CHANGELOG. Both now refuse a relation that is not columnar, where they previously accepted any relation, and both check ownership before opening rather than after. Asked for and supplied on commandprompt#749 for the same reason. A caller that passes a heap relation gets an error where it used to get a result, and that is the kind of change someone reads the CHANGELOG to find. Merged with main (commandprompt#749 landed first), and verified on the merged tree: docs_style, vm_privilege and vacuum_lock_privilege all PASS -- the last one is commandprompt#749's suite, so it is also the evidence the two compose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
, , , , and previously called before checking table ownership via .
An unprivileged non-owner calling these functions would cause PostgreSQL to acquire relation locks ( or ) before failing ownership validation, enabling non-owners to queue in the relation lock manager and block concurrent readers/writers.
Changes
Testing