diff --git a/CHANGELOG.md b/CHANGELOG.md index 803430e9..7db731a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/limitations.md b/docs/limitations.md index 7b343fd3..d64ba6fe 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -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. diff --git a/docs/sql-reference.md b/docs/sql-reference.md index 08527515..d16b8106 100644 --- a/docs/sql-reference.md +++ b/docs/sql-reference.md @@ -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: diff --git a/src/columnar_extent.c b/src/columnar_extent.c index 1a43395e..e4706672 100644 --- a/src/columnar_extent.c +++ b/src/columnar_extent.c @@ -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, diff --git a/src/columnar_vector.c b/src/columnar_vector.c index 7444f2e8..3eb72b5b 100644 --- a/src/columnar_vector.c +++ b/src/columnar_vector.c @@ -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" @@ -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) @@ -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); @@ -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); /* diff --git a/test/check_ledger.tsv b/test/check_ledger.tsv index 40f57800..56095788 100644 --- a/test/check_ledger.tsv +++ b/test/check_ledger.tsv @@ -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) diff --git a/test/check_ledger_budget.txt b/test/check_ledger_budget.txt index 60dbfb04..a3960011 100644 --- a/test/check_ledger_budget.txt +++ b/test/check_ledger_budget.txt @@ -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 diff --git a/test/pytest/TESTS.md b/test/pytest/TESTS.md index 87009bb8..2e32bcdc 100644 --- a/test/pytest/TESTS.md +++ b/test/pytest/TESTS.md @@ -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 @@ -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 | diff --git a/test/pytest/expected_tests.txt b/test/pytest/expected_tests.txt index 59af69ad..e230ed61 100644 --- a/test/pytest/expected_tests.txt +++ b/test/pytest/expected_tests.txt @@ -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 diff --git a/test/pytest/test_compare_to_bash.py b/test/pytest/test_compare_to_bash.py index ffa87bfd..26706682 100644 --- a/test/pytest/test_compare_to_bash.py +++ b/test/pytest/test_compare_to_bash.py @@ -149,6 +149,7 @@ "recovery_minimal_wal", "rewrite_storage_oid", "rls_direct_storage", + "rls_query_paths", "rollup", "savepoint_flush", "scan_decode_cost", diff --git a/test/pytest/test_rls_query_paths.py b/test/pytest/test_rls_query_paths.py new file mode 100644 index 00000000..b817fe60 --- /dev/null +++ b/test/pytest/test_rls_query_paths.py @@ -0,0 +1,148 @@ +"""Row-level security on every columnar read path (#1341). The pytest twin of +`test/rls_query_paths.sh`, 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, a parallel +scan, an index-only scan and a covering projection, and each answer must be heap's; the +plan premises check each path was taken. A cheap function that is not leakproof must +see only that role's rows: its NOTICEs are counted by a notice handler. +""" +import psycopg + +ALICE, BOB = "rqp_alice", "rqp_bob" +AGG = ("SELECT count(*) || '/' || sum(v) || '/' || min(v) || '/' || max(v) || '/' || " + "count(*) FILTER (WHERE owner <> current_user) FROM %t") +GRP = ("SELECT string_agg(grp || ':' || n || ':' || s, ',' ORDER BY grp) " + "FROM (SELECT grp, count(*) n, sum(v) s FROM %t GROUP BY grp) x") +OFF = ("SET pgcolumnar.enable_vectorization = off", "SET pgcolumnar.enable_group_vectorization = off", + "SET pgcolumnar.enable_custom_scan = off") +PAR = ("SET parallel_setup_cost = 0", "SET parallel_tuple_cost = 0", "SET min_parallel_table_scan_size = 0", + "SET max_parallel_workers_per_gather = 2") +IOS = ("SET enable_seqscan = off", "SET enable_bitmapscan = off", "SET pgcolumnar.enable_custom_scan = off") +IQ = "SELECT count(*) || '/' || sum(id) FROM %t WHERE id BETWEEN 100 AND 15000" +PQ = "SELECT count(*) || '/' || sum(v) FROM %t WHERE id BETWEEN 100 AND 15000" + + +def test_row_level_security_holds_on_every_read_path(pgc_cluster, pgc_conn, expect): + with pgc_conn.cursor() as cur: + cur.execute("SELECT current_schema()") + schema = cur.fetchone()[0] + for r in (ALICE, BOB): + cur.execute(f"SELECT 1 FROM pg_roles WHERE rolname = '{r}'") + if cur.fetchone() is None: + cur.execute(f"CREATE ROLE {r} LOGIN NOSUPERUSER") + cur.execute(f'GRANT USAGE ON SCHEMA "{schema}", pgcolumnar TO {r}') + cur.execute("CREATE FUNCTION rq_leak(text) RETURNS bool LANGUAGE plpgsql COST 0.0001 PARALLEL SAFE " + "AS $$ BEGIN RAISE NOTICE 'seen:%', $1; RETURN true; END $$") + for t, am in (("rq", "pgcolumnar"), ("rh", "heap")): + cur.execute(f"CREATE TABLE {t} (id int, owner name, grp int, v int, secret text) USING {am}") + cur.execute(f"INSERT INTO {t} SELECT g, CASE WHEN g % 2 = 0 THEN '{ALICE}' ELSE '{BOB}' END, g % 10, g, " + f"'S-' || CASE WHEN g % 2 = 0 THEN '{ALICE}' ELSE '{BOB}' END || '-' || g " + f"FROM generate_series(1, 20000) g") + cur.execute(f"CREATE INDEX {t}_io ON {t} (id, owner)") + cur.execute(f"ALTER TABLE {t} ENABLE ROW LEVEL SECURITY") + cur.execute(f"CREATE POLICY {t}_own ON {t} USING (owner = current_user)") + cur.execute(f"GRANT SELECT ON {t} TO {ALICE}, {BOB}") + cur.execute(f"VACUUM ANALYZE {t}") + cur.execute("SELECT pgcolumnar.add_projection('rq', 'rq_p', ARRAY['id', 'owner', 'v'], ARRAY['id'])") + cur.execute("SELECT count(*) FROM rq") + total = cur.fetchone()[0] + + alice = psycopg.connect(pgc_cluster.dsn().replace("user=postgres", f"user={ALICE}"), autocommit=True) + notices = [] + alice.add_notice_handler(lambda d: notices.append(d.message_primary or "")) + try: + alice.execute(f'SET search_path TO "{schema}", public, pgcolumnar') + + def run(setup, sql): + try: + for s in setup: + alice.execute(s) + return alice.execute(sql).fetchone()[0] + except psycopg.Error as exc: + return f"ERROR: {exc.sqlstate}" + finally: + alice.execute("RESET ALL") + alice.execute(f'SET search_path TO "{schema}", public, pgcolumnar') + + def same(setup, sql): + c, h = run(setup, sql.replace("%t", "rq")), run(setup, sql.replace("%t", "rh")) + ok = c == h and c is not None and not str(c).startswith("ERROR") + return "same" if ok else f"columnar=[{c}] heap=[{h}]" + + def plan_has(setup, sql, needle): + for s in setup: + alice.execute(s) + plan = "\n".join(r[0] for r in alice.execute("EXPLAIN (COSTS OFF) " + sql.replace("%t", "rq")).fetchall()) + alice.execute("RESET ALL") + alice.execute(f'SET search_path TO "{schema}", public, pgcolumnar') + return sum(1 for line in plan.splitlines() if needle in line) + + def leaks(table, setup, sql="SELECT count(*) FROM %t WHERE rq_leak(secret)"): + notices.clear() + run(setup, sql.replace("%t", table)) + seen = [n for n in notices if n.startswith("seen:")] + return f"{len(seen)}|{sum(1 for n in seen if n.startswith(f'seen:S-{BOB}'))}" + + expect.text(f"{total}|{run((), 'SELECT count(*) FROM rq')}|{run((), 'SELECT count(*) FROM rh')}", + "20000|10000|10000", + "a superuser sees all 20000 rows, and rq_alice her 10000 on both tables") + expect.num(plan_has((), AGG, "Custom Scan (PgColumnarScan)"), 1, + "premise: the aggregate is answered by the columnar custom scan") + expect.text(same((), AGG), "same", "count, sum, min and max under the policy, by the custom scan, are heap's") + expect.num(plan_has((), GRP, "Columnar Vectorized Group Keys"), 1, + "premise: the GROUP BY arm's plan is the grouped vectorized node") + expect.text(same((), GRP), "same", "a GROUP BY under the policy, with vectorization on, is heap's") + # The ungrouped vectorized aggregate builds its own quals too, and any user can + # turn it on. With no qual left, count(*) alone comes from metadata, so a sum + # makes it scan. + uva = ("SET pgcolumnar.enable_ungrouped_vector_agg = on",) + uagg = "SELECT count(*) || '/' || sum(v) FROM %t" + expect.num(plan_has(uva, uagg, "Columnar Vectorized Aggregates"), 1, + "premise: the ungrouped vectorized arm's plan folds the aggregates in the scan") + expect.text(same(uva, uagg), "same", + "count and sum under the policy, by the ungrouped vectorized aggregate, are heap's") + expect.text(f"{same(OFF, GRP)}|{same(OFF, AGG)}", "same|same", "and with vectorization and the custom scan off") + expect.num(plan_has(PAR, AGG, "Gather"), 1, "premise: the parallel arm's plan gathers from workers") + expect.text(f"{same(PAR, AGG)}|{same(PAR, GRP)}", "same|same", + "count, sum, min and max under the policy, by a parallel scan, are heap's") + expect.num(plan_has(IOS, IQ, "Index Only Scan"), 1, "premise: the index-only arm's plan is an index-only scan") + expect.text(same(IOS, IQ), "same", "an index-only scan under the policy is heap's") + expect.num(plan_has((), PQ, "Columnar Projection: rq_p"), 1, + "premise: the projection arm's plan reads the covering projection") + expect.text(same((), PQ), "same", "a covering projection scan under the policy is heap's") + expect.text(f"{leaks('rq', ())}|{leaks('rh', ())}", "10000|0|10000|0", + "a cheap function that is not leakproof sees only rq_alice's rows on columnar, as on heap") + expect.text(leaks("rq", OFF), "10000|0", "and the same with vectorization and the custom scan off") + # PARALLEL SAFE, or the planner keeps the function out of the workers and + # this arm repeats the first one. + expect.num(plan_has(PAR, "SELECT count(*) FROM %t WHERE rq_leak(secret)", "Gather"), 1, + "premise: the parallel leak arm's plan gathers from workers") + expect.text(leaks("rq", PAR), "10000|0", "and through a parallel scan") + # Both vectorized nodes build their own quals, so each must also keep a + # non-leakproof function from rows the policy hides. + expect.num(plan_has((), "SELECT grp, count(*) FROM %t WHERE secret IS NOT NULL GROUP BY grp", + "Columnar Vectorized Group Keys"), 1, + "premise: a GROUP BY with a leakproof filter under the policy is the grouped vectorized node") + lgrp = "SELECT grp, count(*) FROM %t WHERE rq_leak(secret) GROUP BY grp" + expect.text(f"{leaks('rq', (), lgrp)}|{leaks('rh', (), lgrp)}", "10000|0|10000|0", + "a non-leakproof function in a GROUP BY under the policy sees only rq_alice's rows, as on heap") + expect.num(plan_has(uva, "SELECT count(*), sum(v) FROM %t WHERE secret IS NOT NULL", + "Columnar Vectorized Aggregates"), 1, + "premise: an ungrouped count and sum with a leakproof filter, with the setting on, " + "is the ungrouped vectorized aggregate") + expect.text(leaks("rq", uva, "SELECT count(*), sum(v) FROM %t WHERE rq_leak(secret)"), "10000|0", + "and in the ungrouped vectorized aggregate") + # column_extent reads the stored extent, which covers every row, so under a + # policy it would show values from rows the caller cannot see. It refuses, as + # the other direct-storage entry points do (#563); the owner still reads it. + ce = run((), "SELECT count(*) FROM pgcolumnar.column_extent('rq', 'v')") + expect.text("refused" if ce == "ERROR: 0A000" else f"not refused ({ce})", "refused", + "column_extent refuses a caller a policy restricts") + with pgc_conn.cursor() as cur: + cur.execute("SELECT count(*) FROM pgcolumnar.column_extent('rq', 'v')") + owner = cur.fetchone()[0] + expect.num(owner, 1, "and the owner still reads the extent") + finally: + alice.close() diff --git a/test/rls_query_paths.sh b/test/rls_query_paths.sh new file mode 100755 index 00000000..e52c62a6 --- /dev/null +++ b/test/rls_query_paths.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# +# Row-level security on every columnar read path (#1341). The columnar custom scan +# builds its quals from the restriction list, the vectorized and grouped aggregates +# and the covering projection scan answer from their own paths, and an index-only +# scan and a parallel scan read the table differently again. A policy must hold on +# each, and a function that is not leakproof must never see a row the policy hides. +# +# A table and a heap mirror hold the same 20000 rows, half owned by each of two +# roles, with a policy that shows a role only its own rows. Each arm logs in as one of +# the roles (a real login, not SET ROLE) and must answer as heap does, on the path the +# arm names: the plan premises check that path was taken. +# +# Usage: test/rls_query_paths.sh [PG_CONFIG] +# Written fresh for pgColumnar. + +set -uo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +pgc_setup "${1:-/usr/local/pg17/bin/pg_config}" + +psql_run "DROP ROLE IF EXISTS rq_alice; DROP ROLE IF EXISTS rq_bob; + CREATE ROLE rq_alice LOGIN NOSUPERUSER; CREATE ROLE rq_bob LOGIN NOSUPERUSER; + GRANT USAGE ON SCHEMA pgcolumnar TO rq_alice; + CREATE FUNCTION rq_leak(text) RETURNS bool LANGUAGE plpgsql COST 0.0001 PARALLEL SAFE + AS \$\$ BEGIN RAISE NOTICE 'seen:%', \$1; RETURN true; END \$\$;" +for t in rq rh; do + am=pgcolumnar; [ $t = rh ] && am=heap + psql_run "CREATE TABLE $t (id int, owner name, grp int, v int, secret text) USING $am; + INSERT INTO $t SELECT g, CASE WHEN g % 2 = 0 THEN 'rq_alice' ELSE 'rq_bob' END, g % 10, g, + 'S-' || CASE WHEN g % 2 = 0 THEN 'rq_alice' ELSE 'rq_bob' END || '-' || g + FROM generate_series(1, 20000) g; + CREATE INDEX ${t}_io ON $t (id, owner); + ALTER TABLE $t ENABLE ROW LEVEL SECURITY; + CREATE POLICY ${t}_own ON $t USING (owner = current_user); + GRANT SELECT ON $t TO rq_alice, rq_bob;" + psql_run "VACUUM ANALYZE $t;" # not in a transaction block +done +psql_run "SELECT pgcolumnar.add_projection('rq', 'rq_p', ARRAY['id', 'owner', 'v'], ARRAY['id']);" >/dev/null + +as_alice() { # as_alice SQL -> its output, as rq_alice on her own login + env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U rq_alice -d "$PGC_DB" -XAtq \ + -c "SET search_path TO public, pgcolumnar" -c "$1" 2>&1 +} +same() { # same SETUP QUERY -> "same", or both answers; %t is the table + local c h + c="$(as_alice "$1 ${2//%t/rq}" | tail -1)"; h="$(as_alice "$1 ${2//%t/rh}" | tail -1)" + # an error on both sides is not agreement + case "$c" in *ERROR*) echo "columnar=[$c] heap=[$h]"; return ;; esac + [ "$c" = "$h" ] && [ -n "$c" ] && echo same || echo "columnar=[$c] heap=[$h]" +} +plan_has() { # plan_has SETUP QUERY PATTERN -> how many plan lines match, on the columnar table + as_alice "$1 EXPLAIN (COSTS OFF) ${2//%t/rq}" | grep -cE "$3" +} + +AGG="SELECT count(*) || '/' || sum(v) || '/' || min(v) || '/' || max(v) || '/' || count(*) FILTER (WHERE owner <> current_user) FROM %t" +GRP="SELECT string_agg(grp || ':' || n || ':' || s, ',' ORDER BY grp) FROM (SELECT grp, count(*) n, sum(v) s FROM %t GROUP BY grp) x" + +check "a superuser sees all 20000 rows, and rq_alice her 10000 on both tables" \ + "$(q "SELECT count(*) FROM rq")|$(as_alice "SELECT count(*) FROM rq")|$(as_alice "SELECT count(*) FROM rh")" "20000|10000|10000" + +# ---- the custom scan and the vectorized aggregates ----------------------------------- +check "premise: the aggregate is answered by the columnar custom scan" "$(plan_has "" "$AGG" 'Custom Scan \(PgColumnarScan\)')" "1" +check "count, sum, min and max under the policy, by the custom scan, are heap's" "$(same "" "$AGG")" "same" +check "premise: the GROUP BY arm's plan is the grouped vectorized node" \ + "$(plan_has "" "$GRP" 'Columnar Vectorized Group Keys')" "1" +check "a GROUP BY under the policy, with vectorization on, is heap's" "$(same "" "$GRP")" "same" +# The ungrouped vectorized aggregate builds its own quals from baserestrictinfo too, +# and any user can turn it on (PGC_USERSET). With no qual left, count(*) alone is +# answered from metadata, so a sum makes it scan (OffgridwithJD, review of #1429). +UVA="SET pgcolumnar.enable_ungrouped_vector_agg = on;" +UAGG="SELECT count(*) || '/' || sum(v) FROM %t" +check "premise: the ungrouped vectorized arm's plan folds the aggregates in the scan" \ + "$(plan_has "$UVA" "$UAGG" 'Columnar Vectorized Aggregates')" "1" +check "count and sum under the policy, by the ungrouped vectorized aggregate, are heap's" "$(same "$UVA" "$UAGG")" "same" +OFF="SET pgcolumnar.enable_vectorization = off; SET pgcolumnar.enable_group_vectorization = off; SET pgcolumnar.enable_custom_scan = off;" +check "and with vectorization and the custom scan off" "$(same "$OFF" "$GRP")|$(same "$OFF" "$AGG")" "same|same" + +# ---- a parallel scan ----------------------------------------------------------------- +PAR="SET parallel_setup_cost = 0; SET parallel_tuple_cost = 0; SET min_parallel_table_scan_size = 0; SET max_parallel_workers_per_gather = 2;" +check "premise: the parallel arm's plan gathers from workers" "$(plan_has "$PAR" "$AGG" 'Gather')" "1" +check "count, sum, min and max under the policy, by a parallel scan, are heap's" "$(same "$PAR" "$AGG")|$(same "$PAR" "$GRP")" "same|same" + +# ---- an index-only scan -------------------------------------------------------------- +IOS="SET enable_seqscan = off; SET enable_bitmapscan = off; SET pgcolumnar.enable_custom_scan = off;" +IQ="SELECT count(*) || '/' || sum(id) FROM %t WHERE id BETWEEN 100 AND 15000" +check "premise: the index-only arm's plan is an index-only scan" "$(plan_has "$IOS" "$IQ" 'Index Only Scan')" "1" +check "an index-only scan under the policy is heap's" "$(same "$IOS" "$IQ")" "same" + +# ---- a covering projection ----------------------------------------------------------- +PQ="SELECT count(*) || '/' || sum(v) FROM %t WHERE id BETWEEN 100 AND 15000" +check "premise: the projection arm's plan reads the covering projection" "$(plan_has "" "$PQ" 'Columnar Projection: rq_p')" "1" +check "a covering projection scan under the policy is heap's" "$(same "" "$PQ")" "same" + +# ---- a function that is not leakproof sees only visible rows -------------------------- +leaks() { # leaks TABLE SETUP [QUERY] -> "|" + local out sql="${3:-SELECT count(*) FROM %t WHERE rq_leak(secret)}" + out="$(as_alice "$2 ${sql//%t/$1}")" + echo "$(grep -c 'seen:' <<<"$out")|$(grep -c 'seen:S-rq_bob' <<<"$out")" +} +check "a cheap function that is not leakproof sees only rq_alice's rows on columnar, as on heap" \ + "$(leaks rq "")|$(leaks rh "")" "10000|0|10000|0" +check "and the same with vectorization and the custom scan off" "$(leaks rq "$OFF")" "10000|0" +# PARALLEL SAFE, or the planner keeps the function out of the workers and this arm +# repeats the first one (OffgridwithJD, review of #1429). +check "premise: the parallel leak arm's plan gathers from workers" \ + "$(plan_has "$PAR" "SELECT count(*) FROM %t WHERE rq_leak(secret)" 'Gather')" "1" +check "and through a parallel scan" "$(leaks rq "$PAR")" "10000|0" +# Both vectorized nodes build their own quals (above), so each must also keep a +# function that is not leakproof from rows the policy hides. Each premise shows the +# arm's query shape, with a leakproof filter in place of the function, is answered by +# that node (OffgridwithJD, review of #1429). +check "premise: a GROUP BY with a leakproof filter under the policy is the grouped vectorized node" \ + "$(plan_has "" "SELECT grp, count(*) FROM %t WHERE secret IS NOT NULL GROUP BY grp" 'Columnar Vectorized Group Keys')" "1" +check "a non-leakproof function in a GROUP BY under the policy sees only rq_alice's rows, as on heap" \ + "$(leaks rq "" "SELECT grp, count(*) FROM %t WHERE rq_leak(secret) GROUP BY grp")|$(leaks rh "" "SELECT grp, count(*) FROM %t WHERE rq_leak(secret) GROUP BY grp")" "10000|0|10000|0" +check "premise: an ungrouped count and sum with a leakproof filter, with the setting on, is the ungrouped vectorized aggregate" \ + "$(plan_has "$UVA" "SELECT count(*), sum(v) FROM %t WHERE secret IS NOT NULL" 'Columnar Vectorized Aggregates')" "1" +check "and in the ungrouped vectorized aggregate" "$(leaks rq "$UVA" "SELECT count(*), sum(v) FROM %t WHERE rq_leak(secret)")" "10000|0" +# pgcolumnar.column_extent reads the stored extent, which covers every row, so under a +# policy it would show values from rows the caller cannot see. It refuses, as the +# other direct-storage entry points do (#563), and the owner still reads it +# (OffgridwithJD, review of #1429). +ce="$(as_alice "SELECT count(*) FROM pgcolumnar.column_extent('rq', 'v')")" +check "column_extent refuses a caller a policy restricts" \ + "$(grep -q 'row-level security is in force' <<<"$ce" && echo refused || echo "not refused (${ce//$'\n'/ })")" "refused" +check "and the owner still reads the extent" "$(q "SELECT count(*) FROM pgcolumnar.column_extent('rq', 'v')")" "1" + +pgc_summary diff --git a/test/run_all_versions.sh b/test/run_all_versions.sh index f3961c4b..dbd03a1c 100755 --- a/test/run_all_versions.sh +++ b/test/run_all_versions.sh @@ -294,6 +294,7 @@ SUITES=( rewrite_group_scan rewrite_storage_oid rls_direct_storage + rls_query_paths rollup row_triggers savepoint_flush