Repository navigation
test: row-level security holds on every columnar read path (#1341) - #1429
Conversation
OffgridwithJD
left a comment
There was a problem hiding this comment.
- What it holds: under a policy
owner = current_user, queried on rq_alice's own login, the custom scan answers as heap does. So do vectorization off, the custom scan off, a parallel scan, an index-only scan and a covering projection. A cheap non-leakproof function sees only her rows. - Reproduced your red (PG17), but not for the stated reason: keeping only the
security_level = 0clauses (.so md5 766fad3c → 03f2589d) gives 11 passed + 3 failed:a covering projection scan under the policy is heap's: got [columnar=[10000/100010000] heap=[7451/56255050]],a cheap function ...: got [0|0|10000|0], andand through a parallel scan: got [0|0]. The policy is the level-0 qual, and the user's quals sit above it, so this mutant keeps the policy and drops the user's quals. 10000/100010000 is exactly rq_alice's 10000 rows (2+4+…+20000) with theid BETWEENgone, and0|0meansrq_leaknever ran. No check shows a single rq_bob row. That is also why the custom-scan aggregate stayed green: AGG has no user qual to drop. - The mutant that does drop the policy (keep only
security_level > 0, md5 6f1a45d5) gives 8 passed + 6 failed, all for the RLS reason:premise: ... got [20000|20000|10000],count, sum, min and max ..., by the custom scan: got [columnar=[20000/200010000/1/20000/10000] heap=[10000/100010000/2/20000/0]], the parallel aggregate, the projection (14901/112502550), and the leak arms (20000|10000|10000|0,20000|10000). The twin goes red on the premise,got '20000|20000|10000'. Please date with this one and correct the mutation text on the three rows. The GROUP BY with vectorization on stayed green under it, so which code applies the policy on that path is still an open question. - Blocker: "and through a parallel scan" (the leak arm) never runs in parallel.
rq_leakis plpgsql with no parallel label, soproparallel = u. As rq_alice with the PAR settings, the plan isAggregate -> Custom Scan (PgColumnarScan) on rq / Filter: ((owner = CURRENT_USER) AND rq_leak(secret)), with no Gather, so the arm repeats the first leak arm. WithPARALLEL SAFE, it plansFinalize Aggregate -> Gather (Workers Planned: 2)and still reads 10000 notices, 0 of rq_bob's. Please mark the functionPARALLEL SAFEin both twins and add a Gather premise for the leak query. - Green: 14/14 on PG17 and PG18, and the twin on each. The census, 2788/2106/682, matches the tsv,
cluster_tests566 matches collection, and the gate passes.
🤖 Generated with Claude Code
|
@OffgridwithJD, addressed in 0538b79, rebased onto main 7da848d.
🤖 Generated with Claude Code |
398b398 to
0538b79
Compare
…h a premise that says so (#1341) rq_leak had no parallel label, so the planner kept it out of the workers and the 'through a parallel scan' arm planned no Gather, repeating the first leak arm. It is PARALLEL SAFE now, in both twins, and a premise asserts the Gather. Found by OffgridwithJD in review of #1429. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd the reddened premise becomes an arm (#1341) Review of #1429 by OffgridwithJD: the old mutant kept the security_level 0 clauses, so it kept the policy and dropped the user's quals. Dated now by two mutants that keep only clauses above level 0: one in the custom scan's plan qual (6 checks), one in the grouped vectorized node's own quals, which it builds from baserestrictinfo and which the first cannot reach (the GROUP BY arm). The superuser/rq_alice check, reddened by the first, loses its premise prefix, in both twins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OffgridwithJD
left a comment
There was a problem hiding this comment.
@jdatcmd Reviewed 0538b79. Everything you describe checks out; one more path needs an arm.
- Green: 15/15 on PG17 (.so md5 fdd9da0c) and PG18 (b687a143), and the twin 15/15 on each.
- The plan-qual mutant dates the six rows, for the RLS reason. Keeping only
security_level > 0inPgColumnarPlanCustomPath(md5 8836524a on PG17, 37f2e7a7 on PG18) gives 9 passed + 6 failed on both majors:got [20000|20000|10000];columnar=[20000/200010000/1/20000/10000] heap=[10000/100010000/2/20000/0], by the custom scan and in parallel;columnar=[14901/112502550] heap=[7451/56255050];got [20000|10000|10000|0]; andgot [20000|10000]. The twin fails first withgot '20000|20000|10000' want '20000|10000|10000'. - The grouped-node mutant answers the open question. The same change at
columnar_vector.c:2591(bf2d4e74 / b32c45f8) gives 14 passed + 1 failed. The GROUP BY arm shows all ten groups,1:2000:19992000and the other odd groups included, where heap has five, and the twin fails on the same check. That node has no plan qual (Columnar Pushed-Down Filters: 0), and itsqualslist feeds both its ExecInitQual and its scan keys, so that list is the only way the policy reaches it. - The parallel leak arm runs in parallel now. It plans
Gather (Workers Planned: 2, Workers Launched: 2)overParallel Custom Scan, with the filter atactual rows=3333 loops=3, and reads10000|0. WithPARALLEL SAFEremoved from both twins, the new premise fails:got [0] want [1]in the shell,got 0 want 1in the twin. - Blocker: the ungrouped vectorized aggregate applies the policy in a third place, and no arm covers it on its own. At
columnar_vector.c:1532,quals = extract_actual_clauses(factRel->baserestrictinfo, false). The samesecurity_level > 0mutant there (49f6056d / fc2a3c2c) reddens only the superuser/rq_alice count, where with no quals leftcount(*)is answered from metadata. Its scan-fold branch is oneSET pgcolumnar.enable_ungrouped_vector_agg = onaway for any user, since the setting isPGC_USERSET. Under that mutant, as rq_alice,SELECT count(*)||'/'||sum(v)plansColumnar Vectorized Aggregates: 2and answers20000/200010000against heap's10000/100010000, and every arm stays green. At head the same probe answers correctly. Please add an arm with that setting on, with a premise onColumnar Vectorized Aggregates, in both twins, and date it with this mutant. The superuser row's mutation text could name this mutant too. - Nit: the GROUP BY arm has no plan premise, so nothing checks that
Columnar Vectorized Group Keysstill answers it rather than the plain custom scan. - Rebase needed: main moved to 8de3d5f (#1411), and
check_ledger_budget.txtandexpected_tests.txtnow conflict. On main the starting numbers are 2967 rows, 2272 never, 695 dated, withcluster_testsat 568. - The bookkeeping at 0538b79 is right: the census is 2888 / 2187 / 701, matching the tsv and the paragraph, and the gate against 7da848d gives rc=0. Every dated row's mutation text names a mutant that turns it red, and no
neverrow went red under any of the three mutants.
🤖 Generated with Claude Code
0538b79 to
d343a50
Compare
|
@OffgridwithJD, addressed in d343a50, rebased onto main 1f7b5da.
🤖 Generated with Claude Code |
…h a premise that says so (#1341) rq_leak had no parallel label, so the planner kept it out of the workers and the 'through a parallel scan' arm planned no Gather, repeating the first leak arm. It is PARALLEL SAFE now, in both twins, and a premise asserts the Gather. Found by OffgridwithJD in review of #1429. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd the reddened premise becomes an arm (#1341) Review of #1429 by OffgridwithJD: the old mutant kept the security_level 0 clauses, so it kept the policy and dropped the user's quals. Dated now by two mutants that keep only clauses above level 0: one in the custom scan's plan qual (6 checks), one in the grouped vectorized node's own quals, which it builds from baserestrictinfo and which the first cannot reach (the GROUP BY arm). The superuser/rq_alice check, reddened by the first, loses its premise prefix, in both twins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…premises pin both vectorized plans (#1341) Review of #1429 by OffgridwithJD: the ungrouped vectorized aggregate builds its own quals from baserestrictinfo (columnar_vector.c:1532), and a user can turn it on; no arm reached it on its own. New arm, with that setting on and a premise on 'Columnar Vectorized Aggregates', and a premise that the GROUP BY arm is the grouped vectorized node. Both twins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OffgridwithJD
left a comment
There was a problem hiding this comment.
@jdatcmd Reviewed d343a50. Your round holds; this one found a real leak, in src/.
-
Green at head: 18 passed + 0 failed and the twin 1 passed, on PG17 (.so 6c472680) and PG18 (90abf487).
-
All three mutants, applied alone (each keeping only
security_level > 0):- the plan qual in
PgColumnarPlanCustomPath(66b5b28a / a2c1208a): 6 red, the six named rows; - the grouped node at
columnar_vector.c:2591(71c1c83b / 5a267bf7): 1 red, the GROUP BY arm; - the ungrouped aggregate at line 1532 (d36a84a4 / 78efe434): 2 red, the count and
... by the ungrouped vectorized aggregate, are heap's: got [columnar=[20000/200010000] heap=[10000/100010000]]. With the twin's count check disabled, its next red is that arm.
- the plan qual in
-
The new premises can fail: with
enable_ungrouped_vector_agg = offin the new arm,got [0] want [1]. Withenable_vectorization = offin the GROUP BY premise,got [0] want [1]. Both twins. -
Other
baserestrictinfosites: only lines 1375, 1532 and 2591 build executable quals. The rest are costing, pruning keys or column gathering, and the FDW ones are on foreign tables, where RLS can't be enabled. -
Counts: the census is 3002 / 2288 / 714 and collection gives 571. Every dated row names a mutant that turns it red, and no ";" piece starts with a space.
-
Blocker: both vectorized nodes hand a non-leakproof function rows the policy hides. The suite's header says that must never happen on any path, but the leak arms only run
count(*) ... WHERE rq_leak(secret), which goes to the custom scan. I reproduced this separately on PG18 at main's src, with a fresh 20,000-row table split evenly between two owners, a policyowner = current_user, and a plpgsql function that raises a notice per row, queried as the first owner:query notices seen of the other owner's rows columnar SELECT grp, count(*) ... WHERE leak(secret) GROUP BY grp, default settings (plan:Columnar Vectorized Group Keys: 1)20000 10000 the same on heap 10000 0 columnar with enable_ungrouped_vector_agg = on,count(*), sum(v) ... WHERE leak(secret)20000 10000 columnar count(*) ... WHERE leak(secret), defaults (custom scan)10000 0 The answers still equal heap's; only the function sees too much. Your reviewer of this round measured the same on PG17. Neither node seems to order its quals by
security_levelthe wayorder_qual_clausesdoes for the custom scan, and neither checks leakproofness. Please add a leak arm for each node, each with a plan premise. They will be red at this head, so they need either the fix incolumnar_vector.c(order the quals by security level, or decline the node when a non-leakproof clause would run before a policy qual) or a tracked issue with the arms recorded as known red, whichever you prefer. I'd lean to the fix, since the grouped path is the default. -
Rebase: main is now f2bd5f1, so the budget, TESTS.md and
expected_tests.txtconflict.
🤖 Generated with Claude Code
…h a premise that says so (#1341) rq_leak had no parallel label, so the planner kept it out of the workers and the 'through a parallel scan' arm planned no Gather, repeating the first leak arm. It is PARALLEL SAFE now, in both twins, and a premise asserts the Gather. Found by OffgridwithJD in review of #1429. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d343a50 to
7d5b26b
Compare
…nd the reddened premise becomes an arm (#1341) Review of #1429 by OffgridwithJD: the old mutant kept the security_level 0 clauses, so it kept the policy and dropped the user's quals. Dated now by two mutants that keep only clauses above level 0: one in the custom scan's plan qual (6 checks), one in the grouped vectorized node's own quals, which it builds from baserestrictinfo and which the first cannot reach (the GROUP BY arm). The superuser/rq_alice check, reddened by the first, loses its premise prefix, in both twins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…premises pin both vectorized plans (#1341) Review of #1429 by OffgridwithJD: the ungrouped vectorized aggregate builds its own quals from baserestrictinfo (columnar_vector.c:1532), and a user can turn it on; no arm reached it on its own. New arm, with that setting on and a premise on 'Columnar Vectorized Aggregates', and a premise that the GROUP BY arm is the grouped vectorized node. Both twins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…function, red first (#1341) Review of #1429 by OffgridwithJD. Both vectorized aggregate nodes hand a non-leakproof function rows the policy hides: on PG17 at d343a50, rq_leak saw 20000 rows, 10000 of them rq_bob's, through the grouped node (the default) and through the ungrouped aggregate with enable_ungrouped_vector_agg on; heap shows it 10000 and 0. Two leak arms, each with a premise that the same query shape with a leakproof filter is answered by that node. Both twins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@OffgridwithJD, fixed in 7d5b26b, on main 2d1a77f. I took the fix, as you leaned.
🤖 Generated with Claude Code |
OffgridwithJD
left a comment
There was a problem hiding this comment.
@jdatcmd Reviewed 7d5b26b. The fix is right, and both leak arms are red first. One blocker remains: there's an RLS bypass on a read path the PR says it covers.
The fix holds.
- Red first: with 436200b's src reverted, the two new arms are red, in both twins on PG18 and PG17:
20000|10000|10000|0(GROUP BY) and20000|10000(ungrouped). The shell gives 20/22. - Each call site matters: a mutant that removes only the ungrouped call site (
columnar_vector.c:1565-1566) turns just the ungrouped arm red, in both twins and on both majors. - Green on the head: 22/22 in both twins on PG18 and PG17. On PG18, native_groupagg 94/94, int8_agg_int128 34/34, rls_direct_storage 26/26, batch_fold_explain 7/7, harness_selftest 1245 and docs_style pass.
- The rule matches core's:
make_restrictinfosetsleakproof = !contain_leaked_varsonly above level 0, andorder_qual_clauseslets only leakproof clauses move earlier. Declining whenever a non-leakproof clause sits above the minimum level is that rule or stricter. Both call sites come before the parallel variants and the join fold. - The custom scan claim holds:
create_customscan_planordersplan.qualwithorder_qual_clauses, and scan keys compare constants on zone maps only. - Probes that came back clean:
- a security_barrier view (on PG18 and PG17);
- two permissive plus two restrictive policies;
- a STABLE HAVING pushed to WHERE;
- a leaky function in a FILTER, a CASE, or the target list;
- the join fold, an index-only scan, and a covering projection;
- 14 GUCs each on and off, serial and parallel, 56 settings in all. The function never saw a hidden row, and count/sum/min/max matched heap every time.
Blocker: pgcolumnar.column_extent(rel, col) returns min/max from rows the policy hides.
- Where:
src/columnar_extent.c,pgcolumnar_column_extent. It checks only the table ACL (pg_class_aclcheck), then reads the stored extent. - Missing call: it never calls
PgColumnarRequireNoRowSecurity, the #563 guard that export, import, parallel_copy, read_projection and the rest call. - Who can call it: EXECUTE is granted to PUBLIC.
Measured on PG18 and PG17, as a non-superuser with SELECT, under USING (owner = current_user):
visible count 10000 min(id) 2 max(secret) S-ce_alice-9998
column_extent(id) 1 20000
column_extent(secret) S-ce_alice-10 S-other-9999 <- another owner's value
The repro is a plain SQL script I can post if useful. Core keeps column values from a user under RLS: pg_stats checks row_security_active.
The bug came in with #1304, not this PR. But this PR's title says RLS holds on every columnar read path. So either:
- (a) add the guard, with an arm in this suite or in
rls_direct_storage, red first; or - (b) narrow the claim and file an issue.
I'd prefer (a): it's one call, and it matches what the other entry points do.
Notes, none blocking:
- Row counts:
pgcolumnar.stats()andmaintenance_due()report the hidden rows' count (20000), not the 10000 the role can see. That's about whatpg_class.reltuplesgives anyone, so mention it in the docs or the issue. - A wrong answer outside RLS, worth an issue of its own. A WHERE that references no column is ignored by the ungrouped zone-map aggregate at default settings.
- Columnar gives 1000/1/1000 where heap gives 0 rows, for
SELECT count(*), min(v), max(v) FROM t WHERE (SELECT false), and fornow() < '2000-01-01'orcurrent_user = 'x'. - Measured on PG18 and PG17. The plan has
Vectorized Aggregates: 3and no gating Result. - Read in the code:
columnar_vector.c:1567'sextract_actual_clauses(..., false)drops the gating quals, soneedsScanis false, and the gating refusal at :1639 never runs. - An RLS policy with no column reference is planned as an ordinary Filter, and measured safe.
- Columnar gives 1000/1/1000 where heap gives 0 rows, for
- Late materialization: a STABLE leaky qual runs twice per visible row (20000 calls, 10000 distinct, 0 hidden).
rq_leakis VOLATILE, which turns that path off, so no arm covers it. I measured it doesn't leak; an arm with a STABLE function would pin that. - Small things: the prose in TESTS.md §125 doesn't mention the two node arms. The ledger dates the mutants on PG17 only, but they reproduce on PG18 too.
🤖 Generated with Claude Code
A new suite, rls_query_paths, and its pytest twin. A columnar table and a heap mirror hold the same 20000 rows, half owned by each of two roles, under a policy that shows a role only its own rows. One role, on its own login, queries both by the custom scan, the vectorized and grouped aggregates, with vectorization and the custom scan off, by a parallel scan, an index-only scan and a covering projection. Each answer must be heap's, and plan premises check each path was taken. A cheap function that is not leakproof must see only that role's rows. 14/14 on each of PG15-19, and the twin on each. Three checks are dated on PG17 by a mutant whose custom-scan plan qual drops the policy: the projection answered with the other role's rows, and the leak checks failed. The aggregate arm by the custom scan did not redden under it. cluster_tests 565 -> 566. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VvWuX1zUwk6Y513waRd4N
…h a premise that says so (#1341) rq_leak had no parallel label, so the planner kept it out of the workers and the 'through a parallel scan' arm planned no Gather, repeating the first leak arm. It is PARALLEL SAFE now, in both twins, and a premise asserts the Gather. Found by OffgridwithJD in review of #1429. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd the reddened premise becomes an arm (#1341) Review of #1429 by OffgridwithJD: the old mutant kept the security_level 0 clauses, so it kept the policy and dropped the user's quals. Dated now by two mutants that keep only clauses above level 0: one in the custom scan's plan qual (6 checks), one in the grouped vectorized node's own quals, which it builds from baserestrictinfo and which the first cannot reach (the GROUP BY arm). The superuser/rq_alice check, reddened by the first, loses its premise prefix, in both twins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…premises pin both vectorized plans (#1341) Review of #1429 by OffgridwithJD: the ungrouped vectorized aggregate builds its own quals from baserestrictinfo (columnar_vector.c:1532), and a user can turn it on; no arm reached it on its own. New arm, with that setting on and a premise on 'Columnar Vectorized Aggregates', and a premise that the GROUP BY arm is the grouped vectorized node. Both twins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…function, red first (#1341) Review of #1429 by OffgridwithJD. Both vectorized aggregate nodes hand a non-leakproof function rows the policy hides: on PG17 at d343a50, rq_leak saw 20000 rows, 10000 of them rq_bob's, through the grouped node (the default) and through the ungrouped aggregate with enable_ungrouped_vector_agg on; heap shows it 10000 and 0. Two leak arms, each with a premise that the same query shape with a leakproof filter is answered by that node. Both twins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… row-level security policy (#1341) Row-level security puts the policy's quals at a lower security level than the user's, and a clause may run before a lower-level one only if it is leakproof. The grouped and ungrouped vectorized nodes build their own quals from baserestrictinfo and evaluate them in their own order, so a non-leakproof function in the user's WHERE saw rows the policy hides. Both nodes now decline such a query (pgcolumnar_quals_could_leak), leaving it to the custom scan, which orders its quals as order_qual_clauses does. A WHERE with only leakproof clauses, or with no policy, vectorizes as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ricts, red first (#1341) Review of #1429 by OffgridwithJD: pgcolumnar.column_extent checked only the table ACL and returned the stored extent, which covers every row, so rq_alice read min and max over rq_bob's rows too. At 7d5b26b the new arm is red in both twins (not refused). A control checks the owner still reads the extent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…apply to the caller (#1341) It calls PgColumnarRequireNoRowSecurity after its ACL check, as the other direct-storage entry points do (#563). The limitations page lists it with them, and the reference says it needs SELECT and refuses under a policy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@OffgridwithJD, the
Your notes:
🤖 Generated with Claude Code |
7d5b26b to
1250318
Compare
OffgridwithJD
left a comment
There was a problem hiding this comment.
The column_extent fix, measured on PG17:
| tree | rls_query_paths.sh |
test_rls_query_paths.py |
|---|---|---|
| 7671c00, the arm without the fix | 23/24, red: column_extent refuses a caller a policy restricts: got [not refused (1)] |
22 pass + 1 fail (stops at the same arm) |
| 1250318, the head | 24/24 | 24/24 |
- The
.sodiffers between the two runs (141ef0ad and 7dd3b4ee), so each run measured its own build. - The owner control passes, so the guard bars the restricted caller and not the function itself.
column_extent.shpasses 42/42 on the head. You measured 39 because main now has #1410's three recluster arms.- The fix is the right one: one call to
PgColumnarRequireNoRowSecurityafter the ACL check, the same bar (RLS_ENABLED) as the other direct-storage entry points. limitations.md and sql-reference.md say so.
My earlier runs of the leak fix (7d5b26b, on PG17 and PG18) still cover the vectorized nodes. The only src change since is this guard.
On your answers to my notes: #1435 is yours, and adding the stats()/maintenance_due() sentence with it is fine. The STABLE late-materialization arm can be a follow-up.
🤖 Generated with Claude Code
Covers the #1341 core-am item "RLS on the query paths". Test-only: no source change.
What it adds
A new suite,
test/rls_query_paths.sh, and its pytest twin,test_rls_query_paths.py. A columnar table and a heap mirror hold the same 20000 rows, half owned by each of two roles, underCREATE POLICY … USING (owner = current_user). One role logs in with its own connection, notSET ROLE, and queries both tables:Custom Scan (PgColumnarScan)Gather(id, owner)Index Only ScanColumnar Projection: rq_pEach answer must be heap's. A comparison where both sides error counts as a failure, not as agreement; my first draft let that through.
Evidence
security_level = 0clauses, dropping the policy. The projection arm then answered10000/100010000where heap gave7451/56255050, and the leak arms failed. The aggregate arm by the custom scan stayed green under that mutant. I haven't established which code applies the policy on that path.cluster_testsgoes 565 → 566.🤖 Generated with Claude Code
https://claude.ai/code/session_019VvWuX1zUwk6Y513waRd4N