From 90eea367eea9fdedb8913bad7149fdf87817679f Mon Sep 17 00:00:00 2001 From: John Gemignani Date: Thu, 8 Oct 2026 05:08:19 +0000 Subject: [PATCH 1/5] Keep VLE graph contexts alive for the whole query A traversal points into a backend-local graph context that the next acquisition could free. A context loaded through RLS is never valid for a new acquisition, so when two traversals ran in one query the second one freed the first one's context. Interleaved traversals lost results, and materializing a path after its context was freed dereferenced a NULL pointer (review issue 1 on #2561). Pin a traversal's graph context to the query's per-query memory context. A pinned context that becomes invalid is retired: it is no longer offered to new acquisitions, and a memory-context callback frees it when the last query holding it ends, including after an error. Deleting the caches retires pinned contexts the same way. A VLE path is materialized from a context that holds all of its vertices and edges; when there is none, the materializers raise an error instead of dereferencing a missing context. Within one query, reuse a pinned context under the rules PostgreSQL gives a statement's scans, so that the policies run once for each row a load scans instead of again for every traversal. The 400-edge workload from the review evaluated its policy 160,000 times and now evaluates it 400 times, for stable and volatile policies alike: - A load made with another role, enforcement setting or snapshot is a different scan. - A later command id means that a traversal which has not loaded the graph yet must see the statement's own writes. The traversal that loaded it keeps it while the statement writes, as a traversal of a graph without RLS does. Pinning a new copy for every row would keep them all until the query ends. - Policies and privileges hold for the whole statement: PostgreSQL applies the policies when it plans the statement and checks the privileges when it starts it, so a change made while the statement runs does not end the reuse. - When the RLS quals call only IMMUTABLE functions, besides values such as current_user that cannot change unnoticed, any traversal in the query may share the graph. Otherwise only the traversal that loaded it reuses it, as when PostgreSQL rescans the result of a scan, and another traversal loads again: a STABLE function such as current_setting() can give another result once the statement changes the setting. A traversal is identified by its FmgrInfo, which stays the same when it runs again for another row. Policies from the row-security hooks become the same security quals as catalog policies, so they are inspected as well. RLS-filtered contexts are still never reused across statements. An RLS traversal in a DETACH DELETE query loaded its graph again for every row. A reload saw a deleted vertex whose edge was still visible, and the traversal failed with "add_valid_vertex_edges: no vertex found", while the same query without RLS succeeded. Keeping the graph makes the two return the same rows. Tests cover interleaved traversals, two RLS graphs materialized in one statement, policy evaluation counts for one and two traversals with immutable, stable and volatile policies across traversal shapes, a policy reading a setting the statement changes, the contexts left after each query, a statement's own write, a policy change and a role switch made while a statement runs, and traversals before and after a DETACH DELETE with and without RLS, including the graph copies held. They also build stored paths after the caches are deleted and for a role whose context lacks their edges, run queries that fail after their traversal, and delete the caches while a query holds its graph. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- regress/expected/rls_vle.out | 895 +++++++++++++++++++++++ regress/sql/rls_vle.sql | 528 +++++++++++++ src/backend/utils/adt/age_global_graph.c | 580 +++++++++++++-- src/backend/utils/adt/age_vle.c | 49 +- src/include/utils/age_global_graph.h | 9 + 5 files changed, 1995 insertions(+), 66 deletions(-) diff --git a/regress/expected/rls_vle.out b/regress/expected/rls_vle.out index a3f16caed..ec7220f97 100644 --- a/regress/expected/rls_vle.out +++ b/regress/expected/rls_vle.out @@ -350,6 +350,901 @@ NOTICE: graph "rls_acl" has been dropped (1 row) RESET age.enforce_rls_in_traversal; +-- ============================================================================ +-- PART 8: Traversal lifetime -- a traversal's graph context stays alive until +-- its query ends, even when another traversal in the same query must load its +-- own RLS-filtered context. Within one query an RLS-filtered context is reused +-- under the rules PostgreSQL gives a statement's scans (see 8.3 to 8.7). +-- ============================================================================ +SELECT create_graph('rls_vle_chain'); +NOTICE: graph "rls_vle_chain" has been created + create_graph +-------------- + +(1 row) + +SELECT * FROM cypher('rls_vle_chain', $$ + CREATE (:Node {n: 1})-[:LINK]->(:Node {n: 2})-[:LINK]->(:Node {n: 3})-[:LINK]->(:Node {n: 4}) +$$) AS (x agtype); + x +--- +(0 rows) + +SELECT create_graph('rls_vle_chain2'); +NOTICE: graph "rls_vle_chain2" has been created + create_graph +-------------- + +(1 row) + +SELECT * FROM cypher('rls_vle_chain2', $$ + CREATE (:Node {n: 1})-[:LINK]->(:Node {n: 2})-[:LINK]->(:Node {n: 3}) +$$) AS (x agtype); + x +--- +(0 rows) + +SELECT create_graph('rls_vle_many'); +NOTICE: graph "rls_vle_many" has been created + create_graph +-------------- + +(1 row) + +SELECT * FROM cypher('rls_vle_many', $$ + UNWIND range(1, 20) AS i + CREATE (:Source {i: i})-[:LINK]->(:Target {i: i}) +$$) AS (x agtype); + x +--- +(0 rows) + +GRANT USAGE ON SCHEMA rls_vle_chain, rls_vle_chain2, rls_vle_many TO rls_vle_user; +GRANT SELECT ON ALL TABLES IN SCHEMA rls_vle_chain, rls_vle_chain2, rls_vle_many + TO rls_vle_user; +ALTER TABLE rls_vle_chain."LINK" ENABLE ROW LEVEL SECURITY; +CREATE POLICY link_all ON rls_vle_chain."LINK" FOR SELECT USING (true); +ALTER TABLE rls_vle_chain2."LINK" ENABLE ROW LEVEL SECURITY; +CREATE POLICY link_all ON rls_vle_chain2."LINK" FOR SELECT USING (true); +ALTER TABLE rls_vle_many."LINK" ENABLE ROW LEVEL SECURITY; +CREATE POLICY link_all ON rls_vle_many."LINK" FOR SELECT USING (true); +SET ROLE rls_vle_user; +-- 8.1: two traversals advanced row by row in one target list: 6 and 20 paths +SELECT count(a) AS chain_paths, count(b) AS independent_paths +FROM ( + SELECT + (age_vle('"rls_vle_chain"', NULL, NULL, + age_build_vle_match_edge('"LINK"', NULL), + '1', '3', '1')).start_id AS a, + (age_vle('"rls_vle_many"', NULL, NULL, + age_build_vle_match_edge('"LINK"', NULL), + '1', '2', '1')).end_id AS b +) AS interleaved; + chain_paths | independent_paths +-------------+------------------- + 6 | 20 +(1 row) + +-- 8.2: paths of the first graph are materialized after the second graph's +-- traversal loaded its own context: 6 x 3 pairs of distinct paths +SELECT count(*) AS pairs, count(DISTINCT p) AS chain_paths, + count(DISTINCT q) AS chain2_paths +FROM cypher('rls_vle_chain', $$ + MATCH p = (a)-[:LINK*1..3]->(b) RETURN p + $$) AS one(p agtype), + cypher('rls_vle_chain2', $$ + MATCH q = (a)-[:LINK*1..2]->(b) RETURN q + $$) AS two(q agtype); + pairs | chain_paths | chain2_paths +-------+-------------+-------------- + 18 | 6 | 3 +(1 row) + +RESET ROLE; +-- 8.3: policy evaluations per query. Each traversal loads the 20 visible +-- edges once and reuses its load when it is evaluated again for another row, +-- as PostgreSQL rescans a materialized scan. Another traversal in the query +-- shares that load only when the policy gives the same result throughout the +-- statement: when it calls only IMMUTABLE functions, besides values such as +-- current_user that cannot change unnoticed. Otherwise, as with the STABLE +-- and volatile counters below, it loads the graph again, as a separate scan +-- would evaluate the policy again. +SELECT age_delete_global_graphs(NULL) IS NOT NULL AS graph_caches_cleared; + graph_caches_cleared +---------------------- + t +(1 row) + +CREATE SEQUENCE public.rls_vle_policy_calls; +GRANT USAGE, SELECT ON SEQUENCE public.rls_vle_policy_calls TO rls_vle_user; +CREATE FUNCTION public.rls_vle_count_stable() RETURNS boolean + LANGUAGE plpgsql STABLE +AS $f$ +BEGIN + PERFORM nextval('public.rls_vle_policy_calls'); + RETURN true; +END +$f$; +ALTER POLICY link_all ON rls_vle_many."LINK" + USING (public.rls_vle_count_stable()); +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) RETURN count(*) +$$) AS (n agtype); + n +---- + 20 +(1 row) + +RESET ROLE; +SELECT last_value AS stable_one_hop_evaluations FROM public.rls_vle_policy_calls; + stable_one_hop_evaluations +---------------------------- + 20 +(1 row) + +ALTER SEQUENCE public.rls_vle_policy_calls RESTART; +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..2]->(b:Target) RETURN count(*) +$$) AS (n agtype); + n +---- + 20 +(1 row) + +RESET ROLE; +SELECT last_value AS stable_one_to_two_hop_evaluations +FROM public.rls_vle_policy_calls; + stable_one_to_two_hop_evaluations +----------------------------------- + 20 +(1 row) + +ALTER SEQUENCE public.rls_vle_policy_calls RESTART; +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle_many', $$ + MATCH p = (a:Source)-[:LINK*1..1]->(b:Target) RETURN count(p) +$$) AS (n agtype); + n +---- + 20 +(1 row) + +RESET ROLE; +SELECT last_value AS stable_named_path_evaluations +FROM public.rls_vle_policy_calls; + stable_named_path_evaluations +------------------------------- + 20 +(1 row) + +ALTER SEQUENCE public.rls_vle_policy_calls RESTART; +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) + WITH count(*) AS first + MATCH (c:Source)-[:LINK*1..1]->(d:Target) + RETURN first, count(*) +$$) AS (first agtype, second agtype); + first | second +-------+-------- + 20 | 20 +(1 row) + +RESET ROLE; +SELECT last_value AS stable_two_call_site_evaluations +FROM public.rls_vle_policy_calls; + stable_two_call_site_evaluations +---------------------------------- + 40 +(1 row) + +-- each traversal loaded its own context: the first one's was freed when the +-- query ended, and the second one's stays in the backend until the next +-- graph load frees it +SELECT count(*) AS contexts_after_stable_query +FROM pg_backend_memory_contexts WHERE name = 'AGE edge_table'; + contexts_after_stable_query +----------------------------- + 1 +(1 row) + +SELECT age_delete_global_graphs(NULL) IS NOT NULL AS graph_caches_cleared; + graph_caches_cleared +---------------------- + t +(1 row) + +-- policies that give the same result throughout the statement: the two +-- traversals share one load. The counter is declared IMMUTABLE so that the +-- policy counts as fixed, and its argument keeps it from being evaluated +-- once at planning. +CREATE FUNCTION public.rls_vle_count_fixed(id graphid) RETURNS boolean + LANGUAGE plpgsql IMMUTABLE +AS $f$ +BEGIN + PERFORM nextval('public.rls_vle_policy_calls'); + RETURN true; +END +$f$; +ALTER POLICY link_all ON rls_vle_many."LINK" + USING (public.rls_vle_count_fixed(id)); +ALTER SEQUENCE public.rls_vle_policy_calls RESTART; +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) + WITH count(*) AS first + MATCH (c:Source)-[:LINK*1..1]->(d:Target) + RETURN first, count(*) +$$) AS (first agtype, second agtype); + first | second +-------+-------- + 20 | 20 +(1 row) + +RESET ROLE; +SELECT last_value AS immutable_two_call_site_evaluations +FROM public.rls_vle_policy_calls; + immutable_two_call_site_evaluations +------------------------------------- + 20 +(1 row) + +ALTER POLICY link_all ON rls_vle_many."LINK" + USING (public.rls_vle_count_fixed(id) AND current_user = 'rls_vle_user'); +ALTER SEQUENCE public.rls_vle_policy_calls RESTART; +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) + WITH count(*) AS first + MATCH (c:Source)-[:LINK*1..1]->(d:Target) + RETURN first, count(*) +$$) AS (first agtype, second agtype); + first | second +-------+-------- + 20 | 20 +(1 row) + +RESET ROLE; +SELECT last_value AS current_user_two_call_site_evaluations +FROM public.rls_vle_policy_calls; + current_user_two_call_site_evaluations +---------------------------------------- + 20 +(1 row) + +-- the search path can change during the statement, and a STABLE function +-- in a subquery of the policy counts as in the policy itself: each +-- traversal loads the graph +ALTER POLICY link_all ON rls_vle_many."LINK" + USING (public.rls_vle_count_fixed(id) AND current_schema IS NOT NULL); +ALTER SEQUENCE public.rls_vle_policy_calls RESTART; +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) + WITH count(*) AS first + MATCH (c:Source)-[:LINK*1..1]->(d:Target) + RETURN first, count(*) +$$) AS (first agtype, second agtype); + first | second +-------+-------- + 20 | 20 +(1 row) + +RESET ROLE; +SELECT last_value AS current_schema_two_call_site_evaluations +FROM public.rls_vle_policy_calls; + current_schema_two_call_site_evaluations +------------------------------------------ + 40 +(1 row) + +ALTER POLICY link_all ON rls_vle_many."LINK" + USING (EXISTS (SELECT 1 WHERE id IS NOT NULL + AND public.rls_vle_count_stable())); +ALTER SEQUENCE public.rls_vle_policy_calls RESTART; +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) + WITH count(*) AS first + MATCH (c:Source)-[:LINK*1..1]->(d:Target) + RETURN first, count(*) +$$) AS (first agtype, second agtype); + first | second +-------+-------- + 20 | 20 +(1 row) + +RESET ROLE; +SELECT last_value AS stable_subquery_two_call_site_evaluations +FROM public.rls_vle_policy_calls; + stable_subquery_two_call_site_evaluations +------------------------------------------- + 40 +(1 row) + +SELECT age_delete_global_graphs(NULL) IS NOT NULL AS graph_caches_cleared; + graph_caches_cleared +---------------------- + t +(1 row) + +ALTER POLICY link_all ON rls_vle_many."LINK" + USING (nextval('public.rls_vle_policy_calls') > 0); +ALTER SEQUENCE public.rls_vle_policy_calls RESTART; +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) RETURN count(*) +$$) AS (n agtype); + n +---- + 20 +(1 row) + +RESET ROLE; +SELECT last_value AS volatile_one_hop_evaluations +FROM public.rls_vle_policy_calls; + volatile_one_hop_evaluations +------------------------------ + 20 +(1 row) + +ALTER SEQUENCE public.rls_vle_policy_calls RESTART; +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) + WITH count(*) AS first + MATCH (c:Source)-[:LINK*1..1]->(d:Target) + RETURN first, count(*) +$$) AS (first agtype, second agtype); + first | second +-------+-------- + 20 | 20 +(1 row) + +RESET ROLE; +SELECT last_value AS volatile_two_call_site_evaluations +FROM public.rls_vle_policy_calls; + volatile_two_call_site_evaluations +------------------------------------ + 40 +(1 row) + +-- the first call site's context was freed when the query ended; the second +-- one is kept, as for the stable policy +SELECT count(*) AS contexts_after_volatile_query +FROM pg_backend_memory_contexts WHERE name = 'AGE edge_table'; + contexts_after_volatile_query +------------------------------- + 1 +(1 row) + +ALTER POLICY link_all ON rls_vle_many."LINK" USING (true); +-- a policy that reads a setting with current_setting(), which the statement +-- itself changes: each traversal sets the limit through its graph argument +-- and sees its own Targets, the first those up to 5 and the second those up +-- to 10, as two scans of the label in PostgreSQL would +ALTER TABLE rls_vle_many."Target" ENABLE ROW LEVEL SECURITY; +CREATE POLICY target_limit ON rls_vle_many."Target" FOR SELECT + USING ((properties->>'"i"')::int <= + coalesce(nullif(current_setting('rls_vle.max_target', true), ''), + '0')::int); +CREATE FUNCTION public.rls_vle_limit_targets(n int) RETURNS agtype + LANGUAGE plpgsql VOLATILE +AS $f$ +BEGIN + PERFORM set_config('rls_vle.max_target', n::text, false); + RETURN '"rls_vle_many"'::agtype; +END +$f$; +SET ROLE rls_vle_user; +SELECT count(DISTINCT first.end_id) AS first_targets, + count(DISTINCT second.end_id) AS second_targets +FROM age_vle(public.rls_vle_limit_targets(5), NULL, NULL, + age_build_vle_match_edge('"LINK"', NULL), + '1', '1', '1') AS first, + LATERAL age_vle(public.rls_vle_limit_targets( + CASE WHEN first.start_id IS NOT NULL THEN 10 END), + NULL, NULL, + age_build_vle_match_edge('"LINK"', NULL), + '1', '1', '1') AS second; + first_targets | second_targets +---------------+---------------- + 5 | 10 +(1 row) + +RESET ROLE; +RESET rls_vle.max_target; +DROP POLICY target_limit ON rls_vle_many."Target"; +ALTER TABLE rls_vle_many."Target" DISABLE ROW LEVEL SECURITY; +DROP FUNCTION public.rls_vle_limit_targets(int); +-- 8.4: a statement's own write is visible to its later traversal. The write +-- advances the command id, so the later traversal does not reuse the graph +-- loaded before it. +BEGIN; +GRANT INSERT ON rls_vle_many."LINK" TO rls_vle_user; +GRANT USAGE ON SEQUENCE rls_vle_many."LINK_id_seq" TO rls_vle_user; +CREATE POLICY link_insert ON rls_vle_many."LINK" FOR INSERT WITH CHECK (true); +SET LOCAL ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle_many', $$ + MATCH (a:Source {i: 1})-[:LINK*1..1]->(b:Target) + WITH a, count(b) AS before + MATCH (t:Target {i: 2}) + CREATE (a)-[:LINK]->(t) + WITH a, before, count(*) AS created + MATCH (a)-[:LINK*1..1]->(c:Target) + RETURN before, created, count(c) +$$) AS (before agtype, created agtype, after agtype); + before | created | after +--------+---------+------- + 1 | 1 | 2 +(1 row) + +ROLLBACK; +-- 8.5: a policy change made while a statement runs does not change what that +-- statement's traversal sees, as for a PostgreSQL scan; the next statement +-- sees it. The start vertex argument hides every edge when the traversal is +-- evaluated for the second row, after the first row loaded the graph. +CREATE FUNCTION public.rls_vle_start_hiding_links(id graphid, rn bigint) + RETURNS ag_catalog.agtype + LANGUAGE plpgsql SECURITY DEFINER +AS $f$ +BEGIN + IF rn = 2 THEN + ALTER POLICY link_all ON rls_vle_many."LINK" USING (false); + END IF; + RETURN (id::text)::ag_catalog.agtype; +END +$f$; +SET ROLE rls_vle_user; +SELECT count(*) AS paths_with_policy_changed_midway +FROM (SELECT id, row_number() OVER (ORDER BY id) AS rn + FROM rls_vle_many."Source") AS s, + LATERAL age_vle('"rls_vle_many"', + public.rls_vle_start_hiding_links(s.id, s.rn), NULL, + age_build_vle_match_edge('"LINK"', NULL), + '1', '1', '1') AS v; + paths_with_policy_changed_midway +---------------------------------- + 20 +(1 row) + +SELECT count(*) AS paths_in_next_statement +FROM rls_vle_many."Source" AS s, + LATERAL age_vle('"rls_vle_many"', (s.id::text)::agtype, NULL, + age_build_vle_match_edge('"LINK"', NULL), + '1', '1', '1') AS v; + paths_in_next_statement +------------------------- + 0 +(1 row) + +RESET ROLE; +-- 8.6: a graph loaded for one role is not reused for another, even within a +-- statement. The start vertex argument switches to rls_vle_user2, from whom +-- every edge is hidden, when the traversal is evaluated for the second row. +ALTER POLICY link_all ON rls_vle_many."LINK" USING (true); +GRANT USAGE ON SCHEMA rls_vle_many TO rls_vle_user2; +GRANT SELECT ON ALL TABLES IN SCHEMA rls_vle_many TO rls_vle_user2; +CREATE POLICY link_hidden_from_user2 ON rls_vle_many."LINK" AS RESTRICTIVE + FOR SELECT TO rls_vle_user2 USING (false); +CREATE FUNCTION public.rls_vle_start_as_user2(id graphid, rn bigint) + RETURNS ag_catalog.agtype + LANGUAGE plpgsql +AS $f$ +BEGIN + IF rn = 2 THEN + PERFORM set_config('role', 'rls_vle_user2', true); + END IF; + RETURN (id::text)::ag_catalog.agtype; +END +$f$; +SET ROLE rls_vle_user; +SELECT count(*) AS paths_with_role_changed_midway +FROM (SELECT id, row_number() OVER (ORDER BY id) AS rn + FROM rls_vle_many."Source") AS s, + LATERAL age_vle('"rls_vle_many"', + public.rls_vle_start_as_user2(s.id, s.rn), NULL, + age_build_vle_match_edge('"LINK"', NULL), + '1', '1', '1') AS v; + paths_with_role_changed_midway +-------------------------------- + 1 +(1 row) + +RESET ROLE; +-- 8.7: a statement's own deletes do not make a traversal load its graph +-- again. The traversal keeps the graph it loaded, as it does without RLS, so +-- a policy that allows every row returns what no policy returns, and the +-- query holds one copy of the graph instead of one for every row. +SELECT create_graph('rls_vle_before_plain'); +NOTICE: graph "rls_vle_before_plain" has been created + create_graph +-------------- + +(1 row) + +SELECT create_graph('rls_vle_before_rls'); +NOTICE: graph "rls_vle_before_rls" has been created + create_graph +-------------- + +(1 row) + +SELECT create_graph('rls_vle_after_plain'); +NOTICE: graph "rls_vle_after_plain" has been created + create_graph +-------------- + +(1 row) + +SELECT create_graph('rls_vle_after_rls'); +NOTICE: graph "rls_vle_after_rls" has been created + create_graph +-------------- + +(1 row) + +SELECT * FROM cypher('rls_vle_before_plain', $$ + CREATE (:Source {i: 1})-[:LINK]->(m:Mid {i: 0})-[:LINK]->(:Target {i: 9}), + (:Source {i: 2})-[:LINK]->(m) +$$) AS (x agtype); + x +--- +(0 rows) + +SELECT * FROM cypher('rls_vle_before_rls', $$ + CREATE (:Source {i: 1})-[:LINK]->(m:Mid {i: 0})-[:LINK]->(:Target {i: 9}), + (:Source {i: 2})-[:LINK]->(m) +$$) AS (x agtype); + x +--- +(0 rows) + +SELECT * FROM cypher('rls_vle_after_plain', $$ + CREATE (:Node {i: 1, ord: 0})-[:LINK]->(:Node {i: 2, ord: 2}) + -[:LINK]->(:Node {i: 3, ord: 3}), + (:Node {i: 9, ord: 1}) +$$) AS (x agtype); + x +--- +(0 rows) + +SELECT * FROM cypher('rls_vle_after_rls', $$ + CREATE (:Node {i: 1, ord: 0})-[:LINK]->(:Node {i: 2, ord: 2}) + -[:LINK]->(:Node {i: 3, ord: 3}), + (:Node {i: 9, ord: 1}) +$$) AS (x agtype); + x +--- +(0 rows) + +GRANT USAGE ON SCHEMA rls_vle_before_plain, rls_vle_before_rls, + rls_vle_after_plain, rls_vle_after_rls TO rls_vle_user; +GRANT SELECT, DELETE ON ALL TABLES IN SCHEMA rls_vle_before_plain, + rls_vle_before_rls, rls_vle_after_plain, rls_vle_after_rls + TO rls_vle_user; +ALTER TABLE rls_vle_before_rls."LINK" ENABLE ROW LEVEL SECURITY; +CREATE POLICY link_read ON rls_vle_before_rls."LINK" FOR SELECT USING (true); +CREATE POLICY link_delete ON rls_vle_before_rls."LINK" FOR DELETE USING (true); +ALTER TABLE rls_vle_after_rls."LINK" ENABLE ROW LEVEL SECURITY; +CREATE POLICY link_read ON rls_vle_after_rls."LINK" FOR SELECT USING (true); +CREATE POLICY link_delete ON rls_vle_after_rls."LINK" FOR DELETE USING (true); +SET ROLE rls_vle_user; +-- a traversal before the delete, for each Source: s1 -> m <- s2, m -> t +SELECT * FROM cypher('rls_vle_before_plain', $$ + MATCH (a:Source)-[:LINK*1..2]->(b) + DETACH DELETE b + RETURN a.i, b.i +$$) AS (source agtype, deleted agtype) ORDER BY source, deleted; + source | deleted +--------+--------- + 1 | 0 + 1 | 9 + 2 | 9 +(3 rows) + +SELECT * FROM cypher('rls_vle_before_rls', $$ + MATCH (a:Source)-[:LINK*1..2]->(b) + DETACH DELETE b + RETURN a.i, b.i +$$) AS (source agtype, deleted agtype) ORDER BY source, deleted; + source | deleted +--------+--------- + 1 | 0 + 1 | 9 + 2 | 9 +(3 rows) + +-- a traversal after the delete of each node but n1, in the order of ord: +-- n1 -> n2 -> n3 and an unconnected n9, which is deleted first +SELECT * FROM cypher('rls_vle_after_plain', $$ + MATCH (d:Node) WHERE d.i > 1 + WITH d ORDER BY d.ord + DETACH DELETE d + WITH d + MATCH p = (n:Node {i: 1})-[:LINK*1..3]->() + RETURN d.i, length(p) +$$) AS (deleted agtype, path_length agtype) ORDER BY deleted, path_length; + deleted | path_length +---------+------------- + 2 | 2 + 9 | 1 + 9 | 2 +(3 rows) + +SELECT * FROM cypher('rls_vle_after_rls', $$ + MATCH (d:Node) WHERE d.i > 1 + WITH d ORDER BY d.ord + DETACH DELETE d + WITH d + MATCH p = (n:Node {i: 1})-[:LINK*1..3]->() + RETURN d.i, length(p) +$$) AS (deleted agtype, path_length agtype) ORDER BY deleted, path_length; + deleted | path_length +---------+------------- + 2 | 2 + 9 | 1 + 9 | 2 +(3 rows) + +RESET ROLE; +-- the copies of the graph held while a traversal for each Source is +-- followed by a delete, counted after every row +CREATE FUNCTION public.rls_vle_graph_copies(anyelement) RETURNS bigint + LANGUAGE plpgsql VOLATILE SECURITY DEFINER +AS $f$ +BEGIN + RETURN (SELECT count(*) FROM pg_backend_memory_contexts + WHERE name = 'AGE edge_table'); +END +$f$; +SELECT age_delete_global_graphs(NULL) IS NOT NULL AS graph_caches_cleared; + graph_caches_cleared +---------------------- + t +(1 row) + +BEGIN; +GRANT DELETE ON ALL TABLES IN SCHEMA rls_vle_many TO rls_vle_user; +CREATE POLICY link_delete ON rls_vle_many."LINK" FOR DELETE USING (true); +SET LOCAL ROLE rls_vle_user; +SELECT count(*) AS deleted, max(copies) AS most_graph_copies_held +FROM (SELECT public.rls_vle_graph_copies(i) AS copies + FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) + DETACH DELETE b + RETURN a.i + $$) AS (i agtype)) AS per_row; + deleted | most_graph_copies_held +---------+------------------------ + 20 | 1 +(1 row) + +ROLLBACK; +-- 8.8: a path value kept after its query is built from a graph context that +-- holds all of its vertices and edges. When no context does, building it +-- raises an error: after the caches are deleted, and for a role whose +-- context lacks the path's edges. +CREATE TABLE public.rls_vle_stored_paths AS +SELECT edges FROM age_vle('"rls_vle_many"', NULL, NULL, + age_build_vle_match_edge('"LINK"', NULL), + '1', '1', '1'); +SELECT count(age_materialize_vle_path(edges)) AS stored_paths_built +FROM public.rls_vle_stored_paths; + stored_paths_built +-------------------- + 20 +(1 row) + +SELECT age_delete_global_graphs(NULL) IS NOT NULL AS graph_caches_cleared; + graph_caches_cleared +---------------------- + t +(1 row) + +SELECT count(age_materialize_vle_path(edges)) AS stored_paths_built +FROM public.rls_vle_stored_paths; +ERROR: graph data for the referenced VLE path is not available +SELECT count(age_materialize_vle_edges(edges)) AS stored_edge_lists_built +FROM public.rls_vle_stored_paths; +ERROR: graph data for the referenced VLE path is not available +GRANT SELECT ON public.rls_vle_stored_paths TO rls_vle_user2; +SET ROLE rls_vle_user2; +SELECT count(*) AS paths_visible_to_user2 +FROM age_vle('"rls_vle_many"', NULL, NULL, + age_build_vle_match_edge('"LINK"', NULL), '1', '1', '1'); + paths_visible_to_user2 +------------------------ + 0 +(1 row) + +SELECT count(age_materialize_vle_path(edges)) AS stored_paths_built +FROM public.rls_vle_stored_paths; +ERROR: graph data for the referenced VLE path is not available +SELECT count(age_materialize_vle_edges(edges)) AS stored_edge_lists_built +FROM public.rls_vle_stored_paths; +ERROR: graph data for the referenced VLE path is not available +RESET ROLE; +DROP TABLE public.rls_vle_stored_paths; +-- 8.9: a query that fails after its traversal releases its graph context +-- during the error cleanup, at the top level and in PL/pgSQL, so failed +-- queries leave no contexts behind +SET ROLE rls_vle_user; +SELECT 1 / ((n::text)::int - 20) AS fails_after_traversal +FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) RETURN count(*) +$$) AS (n agtype); +ERROR: division by zero +SELECT 1 / ((n::text)::int - 20) AS fails_after_traversal +FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) RETURN count(*) +$$) AS (n agtype); +ERROR: division by zero +DO $d$ +BEGIN + PERFORM 1 / ((n::text)::int - 20) + FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) RETURN count(*) + $$) AS (n agtype); +EXCEPTION WHEN division_by_zero THEN + NULL; +END +$d$; +SELECT * FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) RETURN count(*) +$$) AS (paths agtype); + paths +------- + 20 +(1 row) + +RESET ROLE; +SELECT count(*) AS contexts_after_failed_queries +FROM pg_backend_memory_contexts WHERE name = 'AGE edge_table'; + contexts_after_failed_queries +------------------------------- + 1 +(1 row) + +-- 8.10: deleting the graph caches while a query holds its graph retires the +-- context instead of freeing it. The query still builds its paths from it, +-- and it is freed when the query ends, also when the query then fails. +SET ROLE rls_vle_user; +SELECT count(age_materialize_vle_path(v.edges)) AS paths_built_after_delete +FROM (SELECT edges, row_number() OVER () AS rn + FROM age_vle('"rls_vle_many"', NULL, NULL, + age_build_vle_match_edge('"LINK"', NULL), + '1', '1', '1')) AS v +WHERE CASE WHEN v.rn = 1 THEN age_delete_global_graphs(NULL) IS NOT NULL + ELSE true END; + paths_built_after_delete +-------------------------- + 20 +(1 row) + +RESET ROLE; +SELECT count(*) AS contexts_after_query +FROM pg_backend_memory_contexts WHERE name = 'AGE edge_table'; + contexts_after_query +---------------------- + 0 +(1 row) + +SET ROLE rls_vle_user; +SELECT 1 / (count(age_materialize_vle_path(v.edges)) - 20) AS fails_after_paths +FROM (SELECT edges, row_number() OVER () AS rn + FROM age_vle('"rls_vle_many"', NULL, NULL, + age_build_vle_match_edge('"LINK"', NULL), + '1', '1', '1')) AS v +WHERE CASE WHEN v.rn = 1 + THEN age_delete_global_graphs('"rls_vle_many"') IS NOT NULL + ELSE true END; +ERROR: division by zero +RESET ROLE; +SELECT count(*) AS contexts_after_failed_query +FROM pg_backend_memory_contexts WHERE name = 'AGE edge_table'; + contexts_after_failed_query +----------------------------- + 0 +(1 row) + +DROP FUNCTION public.rls_vle_graph_copies(anyelement); +DROP FUNCTION public.rls_vle_start_as_user2(graphid, bigint); +DROP FUNCTION public.rls_vle_start_hiding_links(graphid, bigint); +DROP FUNCTION public.rls_vle_count_fixed(graphid); +DROP FUNCTION public.rls_vle_count_stable(); +DROP SEQUENCE public.rls_vle_policy_calls; +SELECT drop_graph('rls_vle_chain', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table rls_vle_chain._ag_label_vertex +drop cascades to table rls_vle_chain._ag_label_edge +drop cascades to table rls_vle_chain."Node" +drop cascades to table rls_vle_chain."LINK" +NOTICE: graph "rls_vle_chain" has been dropped + drop_graph +------------ + +(1 row) + +SELECT drop_graph('rls_vle_chain2', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table rls_vle_chain2._ag_label_vertex +drop cascades to table rls_vle_chain2._ag_label_edge +drop cascades to table rls_vle_chain2."Node" +drop cascades to table rls_vle_chain2."LINK" +NOTICE: graph "rls_vle_chain2" has been dropped + drop_graph +------------ + +(1 row) + +SELECT drop_graph('rls_vle_many', true); +NOTICE: drop cascades to 5 other objects +DETAIL: drop cascades to table rls_vle_many._ag_label_vertex +drop cascades to table rls_vle_many._ag_label_edge +drop cascades to table rls_vle_many."Source" +drop cascades to table rls_vle_many."LINK" +drop cascades to table rls_vle_many."Target" +NOTICE: graph "rls_vle_many" has been dropped + drop_graph +------------ + +(1 row) + +SELECT drop_graph('rls_vle_before_plain', true); +NOTICE: drop cascades to 6 other objects +DETAIL: drop cascades to table rls_vle_before_plain._ag_label_vertex +drop cascades to table rls_vle_before_plain._ag_label_edge +drop cascades to table rls_vle_before_plain."Source" +drop cascades to table rls_vle_before_plain."LINK" +drop cascades to table rls_vle_before_plain."Mid" +drop cascades to table rls_vle_before_plain."Target" +NOTICE: graph "rls_vle_before_plain" has been dropped + drop_graph +------------ + +(1 row) + +SELECT drop_graph('rls_vle_before_rls', true); +NOTICE: drop cascades to 6 other objects +DETAIL: drop cascades to table rls_vle_before_rls._ag_label_vertex +drop cascades to table rls_vle_before_rls._ag_label_edge +drop cascades to table rls_vle_before_rls."Source" +drop cascades to table rls_vle_before_rls."LINK" +drop cascades to table rls_vle_before_rls."Mid" +drop cascades to table rls_vle_before_rls."Target" +NOTICE: graph "rls_vle_before_rls" has been dropped + drop_graph +------------ + +(1 row) + +SELECT drop_graph('rls_vle_after_plain', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table rls_vle_after_plain._ag_label_vertex +drop cascades to table rls_vle_after_plain._ag_label_edge +drop cascades to table rls_vle_after_plain."Node" +drop cascades to table rls_vle_after_plain."LINK" +NOTICE: graph "rls_vle_after_plain" has been dropped + drop_graph +------------ + +(1 row) + +SELECT drop_graph('rls_vle_after_rls', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table rls_vle_after_rls._ag_label_vertex +drop cascades to table rls_vle_after_rls._ag_label_edge +drop cascades to table rls_vle_after_rls."Node" +drop cascades to table rls_vle_after_rls."LINK" +NOTICE: graph "rls_vle_after_rls" has been dropped + drop_graph +------------ + +(1 row) + -- ============================================================================ -- cleanup -- ============================================================================ diff --git a/regress/sql/rls_vle.sql b/regress/sql/rls_vle.sql index d171acd97..b12df277c 100644 --- a/regress/sql/rls_vle.sql +++ b/regress/sql/rls_vle.sql @@ -256,6 +256,534 @@ RESET ROLE; SELECT drop_graph('rls_acl', true); RESET age.enforce_rls_in_traversal; +-- ============================================================================ +-- PART 8: Traversal lifetime -- a traversal's graph context stays alive until +-- its query ends, even when another traversal in the same query must load its +-- own RLS-filtered context. Within one query an RLS-filtered context is reused +-- under the rules PostgreSQL gives a statement's scans (see 8.3 to 8.7). +-- ============================================================================ +SELECT create_graph('rls_vle_chain'); +SELECT * FROM cypher('rls_vle_chain', $$ + CREATE (:Node {n: 1})-[:LINK]->(:Node {n: 2})-[:LINK]->(:Node {n: 3})-[:LINK]->(:Node {n: 4}) +$$) AS (x agtype); +SELECT create_graph('rls_vle_chain2'); +SELECT * FROM cypher('rls_vle_chain2', $$ + CREATE (:Node {n: 1})-[:LINK]->(:Node {n: 2})-[:LINK]->(:Node {n: 3}) +$$) AS (x agtype); +SELECT create_graph('rls_vle_many'); +SELECT * FROM cypher('rls_vle_many', $$ + UNWIND range(1, 20) AS i + CREATE (:Source {i: i})-[:LINK]->(:Target {i: i}) +$$) AS (x agtype); +GRANT USAGE ON SCHEMA rls_vle_chain, rls_vle_chain2, rls_vle_many TO rls_vle_user; +GRANT SELECT ON ALL TABLES IN SCHEMA rls_vle_chain, rls_vle_chain2, rls_vle_many + TO rls_vle_user; +ALTER TABLE rls_vle_chain."LINK" ENABLE ROW LEVEL SECURITY; +CREATE POLICY link_all ON rls_vle_chain."LINK" FOR SELECT USING (true); +ALTER TABLE rls_vle_chain2."LINK" ENABLE ROW LEVEL SECURITY; +CREATE POLICY link_all ON rls_vle_chain2."LINK" FOR SELECT USING (true); +ALTER TABLE rls_vle_many."LINK" ENABLE ROW LEVEL SECURITY; +CREATE POLICY link_all ON rls_vle_many."LINK" FOR SELECT USING (true); + +SET ROLE rls_vle_user; +-- 8.1: two traversals advanced row by row in one target list: 6 and 20 paths +SELECT count(a) AS chain_paths, count(b) AS independent_paths +FROM ( + SELECT + (age_vle('"rls_vle_chain"', NULL, NULL, + age_build_vle_match_edge('"LINK"', NULL), + '1', '3', '1')).start_id AS a, + (age_vle('"rls_vle_many"', NULL, NULL, + age_build_vle_match_edge('"LINK"', NULL), + '1', '2', '1')).end_id AS b +) AS interleaved; + +-- 8.2: paths of the first graph are materialized after the second graph's +-- traversal loaded its own context: 6 x 3 pairs of distinct paths +SELECT count(*) AS pairs, count(DISTINCT p) AS chain_paths, + count(DISTINCT q) AS chain2_paths +FROM cypher('rls_vle_chain', $$ + MATCH p = (a)-[:LINK*1..3]->(b) RETURN p + $$) AS one(p agtype), + cypher('rls_vle_chain2', $$ + MATCH q = (a)-[:LINK*1..2]->(b) RETURN q + $$) AS two(q agtype); +RESET ROLE; + +-- 8.3: policy evaluations per query. Each traversal loads the 20 visible +-- edges once and reuses its load when it is evaluated again for another row, +-- as PostgreSQL rescans a materialized scan. Another traversal in the query +-- shares that load only when the policy gives the same result throughout the +-- statement: when it calls only IMMUTABLE functions, besides values such as +-- current_user that cannot change unnoticed. Otherwise, as with the STABLE +-- and volatile counters below, it loads the graph again, as a separate scan +-- would evaluate the policy again. +SELECT age_delete_global_graphs(NULL) IS NOT NULL AS graph_caches_cleared; +CREATE SEQUENCE public.rls_vle_policy_calls; +GRANT USAGE, SELECT ON SEQUENCE public.rls_vle_policy_calls TO rls_vle_user; +CREATE FUNCTION public.rls_vle_count_stable() RETURNS boolean + LANGUAGE plpgsql STABLE +AS $f$ +BEGIN + PERFORM nextval('public.rls_vle_policy_calls'); + RETURN true; +END +$f$; +ALTER POLICY link_all ON rls_vle_many."LINK" + USING (public.rls_vle_count_stable()); + +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) RETURN count(*) +$$) AS (n agtype); +RESET ROLE; +SELECT last_value AS stable_one_hop_evaluations FROM public.rls_vle_policy_calls; +ALTER SEQUENCE public.rls_vle_policy_calls RESTART; + +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..2]->(b:Target) RETURN count(*) +$$) AS (n agtype); +RESET ROLE; +SELECT last_value AS stable_one_to_two_hop_evaluations +FROM public.rls_vle_policy_calls; +ALTER SEQUENCE public.rls_vle_policy_calls RESTART; + +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle_many', $$ + MATCH p = (a:Source)-[:LINK*1..1]->(b:Target) RETURN count(p) +$$) AS (n agtype); +RESET ROLE; +SELECT last_value AS stable_named_path_evaluations +FROM public.rls_vle_policy_calls; +ALTER SEQUENCE public.rls_vle_policy_calls RESTART; + +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) + WITH count(*) AS first + MATCH (c:Source)-[:LINK*1..1]->(d:Target) + RETURN first, count(*) +$$) AS (first agtype, second agtype); +RESET ROLE; +SELECT last_value AS stable_two_call_site_evaluations +FROM public.rls_vle_policy_calls; + +-- each traversal loaded its own context: the first one's was freed when the +-- query ended, and the second one's stays in the backend until the next +-- graph load frees it +SELECT count(*) AS contexts_after_stable_query +FROM pg_backend_memory_contexts WHERE name = 'AGE edge_table'; +SELECT age_delete_global_graphs(NULL) IS NOT NULL AS graph_caches_cleared; + +-- policies that give the same result throughout the statement: the two +-- traversals share one load. The counter is declared IMMUTABLE so that the +-- policy counts as fixed, and its argument keeps it from being evaluated +-- once at planning. +CREATE FUNCTION public.rls_vle_count_fixed(id graphid) RETURNS boolean + LANGUAGE plpgsql IMMUTABLE +AS $f$ +BEGIN + PERFORM nextval('public.rls_vle_policy_calls'); + RETURN true; +END +$f$; +ALTER POLICY link_all ON rls_vle_many."LINK" + USING (public.rls_vle_count_fixed(id)); +ALTER SEQUENCE public.rls_vle_policy_calls RESTART; +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) + WITH count(*) AS first + MATCH (c:Source)-[:LINK*1..1]->(d:Target) + RETURN first, count(*) +$$) AS (first agtype, second agtype); +RESET ROLE; +SELECT last_value AS immutable_two_call_site_evaluations +FROM public.rls_vle_policy_calls; + +ALTER POLICY link_all ON rls_vle_many."LINK" + USING (public.rls_vle_count_fixed(id) AND current_user = 'rls_vle_user'); +ALTER SEQUENCE public.rls_vle_policy_calls RESTART; +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) + WITH count(*) AS first + MATCH (c:Source)-[:LINK*1..1]->(d:Target) + RETURN first, count(*) +$$) AS (first agtype, second agtype); +RESET ROLE; +SELECT last_value AS current_user_two_call_site_evaluations +FROM public.rls_vle_policy_calls; + +-- the search path can change during the statement, and a STABLE function +-- in a subquery of the policy counts as in the policy itself: each +-- traversal loads the graph +ALTER POLICY link_all ON rls_vle_many."LINK" + USING (public.rls_vle_count_fixed(id) AND current_schema IS NOT NULL); +ALTER SEQUENCE public.rls_vle_policy_calls RESTART; +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) + WITH count(*) AS first + MATCH (c:Source)-[:LINK*1..1]->(d:Target) + RETURN first, count(*) +$$) AS (first agtype, second agtype); +RESET ROLE; +SELECT last_value AS current_schema_two_call_site_evaluations +FROM public.rls_vle_policy_calls; + +ALTER POLICY link_all ON rls_vle_many."LINK" + USING (EXISTS (SELECT 1 WHERE id IS NOT NULL + AND public.rls_vle_count_stable())); +ALTER SEQUENCE public.rls_vle_policy_calls RESTART; +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) + WITH count(*) AS first + MATCH (c:Source)-[:LINK*1..1]->(d:Target) + RETURN first, count(*) +$$) AS (first agtype, second agtype); +RESET ROLE; +SELECT last_value AS stable_subquery_two_call_site_evaluations +FROM public.rls_vle_policy_calls; +SELECT age_delete_global_graphs(NULL) IS NOT NULL AS graph_caches_cleared; + +ALTER POLICY link_all ON rls_vle_many."LINK" + USING (nextval('public.rls_vle_policy_calls') > 0); +ALTER SEQUENCE public.rls_vle_policy_calls RESTART; +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) RETURN count(*) +$$) AS (n agtype); +RESET ROLE; +SELECT last_value AS volatile_one_hop_evaluations +FROM public.rls_vle_policy_calls; +ALTER SEQUENCE public.rls_vle_policy_calls RESTART; + +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) + WITH count(*) AS first + MATCH (c:Source)-[:LINK*1..1]->(d:Target) + RETURN first, count(*) +$$) AS (first agtype, second agtype); +RESET ROLE; +SELECT last_value AS volatile_two_call_site_evaluations +FROM public.rls_vle_policy_calls; + +-- the first call site's context was freed when the query ended; the second +-- one is kept, as for the stable policy +SELECT count(*) AS contexts_after_volatile_query +FROM pg_backend_memory_contexts WHERE name = 'AGE edge_table'; +ALTER POLICY link_all ON rls_vle_many."LINK" USING (true); + +-- a policy that reads a setting with current_setting(), which the statement +-- itself changes: each traversal sets the limit through its graph argument +-- and sees its own Targets, the first those up to 5 and the second those up +-- to 10, as two scans of the label in PostgreSQL would +ALTER TABLE rls_vle_many."Target" ENABLE ROW LEVEL SECURITY; +CREATE POLICY target_limit ON rls_vle_many."Target" FOR SELECT + USING ((properties->>'"i"')::int <= + coalesce(nullif(current_setting('rls_vle.max_target', true), ''), + '0')::int); +CREATE FUNCTION public.rls_vle_limit_targets(n int) RETURNS agtype + LANGUAGE plpgsql VOLATILE +AS $f$ +BEGIN + PERFORM set_config('rls_vle.max_target', n::text, false); + RETURN '"rls_vle_many"'::agtype; +END +$f$; +SET ROLE rls_vle_user; +SELECT count(DISTINCT first.end_id) AS first_targets, + count(DISTINCT second.end_id) AS second_targets +FROM age_vle(public.rls_vle_limit_targets(5), NULL, NULL, + age_build_vle_match_edge('"LINK"', NULL), + '1', '1', '1') AS first, + LATERAL age_vle(public.rls_vle_limit_targets( + CASE WHEN first.start_id IS NOT NULL THEN 10 END), + NULL, NULL, + age_build_vle_match_edge('"LINK"', NULL), + '1', '1', '1') AS second; +RESET ROLE; +RESET rls_vle.max_target; +DROP POLICY target_limit ON rls_vle_many."Target"; +ALTER TABLE rls_vle_many."Target" DISABLE ROW LEVEL SECURITY; +DROP FUNCTION public.rls_vle_limit_targets(int); + +-- 8.4: a statement's own write is visible to its later traversal. The write +-- advances the command id, so the later traversal does not reuse the graph +-- loaded before it. +BEGIN; +GRANT INSERT ON rls_vle_many."LINK" TO rls_vle_user; +GRANT USAGE ON SEQUENCE rls_vle_many."LINK_id_seq" TO rls_vle_user; +CREATE POLICY link_insert ON rls_vle_many."LINK" FOR INSERT WITH CHECK (true); +SET LOCAL ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle_many', $$ + MATCH (a:Source {i: 1})-[:LINK*1..1]->(b:Target) + WITH a, count(b) AS before + MATCH (t:Target {i: 2}) + CREATE (a)-[:LINK]->(t) + WITH a, before, count(*) AS created + MATCH (a)-[:LINK*1..1]->(c:Target) + RETURN before, created, count(c) +$$) AS (before agtype, created agtype, after agtype); +ROLLBACK; + +-- 8.5: a policy change made while a statement runs does not change what that +-- statement's traversal sees, as for a PostgreSQL scan; the next statement +-- sees it. The start vertex argument hides every edge when the traversal is +-- evaluated for the second row, after the first row loaded the graph. +CREATE FUNCTION public.rls_vle_start_hiding_links(id graphid, rn bigint) + RETURNS ag_catalog.agtype + LANGUAGE plpgsql SECURITY DEFINER +AS $f$ +BEGIN + IF rn = 2 THEN + ALTER POLICY link_all ON rls_vle_many."LINK" USING (false); + END IF; + RETURN (id::text)::ag_catalog.agtype; +END +$f$; + +SET ROLE rls_vle_user; +SELECT count(*) AS paths_with_policy_changed_midway +FROM (SELECT id, row_number() OVER (ORDER BY id) AS rn + FROM rls_vle_many."Source") AS s, + LATERAL age_vle('"rls_vle_many"', + public.rls_vle_start_hiding_links(s.id, s.rn), NULL, + age_build_vle_match_edge('"LINK"', NULL), + '1', '1', '1') AS v; +SELECT count(*) AS paths_in_next_statement +FROM rls_vle_many."Source" AS s, + LATERAL age_vle('"rls_vle_many"', (s.id::text)::agtype, NULL, + age_build_vle_match_edge('"LINK"', NULL), + '1', '1', '1') AS v; +RESET ROLE; + +-- 8.6: a graph loaded for one role is not reused for another, even within a +-- statement. The start vertex argument switches to rls_vle_user2, from whom +-- every edge is hidden, when the traversal is evaluated for the second row. +ALTER POLICY link_all ON rls_vle_many."LINK" USING (true); +GRANT USAGE ON SCHEMA rls_vle_many TO rls_vle_user2; +GRANT SELECT ON ALL TABLES IN SCHEMA rls_vle_many TO rls_vle_user2; +CREATE POLICY link_hidden_from_user2 ON rls_vle_many."LINK" AS RESTRICTIVE + FOR SELECT TO rls_vle_user2 USING (false); +CREATE FUNCTION public.rls_vle_start_as_user2(id graphid, rn bigint) + RETURNS ag_catalog.agtype + LANGUAGE plpgsql +AS $f$ +BEGIN + IF rn = 2 THEN + PERFORM set_config('role', 'rls_vle_user2', true); + END IF; + RETURN (id::text)::ag_catalog.agtype; +END +$f$; + +SET ROLE rls_vle_user; +SELECT count(*) AS paths_with_role_changed_midway +FROM (SELECT id, row_number() OVER (ORDER BY id) AS rn + FROM rls_vle_many."Source") AS s, + LATERAL age_vle('"rls_vle_many"', + public.rls_vle_start_as_user2(s.id, s.rn), NULL, + age_build_vle_match_edge('"LINK"', NULL), + '1', '1', '1') AS v; +RESET ROLE; + +-- 8.7: a statement's own deletes do not make a traversal load its graph +-- again. The traversal keeps the graph it loaded, as it does without RLS, so +-- a policy that allows every row returns what no policy returns, and the +-- query holds one copy of the graph instead of one for every row. +SELECT create_graph('rls_vle_before_plain'); +SELECT create_graph('rls_vle_before_rls'); +SELECT create_graph('rls_vle_after_plain'); +SELECT create_graph('rls_vle_after_rls'); +SELECT * FROM cypher('rls_vle_before_plain', $$ + CREATE (:Source {i: 1})-[:LINK]->(m:Mid {i: 0})-[:LINK]->(:Target {i: 9}), + (:Source {i: 2})-[:LINK]->(m) +$$) AS (x agtype); +SELECT * FROM cypher('rls_vle_before_rls', $$ + CREATE (:Source {i: 1})-[:LINK]->(m:Mid {i: 0})-[:LINK]->(:Target {i: 9}), + (:Source {i: 2})-[:LINK]->(m) +$$) AS (x agtype); +SELECT * FROM cypher('rls_vle_after_plain', $$ + CREATE (:Node {i: 1, ord: 0})-[:LINK]->(:Node {i: 2, ord: 2}) + -[:LINK]->(:Node {i: 3, ord: 3}), + (:Node {i: 9, ord: 1}) +$$) AS (x agtype); +SELECT * FROM cypher('rls_vle_after_rls', $$ + CREATE (:Node {i: 1, ord: 0})-[:LINK]->(:Node {i: 2, ord: 2}) + -[:LINK]->(:Node {i: 3, ord: 3}), + (:Node {i: 9, ord: 1}) +$$) AS (x agtype); +GRANT USAGE ON SCHEMA rls_vle_before_plain, rls_vle_before_rls, + rls_vle_after_plain, rls_vle_after_rls TO rls_vle_user; +GRANT SELECT, DELETE ON ALL TABLES IN SCHEMA rls_vle_before_plain, + rls_vle_before_rls, rls_vle_after_plain, rls_vle_after_rls + TO rls_vle_user; +ALTER TABLE rls_vle_before_rls."LINK" ENABLE ROW LEVEL SECURITY; +CREATE POLICY link_read ON rls_vle_before_rls."LINK" FOR SELECT USING (true); +CREATE POLICY link_delete ON rls_vle_before_rls."LINK" FOR DELETE USING (true); +ALTER TABLE rls_vle_after_rls."LINK" ENABLE ROW LEVEL SECURITY; +CREATE POLICY link_read ON rls_vle_after_rls."LINK" FOR SELECT USING (true); +CREATE POLICY link_delete ON rls_vle_after_rls."LINK" FOR DELETE USING (true); + +SET ROLE rls_vle_user; +-- a traversal before the delete, for each Source: s1 -> m <- s2, m -> t +SELECT * FROM cypher('rls_vle_before_plain', $$ + MATCH (a:Source)-[:LINK*1..2]->(b) + DETACH DELETE b + RETURN a.i, b.i +$$) AS (source agtype, deleted agtype) ORDER BY source, deleted; +SELECT * FROM cypher('rls_vle_before_rls', $$ + MATCH (a:Source)-[:LINK*1..2]->(b) + DETACH DELETE b + RETURN a.i, b.i +$$) AS (source agtype, deleted agtype) ORDER BY source, deleted; + +-- a traversal after the delete of each node but n1, in the order of ord: +-- n1 -> n2 -> n3 and an unconnected n9, which is deleted first +SELECT * FROM cypher('rls_vle_after_plain', $$ + MATCH (d:Node) WHERE d.i > 1 + WITH d ORDER BY d.ord + DETACH DELETE d + WITH d + MATCH p = (n:Node {i: 1})-[:LINK*1..3]->() + RETURN d.i, length(p) +$$) AS (deleted agtype, path_length agtype) ORDER BY deleted, path_length; +SELECT * FROM cypher('rls_vle_after_rls', $$ + MATCH (d:Node) WHERE d.i > 1 + WITH d ORDER BY d.ord + DETACH DELETE d + WITH d + MATCH p = (n:Node {i: 1})-[:LINK*1..3]->() + RETURN d.i, length(p) +$$) AS (deleted agtype, path_length agtype) ORDER BY deleted, path_length; +RESET ROLE; + +-- the copies of the graph held while a traversal for each Source is +-- followed by a delete, counted after every row +CREATE FUNCTION public.rls_vle_graph_copies(anyelement) RETURNS bigint + LANGUAGE plpgsql VOLATILE SECURITY DEFINER +AS $f$ +BEGIN + RETURN (SELECT count(*) FROM pg_backend_memory_contexts + WHERE name = 'AGE edge_table'); +END +$f$; +SELECT age_delete_global_graphs(NULL) IS NOT NULL AS graph_caches_cleared; +BEGIN; +GRANT DELETE ON ALL TABLES IN SCHEMA rls_vle_many TO rls_vle_user; +CREATE POLICY link_delete ON rls_vle_many."LINK" FOR DELETE USING (true); +SET LOCAL ROLE rls_vle_user; +SELECT count(*) AS deleted, max(copies) AS most_graph_copies_held +FROM (SELECT public.rls_vle_graph_copies(i) AS copies + FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) + DETACH DELETE b + RETURN a.i + $$) AS (i agtype)) AS per_row; +ROLLBACK; + +-- 8.8: a path value kept after its query is built from a graph context that +-- holds all of its vertices and edges. When no context does, building it +-- raises an error: after the caches are deleted, and for a role whose +-- context lacks the path's edges. +CREATE TABLE public.rls_vle_stored_paths AS +SELECT edges FROM age_vle('"rls_vle_many"', NULL, NULL, + age_build_vle_match_edge('"LINK"', NULL), + '1', '1', '1'); +SELECT count(age_materialize_vle_path(edges)) AS stored_paths_built +FROM public.rls_vle_stored_paths; +SELECT age_delete_global_graphs(NULL) IS NOT NULL AS graph_caches_cleared; +SELECT count(age_materialize_vle_path(edges)) AS stored_paths_built +FROM public.rls_vle_stored_paths; +SELECT count(age_materialize_vle_edges(edges)) AS stored_edge_lists_built +FROM public.rls_vle_stored_paths; +GRANT SELECT ON public.rls_vle_stored_paths TO rls_vle_user2; +SET ROLE rls_vle_user2; +SELECT count(*) AS paths_visible_to_user2 +FROM age_vle('"rls_vle_many"', NULL, NULL, + age_build_vle_match_edge('"LINK"', NULL), '1', '1', '1'); +SELECT count(age_materialize_vle_path(edges)) AS stored_paths_built +FROM public.rls_vle_stored_paths; +SELECT count(age_materialize_vle_edges(edges)) AS stored_edge_lists_built +FROM public.rls_vle_stored_paths; +RESET ROLE; +DROP TABLE public.rls_vle_stored_paths; + +-- 8.9: a query that fails after its traversal releases its graph context +-- during the error cleanup, at the top level and in PL/pgSQL, so failed +-- queries leave no contexts behind +SET ROLE rls_vle_user; +SELECT 1 / ((n::text)::int - 20) AS fails_after_traversal +FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) RETURN count(*) +$$) AS (n agtype); +SELECT 1 / ((n::text)::int - 20) AS fails_after_traversal +FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) RETURN count(*) +$$) AS (n agtype); +DO $d$ +BEGIN + PERFORM 1 / ((n::text)::int - 20) + FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) RETURN count(*) + $$) AS (n agtype); +EXCEPTION WHEN division_by_zero THEN + NULL; +END +$d$; +SELECT * FROM cypher('rls_vle_many', $$ + MATCH (a:Source)-[:LINK*1..1]->(b:Target) RETURN count(*) +$$) AS (paths agtype); +RESET ROLE; +SELECT count(*) AS contexts_after_failed_queries +FROM pg_backend_memory_contexts WHERE name = 'AGE edge_table'; + +-- 8.10: deleting the graph caches while a query holds its graph retires the +-- context instead of freeing it. The query still builds its paths from it, +-- and it is freed when the query ends, also when the query then fails. +SET ROLE rls_vle_user; +SELECT count(age_materialize_vle_path(v.edges)) AS paths_built_after_delete +FROM (SELECT edges, row_number() OVER () AS rn + FROM age_vle('"rls_vle_many"', NULL, NULL, + age_build_vle_match_edge('"LINK"', NULL), + '1', '1', '1')) AS v +WHERE CASE WHEN v.rn = 1 THEN age_delete_global_graphs(NULL) IS NOT NULL + ELSE true END; +RESET ROLE; +SELECT count(*) AS contexts_after_query +FROM pg_backend_memory_contexts WHERE name = 'AGE edge_table'; +SET ROLE rls_vle_user; +SELECT 1 / (count(age_materialize_vle_path(v.edges)) - 20) AS fails_after_paths +FROM (SELECT edges, row_number() OVER () AS rn + FROM age_vle('"rls_vle_many"', NULL, NULL, + age_build_vle_match_edge('"LINK"', NULL), + '1', '1', '1')) AS v +WHERE CASE WHEN v.rn = 1 + THEN age_delete_global_graphs('"rls_vle_many"') IS NOT NULL + ELSE true END; +RESET ROLE; +SELECT count(*) AS contexts_after_failed_query +FROM pg_backend_memory_contexts WHERE name = 'AGE edge_table'; + +DROP FUNCTION public.rls_vle_graph_copies(anyelement); +DROP FUNCTION public.rls_vle_start_as_user2(graphid, bigint); +DROP FUNCTION public.rls_vle_start_hiding_links(graphid, bigint); +DROP FUNCTION public.rls_vle_count_fixed(graphid); +DROP FUNCTION public.rls_vle_count_stable(); +DROP SEQUENCE public.rls_vle_policy_calls; +SELECT drop_graph('rls_vle_chain', true); +SELECT drop_graph('rls_vle_chain2', true); +SELECT drop_graph('rls_vle_many', true); +SELECT drop_graph('rls_vle_before_plain', true); +SELECT drop_graph('rls_vle_before_rls', true); +SELECT drop_graph('rls_vle_after_plain', true); +SELECT drop_graph('rls_vle_after_rls', true); + -- ============================================================================ -- cleanup -- ============================================================================ diff --git a/src/backend/utils/adt/age_global_graph.c b/src/backend/utils/adt/age_global_graph.c index 44163bfbd..ce9730754 100644 --- a/src/backend/utils/adt/age_global_graph.c +++ b/src/backend/utils/adt/age_global_graph.c @@ -38,6 +38,13 @@ #include "executor/spi.h" #include "lib/stringinfo.h" #include "miscadmin.h" +#include "optimizer/optimizer.h" +#include "catalog/pg_proc.h" +#include "nodes/nodeFuncs.h" +#include "utils/json.h" +#include "utils/jsonb.h" +#include "utils/jsonpath.h" +#include "utils/plancache.h" #include "utils/rls.h" #include "utils/acl.h" #include "utils/inval.h" @@ -170,7 +177,10 @@ typedef struct GRAPH_global_context bool loaded_rls_enforced; /* age.enforce_rls_in_traversal value at load */ bool vertices_rls_filtered; /* true if any vertex label was RLS-filtered at load */ bool loaded_with_rls; /* true if any label was loaded through RLS (SPI) */ + bool rls_quals_vary; /* RLS quals can vary in a statement */ bool security_invalidated; /* set by ACL/RLS catalog inval callbacks */ + bool retired; /* not offered to new acquisitions */ + int pin_count; /* queries still using this context */ List *label_table_oids; /* label table OIDs, for targeted security inval */ int64 num_loaded_vertices; /* number of loaded vertices in this graph */ int64 num_loaded_edges; /* number of loaded edges in this graph */ @@ -181,6 +191,24 @@ typedef struct GRAPH_global_context /* global variable to hold the per process GRAPH global contexts */ static GRAPH_global_context *global_graph_contexts = NULL; +/* + * A query that traverses a graph pins its context until the query's memory + * context is reset or deleted. Results of the traversal can be materialized + * after the traversal itself has finished, so a pinned context is retired + * instead of freed when it becomes invalid. + */ +typedef struct GRAPH_context_pin +{ + GRAPH_global_context *ggctx; /* pinned graph context */ + MemoryContext owner; /* per-query memory context holding it */ + const void *call_site; /* traversal call site that pinned it */ + MemoryContextCallback release; /* unpins when owner goes away */ + struct GRAPH_context_pin *next; +} GRAPH_context_pin; + +/* backend-local list of live pins */ +static GRAPH_context_pin *graph_context_pins = NULL; + /* * VertexEdgeArray helpers — flat-array adjacency container used by * vertex_entry's edges_in / edges_out / edges_self. @@ -257,6 +285,13 @@ static void load_vertex_label_rls(GRAPH_global_context *ggctx, static void load_edge_label_rls(GRAPH_global_context *ggctx, Oid edge_label_table_oid, char *edge_label_name); +static bool rls_function_not_immutable(Oid func_id, void *context); +static bool rls_quals_vary_walker(Node *node, void *context); +static bool rls_plan_quals_vary(SPIPlanPtr plan); +static void unlink_GRAPH_global_context(GRAPH_global_context *ggctx); +static void release_GRAPH_context_pin(void *arg); +static bool graph_context_reusable(GRAPH_context_pin *pin, + const void *call_site); /* definitions */ /* @@ -272,6 +307,12 @@ static void load_edge_label_rls(GRAPH_global_context *ggctx, */ bool is_ggctx_invalid(GRAPH_global_context *ggctx) { + /* a retired context is only kept for the queries still pinning it */ + if (ggctx->retired) + { + return true; + } + /* * Security-relevant cache keys, checked before the version-counter * fast-path below. A cache that applied row-level security is never safe @@ -786,6 +827,157 @@ static bool insert_vertex_edge(GRAPH_global_context *ggctx, return false; } +/* checker for check_functions_in_node: a function that is not IMMUTABLE */ +static bool rls_function_not_immutable(Oid func_id, void *context) +{ + return (func_volatile(func_id) != PROVOLATILE_IMMUTABLE); +} + +/* + * PostgreSQL's contain_mutable_functions_walker, except for the SQL value + * functions whose result cannot change during a statement without the reuse + * check noticing: the date and time ones are fixed for the transaction, + * CURRENT_ROLE, CURRENT_USER and USER are the current role, which the reuse + * check compares, and CURRENT_CATALOG is fixed for the session. SESSION_USER + * and CURRENT_SCHEMA can change within a statement. + */ +static bool rls_quals_vary_walker(Node *node, void *context) +{ + if (node == NULL) + { + return false; + } + + if (check_functions_in_node(node, rls_function_not_immutable, context)) + { + return true; + } + + if (IsA(node, JsonConstructorExpr)) + { + JsonConstructorExpr *ctor = (JsonConstructorExpr *) node; + bool is_jsonb = (ctor->returning->format->format_type == + JS_FORMAT_JSONB); + ListCell *lc; + + foreach (lc, ctor->args) + { + Oid typid = exprType(lfirst(lc)); + + if (is_jsonb ? !to_jsonb_is_immutable(typid) : + !to_json_is_immutable(typid)) + { + return true; + } + } + } + + if (IsA(node, JsonExpr)) + { + JsonExpr *jexpr = (JsonExpr *) node; + Const *cnst; + + if (!IsA(jexpr->path_spec, Const)) + { + return true; + } + + cnst = (Const *) jexpr->path_spec; + if (cnst->constisnull) + { + return false; + } + + if (jspIsMutable(DatumGetJsonPathP(cnst->constvalue), + jexpr->passing_names, jexpr->passing_values)) + { + return true; + } + } + + if (IsA(node, SQLValueFunction)) + { + switch (((SQLValueFunction *) node)->op) + { + case SVFOP_CURRENT_DATE: + case SVFOP_CURRENT_TIME: + case SVFOP_CURRENT_TIME_N: + case SVFOP_CURRENT_TIMESTAMP: + case SVFOP_CURRENT_TIMESTAMP_N: + case SVFOP_LOCALTIME: + case SVFOP_LOCALTIME_N: + case SVFOP_LOCALTIMESTAMP: + case SVFOP_LOCALTIMESTAMP_N: + case SVFOP_CURRENT_ROLE: + case SVFOP_CURRENT_USER: + case SVFOP_USER: + case SVFOP_CURRENT_CATALOG: + return false; + default: + return true; + } + } + + if (IsA(node, NextValueExpr)) + { + return true; + } + + if (IsA(node, Query)) + { + return query_tree_walker((Query *) node, rls_quals_vary_walker, + context, 0); + } + + return expression_tree_walker(node, rls_quals_vary_walker, context); +} + +/* + * Whether the row-security quals the planner applied to an RLS load can give + * another result later in the same statement, so that another scan of the + * label could return other rows. They can when they call a function that is + * not IMMUTABLE: a volatile one, or a STABLE one such as current_setting(), + * whose input the statement itself can change with set_config(). Policies + * from row_security_policy_hook_permissive and _restrictive are turned into + * the same securityQuals as catalog policies, so they are inspected too. A + * plan whose queries cannot be inspected is reported as varying; reuse is + * then limited to the call site that loaded the graph, which is valid for any + * policy. + */ +static bool rls_plan_quals_vary(SPIPlanPtr plan) +{ + List *sources; + ListCell *source_cell; + + sources = SPI_plan_get_plan_sources(plan); + if (sources == NIL) + { + return true; + } + + foreach (source_cell, sources) + { + CachedPlanSource *source = lfirst(source_cell); + ListCell *query_cell; + + if (source->query_list == NIL) + { + return true; + } + + foreach (query_cell, source->query_list) + { + /* walks the range table's securityQuals and any sublinks */ + if (rls_quals_vary_walker(lfirst(query_cell), NULL)) + { + return true; + } + } + } + + return false; +} + /* * RLS-aware loader for a single vertex label table. * @@ -837,6 +1029,11 @@ static void load_vertex_label_rls(GRAPH_global_context *ggctx, SPI_result_code_string(SPI_result)))); } + if (rls_plan_quals_vary(plan)) + { + ggctx->rls_quals_vary = true; + } + /* read-only cursor: reuses the active snapshot; RLS applied by planner */ portal = SPI_cursor_open(NULL, plan, NULL, NULL, true); @@ -926,6 +1123,11 @@ static void load_edge_label_rls(GRAPH_global_context *ggctx, SPI_result_code_string(SPI_result)))); } + if (rls_plan_quals_vary(plan)) + { + ggctx->rls_quals_vary = true; + } + /* read-only cursor: reuses the active snapshot; RLS applied by planner */ portal = SPI_cursor_open(NULL, plan, NULL, NULL, true); @@ -1608,6 +1810,15 @@ GRAPH_global_context *manage_GRAPH_global_contexts(char *graph_name, { bool success = false; + /* a query still using it keeps it; it is freed when unpinned */ + if (curr_ggctx->pin_count > 0) + { + curr_ggctx->retired = true; + prev_ggctx = curr_ggctx; + curr_ggctx = next_ggctx; + continue; + } + /* * If prev_ggctx is NULL then we are freeing the top of the * contexts. So, we need to point the contexts variable to the @@ -1646,7 +1857,7 @@ GRAPH_global_context *manage_GRAPH_global_contexts(char *graph_name, curr_ggctx = global_graph_contexts; while (curr_ggctx != NULL) { - if (curr_ggctx->graph_oid == graph_oid) + if (curr_ggctx->graph_oid == graph_oid && !curr_ggctx->retired) { /* switch our context back */ MemoryContextSwitchTo(oldctx); @@ -1736,47 +1947,60 @@ GRAPH_global_context *manage_GRAPH_global_contexts(char *graph_name, * through a SECURITY DEFINER RLS policy can attach a context loaded as a * different role; returning it here would expose rows the current role may * not be allowed to see. If the existing context is not reusable, detach - * and discard it so the list keeps a single, correct context per graph. + * and discard it so the list keeps a single, correct context per graph; + * a context that a query still pins is retired instead. */ prev_ggctx = NULL; curr_ggctx = global_graph_contexts; while (curr_ggctx != NULL) { - if (curr_ggctx->graph_oid == graph_oid) + GRAPH_global_context *next_ggctx = curr_ggctx->next; + + if (curr_ggctx->graph_oid != graph_oid || curr_ggctx->retired) { - if (!is_ggctx_invalid(curr_ggctx)) - { - (void) free_specific_GRAPH_global_context(new_ggctx); - MemoryContextSwitchTo(oldctx); - return curr_ggctx; - } + prev_ggctx = curr_ggctx; + curr_ggctx = next_ggctx; + continue; + } - /* detach the incompatible context and discard it */ - if (prev_ggctx == NULL) - { - global_graph_contexts = curr_ggctx->next; - } - else - { - prev_ggctx->next = curr_ggctx->next; - } - if (!free_specific_GRAPH_global_context(curr_ggctx)) - { - /* - * The detached context was internally inconsistent (a - * vertex or edge entry went missing). Free our freshly- - * built context first so it does not leak, then surface - * the corruption as the other cleanup paths in this file - * do. - */ - (void) free_specific_GRAPH_global_context(new_ggctx); - ereport(ERROR, (errcode(ERRCODE_DATA_EXCEPTION), - errmsg("missing vertex or edge entry during free"))); - } - break; + if (!is_ggctx_invalid(curr_ggctx)) + { + (void) free_specific_GRAPH_global_context(new_ggctx); + MemoryContextSwitchTo(oldctx); + return curr_ggctx; } - prev_ggctx = curr_ggctx; - curr_ggctx = curr_ggctx->next; + + if (curr_ggctx->pin_count > 0) + { + curr_ggctx->retired = true; + prev_ggctx = curr_ggctx; + curr_ggctx = next_ggctx; + continue; + } + + /* detach the incompatible context and discard it */ + if (prev_ggctx == NULL) + { + global_graph_contexts = next_ggctx; + } + else + { + prev_ggctx->next = next_ggctx; + } + if (!free_specific_GRAPH_global_context(curr_ggctx)) + { + /* + * The detached context was internally inconsistent (a + * vertex or edge entry went missing). Free our freshly- + * built context first so it does not leak, then surface + * the corruption as the other cleanup paths in this file + * do. + */ + (void) free_specific_GRAPH_global_context(new_ggctx); + ereport(ERROR, (errcode(ERRCODE_DATA_EXCEPTION), + errmsg("missing vertex or edge entry during free"))); + } + curr_ggctx = next_ggctx; } /* prepend our freshly-built context to the shared list */ @@ -1791,12 +2015,12 @@ GRAPH_global_context *manage_GRAPH_global_contexts(char *graph_name, /* * Helper function to delete all of the global graph contexts used by the - * process. When done the global global_graph_contexts will be NULL. - * - * + * process. Contexts that a query still pins are retired instead and freed + * when the last pin is released. */ static bool delete_GRAPH_global_contexts(void) { + GRAPH_global_context *prev_ggctx = NULL; GRAPH_global_context *curr_ggctx = NULL; bool retval = false; @@ -1810,6 +2034,25 @@ static bool delete_GRAPH_global_contexts(void) GRAPH_global_context *next_ggctx = curr_ggctx->next; bool success = false; + retval = true; + + if (curr_ggctx->pin_count > 0) + { + curr_ggctx->retired = true; + prev_ggctx = curr_ggctx; + curr_ggctx = next_ggctx; + continue; + } + + if (prev_ggctx == NULL) + { + global_graph_contexts = next_ggctx; + } + else + { + prev_ggctx->next = next_ggctx; + } + /* free the current graph context */ success = free_specific_GRAPH_global_context(curr_ggctx); @@ -1823,26 +2066,21 @@ static bool delete_GRAPH_global_contexts(void) /* advance to the next context */ curr_ggctx = next_ggctx; - - retval = true; } - /* reset the head of the contexts to NULL */ - global_graph_contexts = NULL; - - return retval; } /* - * Helper function to delete a specific global graph context used by the - * process. + * Helper function to delete the global graph contexts of a specific graph. + * Contexts that a query still pins are retired instead. */ static bool delete_specific_GRAPH_global_contexts(char *graph_name) { GRAPH_global_context *prev_ggctx = NULL; GRAPH_global_context *curr_ggctx = NULL; Oid graph_oid = InvalidOid; + bool found = false; if (graph_name == NULL) { @@ -1856,7 +2094,7 @@ static bool delete_specific_GRAPH_global_contexts(char *graph_name) /* get the first context, if any */ curr_ggctx = global_graph_contexts; - /* find the specified GRAPH global context */ + /* find the specified GRAPH global contexts */ while (curr_ggctx != NULL) { GRAPH_global_context *next_ggctx = curr_ggctx->next; @@ -1864,6 +2102,17 @@ static bool delete_specific_GRAPH_global_contexts(char *graph_name) if (curr_ggctx->graph_oid == graph_oid) { bool success = false; + + found = true; + + if (curr_ggctx->pin_count > 0) + { + curr_ggctx->retired = true; + prev_ggctx = curr_ggctx; + curr_ggctx = next_ggctx; + continue; + } + /* * If prev_ggctx is NULL then we are freeing the top of the * contexts. So, we need to point the global variable to the @@ -1875,7 +2124,7 @@ static bool delete_specific_GRAPH_global_contexts(char *graph_name) } else { - prev_ggctx->next = curr_ggctx->next; + prev_ggctx->next = next_ggctx; } /* free the current graph context */ @@ -1889,8 +2138,8 @@ static bool delete_specific_GRAPH_global_contexts(char *graph_name) errmsg("missing vertex_entry during free"))); } - /* we found and freed it, return true */ - return true; + curr_ggctx = next_ggctx; + continue; } /* save the current as previous and advance to the next one */ @@ -1898,9 +2147,7 @@ static bool delete_specific_GRAPH_global_contexts(char *graph_name) curr_ggctx = next_ggctx; } - - /* we didn't find it, return false */ - return false; + return found; } /* @@ -1965,7 +2212,7 @@ GRAPH_global_context *find_GRAPH_global_context(Oid graph_oid) while(ggctx != NULL) { /* if we found it return it */ - if (ggctx->graph_oid == graph_oid) + if (ggctx->graph_oid == graph_oid && !ggctx->retired) { return ggctx; @@ -1980,6 +2227,233 @@ GRAPH_global_context *find_GRAPH_global_context(Oid graph_oid) return NULL; } +/* + * Find a graph context for materializing a VLE path. The path may come from a + * traversal whose context has since been retired, so retired contexts that a + * query still pins are considered too. The first context that holds every + * vertex and edge of the path is returned, or NULL if there is none. + */ +GRAPH_global_context *find_GRAPH_global_context_for_path(Oid graph_oid, + const graphid *ids, + int64 count) +{ + GRAPH_global_context *ggctx = NULL; + + for (ggctx = global_graph_contexts; ggctx != NULL; ggctx = ggctx->next) + { + bool complete = true; + int64 i; + + if (ggctx->graph_oid != graph_oid) + { + continue; + } + + for (i = 0; i < count; i++) + { + /* + * get_edge_entry() asserts that the edge exists; a context may + * lack it here, so look the edge up directly. + */ + if ((i % 2 == 0 && get_vertex_entry(ggctx, ids[i]) == NULL) || + (i % 2 == 1 && + agehash_lookup(ggctx->edge_table, &ids[i]) == NULL)) + { + complete = false; + break; + } + } + + if (complete) + { + return ggctx; + } + } + + return NULL; +} + +/* remove a context from the backend-local list without freeing it */ +static void unlink_GRAPH_global_context(GRAPH_global_context *ggctx) +{ + GRAPH_global_context **link = &global_graph_contexts; + + while (*link != NULL && *link != ggctx) + { + link = &(*link)->next; + } + + if (*link == ggctx) + { + *link = ggctx->next; + } + + ggctx->next = NULL; +} + +/* + * Memory-context callback for a pin. Runs when the query's memory context is + * reset or deleted, including during error cleanup. A retired context is + * freed once no query pins it. It only uses pfree, hash_destroy and + * MemoryContextDelete, so it is safe in an aborted transaction. + */ +static void release_GRAPH_context_pin(void *arg) +{ + GRAPH_context_pin *pin = (GRAPH_context_pin *) arg; + GRAPH_context_pin **link = &graph_context_pins; + GRAPH_global_context *ggctx = NULL; + + while (*link != NULL && *link != pin) + { + link = &(*link)->next; + } + + if (*link == NULL) + { + return; + } + + *link = pin->next; + ggctx = pin->ggctx; + + if (ggctx->pin_count > 0) + { + ggctx->pin_count--; + } + + if (ggctx->pin_count == 0 && ggctx->retired) + { + unlink_GRAPH_global_context(ggctx); + (void) free_specific_GRAPH_global_context(ggctx); + } +} + +/* + * Keep a graph context alive until the given per-query memory context goes + * away, recording the traversal call site that pinned it. Pinning the same + * context twice for one query is a no-op. + */ +void pin_GRAPH_global_context(GRAPH_global_context *ggctx, MemoryContext owner, + const void *call_site) +{ + GRAPH_context_pin *pin = NULL; + + if (ggctx == NULL || owner == NULL) + { + return; + } + + for (pin = graph_context_pins; pin != NULL; pin = pin->next) + { + if (pin->ggctx == ggctx && pin->owner == owner) + { + return; + } + } + + pin = MemoryContextAllocZero(owner, sizeof(GRAPH_context_pin)); + pin->ggctx = ggctx; + pin->owner = owner; + pin->call_site = call_site; + pin->release.func = release_GRAPH_context_pin; + pin->release.arg = pin; + MemoryContextRegisterResetCallback(owner, &pin->release); + + pin->next = graph_context_pins; + graph_context_pins = pin; + ggctx->pin_count++; +} + +/* + * Whether a graph context this query already pins can serve another + * acquisition in the same query, under the semantics PostgreSQL gives a + * statement's scans: + * + * - A context loaded with another role, enforcement setting or snapshot is a + * different scan. + * - A later command id means that a traversal which has not loaded the graph + * yet must see the statement's own writes. The traversal that loaded it + * keeps it while the statement writes, as a traversal of a graph without + * RLS does, instead of loading the graph again for every row. + * - Policies and privileges hold for the whole statement: PostgreSQL applies + * the policies when it plans the statement and checks the privileges when + * it starts it, so an authorization change made while the statement runs + * does not end the reuse. + * - When the RLS quals give the same result throughout the statement, every + * scan of the labels in this statement returns the same rows, so any + * traversal may share the graph (see rls_plan_quals_vary). + * - Otherwise only the traversal call site that loaded the graph shares it, + * as when PostgreSQL scans a relation once and rescans the result. Another + * call site is a separate scan and evaluates the policies itself. + */ +static bool graph_context_reusable(GRAPH_context_pin *pin, + const void *call_site) +{ + GRAPH_global_context *ggctx = pin->ggctx; + Snapshot snapshot = GetActiveSnapshot(); + + if (ggctx->load_as_role != GetUserId() || + ggctx->loaded_rls_enforced != age_enforce_rls_in_traversal || + ggctx->xmin != snapshot->xmin || + ggctx->xmax != snapshot->xmax) + { + return false; + } + + if (ggctx->curcid != snapshot->curcid && + (call_site == NULL || pin->call_site != call_site)) + { + return false; + } + + if (ggctx->rls_quals_vary && + (call_site == NULL || pin->call_site != call_site)) + { + return false; + } + + return true; +} + +/* + * Acquire a graph context for a traversal and pin it for the rest of the + * query that owns query_owner, so a later acquisition cannot free it while + * the traversal or the materialization of its results still needs it. + * call_site identifies the traversal within the query; it stays the same + * when the traversal is evaluated again for another row. A context the query + * already pins is reused when graph_context_reusable() allows it, so the + * policies run once for each row a load scans rather than again for every + * traversal. + */ +GRAPH_global_context *acquire_GRAPH_global_context(char *graph_name, + Oid graph_oid, + MemoryContext query_owner, + const void *call_site) +{ + GRAPH_global_context *ggctx = NULL; + GRAPH_context_pin *pin = NULL; + + if (query_owner == NULL) + { + return manage_GRAPH_global_contexts(graph_name, graph_oid); + } + + for (pin = graph_context_pins; pin != NULL; pin = pin->next) + { + if (pin->owner == query_owner && + pin->ggctx->graph_oid == graph_oid && + graph_context_reusable(pin, call_site)) + { + return pin->ggctx; + } + } + + ggctx = manage_GRAPH_global_contexts(graph_name, graph_oid); + pin_GRAPH_global_context(ggctx, query_owner, call_site); + + return ggctx; +} + /* graph vertices accessor */ ListGraphId *get_graph_vertices(GRAPH_global_context *ggctx) { diff --git a/src/backend/utils/adt/age_vle.c b/src/backend/utils/adt/age_vle.c index cb036b154..d3f22de5d 100644 --- a/src/backend/utils/adt/age_vle.c +++ b/src/backend/utils/adt/age_vle.c @@ -639,6 +639,14 @@ static VLE_local_context *build_local_vle_context(FunctionCallInfo fcinfo, Oid graph_oid = InvalidOid; int64 vle_grammar_node_id = 0; bool use_cache = false; + ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo; + MemoryContext query_owner = NULL; + + /* the graph context stays pinned for the rest of this query */ + if (rsinfo != NULL && IsA(rsinfo, ReturnSetInfo) && rsinfo->econtext != NULL) + { + query_owner = rsinfo->econtext->ecxt_per_query_memory; + } /* * Get the VLE grammar node id, if it exists. Remember, we overload the @@ -668,6 +676,8 @@ static VLE_local_context *build_local_vle_context(FunctionCallInfo fcinfo, * be stored in that context since the memory is allocated there. */ + pin_GRAPH_global_context(vlelctx->ggctx, query_owner, fcinfo->flinfo); + /* get and update the start vertex id */ if (PG_ARGISNULL(1) || is_agtype_null(AG_GET_ARG_AGTYPE_P(1))) { @@ -758,10 +768,13 @@ static VLE_local_context *build_local_vle_context(FunctionCallInfo fcinfo, graph_oid = get_graph_oid(graph_name); /* - * Create or retrieve the GRAPH global context for this graph. This function - * will also purge off invalidated contexts. + * Create or retrieve the GRAPH global context for this graph and pin it + * for this query. This function will also purge off invalidated contexts. + * The function's FmgrInfo identifies this traversal within the query; it + * stays the same when the traversal is evaluated again for another row. */ - ggctx = manage_GRAPH_global_contexts(graph_name, graph_oid); + ggctx = acquire_GRAPH_global_context(graph_name, graph_oid, query_owner, + fcinfo->flinfo); /* allocate and initialize local VLE context */ vlelctx = palloc0(sizeof(VLE_local_context)); @@ -1754,15 +1767,20 @@ static agtype_value *build_edge_list(VLE_path_container *vpc) /* get the graph_oid */ graph_oid = vpc->graph_oid; - /* get the GRAPH global context for this graph */ - ggctx = find_GRAPH_global_context(graph_oid); - /* verify we got a global context */ - Assert(ggctx != NULL); - /* get the graphid_array and size */ graphid_array = GET_GRAPHID_ARRAY_FROM_CONTAINER(vpc); graphid_array_size = vpc->graphid_array_size; + /* get a GRAPH global context that holds every entity of this path */ + ggctx = find_GRAPH_global_context_for_path(graph_oid, graphid_array, + graphid_array_size); + if (ggctx == NULL) + { + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("graph data for the referenced VLE path is not available"))); + } + /* initialize our agtype array */ MemSet(&edges_result, 0, sizeof(agtype_in_state)); edges_result.res = push_agtype_value(&edges_result.parse_state, @@ -1818,15 +1836,20 @@ static agtype_value *build_path(VLE_path_container *vpc) /* get the graph_oid */ graph_oid = vpc->graph_oid; - /* get the GRAPH global context for this graph */ - ggctx = find_GRAPH_global_context(graph_oid); - /* verify we got a global context */ - Assert(ggctx != NULL); - /* get the graphid_array and size */ graphid_array = GET_GRAPHID_ARRAY_FROM_CONTAINER(vpc); graphid_array_size = vpc->graphid_array_size; + /* get a GRAPH global context that holds every entity of this path */ + ggctx = find_GRAPH_global_context_for_path(graph_oid, graphid_array, + graphid_array_size); + if (ggctx == NULL) + { + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("graph data for the referenced VLE path is not available"))); + } + /* initialize our agtype array */ MemSet(&path_result, 0, sizeof(agtype_in_state)); path_result.res = push_agtype_value(&path_result.parse_state, diff --git a/src/include/utils/age_global_graph.h b/src/include/utils/age_global_graph.h index b08b0d18f..c3d355ed3 100644 --- a/src/include/utils/age_global_graph.h +++ b/src/include/utils/age_global_graph.h @@ -58,7 +58,16 @@ typedef struct GRAPH_global_context GRAPH_global_context; /* GRAPH global context functions */ GRAPH_global_context *manage_GRAPH_global_contexts(char *graph_name, Oid graph_oid); +GRAPH_global_context *acquire_GRAPH_global_context(char *graph_name, + Oid graph_oid, + MemoryContext query_owner, + const void *call_site); +void pin_GRAPH_global_context(GRAPH_global_context *ggctx, + MemoryContext owner, const void *call_site); GRAPH_global_context *find_GRAPH_global_context(Oid graph_oid); +GRAPH_global_context *find_GRAPH_global_context_for_path(Oid graph_oid, + const graphid *ids, + int64 count); bool is_ggctx_invalid(GRAPH_global_context *ggctx); /* GRAPH retrieval functions */ ListGraphId *get_graph_vertices(GRAPH_global_context *ggctx); From 360797b86556503ac64d4ca63f1e9c6ab89a8422 Mon Sep 17 00:00:00 2001 From: John Gemignani Date: Thu, 8 Oct 2026 05:15:37 +0000 Subject: [PATCH 2/5] Return null from vertex_stats for a vertex outside the graph vertex_stats() looked the vertex up in the traversal graph and used the entry without checking it. Since #2561, row-level security can hide a vertex from that graph while an untyped MATCH, which scans the parent label table, still returns it, so vertex_stats() dereferenced a NULL entry and terminated the backend (review issue 2 on #2561). Return null when the vertex is not in the graph the caller can traverse. The statistics of visible vertices are computed over that graph, so edges to hidden vertices are not counted. The new rls_vle part hides a vertex with a policy and checks the statistics of every vertex an untyped MATCH returns. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- regress/expected/rls_vle.out | 38 ++++++++++++++++++++++++ regress/sql/rls_vle.sql | 24 +++++++++++++++ src/backend/utils/adt/age_global_graph.c | 11 +++++++ 3 files changed, 73 insertions(+) diff --git a/regress/expected/rls_vle.out b/regress/expected/rls_vle.out index ec7220f97..b04f3a5de 100644 --- a/regress/expected/rls_vle.out +++ b/regress/expected/rls_vle.out @@ -1245,6 +1245,44 @@ NOTICE: graph "rls_vle_after_rls" has been dropped (1 row) +-- ============================================================================ +-- PART 9: vertex_stats on a vertex hidden from traversals. An untyped MATCH +-- scans the parent label table, which the child table's policy does not +-- filter, so it can return a vertex that the traversal graph does not hold. +-- vertex_stats returns null for it; visible vertices get statistics computed +-- over the graph the caller can traverse. +-- ============================================================================ +ALTER TABLE rls_vle."Node" ENABLE ROW LEVEL SECURITY; +CREATE POLICY node_visible ON rls_vle."Node" + FOR SELECT USING ((properties->>'"name"') <> 'n2'); +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle', $$ + MATCH (n:Node) RETURN n.name +$$) AS (name agtype) ORDER BY name; + name +------ + "n1" + "n3" + "n4" +(3 rows) + +SELECT * FROM cypher('rls_vle', $$ + MATCH (n) + WITH n, vertex_stats(n) AS s + RETURN n.name, s IS NULL, s.in_degree, s.out_degree +$$) AS (name agtype, no_stats agtype, in_degree agtype, out_degree agtype) +ORDER BY name; + name | no_stats | in_degree | out_degree +------+----------+-----------+------------ + "n1" | false | 0 | 0 + "n2" | true | | + "n3" | false | 0 | 1 + "n4" | false | 1 | 0 +(4 rows) + +RESET ROLE; +DROP POLICY node_visible ON rls_vle."Node"; +ALTER TABLE rls_vle."Node" DISABLE ROW LEVEL SECURITY; -- ============================================================================ -- cleanup -- ============================================================================ diff --git a/regress/sql/rls_vle.sql b/regress/sql/rls_vle.sql index b12df277c..70c4703cd 100644 --- a/regress/sql/rls_vle.sql +++ b/regress/sql/rls_vle.sql @@ -784,6 +784,30 @@ SELECT drop_graph('rls_vle_before_rls', true); SELECT drop_graph('rls_vle_after_plain', true); SELECT drop_graph('rls_vle_after_rls', true); +-- ============================================================================ +-- PART 9: vertex_stats on a vertex hidden from traversals. An untyped MATCH +-- scans the parent label table, which the child table's policy does not +-- filter, so it can return a vertex that the traversal graph does not hold. +-- vertex_stats returns null for it; visible vertices get statistics computed +-- over the graph the caller can traverse. +-- ============================================================================ +ALTER TABLE rls_vle."Node" ENABLE ROW LEVEL SECURITY; +CREATE POLICY node_visible ON rls_vle."Node" + FOR SELECT USING ((properties->>'"name"') <> 'n2'); +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle', $$ + MATCH (n:Node) RETURN n.name +$$) AS (name agtype) ORDER BY name; +SELECT * FROM cypher('rls_vle', $$ + MATCH (n) + WITH n, vertex_stats(n) AS s + RETURN n.name, s IS NULL, s.in_degree, s.out_degree +$$) AS (name agtype, no_stats agtype, in_degree agtype, out_degree agtype) +ORDER BY name; +RESET ROLE; +DROP POLICY node_visible ON rls_vle."Node"; +ALTER TABLE rls_vle."Node" DISABLE ROW LEVEL SECURITY; + -- ============================================================================ -- cleanup -- ============================================================================ diff --git a/src/backend/utils/adt/age_global_graph.c b/src/backend/utils/adt/age_global_graph.c index ce9730754..460c946c6 100644 --- a/src/backend/utils/adt/age_global_graph.c +++ b/src/backend/utils/adt/age_global_graph.c @@ -2787,6 +2787,17 @@ Datum age_vertex_stats(PG_FUNCTION_ARGS) /* get the vertex entry */ ve = get_vertex_entry(ggctx, vid); + /* + * The vertex is absent from the graph the caller can traverse, for + * example when row-level security hides it while an untyped MATCH, which + * scans the parent label table, still returns it. There are no + * statistics for it. + */ + if (ve == NULL) + { + PG_RETURN_NULL(); + } + /* zero the state */ memset(&result, 0, sizeof(agtype_in_state)); From 1b49b30298c1af0c4327d03414f2458aaffb9ba2 Mon Sep 17 00:00:00 2001 From: John Gemignani Date: Thu, 8 Oct 2026 05:28:16 +0000 Subject: [PATCH 3/5] Allow column-privileged roles to traverse RLS-protected labels The RLS loaders read each visible row's ctid so that properties could be fetched from the heap later. A role granted SELECT on every user column of a label, but not on the table, can read the label with MATCH, yet a traversal failed with "permission denied" because column privileges do not cover ctid (review issue 3 on #2561). A role that holds SELECT on the table keeps the ctid and the lazy fetch. Otherwise the loader selects the properties column and the entry keeps an owned, detoasted copy, which is freed with the graph context. An entry stores either the tuple location or the properties in the same field, so vertex and edge entries keep their size. The column privileges are checked before the load as before, and the SPI query enforces them, so no row or column the role cannot read is loaded. The new rls_vle part grants only column privileges on an RLS-protected edge label and then on a vertex label, and checks that traversals return the expected paths and properties. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- regress/expected/rls_vle.out | 57 ++++++ regress/sql/rls_vle.sql | 40 +++++ src/backend/utils/adt/age_global_graph.c | 215 ++++++++++++++++++----- 3 files changed, 270 insertions(+), 42 deletions(-) diff --git a/regress/expected/rls_vle.out b/regress/expected/rls_vle.out index b04f3a5de..f8edf71ee 100644 --- a/regress/expected/rls_vle.out +++ b/regress/expected/rls_vle.out @@ -1284,6 +1284,63 @@ RESET ROLE; DROP POLICY node_visible ON rls_vle."Node"; ALTER TABLE rls_vle."Node" DISABLE ROW LEVEL SECURITY; -- ============================================================================ +-- PART 10: Column privileges. A role granted SELECT on every user column of +-- an RLS-protected label, but not on the table, traverses it just as MATCH +-- reads it. It cannot read ctid, so its graph keeps the rows' properties. +-- ============================================================================ +ALTER TABLE rls_vle."LINK" ENABLE ROW LEVEL SECURITY; +CREATE POLICY link_all ON rls_vle."LINK" FOR SELECT USING (true); +REVOKE SELECT ON rls_vle."LINK" FROM rls_vle_user; +GRANT SELECT (id, start_id, end_id, properties) ON rls_vle."LINK" + TO rls_vle_user; +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle', $$ + MATCH (a:Node {name: 'n1'})-[:LINK]->(b) RETURN b.name +$$) AS (name agtype); + name +------ + "n2" +(1 row) + +SELECT * FROM cypher('rls_vle', $$ + MATCH (a:Node {name: 'n1'})-[e:LINK*1..3]->(b) + RETURN b.name, [r IN e | r.visible] +$$) AS (name agtype, visible agtype) ORDER BY name; + name | visible +------+--------------------- + "n2" | [true] + "n3" | [true, false] + "n4" | [true, false, true] +(3 rows) + +RESET ROLE; +REVOKE SELECT (id, start_id, end_id, properties) ON rls_vle."LINK" + FROM rls_vle_user; +GRANT SELECT ON rls_vle."LINK" TO rls_vle_user; +DROP POLICY link_all ON rls_vle."LINK"; +ALTER TABLE rls_vle."LINK" DISABLE ROW LEVEL SECURITY; +ALTER TABLE rls_vle."Node" ENABLE ROW LEVEL SECURITY; +CREATE POLICY node_all ON rls_vle."Node" FOR SELECT USING (true); +REVOKE SELECT ON rls_vle."Node" FROM rls_vle_user; +GRANT SELECT (id, properties) ON rls_vle."Node" TO rls_vle_user; +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle', $$ + MATCH p = (a:Node {name: 'n1'})-[:LINK*1..3]->(b) + RETURN b.name, [v IN nodes(p) | v.name] +$$) AS (name agtype, names agtype) ORDER BY name; + name | names +------+-------------------------- + "n2" | ["n1", "n2"] + "n3" | ["n1", "n2", "n3"] + "n4" | ["n1", "n2", "n3", "n4"] +(3 rows) + +RESET ROLE; +REVOKE SELECT (id, properties) ON rls_vle."Node" FROM rls_vle_user; +GRANT SELECT ON rls_vle."Node" TO rls_vle_user; +DROP POLICY node_all ON rls_vle."Node"; +ALTER TABLE rls_vle."Node" DISABLE ROW LEVEL SECURITY; +-- ============================================================================ -- cleanup -- ============================================================================ SELECT drop_graph('rls_vle', true); diff --git a/regress/sql/rls_vle.sql b/regress/sql/rls_vle.sql index 70c4703cd..ebb9bc5e5 100644 --- a/regress/sql/rls_vle.sql +++ b/regress/sql/rls_vle.sql @@ -808,6 +808,46 @@ RESET ROLE; DROP POLICY node_visible ON rls_vle."Node"; ALTER TABLE rls_vle."Node" DISABLE ROW LEVEL SECURITY; +-- ============================================================================ +-- PART 10: Column privileges. A role granted SELECT on every user column of +-- an RLS-protected label, but not on the table, traverses it just as MATCH +-- reads it. It cannot read ctid, so its graph keeps the rows' properties. +-- ============================================================================ +ALTER TABLE rls_vle."LINK" ENABLE ROW LEVEL SECURITY; +CREATE POLICY link_all ON rls_vle."LINK" FOR SELECT USING (true); +REVOKE SELECT ON rls_vle."LINK" FROM rls_vle_user; +GRANT SELECT (id, start_id, end_id, properties) ON rls_vle."LINK" + TO rls_vle_user; +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle', $$ + MATCH (a:Node {name: 'n1'})-[:LINK]->(b) RETURN b.name +$$) AS (name agtype); +SELECT * FROM cypher('rls_vle', $$ + MATCH (a:Node {name: 'n1'})-[e:LINK*1..3]->(b) + RETURN b.name, [r IN e | r.visible] +$$) AS (name agtype, visible agtype) ORDER BY name; +RESET ROLE; +REVOKE SELECT (id, start_id, end_id, properties) ON rls_vle."LINK" + FROM rls_vle_user; +GRANT SELECT ON rls_vle."LINK" TO rls_vle_user; +DROP POLICY link_all ON rls_vle."LINK"; +ALTER TABLE rls_vle."LINK" DISABLE ROW LEVEL SECURITY; + +ALTER TABLE rls_vle."Node" ENABLE ROW LEVEL SECURITY; +CREATE POLICY node_all ON rls_vle."Node" FOR SELECT USING (true); +REVOKE SELECT ON rls_vle."Node" FROM rls_vle_user; +GRANT SELECT (id, properties) ON rls_vle."Node" TO rls_vle_user; +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle', $$ + MATCH p = (a:Node {name: 'n1'})-[:LINK*1..3]->(b) + RETURN b.name, [v IN nodes(p) | v.name] +$$) AS (name agtype, names agtype) ORDER BY name; +RESET ROLE; +REVOKE SELECT (id, properties) ON rls_vle."Node" FROM rls_vle_user; +GRANT SELECT ON rls_vle."Node" TO rls_vle_user; +DROP POLICY node_all ON rls_vle."Node"; +ALTER TABLE rls_vle."Node" DISABLE ROW LEVEL SECURITY; + -- ============================================================================ -- cleanup -- ============================================================================ diff --git a/src/backend/utils/adt/age_global_graph.c b/src/backend/utils/adt/age_global_graph.c index 460c946c6..d7b861b23 100644 --- a/src/backend/utils/adt/age_global_graph.c +++ b/src/backend/utils/adt/age_global_graph.c @@ -127,6 +127,18 @@ static GraphVersionState *shmem_version_state = NULL; /* internal data structures implementation */ +/* + * Where an entry's properties come from. An entry normally keeps its tuple's + * location and reads the properties when a path is materialized. An RLS load + * for a role that holds only column privileges cannot read ctid, so it keeps + * an owned copy of the properties instead. + */ +typedef union entry_storage +{ + ItemPointerData tid; /* physical tuple location for lazy fetch */ + Datum properties; /* owned, detoasted properties */ +} entry_storage; + /* vertex entry for the vertex_hashtable */ typedef struct vertex_entry { @@ -135,7 +147,8 @@ typedef struct vertex_entry VertexEdgeArray edges_out; /* outgoing edge graphids (flat array) */ VertexEdgeArray edges_self; /* self-loop edge graphids (flat array) */ Oid vertex_label_table_oid; /* the label table oid */ - ItemPointerData tid; /* physical tuple location for lazy fetch */ + bool has_properties; /* storage holds properties, not a tid */ + entry_storage storage; /* tuple location or properties */ } vertex_entry; /* @@ -152,7 +165,8 @@ typedef struct vertex_entry typedef struct edge_entry { Oid edge_label_table_oid; /* the label table oid */ - ItemPointerData tid; /* physical tuple location for lazy fetch */ + bool has_properties; /* storage holds properties, not a tid */ + entry_storage storage; /* tuple location or properties */ graphid start_vertex_id; /* start vertex */ graphid end_vertex_id; /* end vertex */ } edge_entry; @@ -169,6 +183,7 @@ typedef struct GRAPH_global_context HTAB *vertex_hashtable; /* hashtable to hold vertex edge lists */ AgeHashTable *edge_table; /* edge to vertex map (Robin Hood) */ MemoryContext edge_table_mcxt; /* private context owning edge_table */ + MemoryContext properties_mcxt; /* owns entry property copies, if any */ uint64 graph_version; /* version counter for cache invalidation */ TransactionId xmin; /* snapshot fallback: transaction xmin */ TransactionId xmax; /* snapshot fallback: transaction xmax */ @@ -271,14 +286,15 @@ static void freeze_GRAPH_global_hashtables(GRAPH_global_context *ggctx); static List *get_ag_labels_names(Snapshot snapshot, Oid graph_oid, char label_type); static bool insert_edge_entry(GRAPH_global_context *ggctx, graphid edge_id, - ItemPointerData tid, graphid start_vertex_id, + ItemPointerData tid, Datum properties, + graphid start_vertex_id, graphid end_vertex_id, Oid edge_label_table_oid); static bool insert_vertex_edge(GRAPH_global_context *ggctx, graphid start_vertex_id, graphid end_vertex_id, graphid edge_id, char *edge_label_name); static bool insert_vertex_entry(GRAPH_global_context *ggctx, graphid vertex_id, Oid vertex_label_table_oid, - ItemPointerData tid); + ItemPointerData tid, Datum properties); static void load_vertex_label_rls(GRAPH_global_context *ggctx, Oid vertex_label_table_oid, char *vertex_label_name); @@ -621,10 +637,12 @@ static List *get_ag_labels_names(Snapshot snapshot, Oid graph_oid, /* * Helper function to insert one edge/edge->vertex, key/value pair, in the - * current GRAPH global edge hashtable. + * current GRAPH global edge hashtable. A non-zero properties datum is an owned + * copy that the entry keeps instead of the tuple location. */ static bool insert_edge_entry(GRAPH_global_context *ggctx, graphid edge_id, - ItemPointerData tid, graphid start_vertex_id, + ItemPointerData tid, Datum properties, + graphid start_vertex_id, graphid end_vertex_id, Oid edge_label_table_oid) { edge_entry *ee = NULL; @@ -667,7 +685,15 @@ static bool insert_edge_entry(GRAPH_global_context *ggctx, graphid edge_id, * in only the fields we care about. The hash key (edge_id) lives in the * slot header; recoverable via get_edge_entry_id() if needed. */ - ee->tid = tid; + if (properties != (Datum) 0) + { + ee->has_properties = true; + ee->storage.properties = properties; + } + else + { + ee->storage.tid = tid; + } ee->start_vertex_id = start_vertex_id; ee->end_vertex_id = end_vertex_id; ee->edge_label_table_oid = edge_label_table_oid; @@ -680,11 +706,12 @@ static bool insert_edge_entry(GRAPH_global_context *ggctx, graphid edge_id, /* * Helper function to insert an entire vertex into the current GRAPH global - * vertex hashtable. It will return false if there is a duplicate. + * vertex hashtable. It will return false if there is a duplicate. A non-zero + * properties datum is an owned copy kept instead of the tuple location. */ static bool insert_vertex_entry(GRAPH_global_context *ggctx, graphid vertex_id, Oid vertex_label_table_oid, - ItemPointerData tid) + ItemPointerData tid, Datum properties) { vertex_entry *ve = NULL; bool found = false; @@ -726,8 +753,16 @@ static bool insert_vertex_entry(GRAPH_global_context *ggctx, graphid vertex_id, ve->vertex_id = vertex_id; /* set the label table oid for this vertex */ ve->vertex_label_table_oid = vertex_label_table_oid; - /* set the TID for lazy property fetch */ - ve->tid = tid; + /* keep the properties, or the TID for a lazy property fetch */ + if (properties != (Datum) 0) + { + ve->has_properties = true; + ve->storage.properties = properties; + } + else + { + ve->storage.tid = tid; + } /* * MemSet above already zeroed the embedded VertexEdgeArray fields * (array=NULL, size=0, capacity=0); no explicit NIL assignment needed. @@ -978,18 +1013,45 @@ static bool rls_plan_quals_vary(SPIPlanPtr plan) return false; } +/* + * Keep an owned, detoasted copy of an entry's properties in the context's + * property memory, which is freed with the context. + */ +static Datum copy_entry_properties(GRAPH_global_context *ggctx, + MemoryContext parent, Datum properties) +{ + MemoryContext oldctx; + Datum copy; + + if (ggctx->properties_mcxt == NULL) + { + ggctx->properties_mcxt = AllocSetContextCreate(parent, + "AGE entry properties", + ALLOCSET_DEFAULT_SIZES); + } + + oldctx = MemoryContextSwitchTo(ggctx->properties_mcxt); + copy = PointerGetDatum(PG_DETOAST_DATUM_COPY(properties)); + MemoryContextSwitchTo(oldctx); + + return copy; +} + /* * RLS-aware loader for a single vertex label table. * - * Loads (id, ctid) through SPI so the planner applies row-level security + * Loads the visible rows through SPI so the planner applies row-level security * policies (and table/column ACL) for the current role, then inserts each - * visible row into the graph's global vertex hashtable. The physical tuple - * location (ctid) is stored for the same lazy property fetch the direct-scan - * path uses (get_vertex_entry_properties); because only RLS-visible rows are - * loaded, that later heap_fetch only ever reads authorized tuples. Used in - * place of the direct heap scan when age.enforce_rls_in_traversal is on and RLS - * is active on the label table. Rows are fetched in batches so the entire label - * table is never materialized at once. + * visible row into the graph's global vertex hashtable. A role with SELECT on + * the table loads (id, ctid) and the physical tuple location is stored for the + * same lazy property fetch the direct-scan path uses + * (get_vertex_entry_properties); because only RLS-visible rows are loaded, that + * later heap_fetch only ever reads authorized tuples. A role holding only + * column privileges cannot read ctid, so it loads (id, properties) and the + * entry keeps a copy of the properties. Used in place of the direct heap scan + * when age.enforce_rls_in_traversal is on and RLS is active on the label table. + * Rows are fetched in batches so the entire label table is never materialized + * at once. */ static void load_vertex_label_rls(GRAPH_global_context *ggctx, Oid vertex_label_table_oid, @@ -999,6 +1061,11 @@ static void load_vertex_label_rls(GRAPH_global_context *ggctx, StringInfoData query; SPIPlanPtr plan; Portal portal; + bool keep_properties; + + /* column privileges cover the user columns but not ctid */ + keep_properties = (pg_class_aclcheck(vertex_label_table_oid, GetUserId(), + ACL_SELECT) != ACLCHECK_OK); if (SPI_connect() != SPI_OK_CONNECT) { @@ -1016,7 +1083,8 @@ static void load_vertex_label_rls(GRAPH_global_context *ggctx, * cache rebuild. */ initStringInfo(&query); - appendStringInfo(&query, "SELECT id, ctid FROM ONLY %s", + appendStringInfo(&query, "SELECT id, %s FROM ONLY %s", + keep_properties ? "properties" : "ctid", quote_qualified_identifier(ggctx->graph_name, vertex_label_name)); @@ -1054,24 +1122,39 @@ static void load_vertex_label_rls(GRAPH_global_context *ggctx, TupleDesc tupdesc = SPI_tuptable->tupdesc; bool isnull = false; graphid vertex_id; - ItemPointer tidptr; + Datum value; ItemPointerData tid; + Datum properties = (Datum) 0; MemoryContext oldctx; vertex_id = DatumGetInt64(SPI_getbinval(tuple, tupdesc, 1, &isnull)); - tidptr = (ItemPointer) DatumGetPointer(SPI_getbinval(tuple, tupdesc, - 2, &isnull)); - /* ctid is never null for a scanned heap tuple; guard defensively */ - if (isnull || tidptr == NULL) + value = SPI_getbinval(tuple, tupdesc, 2, &isnull); + ItemPointerSetInvalid(&tid); + + if (keep_properties) { - continue; + if (isnull) + { + elog(ERROR, "load_vertex_label_rls: vertex " INT64_FORMAT + " has null properties", vertex_id); + } + properties = copy_entry_properties(ggctx, ggctx_cxt, value); + } + else + { + /* ctid is never null for a scanned heap tuple; guard defensively */ + if (isnull || DatumGetPointer(value) == NULL) + { + continue; + } + tid = *((ItemPointer) DatumGetPointer(value)); } - tid = *tidptr; /* build the entry in the cache's (persistent) memory context */ oldctx = MemoryContextSwitchTo(ggctx_cxt); - insert_vertex_entry(ggctx, vertex_id, vertex_label_table_oid, tid); + insert_vertex_entry(ggctx, vertex_id, vertex_label_table_oid, tid, + properties); MemoryContextSwitchTo(oldctx); } @@ -1086,7 +1169,8 @@ static void load_vertex_label_rls(GRAPH_global_context *ggctx, * RLS-aware loader for a single edge label table. See load_vertex_label_rls; * this variant additionally loads start_id/end_id and populates the per-vertex * edge lists, exactly like the direct-scan path, storing the ctid for the same - * lazy property fetch. + * lazy property fetch, or a copy of the properties for a role that holds only + * column privileges. */ static void load_edge_label_rls(GRAPH_global_context *ggctx, Oid edge_label_table_oid, @@ -1096,6 +1180,11 @@ static void load_edge_label_rls(GRAPH_global_context *ggctx, StringInfoData query; SPIPlanPtr plan; Portal portal; + bool keep_properties; + + /* column privileges cover the user columns but not ctid */ + keep_properties = (pg_class_aclcheck(edge_label_table_oid, GetUserId(), + ACL_SELECT) != ACLCHECK_OK); if (SPI_connect() != SPI_OK_CONNECT) { @@ -1110,7 +1199,8 @@ static void load_edge_label_rls(GRAPH_global_context *ggctx, */ initStringInfo(&query); appendStringInfo(&query, - "SELECT id, start_id, end_id, ctid FROM ONLY %s", + "SELECT id, start_id, end_id, %s FROM ONLY %s", + keep_properties ? "properties" : "ctid", quote_qualified_identifier(ggctx->graph_name, edge_label_name)); @@ -1150,8 +1240,9 @@ static void load_edge_label_rls(GRAPH_global_context *ggctx, graphid edge_id; graphid edge_vertex_start_id; graphid edge_vertex_end_id; - ItemPointer tidptr; + Datum value; ItemPointerData tid; + Datum properties = (Datum) 0; MemoryContext oldctx; edge_id = DatumGetInt64(SPI_getbinval(tuple, tupdesc, 1, &isnull)); @@ -1159,14 +1250,26 @@ static void load_edge_label_rls(GRAPH_global_context *ggctx, 2, &isnull)); edge_vertex_end_id = DatumGetInt64(SPI_getbinval(tuple, tupdesc, 3, &isnull)); - tidptr = (ItemPointer) DatumGetPointer(SPI_getbinval(tuple, tupdesc, - 4, &isnull)); - /* ctid is never null for a scanned heap tuple; guard defensively */ - if (isnull || tidptr == NULL) + value = SPI_getbinval(tuple, tupdesc, 4, &isnull); + ItemPointerSetInvalid(&tid); + + if (keep_properties) { - continue; + if (isnull) + { + elog(ERROR, "load_edge_label_rls: edge " INT64_FORMAT + " has null properties", edge_id); + } + } + else + { + /* ctid is never null for a scanned heap tuple; guard defensively */ + if (isnull || DatumGetPointer(value) == NULL) + { + continue; + } + tid = *((ItemPointer) DatumGetPointer(value)); } - tid = *tidptr; /* * If a vertex label was RLS-filtered, drop any edge whose start or @@ -1180,10 +1283,16 @@ static void load_edge_label_rls(GRAPH_global_context *ggctx, continue; } + if (keep_properties) + { + properties = copy_entry_properties(ggctx, ggctx_cxt, value); + } + /* build the entries in the cache's (persistent) memory context */ oldctx = MemoryContextSwitchTo(ggctx_cxt); - insert_edge_entry(ggctx, edge_id, tid, edge_vertex_start_id, - edge_vertex_end_id, edge_label_table_oid); + insert_edge_entry(ggctx, edge_id, tid, properties, + edge_vertex_start_id, edge_vertex_end_id, + edge_label_table_oid); insert_vertex_edge(ggctx, edge_vertex_start_id, edge_vertex_end_id, edge_id, edge_label_name); MemoryContextSwitchTo(oldctx); @@ -1369,7 +1478,7 @@ static void load_vertex_hashtable(GRAPH_global_context *ggctx) /* insert vertex into vertex hashtable with TID (no property copy) */ inserted = insert_vertex_entry(ggctx, vertex_id, vertex_label_table_oid, - tuple->t_self); + tuple->t_self, (Datum) 0); /* warn if there is a duplicate */ if (!inserted) @@ -1546,6 +1655,7 @@ static void load_edge_hashtable(GRAPH_global_context *ggctx) /* insert edge into edge hashtable with TID (no property copy) */ inserted = insert_edge_entry(ggctx, edge_id, tuple->t_self, + (Datum) 0, edge_vertex_start_id, edge_vertex_end_id, edge_label_table_oid); @@ -1676,6 +1786,13 @@ static bool free_specific_GRAPH_global_context(GRAPH_global_context *ggctx) MemoryContextDelete(ggctx->edge_table_mcxt); } + /* property copies kept for column-privilege RLS loads */ + if (ggctx->properties_mcxt != NULL) + { + MemoryContextDelete(ggctx->properties_mcxt); + ggctx->properties_mcxt = NULL; + } + ggctx->vertex_hashtable = NULL; ggctx->edge_table = NULL; ggctx->edge_table_mcxt = NULL; @@ -2602,7 +2719,14 @@ Datum get_vertex_entry_properties(vertex_entry *ve) Datum result; entry_fetch_status status; - result = fetch_entry_properties(ve->vertex_label_table_oid, &ve->tid, + /* properties kept at load time; return the caller its own copy */ + if (ve->has_properties) + { + return datumCopy(ve->storage.properties, false, -1); + } + + result = fetch_entry_properties(ve->vertex_label_table_oid, + &ve->storage.tid, ve->vertex_id, Anum_ag_label_vertex_table_id, Anum_ag_label_vertex_table_properties, @@ -2651,7 +2775,14 @@ Datum get_edge_entry_properties(edge_entry *ee) Datum result; entry_fetch_status status; - result = fetch_entry_properties(ee->edge_label_table_oid, &ee->tid, + /* properties kept at load time; return the caller its own copy */ + if (ee->has_properties) + { + return datumCopy(ee->storage.properties, false, -1); + } + + result = fetch_entry_properties(ee->edge_label_table_oid, + &ee->storage.tid, get_edge_entry_id(ee), Anum_ag_label_edge_table_id, Anum_ag_label_edge_table_properties, From 5de33f6044f903faea667e4ddd4e468ac58b7cdf Mon Sep 17 00:00:00 2001 From: John Gemignani Date: Thu, 8 Oct 2026 05:31:22 +0000 Subject: [PATCH 4/5] Return no paths when RLS hides every vertex A traversal raised "age_vle: empty graph" when the graph held no vertices. Since #2561 the traversal graph holds only the vertices the caller may see, so a policy that hides every vertex turned an ordinary query into an error, although MATCH over the same labels returns no rows (review issue 4 on #2561). Return no paths instead. The check runs before the traversal state is allocated, which reduces what a cached call on this path leaves in long-lived memory from 352 to 64 bytes. The remaining bytes come from parsing the graph name argument in the long-lived context. That predates PR #2561, also happens on every cache miss, and is left to separate hardening. The new rls_vle part hides every vertex and checks a variable-length MATCH, a zero-length lower bound and a direct age_vle() call. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- regress/expected/rls_vle.out | 43 +++++++++++++++++++++++++++++++++ regress/sql/rls_vle.sql | 25 +++++++++++++++++++ src/backend/utils/adt/age_vle.c | 17 +++++++++---- 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/regress/expected/rls_vle.out b/regress/expected/rls_vle.out index f8edf71ee..3a6f72657 100644 --- a/regress/expected/rls_vle.out +++ b/regress/expected/rls_vle.out @@ -1341,6 +1341,49 @@ GRANT SELECT ON rls_vle."Node" TO rls_vle_user; DROP POLICY node_all ON rls_vle."Node"; ALTER TABLE rls_vle."Node" DISABLE ROW LEVEL SECURITY; -- ============================================================================ +-- PART 11: No visible vertices. When a policy hides every vertex, a traversal +-- has nothing to traverse and returns no paths instead of an error. The +-- untyped MATCH still returns the hidden vertices from the parent table, so +-- each traversal starts from a vertex outside the visible graph. +-- ============================================================================ +ALTER TABLE rls_vle."Node" ENABLE ROW LEVEL SECURITY; +CREATE POLICY node_none ON rls_vle."Node" FOR SELECT USING (false); +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle', $$ + MATCH (n:Node) RETURN count(n) +$$) AS (count agtype); + count +------- + 0 +(1 row) + +SELECT * FROM cypher('rls_vle', $$ + MATCH (a)-[:LINK*1..3]->(b) RETURN b.name +$$) AS (name agtype); + name +------ +(0 rows) + +SELECT * FROM cypher('rls_vle', $$ + MATCH (a)-[:LINK*0..1]->(b) RETURN count(*) +$$) AS (count agtype); + count +------- + 0 +(1 row) + +SELECT count(*) AS paths +FROM age_vle('"rls_vle"', NULL, NULL, + age_build_vle_match_edge('"LINK"', NULL), '1', '3', '1'); + paths +------- + 0 +(1 row) + +RESET ROLE; +DROP POLICY node_none ON rls_vle."Node"; +ALTER TABLE rls_vle."Node" DISABLE ROW LEVEL SECURITY; +-- ============================================================================ -- cleanup -- ============================================================================ SELECT drop_graph('rls_vle', true); diff --git a/regress/sql/rls_vle.sql b/regress/sql/rls_vle.sql index ebb9bc5e5..b2738a623 100644 --- a/regress/sql/rls_vle.sql +++ b/regress/sql/rls_vle.sql @@ -848,6 +848,31 @@ GRANT SELECT ON rls_vle."Node" TO rls_vle_user; DROP POLICY node_all ON rls_vle."Node"; ALTER TABLE rls_vle."Node" DISABLE ROW LEVEL SECURITY; +-- ============================================================================ +-- PART 11: No visible vertices. When a policy hides every vertex, a traversal +-- has nothing to traverse and returns no paths instead of an error. The +-- untyped MATCH still returns the hidden vertices from the parent table, so +-- each traversal starts from a vertex outside the visible graph. +-- ============================================================================ +ALTER TABLE rls_vle."Node" ENABLE ROW LEVEL SECURITY; +CREATE POLICY node_none ON rls_vle."Node" FOR SELECT USING (false); +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle', $$ + MATCH (n:Node) RETURN count(n) +$$) AS (count agtype); +SELECT * FROM cypher('rls_vle', $$ + MATCH (a)-[:LINK*1..3]->(b) RETURN b.name +$$) AS (name agtype); +SELECT * FROM cypher('rls_vle', $$ + MATCH (a)-[:LINK*0..1]->(b) RETURN count(*) +$$) AS (count agtype); +SELECT count(*) AS paths +FROM age_vle('"rls_vle"', NULL, NULL, + age_build_vle_match_edge('"LINK"', NULL), '1', '3', '1'); +RESET ROLE; +DROP POLICY node_none ON rls_vle."Node"; +ALTER TABLE rls_vle."Node" DISABLE ROW LEVEL SECURITY; + -- ============================================================================ -- cleanup -- ============================================================================ diff --git a/src/backend/utils/adt/age_vle.c b/src/backend/utils/adt/age_vle.c index d3f22de5d..c4dc7efdb 100644 --- a/src/backend/utils/adt/age_vle.c +++ b/src/backend/utils/adt/age_vle.c @@ -776,6 +776,18 @@ static VLE_local_context *build_local_vle_context(FunctionCallInfo fcinfo, ggctx = acquire_GRAPH_global_context(graph_name, graph_oid, query_owner, fcinfo->flinfo); + /* + * The graph can have no vertices the caller may see, for example when + * row-level security hides all of them. There is nothing to traverse, + * so there are no paths. + */ + if (peek_stack_head(get_graph_vertices(ggctx)) == NULL) + { + pfree_if_not_null(graph_name); + MemoryContextSwitchTo(oldctx); + return NULL; + } + /* allocate and initialize local VLE context */ vlelctx = palloc0(sizeof(VLE_local_context)); @@ -798,11 +810,6 @@ static VLE_local_context *build_local_vle_context(FunctionCallInfo fcinfo, /* initialize the next vertex, in this case the first */ vlelctx->next_vertex = peek_stack_head(get_graph_vertices(ggctx)); - /* if there isn't one, the graph is empty */ - if (vlelctx->next_vertex == NULL) - { - elog(ERROR, "age_vle: empty graph"); - } /* * Get the start vertex id - this is an optional parameter and determines * which path function is used. If a start vertex isn't provided, we From d205f266578853b0fd648ec70489bca8bef6cbbe Mon Sep 17 00:00:00 2001 From: John Gemignani Date: Thu, 8 Oct 2026 05:34:52 +0000 Subject: [PATCH 5/5] Refuse RLS graph loads during parallel operations The traversal graph loads RLS-protected labels through an SPI query, so the policies are evaluated in whichever process loads the graph. vertex_stats() and age_graph_stats() are parallel safe, so under a parallel plan a worker loaded the graph. A worker does not have all of the caller's session state; inet_client_addr(), for one, is NULL there. A policy that hid edges from a client connection therefore admitted them in the worker, and the statistics counted edges the caller could not see. Raise an error (SQLSTATE 25000) instead of loading an RLS-protected label in parallel mode. Roles the policies do not apply to, labels without RLS, serial plans and age.enforce_rls_in_traversal = off are unaffected. The function declarations stay parallel safe, so other queries keep their parallel plans. The new rls_vle part forces a parallel plan and checks that the restricted role is refused while the owner and a serial plan still get statistics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- regress/expected/rls_vle.out | 41 ++++++++++++++++++++++++ regress/sql/rls_vle.sql | 30 +++++++++++++++++ src/backend/utils/adt/age_global_graph.c | 21 ++++++++++++ 3 files changed, 92 insertions(+) diff --git a/regress/expected/rls_vle.out b/regress/expected/rls_vle.out index 3a6f72657..e567da5f5 100644 --- a/regress/expected/rls_vle.out +++ b/regress/expected/rls_vle.out @@ -1384,6 +1384,47 @@ RESET ROLE; DROP POLICY node_none ON rls_vle."Node"; ALTER TABLE rls_vle."Node" DISABLE ROW LEVEL SECURITY; -- ============================================================================ +-- PART 12: Parallel operations. The policies are evaluated by the process +-- that loads the graph, and a parallel worker lacks part of the caller's +-- session state, so loading an RLS-protected label during a parallel +-- operation is refused. Roles that bypass the policy, and serial plans, are +-- unaffected. +-- ============================================================================ +ALTER TABLE rls_vle."LINK" ENABLE ROW LEVEL SECURITY; +CREATE POLICY link_all ON rls_vle."LINK" FOR SELECT USING (true); +SET debug_parallel_query = on; +\set VERBOSITY terse +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle', $$ + MATCH (n:Node) RETURN vertex_stats(n) +$$) AS (stats agtype); +ERROR: cannot load row-level security protected graph data during a parallel operation +SELECT age_graph_stats('"rls_vle"'); +ERROR: cannot load row-level security protected graph data during a parallel operation +RESET ROLE; +\set VERBOSITY default +SELECT * FROM cypher('rls_vle', $$ + MATCH (n:Node {name: 'n1'}) WITH vertex_stats(n) AS s RETURN s.out_degree +$$) AS (out_degree agtype); + out_degree +------------ + 1 +(1 row) + +RESET debug_parallel_query; +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle', $$ + MATCH (n:Node {name: 'n1'}) WITH vertex_stats(n) AS s RETURN s.out_degree +$$) AS (out_degree agtype); + out_degree +------------ + 1 +(1 row) + +RESET ROLE; +DROP POLICY link_all ON rls_vle."LINK"; +ALTER TABLE rls_vle."LINK" DISABLE ROW LEVEL SECURITY; +-- ============================================================================ -- cleanup -- ============================================================================ SELECT drop_graph('rls_vle', true); diff --git a/regress/sql/rls_vle.sql b/regress/sql/rls_vle.sql index b2738a623..11e6de507 100644 --- a/regress/sql/rls_vle.sql +++ b/regress/sql/rls_vle.sql @@ -873,6 +873,36 @@ RESET ROLE; DROP POLICY node_none ON rls_vle."Node"; ALTER TABLE rls_vle."Node" DISABLE ROW LEVEL SECURITY; +-- ============================================================================ +-- PART 12: Parallel operations. The policies are evaluated by the process +-- that loads the graph, and a parallel worker lacks part of the caller's +-- session state, so loading an RLS-protected label during a parallel +-- operation is refused. Roles that bypass the policy, and serial plans, are +-- unaffected. +-- ============================================================================ +ALTER TABLE rls_vle."LINK" ENABLE ROW LEVEL SECURITY; +CREATE POLICY link_all ON rls_vle."LINK" FOR SELECT USING (true); +SET debug_parallel_query = on; +\set VERBOSITY terse +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle', $$ + MATCH (n:Node) RETURN vertex_stats(n) +$$) AS (stats agtype); +SELECT age_graph_stats('"rls_vle"'); +RESET ROLE; +\set VERBOSITY default +SELECT * FROM cypher('rls_vle', $$ + MATCH (n:Node {name: 'n1'}) WITH vertex_stats(n) AS s RETURN s.out_degree +$$) AS (out_degree agtype); +RESET debug_parallel_query; +SET ROLE rls_vle_user; +SELECT * FROM cypher('rls_vle', $$ + MATCH (n:Node {name: 'n1'}) WITH vertex_stats(n) AS s RETURN s.out_degree +$$) AS (out_degree agtype); +RESET ROLE; +DROP POLICY link_all ON rls_vle."LINK"; +ALTER TABLE rls_vle."LINK" DISABLE ROW LEVEL SECURITY; + -- ============================================================================ -- cleanup -- ============================================================================ diff --git a/src/backend/utils/adt/age_global_graph.c b/src/backend/utils/adt/age_global_graph.c index d7b861b23..fae12d794 100644 --- a/src/backend/utils/adt/age_global_graph.c +++ b/src/backend/utils/adt/age_global_graph.c @@ -278,6 +278,7 @@ static void load_GRAPH_global_hashtables(GRAPH_global_context *ggctx); static void load_vertex_hashtable(GRAPH_global_context *ggctx); static void load_edge_hashtable(GRAPH_global_context *ggctx); static void enforce_label_table_select_acl(Oid label_table_oid); +static void prevent_rls_load_in_parallel_mode(void); static void register_ggctx_security_callbacks(void); static void ggctx_security_relcache_callback(Datum arg, Oid relid); static void ggctx_security_syscache_callback(Datum arg, int cacheid, @@ -1370,6 +1371,24 @@ static void enforce_label_table_select_acl(Oid label_table_oid) } } +/* + * The RLS loaders evaluate the policies in whichever process loads the graph. + * A parallel worker does not have all of the caller's session state (for + * example, inet_client_addr() is NULL there), so a policy could admit rows in + * a worker that it hides from the caller. Refuse to load an RLS-protected label + * during a parallel operation. + */ +static void prevent_rls_load_in_parallel_mode(void) +{ + if (IsInParallelMode()) + { + ereport(ERROR, + (errcode(ERRCODE_INVALID_TRANSACTION_STATE), + errmsg("cannot load row-level security protected graph data during a parallel operation"), + errhint("Run the query without parallelism, for example with SET max_parallel_workers_per_gather = 0."))); + } +} + /* helper routine to load all vertices into the GRAPH global vertex hashtable */ static void load_vertex_hashtable(GRAPH_global_context *ggctx) { @@ -1434,6 +1453,7 @@ static void load_vertex_hashtable(GRAPH_global_context *ggctx) check_enable_rls(vertex_label_table_oid, InvalidOid, true) == RLS_ENABLED) { + prevent_rls_load_in_parallel_mode(); load_vertex_label_rls(ggctx, vertex_label_table_oid, vertex_label_name); /* @@ -1594,6 +1614,7 @@ static void load_edge_hashtable(GRAPH_global_context *ggctx) check_enable_rls(edge_label_table_oid, InvalidOid, true) == RLS_ENABLED) { + prevent_rls_load_in_parallel_mode(); load_edge_label_rls(ggctx, edge_label_table_oid, edge_label_name); ggctx->loaded_with_rls = true; continue;