Skip to content

test: row-level security holds on every columnar read path (#1341) - #1429

Merged
jdatcmd merged 10 commits into
mainfrom
test/1341-rls-query-paths
Oct 15, 2026
Merged

jdatcmd merged 10 commits into
mainfrom
test/1341-rls-query-paths

Conversation

@jdatcmd

@jdatcmd jdatcmd commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator

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, under CREATE POLICY … USING (owner = current_user). One role logs in with its own connection, not SET ROLE, and queries both tables:

path plan premise checks
columnar custom scan Custom Scan (PgColumnarScan) count/sum/min/max, GROUP BY
vectorization and custom scan off – the same
parallel scan Gather the same
index-only scan on (id, owner) Index Only Scan count and sum over a range
covering projection Columnar Projection: rq_p count and sum over a range
a cheap, non-leakproof function in WHERE – sees 10000 values, 0 of the other role's; by custom scan, with it off, and in parallel

Each 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

  • Green: 14/14 on each of PG15–19, and the twin on each.
  • Red on PG17, both twins: a mutant whose custom-scan plan qual keeps only security_level = 0 clauses, dropping the policy. The projection arm then answered 10000/100010000 where heap gave 7451/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.
  • Ledger: 14 rows, 3 dated. The census is 2774 + 14 = 2788 rows, 2095 + 11 = 2106 never, and 682 dated. The gate passes against origin/main. The harness selftest passes 1245/1245 and the guard set 433/433. cluster_tests goes 565 → 566.

🤖 Generated with Claude Code

https://claude.ai/code/session_019VvWuX1zUwk6Y513waRd4N

@OffgridwithJD OffgridwithJD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jdatcmd Reviewed 398b398.

  • 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 = 0 clauses (.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], and and 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 the id BETWEEN gone, and 0|0 means rq_leak never 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_leak is plpgsql with no parallel label, so proparallel = u. As rq_alice with the PAR settings, the plan is Aggregate -> Custom Scan (PgColumnarScan) on rq / Filter: ((owner = CURRENT_USER) AND rq_leak(secret)), with no Gather, so the arm repeats the first leak arm. With PARALLEL SAFE, it plans Finalize Aggregate -> Gather (Workers Planned: 2) and still reads 10000 notices, 0 of rq_bob's. Please mark the function PARALLEL SAFE in 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_tests 566 matches collection, and the gate passes.

🤖 Generated with Claude Code

@jdatcmd

jdatcmd commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator Author

@OffgridwithJD, addressed in 0538b79, rebased onto main 7da848d.

  • The parallel leak arm runs in parallel now. rq_leak is PARALLEL SAFE in both twins. A new premise, premise: the parallel leak arm's plan gathers from workers, asserts the Gather on the leak query, and it passes on PG15 to PG19.
  • Dated by the mutants that drop the policy, each keeping only clauses with security_level > 0:
    • In PgColumnarPlanCustomPath's plan qual: 6 red, your six exactly. The superuser/rq_alice count shows got [20000|20000|10000]. Also red: the aggregate by the custom scan and in parallel (columnar=[20000/200010000/1/20000/10000]), the projection (14901/112502550), and both leak arms (20000|10000|10000|0, 20000|10000). The twin fails first on the same count with the same values.
    • In the grouped vectorized node's quals (src/columnar_vector.c, quals = extract_actual_clauses(input_rel->baserestrictinfo, false)): 1 red, the GROUP BY arm, in both twins. That answers your open question. The grouped node sets no plan qual ("WHERE is applied inside the scan"). It builds its own qual list from baserestrictinfo, which a mutant in PgColumnarPlanCustomPath can't reach.
  • The superuser/rq_alice check is an arm now. The gate refuses a premise: row a mutant has turned red, so it loses the prefix, in both twins.
  • The ledger rows are rebuilt from the runs above, so the mutation text is right on every row. 7 are dated and 8 are never.
  • Green: 15/15 in both twins on each of PG15 to PG19.
  • Bookkeeping: the census is 2873 + 15 = 2888 rows, 2179 + 8 = 2187 never, 694 + 7 = 701 dated, matching the tsv. The gate gives rc=0, cluster_tests is 568 by collection, the guard set passes 435/435, and docs_style passes.

🤖 Generated with Claude Code

@jdatcmd
jdatcmd force-pushed the test/1341-rls-query-paths branch from 398b398 to 0538b79 Compare October 15, 2026 17:12
jdatcmd added a commit that referenced this pull request Oct 15, 2026
…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>
jdatcmd added a commit that referenced this pull request Oct 15, 2026
…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 OffgridwithJD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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 > 0 in PgColumnarPlanCustomPath (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]; and got [20000|10000]. The twin fails first with got '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:19992000 and 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 its quals list 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) over Parallel Custom Scan, with the filter at actual rows=3333 loops=3, and reads 10000|0. With PARALLEL SAFE removed from both twins, the new premise fails: got [0] want [1] in the shell, got 0 want 1 in 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 same security_level > 0 mutant there (49f6056d / fc2a3c2c) reddens only the superuser/rq_alice count, where with no quals left count(*) is answered from metadata. Its scan-fold branch is one SET pgcolumnar.enable_ungrouped_vector_agg = on away for any user, since the setting is PGC_USERSET. Under that mutant, as rq_alice, SELECT count(*)||'/'||sum(v) plans Columnar Vectorized Aggregates: 2 and answers 20000/200010000 against heap's 10000/100010000, and every arm stays green. At head the same probe answers correctly. Please add an arm with that setting on, with a premise on Columnar 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 Keys still answers it rather than the plain custom scan.
  • Rebase needed: main moved to 8de3d5f (#1411), and check_ledger_budget.txt and expected_tests.txt now conflict. On main the starting numbers are 2967 rows, 2272 never, 695 dated, with cluster_tests at 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 never row went red under any of the three mutants.

🤖 Generated with Claude Code

@jdatcmd
jdatcmd force-pushed the test/1341-rls-query-paths branch from 0538b79 to d343a50 Compare October 15, 2026 18:11
@jdatcmd

jdatcmd commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator Author

@OffgridwithJD, addressed in d343a50, rebased onto main 1f7b5da.

  • The ungrouped vectorized aggregate has its own arm, in both twins.
    • As rq_alice, with pgcolumnar.enable_ungrouped_vector_agg = on, count(*) || '/' || sum(v) must answer as heap does: count and sum under the policy, by the ungrouped vectorized aggregate, are heap's.
    • A premise checks that the plan shows Columnar Vectorized Aggregates.
  • Dated with your mutant at columnar_vector.c:1532 (quals keeps only security_level > 0). It turns 2 red: the new arm (columnar=[20000/200010000] heap=[10000/100010000], as your probe found) and the superuser/rq_alice count. The twin fails first on that count. The count row's mutation text now names this mutant too.
  • The GROUP BY arm has its plan premise: Columnar Vectorized Group Keys, in both twins.
  • The plan-qual and grouped-node mutants, rerun on this tree, give 6 red and 1 red, as before.
  • Rebased onto 1f7b5da. This suite's rows and paragraph are rebuilt from the runs on the rebased tree. The census is 2984 + 18 = 3002 rows, 2278 + 10 = 2288 never, 706 + 8 = 714 dated, matching the tsv. TESTS.md is §116. The gate gives rc=0, cluster_tests is 571 by collection, the guard set passes 435/435, and docs_style passes.
  • Green: 18/18 in both twins on each of PG15 to PG19.

🤖 Generated with Claude Code

jdatcmd added a commit that referenced this pull request Oct 15, 2026
…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>
jdatcmd added a commit that referenced this pull request Oct 15, 2026
…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>
jdatcmd added a commit that referenced this pull request Oct 15, 2026
…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 OffgridwithJD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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 new premises can fail: with enable_ungrouped_vector_agg = off in the new arm, got [0] want [1]. With enable_vectorization = off in the GROUP BY premise, got [0] want [1]. Both twins.

  • Other baserestrictinfo sites: 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 policy owner = 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_level the way order_qual_clauses does 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 in columnar_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.txt conflict.

🤖 Generated with Claude Code

jdatcmd added a commit that referenced this pull request Oct 15, 2026
…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>
@jdatcmd
jdatcmd force-pushed the test/1341-rls-query-paths branch from d343a50 to 7d5b26b Compare October 15, 2026 20:58
jdatcmd added a commit that referenced this pull request Oct 15, 2026
…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>
jdatcmd added a commit that referenced this pull request Oct 15, 2026
…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>
jdatcmd added a commit that referenced this pull request Oct 15, 2026
…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>
@jdatcmd

jdatcmd commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator Author

@OffgridwithJD, fixed in 7d5b26b, on main 2d1a77f. I took the fix, as you leaned.

  • Red first: d0f453db adds a leak arm per vectorized node, in both twins, each with a premise.
    • The premises: the same query shape with a leakproof filter (secret IS NOT NULL) is answered by that node. Columnar Vectorized Group Keys for the GROUP BY under default settings, and Columnar Vectorized Aggregates for count(*), sum(v) with enable_ungrouped_vector_agg = on. These hold before and after the fix, so each arm is aimed at its node, and only the non-leakproof clause sends it elsewhere.
    • At d343a50, PG17, both arms are red as you measured: got [20000|10000|10000|0] want [10000|0|10000|0] and got [20000|10000]. The twin fails on the first by name.
  • The fix, 436200b9, in src/columnar_vector.c: pgcolumnar_quals_could_leak. It returns true when a clause above the lowest security_level in baserestrictinfo has contain_leaked_vars, which is PostgreSQL's own rule that only leakproof clauses may run before a policy qual. Both the grouped node and the ungrouped aggregate check it and decline, leaving the query to the custom scan, which orders its quals as order_qual_clauses does.
    • Why decline rather than reorder: reordering would also have to cover each node's batched vector predicates. Declining costs only queries that call a non-leakproof function under a policy, and the premises show a leakproof WHERE under the same policy still vectorizes.
    • With no policy every clause is at level 0, so nothing changes.
  • Green with the fix: 22/22 in both twins on each of PG15 to PG19, with no build warnings. On PG17, native_groupagg 94/94, int8_agg_int128 34/34, rls_direct_storage 26/26 and batch_fold_explain 7/7.
  • Re-dated, on the rebased tree:
    • "the tree before the fix" turns exactly the two new arms red;
    • your plan-qual mutant now turns 8 red, both new arms included, because those queries fall back to the custom scan;
    • the grouped-node and ungrouped mutants turn 1 and 2 red, as before.
  • Bookkeeping: the census is 3111 + 22 = 3133 rows, 2344 + 12 = 2356 never, 767 + 10 = 777 dated, matching the tsv. The gate gives rc=0, TESTS.md is §125, cluster_tests is 588 by collection, the guard set passes 435/435, and docs_style passes. The CHANGELOG has a Fixed entry.

🤖 Generated with Claude Code

@OffgridwithJD OffgridwithJD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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) and 20000|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_restrictinfo sets leakproof = !contain_leaked_vars only above level 0, and order_qual_clauses lets 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_plan orders plan.qual with order_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:

  1. Row counts: pgcolumnar.stats() and maintenance_due() report the hidden rows' count (20000), not the 10000 the role can see. That's about what pg_class.reltuples gives anyone, so mention it in the docs or the issue.
  2. 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 for now() < '2000-01-01' or current_user = 'x'.
    • Measured on PG18 and PG17. The plan has Vectorized Aggregates: 3 and no gating Result.
    • Read in the code: columnar_vector.c:1567's extract_actual_clauses(..., false) drops the gating quals, so needsScan is 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.
  3. Late materialization: a STABLE leaky qual runs twice per visible row (20000 calls, 10000 distinct, 0 hidden). rq_leak is 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.
  4. 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

jdatcmd and others added 8 commits October 15, 2026 22:41
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>
…ain 2d1a77f, and the CHANGELOG entry (#1341)

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>
jdatcmd and others added 2 commits October 15, 2026 22:41
…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>
…ated on main 6f0bd12 (#1341)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jdatcmd

jdatcmd commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator Author

@OffgridwithJD, the column_extent bypass is fixed in 1250318, on main 6f0bd12. I took option (a).

  • Red first: 7671c00e adds column_extent refuses a caller a policy restricts to this suite, in both twins, plus a control, and the owner still reads the extent. On the previous head the arm is red: got [not refused (1)] in the shell and got 'not refused (1)' in the twin.

  • The fix, f3dfc979: pgcolumnar_column_extent calls PgColumnarRequireNoRowSecurity(relid) right after its ACL check, as the Every direct-storage read path bypasses row-level security, including parallel_export_parquet, which already does the ACL check — so the fixes proposed on #559 and #562 do not close it #563 guard's comment requires.

    • limitations.md now lists column_extent with the other functions that refuse.
    • sql-reference.md says it needs SELECT and refuses under a policy.
    • The CHANGELOG has a Fixed entry.
  • Green: 24/24 in both twins on each of PG15 to PG19, with no build warnings. On PG17, column_extent 39/39, rls_direct_storage 26/26, the guard set 438/438, and docs_style passes.

  • Re-dated on the rebased tree, with five mutants:

    • the plan qual: 8 red;
    • the grouped node: 1;
    • the ungrouped aggregate: 2;
    • the tree before the leak fix: 2;
    • column_extent without the guard: 1, just its arm, in both twins.

    The census is 3380 + 24 = 3404 rows, 2544 + 13 = 2557 never, 836 + 11 = 847 dated, matching the tsv. The gate gives rc=0, TESTS.md is §130, and cluster_tests is 601 by collection.

Your notes:

🤖 Generated with Claude Code

@jdatcmd
jdatcmd force-pushed the test/1341-rls-query-paths branch from 7d5b26b to 1250318 Compare October 15, 2026 22:47

@OffgridwithJD OffgridwithJD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jdatcmd Approved at 1250318.

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 .so differs 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.sh passes 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 PgColumnarRequireNoRowSecurity after 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

@jdatcmd
jdatcmd merged commit 6391900 into main Oct 15, 2026
15 checks passed
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.

2 participants