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
Loading
Loading