blob: 1882e8cdab103fef3bc95d29c7a520d14f91387d [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
LOAD 'age';
SET search_path TO ag_catalog;
--
-- Row-level security enforcement during VLE / global-graph traversal
-- (controlled by age.enforce_rls_in_traversal, default on).
--
-- Chain: n1 -[LINK visible:true]-> n2 -[LINK visible:false]-> n3 -[LINK visible:true]-> n4
--
SELECT create_graph('rls_vle');
SELECT * FROM cypher('rls_vle', $$ CREATE (:Node {name: 'n1'}) $$) AS (a agtype);
SELECT * FROM cypher('rls_vle', $$ CREATE (:Node {name: 'n2'}) $$) AS (a agtype);
SELECT * FROM cypher('rls_vle', $$ CREATE (:Node {name: 'n3'}) $$) AS (a agtype);
SELECT * FROM cypher('rls_vle', $$ CREATE (:Node {name: 'n4'}) $$) AS (a agtype);
SELECT * FROM cypher('rls_vle', $$
MATCH (a:Node {name: 'n1'}), (b:Node {name: 'n2'})
CREATE (a)-[:LINK {visible: true}]->(b)
$$) AS (a agtype);
SELECT * FROM cypher('rls_vle', $$
MATCH (a:Node {name: 'n2'}), (b:Node {name: 'n3'})
CREATE (a)-[:LINK {visible: false}]->(b)
$$) AS (a agtype);
SELECT * FROM cypher('rls_vle', $$
MATCH (a:Node {name: 'n3'}), (b:Node {name: 'n4'})
CREATE (a)-[:LINK {visible: true}]->(b)
$$) AS (a agtype);
-- restricted role: SELECT on the graph + ag_catalog, nothing more
CREATE ROLE rls_vle_user LOGIN;
GRANT USAGE ON SCHEMA rls_vle TO rls_vle_user;
GRANT SELECT ON ALL TABLES IN SCHEMA rls_vle TO rls_vle_user;
GRANT USAGE ON SCHEMA ag_catalog TO rls_vle_user;
GRANT SELECT ON ALL TABLES IN SCHEMA ag_catalog TO rls_vle_user;
-- ============================================================================
-- PART 1: Baseline (superuser, RLS bypassed) -- VLE from n1 reaches n2, n3, n4.
-- ============================================================================
SELECT * FROM cypher('rls_vle', $$
MATCH (a:Node {name: 'n1'})-[:LINK*1..3]->(b) RETURN b.name
$$) AS (name agtype) ORDER BY name;
-- ============================================================================
-- PART 2: Edge-label RLS -- hide edges whose "visible" property is false.
-- ============================================================================
ALTER TABLE rls_vle."LINK" ENABLE ROW LEVEL SECURITY;
ALTER TABLE rls_vle."LINK" FORCE ROW LEVEL SECURITY;
CREATE POLICY link_visible ON rls_vle."LINK"
FOR SELECT USING ((properties->>'"visible"')::boolean = true);
-- Enforced (default): the restricted role reaches ONLY n2 (n2->n3 hidden).
SET ROLE rls_vle_user;
SHOW age.enforce_rls_in_traversal;
SELECT * FROM cypher('rls_vle', $$
MATCH (a:Node {name: 'n1'})-[:LINK*1..3]->(b) RETURN b.name
$$) AS (name agtype) ORDER BY name;
RESET ROLE;
-- Opt-out: with enforcement off, the legacy direct scan bypasses RLS, so the
-- restricted role again reaches n2, n3, n4.
SET age.enforce_rls_in_traversal = off;
SET ROLE rls_vle_user;
SELECT * FROM cypher('rls_vle', $$
MATCH (a:Node {name: 'n1'})-[:LINK*1..3]->(b) RETURN b.name
$$) AS (name agtype) ORDER BY name;
RESET ROLE;
RESET age.enforce_rls_in_traversal;
-- clear the edge policy for the next part
DROP POLICY link_visible ON rls_vle."LINK";
ALTER TABLE rls_vle."LINK" NO FORCE ROW LEVEL SECURITY;
ALTER TABLE rls_vle."LINK" DISABLE ROW LEVEL SECURITY;
-- ============================================================================
-- PART 3: Vertex-label RLS -- exercises the vertex loader and the dropping of
-- edges whose endpoint vertex is hidden (matches MATCH's inner-join behavior).
-- All edges are visible here; only vertices are filtered.
-- ============================================================================
ALTER TABLE rls_vle."Node" ENABLE ROW LEVEL SECURITY;
ALTER TABLE rls_vle."Node" FORCE ROW LEVEL SECURITY;
-- 3.1: hide the INTERMEDIATE vertex n2. Its edges are dropped, so the traversal
-- cannot pass through it -- expect no rows (and no "no vertex found" error).
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 (a:Node {name: 'n1'})-[:LINK*1..3]->(b) RETURN b.name
$$) AS (name agtype) ORDER BY name;
RESET ROLE;
-- 3.2: hide only the LEAF vertex n4. The edge into n4 is dropped; the traversal
-- still reaches n2 and n3.
DROP POLICY node_visible ON rls_vle."Node";
CREATE POLICY node_visible ON rls_vle."Node"
FOR SELECT USING ((properties->>'"name"') <> 'n4');
SET ROLE rls_vle_user;
SELECT * FROM cypher('rls_vle', $$
MATCH (a:Node {name: 'n1'})-[:LINK*1..3]->(b) RETURN b.name
$$) AS (name agtype) ORDER BY name;
RESET ROLE;
DROP POLICY node_visible ON rls_vle."Node";
ALTER TABLE rls_vle."Node" NO FORCE ROW LEVEL SECURITY;
ALTER TABLE rls_vle."Node" DISABLE ROW LEVEL SECURITY;
-- ============================================================================
-- PART 4: Per-role cache key -- two roles with different edge policies in one
-- session must see different results (the cache is rebuilt when the role
-- changes). user1 sees all edges; user2 sees none.
-- ============================================================================
CREATE ROLE rls_vle_user2 LOGIN;
GRANT USAGE ON SCHEMA rls_vle TO rls_vle_user2;
GRANT SELECT ON ALL TABLES IN SCHEMA rls_vle TO rls_vle_user2;
GRANT USAGE ON SCHEMA ag_catalog TO rls_vle_user2;
GRANT SELECT ON ALL TABLES IN SCHEMA ag_catalog TO rls_vle_user2;
ALTER TABLE rls_vle."LINK" ENABLE ROW LEVEL SECURITY;
ALTER TABLE rls_vle."LINK" FORCE ROW LEVEL SECURITY;
CREATE POLICY link_user1 ON rls_vle."LINK" FOR SELECT TO rls_vle_user USING (true);
CREATE POLICY link_user2 ON rls_vle."LINK" FOR SELECT TO rls_vle_user2 USING (false);
-- user1: all edges visible -> n2, n3, n4
SET ROLE rls_vle_user;
SELECT * FROM cypher('rls_vle', $$
MATCH (a:Node {name: 'n1'})-[:LINK*1..3]->(b) RETURN b.name
$$) AS (name agtype) ORDER BY name;
RESET ROLE;
-- user2: no edges visible -> empty (cache must not be reused from user1)
SET ROLE rls_vle_user2;
SELECT * FROM cypher('rls_vle', $$
MATCH (a:Node {name: 'n1'})-[:LINK*1..3]->(b) RETURN b.name
$$) AS (name agtype) ORDER BY name;
RESET ROLE;
-- tidy up Part 4's edge policies before the next parts
DROP POLICY link_user1 ON rls_vle."LINK";
DROP POLICY link_user2 ON rls_vle."LINK";
ALTER TABLE rls_vle."LINK" NO FORCE ROW LEVEL SECURITY;
ALTER TABLE rls_vle."LINK" DISABLE ROW LEVEL SECURITY;
-- ============================================================================
-- PART 5: SELECT-ACL enforcement -- the low-level loader must not bypass table
-- privileges, not only RLS policies. rls_vle_user3 can read every label table
-- EXCEPT the edge table "LINK".
-- ============================================================================
CREATE ROLE rls_vle_user3 LOGIN;
GRANT USAGE ON SCHEMA rls_vle TO rls_vle_user3;
GRANT SELECT ON ALL TABLES IN SCHEMA rls_vle TO rls_vle_user3;
GRANT USAGE ON SCHEMA ag_catalog TO rls_vle_user3;
GRANT SELECT ON ALL TABLES IN SCHEMA ag_catalog TO rls_vle_user3;
REVOKE SELECT ON rls_vle."LINK" FROM rls_vle_user3;
-- Enforced (default): traversal fails closed with permission denied on "LINK".
-- The start-node MATCH on "Node" is allowed; only the edge load is denied.
SET ROLE rls_vle_user3;
SELECT * FROM cypher('rls_vle', $$
MATCH (a:Node {name: 'n1'})-[:LINK*1..3]->(b) RETURN b.name
$$) AS (name agtype) ORDER BY name;
RESET ROLE;
-- Opt-out: with enforcement off, the legacy direct scan bypasses the ACL, so
-- the same role again reaches n2, n3, n4 (the legacy behavior).
SET age.enforce_rls_in_traversal = off;
SET ROLE rls_vle_user3;
SELECT * FROM cypher('rls_vle', $$
MATCH (a:Node {name: 'n1'})-[:LINK*1..3]->(b) RETURN b.name
$$) AS (name agtype) ORDER BY name;
RESET ROLE;
RESET age.enforce_rls_in_traversal;
-- ============================================================================
-- PART 6: Session-dependent RLS policy -- a policy whose result depends on a
-- session GUC (current_setting) must be re-evaluated every statement, so an
-- RLS-enforced cache is never reused across statements even when the role, the
-- enforcement GUC, and the snapshot are unchanged.
-- ============================================================================
ALTER TABLE rls_vle."LINK" ENABLE ROW LEVEL SECURITY;
ALTER TABLE rls_vle."LINK" FORCE ROW LEVEL SECURITY;
CREATE POLICY link_by_setting ON rls_vle."LINK"
FOR SELECT USING (current_setting('rls_vle.mode', true) = 'all');
SET ROLE rls_vle_user;
-- mode=all: every edge visible -> n2, n3, n4
SET rls_vle.mode = 'all';
SELECT * FROM cypher('rls_vle', $$
MATCH (a:Node {name: 'n1'})-[:LINK*1..3]->(b) RETURN b.name
$$) AS (name agtype) ORDER BY name;
-- mode changed in the same session (same role / GUC / snapshot): the cache must
-- be rebuilt, so now no edge is visible -> empty.
SET rls_vle.mode = 'none';
SELECT * FROM cypher('rls_vle', $$
MATCH (a:Node {name: 'n1'})-[:LINK*1..3]->(b) RETURN b.name
$$) AS (name agtype) ORDER BY name;
RESET rls_vle.mode;
RESET ROLE;
DROP POLICY link_by_setting ON rls_vle."LINK";
ALTER TABLE rls_vle."LINK" NO FORCE ROW LEVEL SECURITY;
ALTER TABLE rls_vle."LINK" DISABLE ROW LEVEL SECURITY;
-- ============================================================================
-- PART 7: ACL-metadata cache invalidation -- an enforced (non-RLS) direct-scan
-- cache must not survive a REVOKE on a label table. In DSM/SHMEM mode the graph
-- version fast path only tracks data changes, so the cache is invalidated via
-- catalog-invalidation callbacks; without them the REVOKE'd rows stay readable.
-- Uses a fresh graph with no RLS so the direct-scan (ACL-only) path is taken.
-- ============================================================================
SET age.enforce_rls_in_traversal = on;
SELECT create_graph('rls_acl');
SELECT * FROM cypher('rls_acl', $$
CREATE (:Node {name: 'a'})-[:LINK]->(:Node {name: 'b'})-[:LINK]->(:Node {name: 'c'})
$$) AS (x agtype);
GRANT USAGE ON SCHEMA rls_acl TO rls_vle_user;
GRANT SELECT ON ALL TABLES IN SCHEMA rls_acl TO rls_vle_user;
-- build the enforced cache: rls_vle_user has SELECT -> reaches b, c
SET ROLE rls_vle_user;
SELECT * FROM cypher('rls_acl', $$
MATCH (a:Node {name: 'a'})-[:LINK*1..2]->(x) RETURN x.name
$$) AS (name agtype) ORDER BY name;
RESET ROLE;
-- REVOKE SELECT on the edge label -- no graph data change, version counter same
REVOKE SELECT ON rls_acl."LINK" FROM rls_vle_user;
-- the cached traversal must now be rebuilt and denied
SET ROLE rls_vle_user;
SELECT * FROM cypher('rls_acl', $$
MATCH (a:Node {name: 'a'})-[:LINK*1..2]->(x) RETURN x.name
$$) AS (name agtype) ORDER BY name;
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
-- loads the graph again, as a separate scan would evaluate the policy again,
-- whichever functions the policy calls.
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;
-- a policy that calls only IMMUTABLE functions, alone and with current_user:
-- each traversal still loads the graph. The counter's 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;
-- a policy that reads the search path, and one that calls a STABLE function
-- in a subquery: 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);
-- ============================================================================
-- 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;
-- ============================================================================
-- 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;
-- ============================================================================
-- 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;
-- ============================================================================
-- 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
-- ============================================================================
SELECT drop_graph('rls_vle', true);
DROP OWNED BY rls_vle_user, rls_vle_user2, rls_vle_user3;
DROP ROLE rls_vle_user, rls_vle_user2, rls_vle_user3;