Reject dropping columns used by projections - #891
Conversation
Co-authored-by: Cursor <cursoragent@cursor.com>
OffgridwithJD
left a comment
There was a problem hiding this comment.
Reviewed at 6bbeb1e on pgcolumnar-audit, PG 18.4 assert build (/usr/local/pg18a). I reproduced
your motivating defect on unfixed main, ran your suite on both trees, and then probed the refusal
with five fixtures it does not have.
The defect is real, the fix works, and four of my five probes came back clean. One did not, and it
is the reason I am requesting changes: a user with no rights to the table can tell which of its
columns a projection covers.
Your premise reproduces exactly
On main 9628414, .so 3e37c0af7b85, fix present = 0 asserted at source:
DROP COLUMN sort_key -> accepted, column gone
next INSERT -> ERROR: type with OID 0 does not exist
rows -> stuck at 1000, table unwritable
Word for word what your header describes.
Your suite, same fixtures, two trees:
| tree | .so |
result |
|---|---|---|
main 9628414 |
3e37c0af7b85 |
0 passed, 6 failed |
your 6bbeb1e |
7d4257a1f330 |
6 passed, 0 failed |
What holds under probing
| probe | result |
|---|---|
DROP COLUMN IF EXISTS <projected> |
refused, 2BP01 — the IF EXISTS spelling does not slip past |
DROP COLUMN IF EXISTS <missing> |
no error — correctly not your business |
ADD COLUMN z, DROP COLUMN <projected> in one ALTER |
refused, and the ADD did not leak: whole statement rolled back |
ALTER TABLE ip DROP COLUMN sk on an inheritance child (not a partition) |
refused, 2BP01, child column preserved |
ALTER TABLE ONLY pp DROP COLUMN sk |
42P16 from core — the !inh branch is unreachable for a partitioned parent, which is fine |
And the invariant your check leans on is enforced: add_projection with a sort_key outside
columns raises 22023, so testing columns alone really is sufficient. That is worth one line in
the comment, because the sufficiency is not local to the code that relies on it.
The finding: the refusal answers before core checks permissions
pgcolumnar_reject_drop_projected_column runs in the pre-statement half of the hook, before
standard_ProcessUtility, so it reports on a table the caller may have no rights to. Measured, as
role nobody (LOGIN, no grants), same table, two columns:
non-owner DROP of a PROJECTED column -> 2BP01 (your dependency error)
non-owner DROP of an UNPROJECTED column -> 42501 (core's permission denied)
The error code differs by whether a projection covers the column, so a stranger can enumerate the
projected columns of a table they cannot read, one statement at a time. It also means such a caller
takes ShareUpdateExclusiveLock on that table before any privilege check — held to end of
transaction by design, per your comment — which is a lock a non-owner should probably not be able to
take at will.
This repo already treats that boundary as load-bearing: there is a projection_privilege suite, and
#562 revoked EXECUTE from PUBLIC on exactly this metadata precisely so the C check is not the only
barrier. The disclosure is modest and I am not calling it a vulnerability. It is a regression in a
boundary the project has already decided to defend, it has no arm, and it is much cheaper to fix now
than after release.
Two shapes of fix, your call: check ownership before reporting (pg_class_ownercheck /
PgColumnarRequireTableOwnerByOid, which add_projection already uses), or move the check to where
core has already decided the caller may proceed. The second is a bigger change and I would not ask
for it.
One comment that is factually wrong, and it is the dangerous kind
* Serialize against add_projection(), which takes ShareUpdateExclusiveLock,pgcolumnar_add_projection takes ShareLock — columnar_projection.c:198, with its own comment
explaining the choice as "the same lock non-concurrent CREATE INDEX takes". Your conclusion is still
right, because ShareUpdateExclusiveLock and ShareLock do conflict, so the serialization you want
does happen. But the stated reason is wrong, and a later reader who trusts it could "simplify" your
lock level against a premise that was never true. Naming the real conflict pair is the fix.
A scope note, not a defect
Your rationale is the crash, and the refusal is broader than the crash. Measured on main:
drop the projection's SORT KEY column -> next INSERT dies, type with OID 0
drop a projected NON-sort-key column -> DROP and INSERT both SUCCEED; the projection
silently reads empty
I think refusing both is the right call — a projection reading empty is worse than a loud refusal —
but the header explains only the first, and the second is the case a reader is more likely to hit.
Worth a sentence so nobody later narrows the check to sort keys on the strength of the rationale.
For completeness, one thing I checked and am not reporting: after DROP COLUMN, the base
projection's columns still names the dropped attnum ({1,2,3} with payload gone), and you skip
projection_id 0 deliberately. I could not demonstrate any consequence — read_projection(t,'base')
raises 42704 with no drop at all, so that is by design and not a symptom. Mentioning it only so
the next reviewer does not spend the same hour on it.
Also: your CI has never run
Both your PRs sit at completed/action_required. You are a first-time contributor pushing from
linuxhikerpm/pgcolumnar with read permission, so GitHub is holding the workflows for a maintainer.
gh pr checks says "no checks reported", which reads like "queued" and is not.
@jdatcmd — both #888 and #891 need that click before either has a gate.
Requesting changes for the privilege ordering and the two comment corrections. The fix itself is
sound, the tests are honest, and your partition arm is better than most in this tree. Once the
ownership check is in and CI is green I expect to approve.
One heads-up, since we are in the same function: I am implementing #887, which adds a post-statement
block to pgcolumnar_process_utility for AlterTableStmt and TruncateStmt. It will conflict with
your hunk at :2588 textually but not semantically — yours refuses before the statement, mine repairs
after it. Whoever lands second rebases; I am happy for that to be me.
|
CI is now released on this PR. The workflow run has moved from Worth stating plainly for the record, because the failure mode here is quiet: this PR had no which reads like "not configured" or "queued" and actually meant "held pending a maintainer's For what it is worth on the safety question, since running a contributor's code is the reason the |
Co-authored-by: Cursor <cursoragent@cursor.com>
|
Addressed the requested changes in
The updated regression and @OffgridwithJD please re-review when convenient. @jdatcmd this push will need CI released again. |
OffgridwithJD
left a comment
There was a problem hiding this comment.
Re-reviewed at 6718048. The leak is closed and I measured it rather than reading the diff. My
blocking finding is resolved.
I re-ran the same probe from the first review, unchanged, against your new tree
(.so 08622af8ad84):
before (6bbeb1ee) now (6718048)
non-owner DROP of a PROJECTED column 2BP01 42501
non-owner DROP of an UNPROJECTED column 42501 42501
Identical codes for both column kinds, so the error no longer distinguishes a projected column from
an unprojected one and a stranger learns nothing about the projection set. Your two new arms assert
exactly that, and they pass.
The fix did not cost the feature, which was the thing I most wanted to check: an over-eager
ownership check could easily have made the refusal stop firing. All four of my other probes still
behave, on your new tree:
| probe | result |
|---|---|
owner, DROP COLUMN IF EXISTS <projected> |
refused, 2BP01 |
owner, DROP COLUMN IF EXISTS <missing> |
no error, correctly not your business |
owner, ADD COLUMN z, DROP COLUMN <projected> |
refused, and the ADD did not leak |
owner, ALTER TABLE ip DROP COLUMN sk on an inheritance child |
refused, 2BP01, child column preserved |
ALTER TABLE ONLY pp DROP COLUMN sk |
42P16 from core, as before |
Your suite is 8 for 8, and the two arms I asked about last time still pass.
Two details I want to credit, because both are better than what I asked for:
You check ownership twice, at :2514 before taking the lock and again at :2526 after. I asked
only for a check. The recheck closes a window I had not named: ALTER TABLE ... OWNER TO could
commit between the first test and the lock, and without the second test the caller would then be
acting on a table they no longer own. Checking before the lock is also the right order for the
disclosure itself, since it means a non-owner never reaches the metadata read at all.
The lock comment now names the real conflict: "add_projection() takes ShareLock, which conflicts
with our ShareUpdateExclusiveLock". That is the correct pair. The old wording asserted
add_projection took ShareUpdateExclusiveLock, and a later reader trusting it could have
"simplified" the lock level against a premise that was never true.
Where this leaves the PR
Your CI is held again. Run 34289398470 on 6718048 is completed/action_required, while the
run on your previous head, 34283785885, finished green. Releasing it is a decision a person makes
each time a fork PR from a read-permission account is pushed, not a formality, so it may take a
little while. I have asked @jdatcmd.
I am leaving my earlier CHANGES_REQUESTED standing only because this repository does not dismiss
stale reviews and I would rather not imply a green I have not seen. To be unambiguous: nothing in
the code is blocking from my side any more. The remaining gap is a green full CI, and I have only
run PG18 locally. When CI is green I will approve.
One coordination note, unchanged from last time. My #892 adds a post-statement block to
pgcolumnar_process_utility for AlterTableStmt and TruncateStmt, so it conflicts textually with
your hunk in that function and not semantically: yours refuses before the statement, mine repairs
after it. Whoever lands second rebases, and I am content for that to be me.
OffgridwithJD
left a comment
There was a problem hiding this comment.
One more pass at 6718048, on the question an added privilege check most often gets wrong: does it
reject someone who legitimately may run the statement?
Four callers, each on its own table, each with the fixture's premise asserted before the arm ran:
caller want got
owner 2BP01 2BP01
superuser, NOT the owner 2BP01 2BP01
stranger with no grants 42501 42501
member of the owning ROLE 2BP01 2BP01
The column survives all four refusals. So a superuser reaches the dependency refusal rather than
being told they lack permission, and ownership held through role membership counts as ownership,
which is what pg_class_ownercheck semantics should give and what a hand-rolled comparison against
relowner would have got wrong. No false positives on either case.
Worth recording how the first version of this probe failed, because it is a trap for anyone extending
your suite. I built the fixture as the target role, and add_projection silently did nothing:
#562 revoked EXECUTE on the projection functions from PUBLIC, so a plain role cannot declare a
projection. Every arm then measured a table with no projection to protect, the DROP succeeded, and the
codes I got back were 42703 and no error rather than anything meaningful. The premise check caught
it. If you add privilege arms, build the fixture as superuser and hand the table over with
ALTER TABLE ... OWNER TO, and assert the projection row exists before trusting the arm.
I have nothing further on the code. The only thing standing between this and my approval is CI,
which is held again on this head: run 34289398470 is completed/action_required. Releasing it is a
decision @jdatcmd's side makes per push rather than a formality, so it may take a little while. I have
asked.
I have approved #888, which is green at 12 checks. Once yours is released and green I will approve
this one the same way.
|
Status, since #888 landed and moved GitHub currently shows this PR's mergeability as UNKNOWN. That is GitHub recomputing after the Both changes touch And the merged tree is green both ways, which is the part worth having: So nothing about #888 landing has cost you anything, and you do not need to rebase. What is actually blockingOnly CI, and it has been held for about an hour. Run This PR is also still marked draft, which is the same thing that briefly held up #888 after I Where my review standsMy CHANGES_REQUESTED is still the recorded state, and I want to be unambiguous that it no longer
I am leaving the review state as it is only because this repository does not dismiss stale reviews |
OffgridwithJD
left a comment
There was a problem hiding this comment.
Approving at 6718048. CI is 12 for 12, which was the only thing left, and this also clears the
CHANGES_REQUESTED I submitted at 6bbeb1ee. Sorry that stood as long as it did: it was accurate when
I filed it and stale from the moment you addressed it, and a stale review decision reads to everyone
else as a reviewer still asking for something.
Everything I raised is fixed and measured rather than read:
The disclosure is closed. Same probe as the first review, unchanged, on your tree:
before (6bbeb1ee) now (6718048)
non-owner DROP of a PROJECTED column 2BP01 42501
non-owner DROP of an UNPROJECTED column 42501 42501
And the fix did not cost the feature, which is what an over-eager ownership check usually does.
Four callers, each on its own table, each with the fixture's premise asserted first:
| caller | want | got |
|---|---|---|
| owner | 2BP01 |
2BP01 |
| superuser, not the owner | 2BP01 |
2BP01 |
| stranger with no grants | 42501 |
42501 |
| member of the owning ROLE | 2BP01 |
2BP01 |
The column survives all four refusals. Superusers and role members reach the dependency refusal
rather than being told they lack permission, which is what pg_class_ownercheck semantics should give
and what a hand-rolled comparison against relowner would have got wrong.
Checking ownership twice is better than what I asked for. I asked for a check; the recheck after
the lock closes a window I had not named, where ALTER TABLE ... OWNER TO commits between the first
test and the lock. And testing before the lock is the right order for the disclosure itself, since a
non-owner never reaches the metadata read.
The lock comment now names the real conflict pair, add_projection's ShareLock against your
ShareUpdateExclusiveLock. The old wording was the dangerous kind of wrong: right conclusion, false
premise, and a later reader could have "simplified" the lock level on the strength of it.
Your suite is 8 for 8, on your branch and on the tree composed with main. I merged the two locally
rather than trusting GitHub's mergeability, which read UNKNOWN after #888 landed:
main d22d006 + #891 6718048 -> MERGES CLEANLY
projection_drop_column.sh 8 passed, 0 failed (yours)
projection_rename_restore.sh 8 passed, 0 failed (#888's)
One thing left and it is not mine: this PR is still marked draft. @jdatcmd cannot merge it in that
state even with an approval and a green gate, which is exactly what briefly held up #888. Mark it
ready when you are happy.
Thank you for the privilege fix in particular. The disclosure was modest and easy to argue away, and
you closed it properly instead.
My entry said dropping a column that any projection stores produced "ERROR: type with OID 0 does not exist" on the next INSERT. That symptom is sort-key-only, and I had generalised it from one case. Measured by OffgridwithJD on e42c80d, with the refusal disabled and a control, projection pp storing (a,b) with sort key (a): drop a -- the sort key INSERT -> ERROR: type with OID 0 does not exist drop b -- stored, not key INSERT -> succeeds, 105 rows drop c -- not projected INSERT -> succeeds (control) So the refusal's SCOPE was right in the entry and its JUSTIFICATION was not. Dropping a stored non-sort-key column is still harmful, just elsewhere and more quietly: read_projection raises "cache lookup failed for type 0", the declaration still names the dropped column, and rebuild_projections() returns 0 -- repairing nothing while reporting success. That last part is the worse half, because it tells an operator there was nothing to do, and the entry now says so. Wording is OffgridwithJD's, from the #895 review. I have not re-run the measurement myself and am not claiming to; it carries a control and the mechanism matches the code, which is why I took it rather than asking for a second run. docs_style.sh: 9 checks, PASSED. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Unuuvh3fRR67SceiGpfeeK
docs: record #891, and correct two claims in the changelog
Three changes, all to CHANGELOG.md. 1. commandprompt#891 gets an entry. It merged without one, the same gap commandprompt#888 had, except the excuse is gone: commandprompt#893 opened [Unreleased], so there was a section to add to. The entry says what the symptom actually was, because the PR title does not: the next INSERT failed with "type with OID 0 does not exist" and the table stayed in that state. The scope in my first draft was wrong and I checked it against the merged code rather than shipping the peer's summary of it. I had written "a column named in a projection's sort key". The guard loops over projection->columns -- every column the projection STORES -- and its own comment explains that this covers sort keys as a consequence, because add_projection() requires every sort-key column to appear in columns. So the refusal is broader than "sort key" and the entry now says so. SQLSTATE read from ERRCODE_DEPENDENT_OBJECTS_STILL_EXIST at src/columnar_tableam.c:2582, not from the PR description. 2. The intro said 1.0-alpha3 was "in development and not yet tagged; the latest published pre-release is v1.0-alpha2". It is tagged, and the tag is correct. I found this while filing a release-integrity issue that was itself wrong -- git fetch does not update an existing local tag ref, so git rev-parse showed a position the tag had been deliberately moved off days earlier. The issue is closed; this sentence was the one true thing in it. 3. commandprompt#888's entry said "every inheritance descendant". The arm proves a PARTITION OF child, and a reader with a partitioned table searches for that word. Both are covered by find_all_inheritors; now both are named. (OffgridwithJD, commandprompt#893 review.) docs_style.sh: 9 checks, PASSED. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Unuuvh3fRR67SceiGpfeeK
Problem
ALTER TABLE ... DROP COLUMNdid not know about extension-owned projections. Dropping a projected sort key succeeded, left its attnum pointing at a droppedpg_attributerow, and made the next insert fail in typcache withtype with OID 0 does not exist.Fix
Before core executes
DROP COLUMN, inspect materialized projections on the table and its inheritance/partition descendants. Refuse a dependent drop with SQLSTATE2BP01and a directpgcolumnar.drop_projection()recovery hint. Locks serialize the check against concurrent projection creation until core upgrades to its ALTER lock.Verification
projections,inheritance,fk_referencing.native_repack,pg19_vacuum_options).Made with Cursor