Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,22 @@ true until the next version shipped.

### Fixed

- **A vectorized aggregate no longer shows a non-leakproof function rows that row-level
security hides.** Under a policy, a query such as `SELECT grp, count(*) FROM t WHERE
f(secret) GROUP BY grp` ran `f` on every row, the other owners' included, through the
grouped vectorized node (the default for a GROUP BY) and the ungrouped one
(`pgcolumnar.enable_ungrouped_vector_agg`). The answers were right; only the function
saw too much. Both nodes now leave a WHERE whose clauses could leak past the policy to
the custom scan, which orders its quals as PostgreSQL does. A WHERE with only leakproof
clauses, or a table with no policy, vectorizes as before. Found by OffgridwithJD in
review. `test/rls_query_paths.sh` and `test_rls_query_paths.py`.

- **`pgcolumnar.column_extent` refuses a table whose row-level security policies
apply to the caller.** It returned the column's stored smallest and largest value
over every row, so a caller limited by a policy could read values from rows it
cannot see. It now raises `row-level security is in force`, as the other functions
that read storage directly do. Found by OffgridwithJD in review.

- **A concurrent `UPDATE` of the same row now fails with 40001 on a table with a
unique index, as documented, not 23505 (#1421).** Two sessions updating one row of
a table with a PRIMARY KEY or UNIQUE index: the second used to wait on the first's
Expand Down
4 changes: 3 additions & 1 deletion docs/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,9 @@ caller every row, so the call is refused instead.

The functions affected are `read_projection`, `reconstruct_via_projection`,
`export_arrow`, `export_parquet`, `parallel_export_parquet`, `import_arrow`,
`import_parquet` and `parallel_copy`.
`import_parquet`, `parallel_copy` and `column_extent`. `column_extent` reports a
column's stored smallest and largest value over every row. Under a policy, that
would show values from rows the caller cannot see.

A superuser, a role with `BYPASSRLS`, and a table's owner without `FORCE ROW
LEVEL SECURITY` are not refused. Each already reads every row by other means.
Expand Down
3 changes: 3 additions & 0 deletions docs/sql-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,9 @@ commit. Every operation that drops or rewrites row groups recomputes it before
committing: `expire`, `compact_rewrite`, `VACUUM`, `recluster`. So it can be
wider than the data only by rows that are deleted but still stored.

It needs `SELECT` on the table, and it refuses a table whose row-level security
policies apply to the caller (see [limitations](limitations.md#row-level-security)).

No row means the planner reads every row group's zone map instead, which gives
the same answer at a cost that grows with the table. That happens for:

Expand Down
6 changes: 6 additions & 0 deletions src/columnar_extent.c
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,12 @@ pgcolumnar_column_extent(PG_FUNCTION_ARGS)
ac = pg_class_aclcheck(relid, GetUserId(), ACL_SELECT);
if (ac != ACLCHECK_OK)
aclcheck_error(ac, OBJECT_TABLE, get_rel_name(relid));
/*
* The stored extent covers every row, so under a policy it would show values
* from rows the caller cannot see. Refused as the other direct-storage entry
* points are (#563), after the ACL check (OffgridwithJD, review of #1429).
*/
PgColumnarRequireNoRowSecurity(relid);
rel = relation_open(relid, AccessShareLock);
if (!PgColumnarIsColumnarRelation(relid))
ereport(ERROR,
Expand Down
41 changes: 40 additions & 1 deletion src/columnar_vector.c
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
#include "nodes/nodeFuncs.h"
#include "nodes/pathnodes.h"
#include "nodes/plannodes.h"
#include "optimizer/clauses.h"
#include "optimizer/optimizer.h"
#include "optimizer/pathnode.h"
#include "optimizer/paths.h"
Expand Down Expand Up @@ -820,6 +821,38 @@ static bool pgcolumnar_batch_shape_eligible(PgColumnarAggScanState *state,
* vectorized aggregate: planning
* ------------------------------------------------------------------------- */


/*
* pgcolumnar_quals_could_leak
* True when a clause above the lowest security level could leak, so a node that
* evaluates the WHERE itself must not take the query.
*
* Row-level security puts the policy's quals at a lower security level than the
* user's. PostgreSQL lets a clause run before a lower-level one only if it is
* leakproof, and order_qual_clauses enforces that for an ordinary plan. The
* 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 was handed
* rows the policy hides (OffgridwithJD, review of #1429). Declining leaves such a
* query to the custom scan, which orders its quals correctly.
*/
static bool
pgcolumnar_quals_could_leak(List *rinfos)
{
Index minLevel = UINT_MAX;
ListCell *lc;

foreach(lc, rinfos)
minLevel = Min(minLevel, lfirst_node(RestrictInfo, lc)->security_level);
foreach(lc, rinfos)
{
RestrictInfo *rinfo = lfirst_node(RestrictInfo, lc);

if (rinfo->security_level > minLevel &&
contain_leaked_vars((Node *) rinfo->clause))
return true;
}
return false;
}
static Plan *
PgColumnarPlanAggPath(PlannerInfo *root, RelOptInfo *rel, CustomPath *best_path,
List *tlist, List *clauses, List *custom_plans)
Expand Down Expand Up @@ -1529,6 +1562,8 @@ PgColumnarCreateUpperPaths(PlannerInfo *root, UpperRelationKind stage,
* zone-map answerable. A filter, or a sum/avg over int8/float/numeric, needs
* the scan-fold path instead (#289).
*/
if (pgcolumnar_quals_could_leak(factRel->baserestrictinfo))
return;
quals = extract_actual_clauses(factRel->baserestrictinfo, false);

needsScan = (quals != NIL);
Expand Down Expand Up @@ -2586,8 +2621,12 @@ PgColumnarTryGroupAggPath(PlannerInfo *root, RelOptInfo *input_rel,

/*
* WHERE is carried whole and rechecked per row, so correctness never depends
* on which clauses become scan keys; the scan keys only prune groups.
* on which clauses become scan keys; the scan keys only prune groups. A WHERE
* whose clauses could leak past a row-level security policy is left to the
* custom scan (pgcolumnar_quals_could_leak).
*/
if (pgcolumnar_quals_could_leak(input_rel->baserestrictinfo))
return;
quals = extract_actual_clauses(input_rel->baserestrictinfo, false);

/*
Expand Down
24 changes: 24 additions & 0 deletions test/check_ledger.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -2861,6 +2861,30 @@ rls_direct_storage rls_direct_storage the policy is in force for ordinary SQL 15
rls_direct_storage rls_direct_storage the source files the import arms need exist 15;16;17;18;19 2026-10-14 export_parquet writes no file (total = 0)
rls_direct_storage rls_direct_storage under FORCE the non-superuser owner IS refused, so owner-only is no substitute 15;16;17;18;19 2026-10-14 PgColumnarRequireNoRowSecurity never refuses (the RLS guard deleted)
rls_direct_storage rls_direct_storage without FORCE the owner is allowed, because RLS_NONE_ENV is not RLS_ENABLED 15;16;17;18;19 2026-10-14 the RLS guard refuses anything but RLS_NONE so the owner and superuser (RLS_NONE_ENV) are refused too
rls_query_paths rls_query_paths a GROUP BY under the policy, with vectorization on, is heap's 15;16;17;18;19 2026-10-15 the grouped vectorized node's quals keep only clauses with security_level above 0, dropping the policy (src/columnar_vector.c, its baserestrictinfo walk)
rls_query_paths rls_query_paths a cheap function that is not leakproof sees only rq_alice's rows on columnar, as on heap 15;16;17;18;19 2026-10-15 the custom scan's plan qual keeps only clauses with security_level above 0, dropping the policy (src/columnar_customscan.c PgColumnarPlanCustomPath)
rls_query_paths rls_query_paths a covering projection scan under the policy is heap's 15;16;17;18;19 2026-10-15 the custom scan's plan qual keeps only clauses with security_level above 0, dropping the policy (src/columnar_customscan.c PgColumnarPlanCustomPath)
rls_query_paths rls_query_paths a non-leakproof function in a GROUP BY under the policy sees only rq_alice's rows, as on heap 15;16;17;18;19 2026-10-15 the custom scan's plan qual keeps only clauses with security_level above 0, dropping the policy (src/columnar_customscan.c PgColumnarPlanCustomPath);the vectorized nodes take a WHERE whose non-leakproof clause would run before the policy (the tree before pgcolumnar_quals_could_leak)
rls_query_paths rls_query_paths a superuser sees all 20000 rows, and rq_alice her 10000 on both tables 15;16;17;18;19 2026-10-15 the custom scan's plan qual keeps only clauses with security_level above 0, dropping the policy (src/columnar_customscan.c PgColumnarPlanCustomPath);the ungrouped vectorized aggregate's quals keep only clauses with security_level above 0, dropping the policy (src/columnar_vector.c, factRel->baserestrictinfo)
rls_query_paths rls_query_paths an index-only scan under the policy is heap's 15;16;17;18;19 never -
rls_query_paths rls_query_paths and in the ungrouped vectorized aggregate 15;16;17;18;19 2026-10-15 the custom scan's plan qual keeps only clauses with security_level above 0, dropping the policy (src/columnar_customscan.c PgColumnarPlanCustomPath);the vectorized nodes take a WHERE whose non-leakproof clause would run before the policy (the tree before pgcolumnar_quals_could_leak)
rls_query_paths rls_query_paths and the owner still reads the extent 15;16;17;18;19 never -
rls_query_paths rls_query_paths and the same with vectorization and the custom scan off 15;16;17;18;19 never -
rls_query_paths rls_query_paths and through a parallel scan 15;16;17;18;19 2026-10-15 the custom scan's plan qual keeps only clauses with security_level above 0, dropping the policy (src/columnar_customscan.c PgColumnarPlanCustomPath)
rls_query_paths rls_query_paths and with vectorization and the custom scan off 15;16;17;18;19 never -
rls_query_paths rls_query_paths column_extent refuses a caller a policy restricts 15;16;17;18;19 2026-10-15 column_extent without PgColumnarRequireNoRowSecurity, so it returns the extent of rows a policy hides
rls_query_paths rls_query_paths count and sum under the policy, by the ungrouped vectorized aggregate, are heap's 15;16;17;18;19 2026-10-15 the ungrouped vectorized aggregate's quals keep only clauses with security_level above 0, dropping the policy (src/columnar_vector.c, factRel->baserestrictinfo)
rls_query_paths rls_query_paths count, sum, min and max under the policy, by a parallel scan, are heap's 15;16;17;18;19 2026-10-15 the custom scan's plan qual keeps only clauses with security_level above 0, dropping the policy (src/columnar_customscan.c PgColumnarPlanCustomPath)
rls_query_paths rls_query_paths count, sum, min and max under the policy, by the custom scan, are heap's 15;16;17;18;19 2026-10-15 the custom scan's plan qual keeps only clauses with security_level above 0, dropping the policy (src/columnar_customscan.c PgColumnarPlanCustomPath)
rls_query_paths rls_query_paths premise: a GROUP BY with a leakproof filter under the policy is the grouped vectorized node 15;16;17;18;19 never -
rls_query_paths rls_query_paths premise: an ungrouped count and sum with a leakproof filter, with the setting on, is the ungrouped vectorized aggregate 15;16;17;18;19 never -
rls_query_paths rls_query_paths premise: the GROUP BY arm's plan is the grouped vectorized node 15;16;17;18;19 never -
rls_query_paths rls_query_paths premise: the aggregate is answered by the columnar custom scan 15;16;17;18;19 never -
rls_query_paths rls_query_paths premise: the index-only arm's plan is an index-only scan 15;16;17;18;19 never -
rls_query_paths rls_query_paths premise: the parallel arm's plan gathers from workers 15;16;17;18;19 never -
rls_query_paths rls_query_paths premise: the parallel leak arm's plan gathers from workers 15;16;17;18;19 never -
rls_query_paths rls_query_paths premise: the projection arm's plan reads the covering projection 15;16;17;18;19 never -
rls_query_paths rls_query_paths premise: the ungrouped vectorized arm's plan folds the aggregates in the scan 15;16;17;18;19 never -
rollup rollup * with anything but count is refused 15;16;17;18;19 2026-10-13 create_rollup allowing * with any function
rollup rollup DROP TABLE of a rollup's source is refused while the rollup exists (2BP01, #1376) 15;16;17;18;19 2026-10-14 a rollup's view, partials and source with no dependency between them, and no catalog cleanup (main before #1376's fix)
rollup rollup DROP VIEW of a rollup takes its partials and its catalog rows with it (#1376) 15;16;17;18;19 2026-10-14 a rollup's view, partials and source with no dependency between them, and no catalog cleanup (main before #1376's fix)
Expand Down
21 changes: 20 additions & 1 deletion test/check_ledger_budget.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2741,4 +2741,23 @@ suites_not_covered 224
# never 2528 + 16 = 2544
# dated 822 + 14 = 836
# 2544 + 836 == 3380
checks_never_observed_red 2544
#
# #1341, row-level security holds on every columnar read path (jdatcmd): 2544 ->
# 2557. rls_query_paths enters with all 24 checks from five-major runs (24/24 on each
# of 15-19). 11 are dated on PG17 by five mutants (OffgridwithJD, reviews of
# #1429; the first three reproduce on PG18 too):
# - three that drop the policy, keeping only clauses with security_level above 0,
# one per place it is applied: the custom scan's plan qual (the counts, the
# aggregates, the projection and every leak arm, the vectorized nodes' included,
# since those fall back to it), the grouped vectorized node's quals (the GROUP BY
# arm), and the ungrouped vectorized aggregate's (its arm and the counts);
# - the tree before pgcolumnar_quals_could_leak: the two vectorized-node leak arms;
# - column_extent without its row-security guard: the column_extent arm.
# The rest arrive never: the plan premises, the owner's extent control, and arms on
# paths these mutants miss.
#
# ledger rows 3380 + 24 = 3404
# never 2544 + 13 = 2557
# dated 836 + 11 = 847
# 2557 + 847 == 3404
checks_never_observed_red 2557
11 changes: 11 additions & 0 deletions test/pytest/TESTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ behaviour, the source of that number is named.
- [127. test_objstore_allowlist.py: the endpoint allow-list, empty by default](#127-test_objstore_allowlistpy-the-endpoint-allow-list-empty-by-default)
- [128. test_objstore_userinfo.py: userinfo in a URL is refused on every entry point](#128-test_objstore_userinfopy-userinfo-in-a-url-is-refused-on-every-entry-point)
- [129. test_savepoint_flush.py: a savepoint rolled back after a flush](#129-test_savepoint_flushpy-a-savepoint-rolled-back-after-a-flush)
- [130. test_rls_query_paths.py: row-level security holds on every read path](#130-test_rls_query_pathspy-row-level-security-holds-on-every-read-path)

## 1. How to read a test in here

Expand Down Expand Up @@ -7164,3 +7165,13 @@ The pytest twin of `test/savepoint_flush.sh` (#1341), every check by the shell's
| test | what it holds |
| --- | --- |
| `test_a_savepoint_rolled_back_after_a_flush` | the reader paths, the flushes, and every shape answering as heap does inside the transaction and after it |

## 130. test_rls_query_paths.py: row-level security holds on every read path

The pytest twin of `test/rls_query_paths.sh` (#1341), every check by the shell's name. 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, by the custom scan, with it off, and in parallel; a notice handler counts what it saw. Each vectorized node also gets a leak arm, with a premise that the same query with a leakproof filter is answered by that node, and `column_extent`, which reads the stored extent of every row, must refuse a caller the policy restricts.

### Every test

| test | what it holds |
| --- | --- |
| `test_row_level_security_holds_on_every_read_path` | the read paths against heap (the custom scan, both vectorized aggregates, parallel, index-only, the covering projection), their plan premises, and the leak arms, including one through each vectorized node |
4 changes: 3 additions & 1 deletion test/pytest/expected_tests.txt
Original file line number Diff line number Diff line change
Expand Up @@ -722,4 +722,6 @@ guard_tests 438
# cluster_tests 598 -> 599 for #1341's test_min_and_max_keep_each_columns_collation in
# test_rollup.py, by collection over the cluster files on this tree.
# cluster_tests 599 -> 600 for #1341's test_savepoint_flush.py, one test, by collection
cluster_tests 600
# cluster_tests 600 -> 601 for #1341's test_rls_query_paths.py, one test, by collection
# over the cluster files on this tree.
cluster_tests 601
1 change: 1 addition & 0 deletions test/pytest/test_compare_to_bash.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@
"recovery_minimal_wal",
"rewrite_storage_oid",
"rls_direct_storage",
"rls_query_paths",
"rollup",
"savepoint_flush",
"scan_decode_cost",
Expand Down
Loading
Loading