You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@age.apache.org by mt...@apache.org on 2026/02/03 13:06:37 UTC
(age) 25/26: Add RLS support and fix permission checks (#2309)
This is an automated email from the ASF dual-hosted git repository.
mtaha pushed a commit to branch PG17
in repository https://gitbox.apache.org/repos/asf/age.git
commit 404c0f4928bca260364cc38c8f37a121e0f0413e
Author: Muhammad Taha Naveed <mt...@apache.org>
AuthorDate: Tue Jan 20 23:44:19 2026 +0500
Add RLS support and fix permission checks (#2309)
- Previously, age only set ACL_SELECT and ACL_INSERT in RTEPermissionInfo,
bypassing pg's privilege checking for DELETE and UPDATE operations.
- Additionally, RLS policies were not enforced because AGE uses CMD_SELECT
for all Cypher queries, causing the rewriter to skip RLS policy application.
Permission fixes:
- Add ACL_DELETE permission flag for DELETE clause operations
- Add ACL_UPDATE permission flag for SET/REMOVE clause operations
- Recursively search RTEs including subqueries for permission info
RLS support:
- Implemented at executor level because age transforms all cypher
queries to CMD_SELECT, so pg's rewriter never adds RLS
policies for INSERT/UPDATE/DELETE operations. There isnt an
appropriate rewriter hook to modify this behavior, so we do it
in executor instead.
- Add setup_wcos() to apply WITH CHECK policies at execution time
for CREATE, SET, and MERGE operations
- Add setup_security_quals() and check_security_quals() to apply
USING policies for UPDATE and DELETE operations
- USING policies silently filter rows (matching pg behavior)
- WITH CHECK policies raise errors on violation
- DETACH DELETE raises error if edge RLS blocks deletion to prevent
dangling edges
- Add permission checks and rls in startnode/endnode functions
- Add regression tests
Assisted-by AI
---
Makefile | 3 +-
regress/expected/security.out | 1657 ++++++++++++++++++++++++++++++++++
regress/sql/security.sql | 1451 +++++++++++++++++++++++++++++
src/backend/executor/cypher_create.c | 8 +
src/backend/executor/cypher_delete.c | 99 +-
src/backend/executor/cypher_merge.c | 10 +-
src/backend/executor/cypher_set.c | 89 +-
src/backend/executor/cypher_utils.c | 780 ++++++++++++++++
src/backend/parser/cypher_clause.c | 103 +++
src/backend/utils/adt/agtype.c | 27 +-
src/include/executor/cypher_utils.h | 22 +
11 files changed, 4243 insertions(+), 6 deletions(-)
diff --git a/Makefile b/Makefile
index a8faa2bb..2d291257 100644
--- a/Makefile
+++ b/Makefile
@@ -112,7 +112,8 @@ REGRESS = scan \
jsonb_operators \
list_comprehension \
map_projection \
- direct_field_access
+ direct_field_access \
+ security
ifneq ($(EXTRA_TESTS),)
REGRESS += $(EXTRA_TESTS)
diff --git a/regress/expected/security.out b/regress/expected/security.out
new file mode 100644
index 00000000..59e58cb0
--- /dev/null
+++ b/regress/expected/security.out
@@ -0,0 +1,1657 @@
+/*
+ * 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;
+--
+-- Test Privileges
+--
+--
+-- Setup: Create test graph and data as superuser
+--
+SELECT create_graph('security_test');
+NOTICE: graph "security_test" has been created
+ create_graph
+--------------
+
+(1 row)
+
+-- Create test vertices
+SELECT * FROM cypher('security_test', $$
+ CREATE (:Person {name: 'Alice', age: 30})
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+SELECT * FROM cypher('security_test', $$
+ CREATE (:Person {name: 'Bob', age: 25})
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+SELECT * FROM cypher('security_test', $$
+ CREATE (:Document {title: 'Secret', content: 'classified'})
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+-- Create test edges
+SELECT * FROM cypher('security_test', $$
+ MATCH (a:Person {name: 'Alice'}), (b:Person {name: 'Bob'})
+ CREATE (a)-[:KNOWS {since: 2020}]->(b)
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+SELECT * FROM cypher('security_test', $$
+ MATCH (a:Person {name: 'Alice'}), (d:Document)
+ CREATE (a)-[:OWNS]->(d)
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+--
+-- Create test roles with different permission levels
+--
+-- Role with only SELECT (read-only)
+CREATE ROLE security_test_readonly LOGIN;
+GRANT USAGE ON SCHEMA security_test TO security_test_readonly;
+GRANT SELECT ON ALL TABLES IN SCHEMA security_test TO security_test_readonly;
+GRANT USAGE ON SCHEMA ag_catalog TO security_test_readonly;
+GRANT SELECT ON ALL TABLES IN SCHEMA ag_catalog TO security_test_readonly;
+-- Role with SELECT and INSERT
+CREATE ROLE security_test_insert LOGIN;
+GRANT USAGE ON SCHEMA security_test TO security_test_insert;
+GRANT SELECT, INSERT ON ALL TABLES IN SCHEMA security_test TO security_test_insert;
+GRANT USAGE ON SCHEMA ag_catalog TO security_test_insert;
+GRANT SELECT ON ALL TABLES IN SCHEMA ag_catalog TO security_test_insert;
+-- Grant sequence usage for ID generation
+GRANT USAGE ON ALL SEQUENCES IN SCHEMA security_test TO security_test_insert;
+-- Role with SELECT and UPDATE
+CREATE ROLE security_test_update LOGIN;
+GRANT USAGE ON SCHEMA security_test TO security_test_update;
+GRANT SELECT, UPDATE ON ALL TABLES IN SCHEMA security_test TO security_test_update;
+GRANT USAGE ON SCHEMA ag_catalog TO security_test_update;
+GRANT SELECT ON ALL TABLES IN SCHEMA ag_catalog TO security_test_update;
+-- Role with SELECT and DELETE
+CREATE ROLE security_test_delete LOGIN;
+GRANT USAGE ON SCHEMA security_test TO security_test_delete;
+GRANT SELECT, DELETE ON ALL TABLES IN SCHEMA security_test TO security_test_delete;
+GRANT USAGE ON SCHEMA ag_catalog TO security_test_delete;
+GRANT SELECT ON ALL TABLES IN SCHEMA ag_catalog TO security_test_delete;
+CREATE ROLE security_test_detach_delete LOGIN;
+GRANT USAGE ON SCHEMA security_test TO security_test_detach_delete;
+GRANT SELECT ON ALL TABLES IN SCHEMA security_test TO security_test_detach_delete;
+GRANT DELETE ON security_test."Person" TO security_test_detach_delete;
+GRANT USAGE ON SCHEMA ag_catalog TO security_test_detach_delete;
+GRANT SELECT ON ALL TABLES IN SCHEMA ag_catalog TO security_test_detach_delete;
+-- Role with all permissions
+CREATE ROLE security_test_full LOGIN;
+GRANT USAGE ON SCHEMA security_test TO security_test_full;
+GRANT ALL ON ALL TABLES IN SCHEMA security_test TO security_test_full;
+GRANT USAGE ON SCHEMA ag_catalog TO security_test_full;
+GRANT SELECT ON ALL TABLES IN SCHEMA ag_catalog TO security_test_full;
+GRANT USAGE ON ALL SEQUENCES IN SCHEMA security_test TO security_test_full;
+-- Role with NO SELECT on graph tables (to test read failures)
+CREATE ROLE security_test_noread LOGIN;
+GRANT USAGE ON SCHEMA security_test TO security_test_noread;
+GRANT USAGE ON SCHEMA ag_catalog TO security_test_noread;
+GRANT SELECT ON ALL TABLES IN SCHEMA ag_catalog TO security_test_noread;
+-- No SELECT on security_test tables
+-- ============================================================================
+-- PART 1: SELECT Permission Tests - Failure Cases (No Read Permission)
+-- ============================================================================
+SET ROLE security_test_noread;
+-- Test: MATCH on vertices should fail without SELECT permission
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person) RETURN p.name
+$$) AS (name agtype);
+ERROR: permission denied for table Person
+-- Test: MATCH on edges should fail without SELECT permission
+SELECT * FROM cypher('security_test', $$
+ MATCH ()-[k:KNOWS]->() RETURN k
+$$) AS (k agtype);
+ERROR: permission denied for table _ag_label_vertex
+-- Test: MATCH with path should fail
+SELECT * FROM cypher('security_test', $$
+ MATCH (a)-[e]->(b) RETURN a, e, b
+$$) AS (a agtype, e agtype, b agtype);
+ERROR: permission denied for table _ag_label_vertex
+RESET ROLE;
+-- Create role with SELECT only on base label tables, not child labels
+-- NOTE: PostgreSQL inheritance allows access to child table rows when querying
+-- through a parent table. This is expected behavior - SELECT on _ag_label_vertex
+-- allows reading all vertices (including Person, Document) via inheritance.
+CREATE ROLE security_test_base_only LOGIN;
+GRANT USAGE ON SCHEMA security_test TO security_test_base_only;
+GRANT USAGE ON SCHEMA ag_catalog TO security_test_base_only;
+GRANT SELECT ON ALL TABLES IN SCHEMA ag_catalog TO security_test_base_only;
+-- Only grant SELECT on base tables, NOT on Person, Document, KNOWS, OWNS
+GRANT SELECT ON security_test._ag_label_vertex TO security_test_base_only;
+GRANT SELECT ON security_test._ag_label_edge TO security_test_base_only;
+SET ROLE security_test_base_only;
+-- Test: MATCH (n) succeeds because PostgreSQL inheritance allows access to child rows
+-- when querying through parent table. Permission on _ag_label_vertex grants read
+-- access to all vertices via inheritance hierarchy.
+SELECT * FROM cypher('security_test', $$
+ MATCH (n) RETURN n
+$$) AS (n agtype);
+ n
+-------------------------------------------------------------------------------------------------------------------
+ {"id": 844424930131969, "label": "Person", "properties": {"age": 30, "name": "Alice"}}::vertex
+ {"id": 844424930131970, "label": "Person", "properties": {"age": 25, "name": "Bob"}}::vertex
+ {"id": 1125899906842625, "label": "Document", "properties": {"title": "Secret", "content": "classified"}}::vertex
+(3 rows)
+
+-- Test: MATCH ()-[e]->() succeeds via inheritance (same reason as above)
+SELECT * FROM cypher('security_test', $$
+ MATCH ()-[e]->() RETURN e
+$$) AS (e agtype);
+ e
+-----------------------------------------------------------------------------------------------------------------------------------------
+ {"id": 1407374883553281, "label": "KNOWS", "end_id": 844424930131970, "start_id": 844424930131969, "properties": {"since": 2020}}::edge
+ {"id": 1688849860263937, "label": "OWNS", "end_id": 1125899906842625, "start_id": 844424930131969, "properties": {}}::edge
+(2 rows)
+
+-- ============================================================================
+-- PART 2: SELECT Permission Tests - Success Cases (Read-Only Role)
+-- ============================================================================
+RESET ROLE;
+SET ROLE security_test_readonly;
+-- Test: MATCH should succeed with SELECT permission
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person) RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+ name
+---------
+ "Alice"
+ "Bob"
+(2 rows)
+
+-- Test: MATCH with edges should succeed
+SELECT * FROM cypher('security_test', $$
+ MATCH (a:Person)-[k:KNOWS]->(b:Person)
+ RETURN a.name, b.name
+$$) AS (a agtype, b agtype);
+ a | b
+---------+-------
+ "Alice" | "Bob"
+(1 row)
+
+-- Test: MATCH across multiple labels should succeed
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person)-[:OWNS]->(d:Document)
+ RETURN p.name, d.title
+$$) AS (person agtype, doc agtype);
+ person | doc
+---------+----------
+ "Alice" | "Secret"
+(1 row)
+
+-- ============================================================================
+-- PART 3: INSERT Permission Tests (CREATE clause)
+-- ============================================================================
+-- Test: CREATE should fail with only SELECT permission
+SELECT * FROM cypher('security_test', $$
+ CREATE (:Person {name: 'Charlie'})
+$$) AS (a agtype);
+ERROR: permission denied for table Person
+-- Test: CREATE edge should fail
+SELECT * FROM cypher('security_test', $$
+ MATCH (a:Person {name: 'Alice'}), (b:Person {name: 'Bob'})
+ CREATE (a)-[:FRIENDS]->(b)
+$$) AS (a agtype);
+ERROR: permission denied for schema security_test
+LINE 1: SELECT * FROM cypher('security_test', $$
+ ^
+RESET ROLE;
+SET ROLE security_test_insert;
+-- Test: CREATE vertex should succeed with INSERT permission
+SELECT * FROM cypher('security_test', $$
+ CREATE (:Person {name: 'Charlie', age: 35})
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+-- Test: CREATE edge should succeed with INSERT permission
+SELECT * FROM cypher('security_test', $$
+ MATCH (a:Person {name: 'Charlie'}), (b:Person {name: 'Alice'})
+ CREATE (a)-[:KNOWS {since: 2023}]->(b)
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+-- Verify the inserts worked
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Charlie'}) RETURN p.name, p.age
+$$) AS (name agtype, age agtype);
+ name | age
+-----------+-----
+ "Charlie" | 35
+(1 row)
+
+-- ============================================================================
+-- PART 4: UPDATE Permission Tests (SET clause)
+-- ============================================================================
+RESET ROLE;
+SET ROLE security_test_readonly;
+-- Test: SET should fail with only SELECT permission
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Alice'})
+ SET p.age = 31
+ RETURN p
+$$) AS (p agtype);
+ERROR: permission denied for table Person
+-- Test: SET on edge should fail
+SELECT * FROM cypher('security_test', $$
+ MATCH ()-[k:KNOWS]->()
+ SET k.since = 2021
+ RETURN k
+$$) AS (k agtype);
+ERROR: permission denied for table KNOWS
+RESET ROLE;
+SET ROLE security_test_update;
+-- Test: SET should succeed with UPDATE permission
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Alice'})
+ SET p.age = 31
+ RETURN p.name, p.age
+$$) AS (name agtype, age agtype);
+ name | age
+---------+-----
+ "Alice" | 31
+(1 row)
+
+-- Test: SET on edge should succeed
+SELECT * FROM cypher('security_test', $$
+ MATCH (a:Person {name: 'Alice'})-[k:KNOWS]->(b:Person {name: 'Bob'})
+ SET k.since = 2019
+ RETURN k.since
+$$) AS (since agtype);
+ since
+-------
+ 2019
+(1 row)
+
+-- Test: SET with map update should succeed
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Bob'})
+ SET p += {hobby: 'reading'}
+ RETURN p.name, p.hobby
+$$) AS (name agtype, hobby agtype);
+ name | hobby
+-------+-----------
+ "Bob" | "reading"
+(1 row)
+
+-- ============================================================================
+-- PART 5: UPDATE Permission Tests (REMOVE clause)
+-- ============================================================================
+RESET ROLE;
+SET ROLE security_test_readonly;
+-- Test: REMOVE should fail with only SELECT permission
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Bob'})
+ REMOVE p.hobby
+ RETURN p
+$$) AS (p agtype);
+ERROR: permission denied for table Person
+RESET ROLE;
+SET ROLE security_test_update;
+-- Test: REMOVE should succeed with UPDATE permission
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Bob'})
+ REMOVE p.hobby
+ RETURN p.name, p.hobby
+$$) AS (name agtype, hobby agtype);
+ name | hobby
+-------+-------
+ "Bob" |
+(1 row)
+
+-- ============================================================================
+-- PART 6: DELETE Permission Tests
+-- ============================================================================
+RESET ROLE;
+SET ROLE security_test_readonly;
+-- Test: DELETE should fail with only SELECT permission
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Charlie'})
+ DELETE p
+$$) AS (a agtype);
+ERROR: permission denied for table Person
+RESET ROLE;
+SET ROLE security_test_update;
+-- Test: DELETE should fail with only UPDATE permission (need DELETE)
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Charlie'})
+ DELETE p
+$$) AS (a agtype);
+ERROR: permission denied for table Person
+RESET ROLE;
+SET ROLE security_test_delete;
+-- Test: DELETE vertex should succeed with DELETE permission
+-- First delete the edge connected to Charlie
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Charlie'})-[k:KNOWS]->()
+ DELETE k
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+-- Now delete the vertex
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Charlie'})
+ DELETE p
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+-- Verify deletion
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Charlie'}) RETURN p
+$$) AS (p agtype);
+ p
+---
+(0 rows)
+
+-- ============================================================================
+-- PART 7: DETACH DELETE Tests
+-- ============================================================================
+RESET ROLE;
+-- Create a new vertex with edge for DETACH DELETE test
+SELECT * FROM cypher('security_test', $$
+ CREATE (:Person {name: 'Dave', age: 40})
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+SELECT * FROM cypher('security_test', $$
+ MATCH (a:Person {name: 'Alice'}), (d:Person {name: 'Dave'})
+ CREATE (a)-[:KNOWS {since: 2022}]->(d)
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+SET ROLE security_test_detach_delete;
+-- Test: DETACH DELETE should fail without DELETE on edge table
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Dave'})
+ DETACH DELETE p
+$$) AS (a agtype);
+ERROR: permission denied for table KNOWS
+RESET ROLE;
+GRANT DELETE ON security_test."KNOWS" TO security_test_detach_delete;
+SET ROLE security_test_detach_delete;
+-- Test: DETACH DELETE should succeed now when user has DELETE on both vertex and edge tables
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Dave'})
+ DETACH DELETE p
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+-- Verify deletion
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Dave'}) RETURN p
+$$) AS (p agtype);
+ p
+---
+(0 rows)
+
+-- ============================================================================
+-- PART 8: MERGE Permission Tests
+-- ============================================================================
+RESET ROLE;
+SET ROLE security_test_readonly;
+-- Test: MERGE that would create should fail without INSERT
+SELECT * FROM cypher('security_test', $$
+ MERGE (p:Person {name: 'Eve'})
+ RETURN p
+$$) AS (p agtype);
+ERROR: permission denied for table Person
+RESET ROLE;
+SET ROLE security_test_insert;
+-- Test: MERGE that creates should succeed with INSERT permission
+SELECT * FROM cypher('security_test', $$
+ MERGE (p:Person {name: 'Eve', age: 28})
+ RETURN p.name, p.age
+$$) AS (name agtype, age agtype);
+ name | age
+-------+-----
+ "Eve" | 28
+(1 row)
+
+-- Test: MERGE that matches existing should succeed (only needs SELECT)
+SELECT * FROM cypher('security_test', $$
+ MERGE (p:Person {name: 'Eve'})
+ RETURN p.name
+$$) AS (name agtype);
+ name
+-------
+ "Eve"
+(1 row)
+
+-- ============================================================================
+-- PART 9: Full Permission Role Tests
+-- ============================================================================
+RESET ROLE;
+SET ROLE security_test_full;
+-- Full permission role should be able to do everything
+SELECT * FROM cypher('security_test', $$
+ CREATE (:Person {name: 'Frank', age: 50})
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Frank'})
+ SET p.age = 51
+ RETURN p.name, p.age
+$$) AS (name agtype, age agtype);
+ name | age
+---------+-----
+ "Frank" | 51
+(1 row)
+
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Frank'})
+ DELETE p
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+-- ============================================================================
+-- PART 10: Permission on Specific Labels
+-- ============================================================================
+RESET ROLE;
+-- Create a role with permission only on Person label, not Document
+CREATE ROLE security_test_person_only LOGIN;
+GRANT USAGE ON SCHEMA security_test TO security_test_person_only;
+GRANT USAGE ON SCHEMA ag_catalog TO security_test_person_only;
+GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA ag_catalog TO security_test_person_only;
+GRANT SELECT ON ALL TABLES IN SCHEMA ag_catalog TO security_test_person_only;
+-- Only grant permissions on Person table
+GRANT SELECT, INSERT, UPDATE, DELETE ON security_test."Person" TO security_test_person_only;
+GRANT SELECT ON security_test."KNOWS" TO security_test_person_only;
+GRANT SELECT ON security_test._ag_label_vertex TO security_test_person_only;
+GRANT SELECT ON security_test._ag_label_edge TO security_test_person_only;
+GRANT USAGE ON ALL SEQUENCES IN SCHEMA security_test TO security_test_person_only;
+SET ROLE security_test_person_only;
+-- Test: Operations on Person should succeed
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Alice'}) RETURN p.name
+$$) AS (name agtype);
+ name
+---------
+ "Alice"
+(1 row)
+
+-- Test: SELECT on Document should fail (no permission)
+SELECT * FROM cypher('security_test', $$
+ MATCH (d:Document) RETURN d.title
+$$) AS (title agtype);
+ERROR: permission denied for table Document
+-- Test: CREATE Document should fail (no permission on Document table)
+SELECT * FROM cypher('security_test', $$
+ CREATE (:Document {title: 'New Doc'})
+$$) AS (a agtype);
+ERROR: permission denied for table Document
+-- ============================================================================
+-- PART 11: Function EXECUTE Permission Tests
+-- ============================================================================
+RESET ROLE;
+-- Create role with no function execute permissions
+CREATE ROLE security_test_noexec LOGIN;
+GRANT USAGE ON SCHEMA security_test TO security_test_noexec;
+GRANT USAGE ON SCHEMA ag_catalog TO security_test_noexec;
+-- Revoke execute from PUBLIC on functions we want to test
+REVOKE EXECUTE ON FUNCTION ag_catalog.create_graph(name) FROM PUBLIC;
+REVOKE EXECUTE ON FUNCTION ag_catalog.drop_graph(name, boolean) FROM PUBLIC;
+REVOKE EXECUTE ON FUNCTION ag_catalog.create_vlabel(cstring, cstring) FROM PUBLIC;
+REVOKE EXECUTE ON FUNCTION ag_catalog.create_elabel(cstring, cstring) FROM PUBLIC;
+SET ROLE security_test_noexec;
+-- Test: create_graph should fail without EXECUTE permission
+SELECT create_graph('unauthorized_graph');
+ERROR: permission denied for function create_graph
+-- Test: drop_graph should fail without EXECUTE permission
+SELECT drop_graph('security_test', true);
+ERROR: permission denied for function drop_graph
+-- Test: create_vlabel should fail without EXECUTE permission
+SELECT create_vlabel('security_test', 'NewLabel');
+ERROR: permission denied for function create_vlabel
+-- Test: create_elabel should fail without EXECUTE permission
+SELECT create_elabel('security_test', 'NewEdge');
+ERROR: permission denied for function create_elabel
+RESET ROLE;
+-- Grant execute on specific function and test
+GRANT EXECUTE ON FUNCTION ag_catalog.create_vlabel(cstring, cstring) TO security_test_noexec;
+SET ROLE security_test_noexec;
+-- Test: create_vlabel should now get past execute check (will fail on schema permission instead)
+SELECT create_vlabel('security_test', 'TestLabel');
+ERROR: permission denied for schema security_test
+-- Test: create_graph should still fail with execute permission denied
+SELECT create_graph('unauthorized_graph');
+ERROR: permission denied for function create_graph
+RESET ROLE;
+-- Restore execute permissions to PUBLIC
+GRANT EXECUTE ON FUNCTION ag_catalog.create_graph(name) TO PUBLIC;
+GRANT EXECUTE ON FUNCTION ag_catalog.drop_graph(name, boolean) TO PUBLIC;
+GRANT EXECUTE ON FUNCTION ag_catalog.create_vlabel(cstring, cstring) TO PUBLIC;
+GRANT EXECUTE ON FUNCTION ag_catalog.create_elabel(cstring, cstring) TO PUBLIC;
+-- ============================================================================
+-- PART 12: startNode/endNode Permission Tests
+-- ============================================================================
+-- Create role with SELECT on base tables but NOT on Person label
+CREATE ROLE security_test_edge_only LOGIN;
+GRANT USAGE ON SCHEMA security_test TO security_test_edge_only;
+GRANT USAGE ON SCHEMA ag_catalog TO security_test_edge_only;
+GRANT SELECT ON ALL TABLES IN SCHEMA ag_catalog TO security_test_edge_only;
+GRANT SELECT ON security_test."KNOWS" TO security_test_edge_only;
+GRANT SELECT ON security_test._ag_label_edge TO security_test_edge_only;
+GRANT SELECT ON security_test._ag_label_vertex TO security_test_edge_only;
+-- Note: NOT granting SELECT on security_test."Person"
+SET ROLE security_test_edge_only;
+-- Test: endNode fails without SELECT on Person table
+SELECT * FROM cypher('security_test', $$
+ MATCH ()-[e:KNOWS]->()
+ RETURN endNode(e)
+$$) AS (end_vertex agtype);
+ERROR: permission denied for table Person
+-- Test: startNode fails without SELECT on Person table
+SELECT * FROM cypher('security_test', $$
+ MATCH ()-[e:KNOWS]->()
+ RETURN startNode(e)
+$$) AS (start_vertex agtype);
+ERROR: permission denied for table Person
+RESET ROLE;
+-- Grant SELECT on Person and verify success
+GRANT SELECT ON security_test."Person" TO security_test_edge_only;
+SET ROLE security_test_edge_only;
+-- Test: Should now succeed with SELECT permission
+SELECT * FROM cypher('security_test', $$
+ MATCH ()-[e:KNOWS]->()
+ RETURN startNode(e).name, endNode(e).name
+$$) AS (start_name agtype, end_name agtype);
+ start_name | end_name
+------------+----------
+ "Alice" | "Bob"
+(1 row)
+
+RESET ROLE;
+-- ============================================================================
+-- Cleanup
+-- ============================================================================
+RESET ROLE;
+-- Drop all owned objects and privileges for each role, then drop the role
+DROP OWNED BY security_test_noread CASCADE;
+DROP ROLE security_test_noread;
+DROP OWNED BY security_test_base_only CASCADE;
+DROP ROLE security_test_base_only;
+DROP OWNED BY security_test_readonly CASCADE;
+DROP ROLE security_test_readonly;
+DROP OWNED BY security_test_insert CASCADE;
+DROP ROLE security_test_insert;
+DROP OWNED BY security_test_update CASCADE;
+DROP ROLE security_test_update;
+DROP OWNED BY security_test_delete CASCADE;
+DROP ROLE security_test_delete;
+DROP OWNED BY security_test_detach_delete CASCADE;
+DROP ROLE security_test_detach_delete;
+DROP OWNED BY security_test_full CASCADE;
+DROP ROLE security_test_full;
+DROP OWNED BY security_test_person_only CASCADE;
+DROP ROLE security_test_person_only;
+DROP OWNED BY security_test_noexec CASCADE;
+DROP ROLE security_test_noexec;
+DROP OWNED BY security_test_edge_only CASCADE;
+DROP ROLE security_test_edge_only;
+-- Drop test graph
+SELECT drop_graph('security_test', true);
+NOTICE: drop cascades to 6 other objects
+DETAIL: drop cascades to table security_test._ag_label_vertex
+drop cascades to table security_test._ag_label_edge
+drop cascades to table security_test."Person"
+drop cascades to table security_test."Document"
+drop cascades to table security_test."KNOWS"
+drop cascades to table security_test."OWNS"
+NOTICE: graph "security_test" has been dropped
+ drop_graph
+------------
+
+(1 row)
+
+--
+-- Row-Level Security (RLS) Tests
+--
+--
+-- Setup: Create test graph, data and roles for RLS tests
+--
+SELECT create_graph('rls_graph');
+NOTICE: graph "rls_graph" has been created
+ create_graph
+--------------
+
+(1 row)
+
+-- Create test roles
+CREATE ROLE rls_user1 LOGIN;
+CREATE ROLE rls_user2 LOGIN;
+CREATE ROLE rls_admin LOGIN BYPASSRLS; -- Role that bypasses RLS
+-- Create base test data FIRST (as superuser) - this creates the label tables
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Person {name: 'Alice', owner: 'rls_user1', department: 'Engineering', level: 1})
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Person {name: 'Bob', owner: 'rls_user2', department: 'Engineering', level: 2})
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Person {name: 'Charlie', owner: 'rls_user1', department: 'Sales', level: 1})
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Person {name: 'Diana', owner: 'rls_user2', department: 'Sales', level: 3})
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+-- Create a second vertex label for multi-label tests
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Document {title: 'Public Doc', classification: 'public', owner: 'rls_user1'})
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Document {title: 'Secret Doc', classification: 'secret', owner: 'rls_user2'})
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+-- Create edges
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a:Person {name: 'Alice'}), (b:Person {name: 'Bob'})
+ CREATE (a)-[:KNOWS {since: 2020, strength: 'weak'}]->(b)
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a:Person {name: 'Charlie'}), (b:Person {name: 'Diana'})
+ CREATE (a)-[:KNOWS {since: 2021, strength: 'strong'}]->(b)
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a:Person {name: 'Alice'}), (b:Person {name: 'Charlie'})
+ CREATE (a)-[:KNOWS {since: 2022, strength: 'strong'}]->(b)
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a:Person {name: 'Alice'}), (d:Document {title: 'Public Doc'})
+ CREATE (a)-[:AUTHORED]->(d)
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+-- Grant permissions AFTER creating tables (so Person, Document, KNOWS, AUTHORED exist)
+GRANT USAGE ON SCHEMA rls_graph TO rls_user1, rls_user2, rls_admin;
+GRANT ALL ON ALL TABLES IN SCHEMA rls_graph TO rls_user1, rls_user2, rls_admin;
+GRANT USAGE ON SCHEMA ag_catalog TO rls_user1, rls_user2, rls_admin;
+GRANT USAGE ON ALL SEQUENCES IN SCHEMA rls_graph TO rls_user1, rls_user2, rls_admin;
+-- ============================================================================
+-- PART 1: Vertex SELECT Policies (USING clause)
+-- ============================================================================
+-- Enable RLS on Person label
+ALTER TABLE rls_graph."Person" ENABLE ROW LEVEL SECURITY;
+ALTER TABLE rls_graph."Person" FORCE ROW LEVEL SECURITY;
+-- 1.1: Basic ownership filtering
+CREATE POLICY person_select_own ON rls_graph."Person"
+ FOR SELECT
+ USING (properties->>'"owner"' = current_user);
+-- Test as rls_user1 - should only see Alice and Charlie (owned by rls_user1)
+SET ROLE rls_user1;
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+ name
+-----------
+ "Alice"
+ "Charlie"
+(2 rows)
+
+-- Test as rls_user2 - should only see Bob and Diana (owned by rls_user2)
+SET ROLE rls_user2;
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+ name
+---------
+ "Bob"
+ "Diana"
+(2 rows)
+
+RESET ROLE;
+-- 1.2: Default deny - no permissive policies means no access
+DROP POLICY person_select_own ON rls_graph."Person";
+-- With no policies, RLS blocks all access
+SET ROLE rls_user1;
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+ name
+------
+(0 rows)
+
+RESET ROLE;
+-- ============================================================================
+-- PART 2: Vertex INSERT Policies (WITH CHECK) - CREATE
+-- ============================================================================
+-- Allow SELECT for all (so we can verify results)
+CREATE POLICY person_select_all ON rls_graph."Person"
+ FOR SELECT USING (true);
+-- 2.1: Basic WITH CHECK - users can only insert rows they own
+CREATE POLICY person_insert_own ON rls_graph."Person"
+ FOR INSERT
+ WITH CHECK (properties->>'"owner"' = current_user);
+-- Test as rls_user1 - should succeed (owner matches current_user)
+SET ROLE rls_user1;
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Person {name: 'User1Created', owner: 'rls_user1', department: 'Test', level: 1})
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+-- Test as rls_user1 - should FAIL (owner doesn't match current_user)
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Person {name: 'User1Fake', owner: 'rls_user2', department: 'Test', level: 1})
+$$) AS (a agtype);
+ERROR: new row violates row-level security policy for table "Person"
+RESET ROLE;
+-- Verify only User1Created was created
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) WHERE p.department = 'Test' RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+ name
+----------------
+ "User1Created"
+(1 row)
+
+-- 2.2: Default deny for INSERT - no INSERT policy blocks all inserts
+DROP POLICY person_insert_own ON rls_graph."Person";
+SET ROLE rls_user1;
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Person {name: 'ShouldFail', owner: 'rls_user1', department: 'Blocked', level: 1})
+$$) AS (a agtype);
+ERROR: new row violates row-level security policy for table "Person"
+RESET ROLE;
+-- Verify nothing was created in Blocked department
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) WHERE p.department = 'Blocked' RETURN p.name
+$$) AS (name agtype);
+ name
+------
+(0 rows)
+
+-- cleanup
+DROP POLICY person_select_all ON rls_graph."Person";
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) WHERE p.department = 'Test' DELETE p
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+-- ============================================================================
+-- PART 3: Vertex UPDATE Policies - SET
+-- ============================================================================
+CREATE POLICY person_select_all ON rls_graph."Person"
+ FOR SELECT USING (true);
+-- 3.1: USING clause only - filter which rows can be updated
+CREATE POLICY person_update_using ON rls_graph."Person"
+ FOR UPDATE
+ USING (properties->>'"owner"' = current_user);
+SET ROLE rls_user1;
+-- Should succeed - rls_user1 owns Alice
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'Alice'}) SET p.updated = true RETURN p.name, p.updated
+$$) AS (name agtype, updated agtype);
+ name | updated
+---------+---------
+ "Alice" | true
+(1 row)
+
+-- Should silently skip - rls_user1 doesn't own Bob (USING filters it out)
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'Bob'}) SET p.updated = true RETURN p.name, p.updated
+$$) AS (name agtype, updated agtype);
+ name | updated
+------+---------
+(0 rows)
+
+RESET ROLE;
+-- Verify Alice was updated, Bob was not
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) WHERE p.name IN ['Alice', 'Bob'] RETURN p.name, p.updated ORDER BY p.name
+$$) AS (name agtype, updated agtype);
+ name | updated
+---------+---------
+ "Alice" | true
+ "Bob" |
+(2 rows)
+
+-- 3.2: WITH CHECK clause - validate new values
+DROP POLICY person_update_using ON rls_graph."Person";
+CREATE POLICY person_update_check ON rls_graph."Person"
+ FOR UPDATE
+ USING (true) -- Can update any row
+ WITH CHECK (properties->>'"owner"' = current_user); -- But new value must keep owner
+SET ROLE rls_user1;
+-- Should succeed - modifying property but keeping owner
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'Alice'}) SET p.verified = true RETURN p.name, p.verified
+$$) AS (name agtype, verified agtype);
+ name | verified
+---------+----------
+ "Alice" | true
+(1 row)
+
+-- Should FAIL - trying to change owner to someone else
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'Alice'}) SET p.owner = 'rls_user2' RETURN p.owner
+$$) AS (owner agtype);
+ERROR: new row violates row-level security policy for table "Person"
+RESET ROLE;
+-- Verify owner wasn't changed
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'Alice'}) RETURN p.owner
+$$) AS (owner agtype);
+ owner
+-------------
+ "rls_user1"
+(1 row)
+
+-- 3.3: Both USING and WITH CHECK together
+DROP POLICY person_update_check ON rls_graph."Person";
+CREATE POLICY person_update_both ON rls_graph."Person"
+ FOR UPDATE
+ USING (properties->>'"owner"' = current_user)
+ WITH CHECK (properties->>'"owner"' = current_user);
+SET ROLE rls_user1;
+-- Should succeed - owns Alice, keeping owner
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'Alice'}) SET p.status = 'active' RETURN p.name, p.status
+$$) AS (name agtype, status agtype);
+ name | status
+---------+----------
+ "Alice" | "active"
+(1 row)
+
+-- Should silently skip - doesn't own Bob (USING filters)
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'Bob'}) SET p.status = 'active' RETURN p.name, p.status
+$$) AS (name agtype, status agtype);
+ name | status
+------+--------
+(0 rows)
+
+RESET ROLE;
+-- ============================================================================
+-- PART 4: Vertex UPDATE Policies - REMOVE
+-- ============================================================================
+-- Keep existing update policy, test REMOVE operation
+SET ROLE rls_user1;
+-- Should succeed - owns Alice
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'Alice'}) REMOVE p.status RETURN p.name, p.status
+$$) AS (name agtype, status agtype);
+ name | status
+---------+--------
+ "Alice" |
+(1 row)
+
+-- Should silently skip - doesn't own Bob
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'Bob'}) REMOVE p.department RETURN p.name, p.department
+$$) AS (name agtype, dept agtype);
+ name | dept
+------+------
+(0 rows)
+
+RESET ROLE;
+-- Verify Bob still has department
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'Bob'}) RETURN p.department
+$$) AS (dept agtype);
+ dept
+---------------
+ "Engineering"
+(1 row)
+
+-- cleanup
+DROP POLICY person_select_all ON rls_graph."Person";
+DROP POLICY person_update_both ON rls_graph."Person";
+-- ============================================================================
+-- PART 5: Vertex DELETE Policies
+-- ============================================================================
+CREATE POLICY person_select_all ON rls_graph."Person"
+ FOR SELECT USING (true);
+-- Create test data for delete tests
+CREATE POLICY person_insert_all ON rls_graph."Person"
+ FOR INSERT WITH CHECK (true);
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Person {name: 'DeleteTest1', owner: 'rls_user1', department: 'DeleteTest', level: 1})
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Person {name: 'DeleteTest2', owner: 'rls_user2', department: 'DeleteTest', level: 1})
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Person {name: 'DeleteTest3', owner: 'rls_user1', department: 'DeleteTest', level: 1})
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+DROP POLICY person_insert_all ON rls_graph."Person";
+-- 5.1: Basic USING filtering for DELETE
+CREATE POLICY person_delete_own ON rls_graph."Person"
+ FOR DELETE
+ USING (properties->>'"owner"' = current_user);
+SET ROLE rls_user1;
+-- Should succeed - owns DeleteTest1
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'DeleteTest1'}) DELETE p
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+-- Should silently skip - doesn't own DeleteTest2
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'DeleteTest2'}) DELETE p
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+RESET ROLE;
+-- Verify DeleteTest1 deleted, DeleteTest2 still exists
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) WHERE p.department = 'DeleteTest' RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+ name
+---------------
+ "DeleteTest2"
+ "DeleteTest3"
+(2 rows)
+
+-- 5.2: Default deny for DELETE - no policy blocks all deletes
+DROP POLICY person_delete_own ON rls_graph."Person";
+SET ROLE rls_user1;
+-- Should silently skip - no DELETE policy means default deny
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'DeleteTest3'}) DELETE p
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+RESET ROLE;
+-- Verify DeleteTest3 still exists
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'DeleteTest3'}) RETURN p.name
+$$) AS (name agtype);
+ name
+---------------
+ "DeleteTest3"
+(1 row)
+
+-- cleanup
+DROP POLICY person_select_all ON rls_graph."Person";
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) WHERE p.department = 'DeleteTest' DELETE p
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+-- ============================================================================
+-- PART 6: MERGE Policies
+-- ============================================================================
+CREATE POLICY person_select_all ON rls_graph."Person"
+ FOR SELECT USING (true);
+CREATE POLICY person_insert_own ON rls_graph."Person"
+ FOR INSERT
+ WITH CHECK (properties->>'"owner"' = current_user);
+-- 6.1: MERGE creating new vertex - INSERT policy applies
+SET ROLE rls_user1;
+-- Should succeed - creating with correct owner
+SELECT * FROM cypher('rls_graph', $$
+ MERGE (p:Person {name: 'MergeNew1', owner: 'rls_user1', department: 'Merge', level: 1})
+ RETURN p.name
+$$) AS (name agtype);
+ name
+-------------
+ "MergeNew1"
+(1 row)
+
+-- Should FAIL - creating with wrong owner
+SELECT * FROM cypher('rls_graph', $$
+ MERGE (p:Person {name: 'MergeNew2', owner: 'rls_user2', department: 'Merge', level: 1})
+ RETURN p.name
+$$) AS (name agtype);
+ERROR: new row violates row-level security policy for table "Person"
+RESET ROLE;
+-- 6.2: MERGE matching existing - only SELECT needed
+SET ROLE rls_user1;
+-- Should succeed - Alice exists and SELECT allowed
+SELECT * FROM cypher('rls_graph', $$
+ MERGE (p:Person {name: 'Alice'})
+ RETURN p.name, p.owner
+$$) AS (name agtype, owner agtype);
+ name | owner
+---------+-------------
+ "Alice" | "rls_user1"
+(1 row)
+
+RESET ROLE;
+-- Verify only MergeNew1 was created
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) WHERE p.department = 'Merge' RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+ name
+-------------
+ "MergeNew1"
+(1 row)
+
+-- cleanup
+DROP POLICY person_select_all ON rls_graph."Person";
+DROP POLICY person_insert_own ON rls_graph."Person";
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) WHERE p.department = 'Merge' DELETE p
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+-- ============================================================================
+-- PART 7: Edge SELECT Policies
+-- ============================================================================
+-- Disable vertex RLS, enable edge RLS
+ALTER TABLE rls_graph."Person" DISABLE ROW LEVEL SECURITY;
+ALTER TABLE rls_graph."KNOWS" ENABLE ROW LEVEL SECURITY;
+ALTER TABLE rls_graph."KNOWS" FORCE ROW LEVEL SECURITY;
+-- Policy: Only see edges from 2021 or later
+CREATE POLICY knows_select_recent ON rls_graph."KNOWS"
+ FOR SELECT
+ USING ((properties->>'"since"')::int >= 2021);
+SET ROLE rls_user1;
+-- Should only see 2021 and 2022 edges (not 2020)
+SELECT * FROM cypher('rls_graph', $$
+ MATCH ()-[k:KNOWS]->() RETURN k.since ORDER BY k.since
+$$) AS (since agtype);
+ since
+-------
+ 2021
+ 2022
+(2 rows)
+
+RESET ROLE;
+-- ============================================================================
+-- PART 8: Edge INSERT Policies (CREATE edge)
+-- ============================================================================
+DROP POLICY knows_select_recent ON rls_graph."KNOWS";
+CREATE POLICY knows_select_all ON rls_graph."KNOWS"
+ FOR SELECT USING (true);
+-- Policy: Can only create edges with strength = 'strong'
+CREATE POLICY knows_insert_strong ON rls_graph."KNOWS"
+ FOR INSERT
+ WITH CHECK (properties->>'"strength"' = 'strong');
+SET ROLE rls_user1;
+-- Should succeed - strength is 'strong'
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a:Person {name: 'Bob'}), (b:Person {name: 'Diana'})
+ CREATE (a)-[:KNOWS {since: 2023, strength: 'strong'}]->(b)
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+-- Should FAIL - strength is 'weak'
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a:Person {name: 'Diana'}), (b:Person {name: 'Alice'})
+ CREATE (a)-[:KNOWS {since: 2023, strength: 'weak'}]->(b)
+$$) AS (a agtype);
+ERROR: new row violates row-level security policy for table "KNOWS"
+RESET ROLE;
+-- Verify only strong edge was created
+SELECT * FROM cypher('rls_graph', $$
+ MATCH ()-[k:KNOWS]->() WHERE k.since = 2023 RETURN k.strength ORDER BY k.strength
+$$) AS (strength agtype);
+ strength
+----------
+ "strong"
+(1 row)
+
+-- cleanup
+DROP POLICY knows_insert_strong ON rls_graph."KNOWS";
+-- ============================================================================
+-- PART 9: Edge UPDATE Policies (SET on edge)
+-- ============================================================================
+-- Policy: Can only update edges with strength = 'strong'
+CREATE POLICY knows_update_strong ON rls_graph."KNOWS"
+ FOR UPDATE
+ USING (properties->>'"strength"' = 'strong')
+ WITH CHECK (properties->>'"strength"' = 'strong');
+SET ROLE rls_user1;
+-- Should succeed - edge has strength 'strong'
+SELECT * FROM cypher('rls_graph', $$
+ MATCH ()-[k:KNOWS {since: 2021}]->() SET k.notes = 'updated' RETURN k.since, k.notes
+$$) AS (since agtype, notes agtype);
+ since | notes
+-------+-----------
+ 2021 | "updated"
+(1 row)
+
+-- Should silently skip - edge has strength 'weak'
+SELECT * FROM cypher('rls_graph', $$
+ MATCH ()-[k:KNOWS {since: 2020}]->() SET k.notes = 'updated' RETURN k.since, k.notes
+$$) AS (since agtype, notes agtype);
+ since | notes
+-------+-------
+(0 rows)
+
+RESET ROLE;
+-- Verify only 2021 edge was updated
+SELECT * FROM cypher('rls_graph', $$
+ MATCH ()-[k:KNOWS]->() WHERE k.since IN [2020, 2021] RETURN k.since, k.notes ORDER BY k.since
+$$) AS (since agtype, notes agtype);
+ since | notes
+-------+-----------
+ 2020 |
+ 2021 | "updated"
+(2 rows)
+
+-- cleanup
+DROP POLICY knows_select_all ON rls_graph."KNOWS";
+DROP POLICY knows_update_strong ON rls_graph."KNOWS";
+-- ============================================================================
+-- PART 10: Edge DELETE Policies
+-- ============================================================================
+CREATE POLICY knows_select_all ON rls_graph."KNOWS"
+ FOR SELECT USING (true);
+-- Create test edges for delete
+CREATE POLICY knows_insert_all ON rls_graph."KNOWS"
+ FOR INSERT WITH CHECK (true);
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a:Person {name: 'Bob'}), (b:Person {name: 'Charlie'})
+ CREATE (a)-[:KNOWS {since: 2018, strength: 'weak'}]->(b)
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a:Person {name: 'Diana'}), (b:Person {name: 'Charlie'})
+ CREATE (a)-[:KNOWS {since: 2019, strength: 'strong'}]->(b)
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+DROP POLICY knows_insert_all ON rls_graph."KNOWS";
+-- Policy: Can only delete edges with strength = 'weak'
+CREATE POLICY knows_delete_weak ON rls_graph."KNOWS"
+ FOR DELETE
+ USING (properties->>'"strength"' = 'weak');
+SET ROLE rls_user1;
+-- Should succeed - edge has strength 'weak'
+SELECT * FROM cypher('rls_graph', $$
+ MATCH ()-[k:KNOWS {since: 2018}]->() DELETE k
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+-- Should silently skip - edge has strength 'strong'
+SELECT * FROM cypher('rls_graph', $$
+ MATCH ()-[k:KNOWS {since: 2019}]->() DELETE k
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+RESET ROLE;
+-- Verify 2018 edge deleted, 2019 edge still exists
+SELECT * FROM cypher('rls_graph', $$
+ MATCH ()-[k:KNOWS]->() WHERE k.since IN [2018, 2019] RETURN k.since ORDER BY k.since
+$$) AS (since agtype);
+ since
+-------
+ 2019
+(1 row)
+
+-- cleanup
+DROP POLICY knows_delete_weak ON rls_graph."KNOWS";
+-- ============================================================================
+-- PART 11: DETACH DELETE
+-- ============================================================================
+-- Re-enable Person RLS
+ALTER TABLE rls_graph."Person" ENABLE ROW LEVEL SECURITY;
+CREATE POLICY person_all ON rls_graph."Person"
+ FOR ALL USING (true) WITH CHECK (true);
+-- Create test data with a protected edge
+CREATE POLICY knows_insert_all ON rls_graph."KNOWS"
+ FOR INSERT WITH CHECK (true);
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Person {name: 'DetachTest1', owner: 'test', department: 'Detach', level: 1})
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Person {name: 'DetachTest2', owner: 'test', department: 'Detach', level: 1})
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a:Person {name: 'DetachTest1'}), (b:Person {name: 'DetachTest2'})
+ CREATE (a)-[:KNOWS {since: 2010, strength: 'protected'}]->(b)
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+DROP POLICY knows_insert_all ON rls_graph."KNOWS";
+-- Policy: Cannot delete edges with strength = 'protected'
+CREATE POLICY knows_delete_not_protected ON rls_graph."KNOWS"
+ FOR DELETE
+ USING (properties->>'"strength"' != 'protected');
+SET ROLE rls_user1;
+-- Should ERROR - DETACH DELETE cannot silently skip (would leave dangling edge)
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'DetachTest1'}) DETACH DELETE p
+$$) AS (a agtype);
+ERROR: cannot delete edge due to row-level security policy on "KNOWS"
+HINT: DETACH DELETE requires permission to delete all connected edges.
+RESET ROLE;
+-- Verify vertex still exists (delete was blocked)
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'DetachTest1'}) RETURN p.name
+$$) AS (name agtype);
+ name
+---------------
+ "DetachTest1"
+(1 row)
+
+-- cleanup
+DROP POLICY person_all ON rls_graph."Person";
+DROP POLICY knows_select_all ON rls_graph."KNOWS";
+DROP POLICY knows_delete_not_protected ON rls_graph."KNOWS";
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) WHERE p.department = 'Detach' DETACH DELETE p
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+-- ============================================================================
+-- PART 12: Multiple Labels in Single Query
+-- ============================================================================
+-- Enable RLS on Document too
+ALTER TABLE rls_graph."Document" ENABLE ROW LEVEL SECURITY;
+ALTER TABLE rls_graph."Document" FORCE ROW LEVEL SECURITY;
+-- Policy: Users see their own Person records
+CREATE POLICY person_own ON rls_graph."Person"
+ FOR SELECT
+ USING (properties->>'"owner"' = current_user);
+-- Policy: Users see only public documents
+CREATE POLICY doc_public ON rls_graph."Document"
+ FOR SELECT
+ USING (properties->>'"classification"' = 'public');
+SET ROLE rls_user1;
+-- Should only see Alice and Charlie (Person) with Public Doc (Document)
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+ name
+-----------
+ "Alice"
+ "Charlie"
+(2 rows)
+
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (d:Document) RETURN d.title ORDER BY d.title
+$$) AS (title agtype);
+ title
+--------------
+ "Public Doc"
+(1 row)
+
+-- Combined query - should respect both policies
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person)-[:AUTHORED]->(d:Document)
+ RETURN p.name, d.title
+$$) AS (person agtype, doc agtype);
+ person | doc
+---------+--------------
+ "Alice" | "Public Doc"
+(1 row)
+
+RESET ROLE;
+-- ============================================================================
+-- PART 13: Permissive vs Restrictive Policies
+-- ============================================================================
+DROP POLICY person_own ON rls_graph."Person";
+DROP POLICY doc_public ON rls_graph."Document";
+ALTER TABLE rls_graph."Document" DISABLE ROW LEVEL SECURITY;
+ALTER TABLE rls_graph."KNOWS" DISABLE ROW LEVEL SECURITY;
+-- 13.1: Multiple permissive policies (OR logic)
+CREATE POLICY person_permissive_own ON rls_graph."Person"
+ AS PERMISSIVE FOR SELECT
+ USING (properties->>'"owner"' = current_user);
+CREATE POLICY person_permissive_eng ON rls_graph."Person"
+ AS PERMISSIVE FOR SELECT
+ USING (properties->>'"department"' = 'Engineering');
+SET ROLE rls_user1;
+-- Should see: Alice (own), Charlie (own), Bob (Engineering)
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) WHERE p.department IN ['Engineering', 'Sales']
+ RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+ name
+-----------
+ "Alice"
+ "Bob"
+ "Charlie"
+(3 rows)
+
+RESET ROLE;
+-- 13.2: Add restrictive policy (AND with permissive)
+CREATE POLICY person_restrictive_level ON rls_graph."Person"
+ AS RESTRICTIVE FOR SELECT
+ USING ((properties->>'"level"')::int <= 2);
+SET ROLE rls_user1;
+-- Should see: Alice (own, level 1), Bob (Engineering, level 2), Charlie (own, level 1)
+-- Diana (level 3) blocked by restrictive
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) RETURN p.name, p.level ORDER BY p.name
+$$) AS (name agtype, level agtype);
+ name | level
+-----------+-------
+ "Alice" | 1
+ "Bob" | 2
+ "Charlie" | 1
+(3 rows)
+
+RESET ROLE;
+-- 13.3: Multiple restrictive policies (all must pass)
+CREATE POLICY person_restrictive_sales ON rls_graph."Person"
+ AS RESTRICTIVE FOR SELECT
+ USING (properties->>'"department"' != 'Sales');
+SET ROLE rls_user1;
+-- Should see: Alice (own, level 1, not Sales), Bob (Engineering, level 2, not Sales)
+-- Charlie blocked by Sales restriction
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+ name
+---------
+ "Alice"
+ "Bob"
+(2 rows)
+
+RESET ROLE;
+-- ============================================================================
+-- PART 14: BYPASSRLS Role and Superuser Behavior
+-- ============================================================================
+DROP POLICY person_permissive_own ON rls_graph."Person";
+DROP POLICY person_permissive_eng ON rls_graph."Person";
+DROP POLICY person_restrictive_level ON rls_graph."Person";
+DROP POLICY person_restrictive_sales ON rls_graph."Person";
+-- Restrictive policy that blocks most access
+CREATE POLICY person_very_restrictive ON rls_graph."Person"
+ FOR SELECT
+ USING (properties->>'"name"' = 'Nobody');
+-- 14.1: Regular user sees nothing
+SET ROLE rls_user1;
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+ name
+------
+(0 rows)
+
+RESET ROLE;
+-- 14.2: BYPASSRLS role sees everything
+SET ROLE rls_admin;
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+ name
+-----------
+ "Alice"
+ "Bob"
+ "Charlie"
+ "Diana"
+(4 rows)
+
+RESET ROLE;
+-- 14.3: Superuser sees everything (implicit bypass)
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+ name
+-----------
+ "Alice"
+ "Bob"
+ "Charlie"
+ "Diana"
+(4 rows)
+
+-- ============================================================================
+-- PART 15: Complex Multi-Operation Queries
+-- ============================================================================
+DROP POLICY person_very_restrictive ON rls_graph."Person";
+CREATE POLICY person_select_all ON rls_graph."Person"
+ FOR SELECT USING (true);
+CREATE POLICY person_insert_own ON rls_graph."Person"
+ FOR INSERT
+ WITH CHECK (properties->>'"owner"' = current_user);
+CREATE POLICY person_update_own ON rls_graph."Person"
+ FOR UPDATE
+ USING (properties->>'"owner"' = current_user)
+ WITH CHECK (properties->>'"owner"' = current_user);
+-- 15.1: MATCH + CREATE in one query
+SET ROLE rls_user1;
+-- Should succeed - creating with correct owner
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a:Person {name: 'Alice'})
+ CREATE (a)-[:KNOWS]->(:Person {name: 'NewFromMatch', owner: 'rls_user1', department: 'Complex', level: 1})
+$$) AS (a agtype);
+ a
+---
+(0 rows)
+
+RESET ROLE;
+-- Verify creation
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'NewFromMatch'}) RETURN p.name, p.owner
+$$) AS (name agtype, owner agtype);
+ name | owner
+----------------+-------------
+ "NewFromMatch" | "rls_user1"
+(1 row)
+
+-- 15.2: MATCH + SET in one query
+SET ROLE rls_user1;
+-- Should succeed on Alice (own), skip Bob (not own)
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) WHERE p.name IN ['Alice', 'Bob']
+ SET p.complexTest = true
+ RETURN p.name, p.complexTest
+$$) AS (name agtype, test agtype);
+ name | test
+---------+------
+ "Alice" | true
+(1 row)
+
+RESET ROLE;
+-- Verify only Alice was updated
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) WHERE p.name IN ['Alice', 'Bob']
+ RETURN p.name, p.complexTest ORDER BY p.name
+$$) AS (name agtype, test agtype);
+ name | test
+---------+------
+ "Alice" | true
+ "Bob" |
+(2 rows)
+
+-- cleanup
+DROP POLICY IF EXISTS person_select_all ON rls_graph."Person";
+DROP POLICY IF EXISTS person_insert_own ON rls_graph."Person";
+DROP POLICY IF EXISTS person_update_own ON rls_graph."Person";
+-- ============================================================================
+-- PART 16: startNode/endNode RLS Enforcement
+-- ============================================================================
+ALTER TABLE rls_graph."Person" DISABLE ROW LEVEL SECURITY;
+-- Enable RLS on Person with restrictive policy
+ALTER TABLE rls_graph."Person" ENABLE ROW LEVEL SECURITY;
+ALTER TABLE rls_graph."Person" FORCE ROW LEVEL SECURITY;
+-- Policy: users can only see their own Person records
+CREATE POLICY person_own ON rls_graph."Person"
+ FOR SELECT
+ USING (properties->>'"owner"' = current_user);
+-- Enable edge access for testing
+ALTER TABLE rls_graph."KNOWS" ENABLE ROW LEVEL SECURITY;
+CREATE POLICY knows_all ON rls_graph."KNOWS"
+ FOR SELECT USING (true);
+-- 16.1: startNode blocked by RLS - should error
+SET ROLE rls_user1;
+-- rls_user1 can see the edge (Alice->Bob) but cannot see Bob (owned by rls_user2)
+-- endNode should error because Bob is blocked by RLS
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a:Person {name: 'Alice'})-[e:KNOWS]->(b)
+ RETURN endNode(e)
+$$) AS (end_vertex agtype);
+ERROR: access to vertex 844424930131970 denied by row-level security policy on "Person"
+-- 16.2: endNode blocked by RLS - should error
+-- rls_user1 cannot see Bob, so startNode on an edge starting from Bob should error
+SET ROLE rls_user2;
+-- rls_user2 can see Bob but not Alice (owned by rls_user1)
+-- startNode should error because Alice is blocked by RLS
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a)-[e:KNOWS]->(b:Person {name: 'Bob'})
+ RETURN startNode(e)
+$$) AS (start_vertex agtype);
+ERROR: access to vertex 844424930131969 denied by row-level security policy on "Person"
+-- 16.3: startNode/endNode succeed when RLS allows access
+SET ROLE rls_user1;
+-- Alice->Charlie edge: rls_user1 owns both, should succeed
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a:Person {name: 'Alice'})-[e:KNOWS]->(c:Person {name: 'Charlie'})
+ RETURN startNode(e).name, endNode(e).name
+$$) AS (start_name agtype, end_name agtype);
+ start_name | end_name
+------------+-----------
+ "Alice" | "Charlie"
+(1 row)
+
+RESET ROLE;
+-- cleanup
+DROP POLICY person_own ON rls_graph."Person";
+DROP POLICY knows_all ON rls_graph."KNOWS";
+ALTER TABLE rls_graph."KNOWS" DISABLE ROW LEVEL SECURITY;
+-- ============================================================================
+-- RLS CLEANUP
+-- ============================================================================
+RESET ROLE;
+-- Disable RLS on all tables
+ALTER TABLE rls_graph."Person" DISABLE ROW LEVEL SECURITY;
+ALTER TABLE rls_graph."Document" DISABLE ROW LEVEL SECURITY;
+ALTER TABLE rls_graph."KNOWS" DISABLE ROW LEVEL SECURITY;
+-- Drop roles
+DROP OWNED BY rls_user1 CASCADE;
+DROP ROLE rls_user1;
+DROP OWNED BY rls_user2 CASCADE;
+DROP ROLE rls_user2;
+DROP OWNED BY rls_admin CASCADE;
+DROP ROLE rls_admin;
+-- Drop test graph
+SELECT drop_graph('rls_graph', true);
+NOTICE: drop cascades to 6 other objects
+DETAIL: drop cascades to table rls_graph._ag_label_vertex
+drop cascades to table rls_graph._ag_label_edge
+drop cascades to table rls_graph."Person"
+drop cascades to table rls_graph."Document"
+drop cascades to table rls_graph."KNOWS"
+drop cascades to table rls_graph."AUTHORED"
+NOTICE: graph "rls_graph" has been dropped
+ drop_graph
+------------
+
+(1 row)
+
diff --git a/regress/sql/security.sql b/regress/sql/security.sql
new file mode 100644
index 00000000..344dd23d
--- /dev/null
+++ b/regress/sql/security.sql
@@ -0,0 +1,1451 @@
+/*
+ * 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;
+
+--
+-- Test Privileges
+--
+
+--
+-- Setup: Create test graph and data as superuser
+--
+SELECT create_graph('security_test');
+
+-- Create test vertices
+SELECT * FROM cypher('security_test', $$
+ CREATE (:Person {name: 'Alice', age: 30})
+$$) AS (a agtype);
+
+SELECT * FROM cypher('security_test', $$
+ CREATE (:Person {name: 'Bob', age: 25})
+$$) AS (a agtype);
+
+SELECT * FROM cypher('security_test', $$
+ CREATE (:Document {title: 'Secret', content: 'classified'})
+$$) AS (a agtype);
+
+-- Create test edges
+SELECT * FROM cypher('security_test', $$
+ MATCH (a:Person {name: 'Alice'}), (b:Person {name: 'Bob'})
+ CREATE (a)-[:KNOWS {since: 2020}]->(b)
+$$) AS (a agtype);
+
+SELECT * FROM cypher('security_test', $$
+ MATCH (a:Person {name: 'Alice'}), (d:Document)
+ CREATE (a)-[:OWNS]->(d)
+$$) AS (a agtype);
+
+--
+-- Create test roles with different permission levels
+--
+
+-- Role with only SELECT (read-only)
+CREATE ROLE security_test_readonly LOGIN;
+GRANT USAGE ON SCHEMA security_test TO security_test_readonly;
+GRANT SELECT ON ALL TABLES IN SCHEMA security_test TO security_test_readonly;
+GRANT USAGE ON SCHEMA ag_catalog TO security_test_readonly;
+GRANT SELECT ON ALL TABLES IN SCHEMA ag_catalog TO security_test_readonly;
+
+-- Role with SELECT and INSERT
+CREATE ROLE security_test_insert LOGIN;
+GRANT USAGE ON SCHEMA security_test TO security_test_insert;
+GRANT SELECT, INSERT ON ALL TABLES IN SCHEMA security_test TO security_test_insert;
+GRANT USAGE ON SCHEMA ag_catalog TO security_test_insert;
+GRANT SELECT ON ALL TABLES IN SCHEMA ag_catalog TO security_test_insert;
+-- Grant sequence usage for ID generation
+GRANT USAGE ON ALL SEQUENCES IN SCHEMA security_test TO security_test_insert;
+
+-- Role with SELECT and UPDATE
+CREATE ROLE security_test_update LOGIN;
+GRANT USAGE ON SCHEMA security_test TO security_test_update;
+GRANT SELECT, UPDATE ON ALL TABLES IN SCHEMA security_test TO security_test_update;
+GRANT USAGE ON SCHEMA ag_catalog TO security_test_update;
+GRANT SELECT ON ALL TABLES IN SCHEMA ag_catalog TO security_test_update;
+
+-- Role with SELECT and DELETE
+CREATE ROLE security_test_delete LOGIN;
+GRANT USAGE ON SCHEMA security_test TO security_test_delete;
+GRANT SELECT, DELETE ON ALL TABLES IN SCHEMA security_test TO security_test_delete;
+GRANT USAGE ON SCHEMA ag_catalog TO security_test_delete;
+GRANT SELECT ON ALL TABLES IN SCHEMA ag_catalog TO security_test_delete;
+
+CREATE ROLE security_test_detach_delete LOGIN;
+GRANT USAGE ON SCHEMA security_test TO security_test_detach_delete;
+GRANT SELECT ON ALL TABLES IN SCHEMA security_test TO security_test_detach_delete;
+GRANT DELETE ON security_test."Person" TO security_test_detach_delete;
+GRANT USAGE ON SCHEMA ag_catalog TO security_test_detach_delete;
+GRANT SELECT ON ALL TABLES IN SCHEMA ag_catalog TO security_test_detach_delete;
+
+-- Role with all permissions
+CREATE ROLE security_test_full LOGIN;
+GRANT USAGE ON SCHEMA security_test TO security_test_full;
+GRANT ALL ON ALL TABLES IN SCHEMA security_test TO security_test_full;
+GRANT USAGE ON SCHEMA ag_catalog TO security_test_full;
+GRANT SELECT ON ALL TABLES IN SCHEMA ag_catalog TO security_test_full;
+GRANT USAGE ON ALL SEQUENCES IN SCHEMA security_test TO security_test_full;
+
+-- Role with NO SELECT on graph tables (to test read failures)
+CREATE ROLE security_test_noread LOGIN;
+GRANT USAGE ON SCHEMA security_test TO security_test_noread;
+GRANT USAGE ON SCHEMA ag_catalog TO security_test_noread;
+GRANT SELECT ON ALL TABLES IN SCHEMA ag_catalog TO security_test_noread;
+-- No SELECT on security_test tables
+
+-- ============================================================================
+-- PART 1: SELECT Permission Tests - Failure Cases (No Read Permission)
+-- ============================================================================
+
+SET ROLE security_test_noread;
+
+-- Test: MATCH on vertices should fail without SELECT permission
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person) RETURN p.name
+$$) AS (name agtype);
+
+-- Test: MATCH on edges should fail without SELECT permission
+SELECT * FROM cypher('security_test', $$
+ MATCH ()-[k:KNOWS]->() RETURN k
+$$) AS (k agtype);
+
+-- Test: MATCH with path should fail
+SELECT * FROM cypher('security_test', $$
+ MATCH (a)-[e]->(b) RETURN a, e, b
+$$) AS (a agtype, e agtype, b agtype);
+
+RESET ROLE;
+
+-- Create role with SELECT only on base label tables, not child labels
+-- NOTE: PostgreSQL inheritance allows access to child table rows when querying
+-- through a parent table. This is expected behavior - SELECT on _ag_label_vertex
+-- allows reading all vertices (including Person, Document) via inheritance.
+CREATE ROLE security_test_base_only LOGIN;
+GRANT USAGE ON SCHEMA security_test TO security_test_base_only;
+GRANT USAGE ON SCHEMA ag_catalog TO security_test_base_only;
+GRANT SELECT ON ALL TABLES IN SCHEMA ag_catalog TO security_test_base_only;
+-- Only grant SELECT on base tables, NOT on Person, Document, KNOWS, OWNS
+GRANT SELECT ON security_test._ag_label_vertex TO security_test_base_only;
+GRANT SELECT ON security_test._ag_label_edge TO security_test_base_only;
+
+SET ROLE security_test_base_only;
+
+-- Test: MATCH (n) succeeds because PostgreSQL inheritance allows access to child rows
+-- when querying through parent table. Permission on _ag_label_vertex grants read
+-- access to all vertices via inheritance hierarchy.
+SELECT * FROM cypher('security_test', $$
+ MATCH (n) RETURN n
+$$) AS (n agtype);
+
+-- Test: MATCH ()-[e]->() succeeds via inheritance (same reason as above)
+SELECT * FROM cypher('security_test', $$
+ MATCH ()-[e]->() RETURN e
+$$) AS (e agtype);
+
+-- ============================================================================
+-- PART 2: SELECT Permission Tests - Success Cases (Read-Only Role)
+-- ============================================================================
+
+RESET ROLE;
+SET ROLE security_test_readonly;
+
+-- Test: MATCH should succeed with SELECT permission
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person) RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+
+-- Test: MATCH with edges should succeed
+SELECT * FROM cypher('security_test', $$
+ MATCH (a:Person)-[k:KNOWS]->(b:Person)
+ RETURN a.name, b.name
+$$) AS (a agtype, b agtype);
+
+-- Test: MATCH across multiple labels should succeed
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person)-[:OWNS]->(d:Document)
+ RETURN p.name, d.title
+$$) AS (person agtype, doc agtype);
+
+-- ============================================================================
+-- PART 3: INSERT Permission Tests (CREATE clause)
+-- ============================================================================
+
+-- Test: CREATE should fail with only SELECT permission
+SELECT * FROM cypher('security_test', $$
+ CREATE (:Person {name: 'Charlie'})
+$$) AS (a agtype);
+
+-- Test: CREATE edge should fail
+SELECT * FROM cypher('security_test', $$
+ MATCH (a:Person {name: 'Alice'}), (b:Person {name: 'Bob'})
+ CREATE (a)-[:FRIENDS]->(b)
+$$) AS (a agtype);
+
+RESET ROLE;
+SET ROLE security_test_insert;
+
+-- Test: CREATE vertex should succeed with INSERT permission
+SELECT * FROM cypher('security_test', $$
+ CREATE (:Person {name: 'Charlie', age: 35})
+$$) AS (a agtype);
+
+-- Test: CREATE edge should succeed with INSERT permission
+SELECT * FROM cypher('security_test', $$
+ MATCH (a:Person {name: 'Charlie'}), (b:Person {name: 'Alice'})
+ CREATE (a)-[:KNOWS {since: 2023}]->(b)
+$$) AS (a agtype);
+
+-- Verify the inserts worked
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Charlie'}) RETURN p.name, p.age
+$$) AS (name agtype, age agtype);
+
+-- ============================================================================
+-- PART 4: UPDATE Permission Tests (SET clause)
+-- ============================================================================
+
+RESET ROLE;
+SET ROLE security_test_readonly;
+
+-- Test: SET should fail with only SELECT permission
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Alice'})
+ SET p.age = 31
+ RETURN p
+$$) AS (p agtype);
+
+-- Test: SET on edge should fail
+SELECT * FROM cypher('security_test', $$
+ MATCH ()-[k:KNOWS]->()
+ SET k.since = 2021
+ RETURN k
+$$) AS (k agtype);
+
+RESET ROLE;
+SET ROLE security_test_update;
+
+-- Test: SET should succeed with UPDATE permission
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Alice'})
+ SET p.age = 31
+ RETURN p.name, p.age
+$$) AS (name agtype, age agtype);
+
+-- Test: SET on edge should succeed
+SELECT * FROM cypher('security_test', $$
+ MATCH (a:Person {name: 'Alice'})-[k:KNOWS]->(b:Person {name: 'Bob'})
+ SET k.since = 2019
+ RETURN k.since
+$$) AS (since agtype);
+
+-- Test: SET with map update should succeed
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Bob'})
+ SET p += {hobby: 'reading'}
+ RETURN p.name, p.hobby
+$$) AS (name agtype, hobby agtype);
+
+-- ============================================================================
+-- PART 5: UPDATE Permission Tests (REMOVE clause)
+-- ============================================================================
+
+RESET ROLE;
+SET ROLE security_test_readonly;
+
+-- Test: REMOVE should fail with only SELECT permission
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Bob'})
+ REMOVE p.hobby
+ RETURN p
+$$) AS (p agtype);
+
+RESET ROLE;
+SET ROLE security_test_update;
+
+-- Test: REMOVE should succeed with UPDATE permission
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Bob'})
+ REMOVE p.hobby
+ RETURN p.name, p.hobby
+$$) AS (name agtype, hobby agtype);
+
+-- ============================================================================
+-- PART 6: DELETE Permission Tests
+-- ============================================================================
+
+RESET ROLE;
+SET ROLE security_test_readonly;
+
+-- Test: DELETE should fail with only SELECT permission
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Charlie'})
+ DELETE p
+$$) AS (a agtype);
+
+RESET ROLE;
+SET ROLE security_test_update;
+
+-- Test: DELETE should fail with only UPDATE permission (need DELETE)
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Charlie'})
+ DELETE p
+$$) AS (a agtype);
+
+RESET ROLE;
+SET ROLE security_test_delete;
+
+-- Test: DELETE vertex should succeed with DELETE permission
+-- First delete the edge connected to Charlie
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Charlie'})-[k:KNOWS]->()
+ DELETE k
+$$) AS (a agtype);
+
+-- Now delete the vertex
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Charlie'})
+ DELETE p
+$$) AS (a agtype);
+
+-- Verify deletion
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Charlie'}) RETURN p
+$$) AS (p agtype);
+
+-- ============================================================================
+-- PART 7: DETACH DELETE Tests
+-- ============================================================================
+
+RESET ROLE;
+
+-- Create a new vertex with edge for DETACH DELETE test
+SELECT * FROM cypher('security_test', $$
+ CREATE (:Person {name: 'Dave', age: 40})
+$$) AS (a agtype);
+
+SELECT * FROM cypher('security_test', $$
+ MATCH (a:Person {name: 'Alice'}), (d:Person {name: 'Dave'})
+ CREATE (a)-[:KNOWS {since: 2022}]->(d)
+$$) AS (a agtype);
+
+SET ROLE security_test_detach_delete;
+
+-- Test: DETACH DELETE should fail without DELETE on edge table
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Dave'})
+ DETACH DELETE p
+$$) AS (a agtype);
+
+RESET ROLE;
+GRANT DELETE ON security_test."KNOWS" TO security_test_detach_delete;
+SET ROLE security_test_detach_delete;
+
+-- Test: DETACH DELETE should succeed now when user has DELETE on both vertex and edge tables
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Dave'})
+ DETACH DELETE p
+$$) AS (a agtype);
+
+-- Verify deletion
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Dave'}) RETURN p
+$$) AS (p agtype);
+
+-- ============================================================================
+-- PART 8: MERGE Permission Tests
+-- ============================================================================
+
+RESET ROLE;
+SET ROLE security_test_readonly;
+
+-- Test: MERGE that would create should fail without INSERT
+SELECT * FROM cypher('security_test', $$
+ MERGE (p:Person {name: 'Eve'})
+ RETURN p
+$$) AS (p agtype);
+
+RESET ROLE;
+SET ROLE security_test_insert;
+
+-- Test: MERGE that creates should succeed with INSERT permission
+SELECT * FROM cypher('security_test', $$
+ MERGE (p:Person {name: 'Eve', age: 28})
+ RETURN p.name, p.age
+$$) AS (name agtype, age agtype);
+
+-- Test: MERGE that matches existing should succeed (only needs SELECT)
+SELECT * FROM cypher('security_test', $$
+ MERGE (p:Person {name: 'Eve'})
+ RETURN p.name
+$$) AS (name agtype);
+
+-- ============================================================================
+-- PART 9: Full Permission Role Tests
+-- ============================================================================
+
+RESET ROLE;
+SET ROLE security_test_full;
+
+-- Full permission role should be able to do everything
+SELECT * FROM cypher('security_test', $$
+ CREATE (:Person {name: 'Frank', age: 50})
+$$) AS (a agtype);
+
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Frank'})
+ SET p.age = 51
+ RETURN p.name, p.age
+$$) AS (name agtype, age agtype);
+
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Frank'})
+ DELETE p
+$$) AS (a agtype);
+
+-- ============================================================================
+-- PART 10: Permission on Specific Labels
+-- ============================================================================
+
+RESET ROLE;
+
+-- Create a role with permission only on Person label, not Document
+CREATE ROLE security_test_person_only LOGIN;
+GRANT USAGE ON SCHEMA security_test TO security_test_person_only;
+GRANT USAGE ON SCHEMA ag_catalog TO security_test_person_only;
+GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA ag_catalog TO security_test_person_only;
+GRANT SELECT ON ALL TABLES IN SCHEMA ag_catalog TO security_test_person_only;
+-- Only grant permissions on Person table
+GRANT SELECT, INSERT, UPDATE, DELETE ON security_test."Person" TO security_test_person_only;
+GRANT SELECT ON security_test."KNOWS" TO security_test_person_only;
+GRANT SELECT ON security_test._ag_label_vertex TO security_test_person_only;
+GRANT SELECT ON security_test._ag_label_edge TO security_test_person_only;
+GRANT USAGE ON ALL SEQUENCES IN SCHEMA security_test TO security_test_person_only;
+
+SET ROLE security_test_person_only;
+
+-- Test: Operations on Person should succeed
+SELECT * FROM cypher('security_test', $$
+ MATCH (p:Person {name: 'Alice'}) RETURN p.name
+$$) AS (name agtype);
+
+-- Test: SELECT on Document should fail (no permission)
+SELECT * FROM cypher('security_test', $$
+ MATCH (d:Document) RETURN d.title
+$$) AS (title agtype);
+
+-- Test: CREATE Document should fail (no permission on Document table)
+SELECT * FROM cypher('security_test', $$
+ CREATE (:Document {title: 'New Doc'})
+$$) AS (a agtype);
+
+-- ============================================================================
+-- PART 11: Function EXECUTE Permission Tests
+-- ============================================================================
+
+RESET ROLE;
+
+-- Create role with no function execute permissions
+CREATE ROLE security_test_noexec LOGIN;
+GRANT USAGE ON SCHEMA security_test TO security_test_noexec;
+GRANT USAGE ON SCHEMA ag_catalog TO security_test_noexec;
+
+-- Revoke execute from PUBLIC on functions we want to test
+REVOKE EXECUTE ON FUNCTION ag_catalog.create_graph(name) FROM PUBLIC;
+REVOKE EXECUTE ON FUNCTION ag_catalog.drop_graph(name, boolean) FROM PUBLIC;
+REVOKE EXECUTE ON FUNCTION ag_catalog.create_vlabel(cstring, cstring) FROM PUBLIC;
+REVOKE EXECUTE ON FUNCTION ag_catalog.create_elabel(cstring, cstring) FROM PUBLIC;
+
+SET ROLE security_test_noexec;
+
+-- Test: create_graph should fail without EXECUTE permission
+SELECT create_graph('unauthorized_graph');
+
+-- Test: drop_graph should fail without EXECUTE permission
+SELECT drop_graph('security_test', true);
+
+-- Test: create_vlabel should fail without EXECUTE permission
+SELECT create_vlabel('security_test', 'NewLabel');
+
+-- Test: create_elabel should fail without EXECUTE permission
+SELECT create_elabel('security_test', 'NewEdge');
+
+RESET ROLE;
+
+-- Grant execute on specific function and test
+GRANT EXECUTE ON FUNCTION ag_catalog.create_vlabel(cstring, cstring) TO security_test_noexec;
+
+SET ROLE security_test_noexec;
+
+-- Test: create_vlabel should now get past execute check (will fail on schema permission instead)
+SELECT create_vlabel('security_test', 'TestLabel');
+
+-- Test: create_graph should still fail with execute permission denied
+SELECT create_graph('unauthorized_graph');
+
+RESET ROLE;
+
+-- Restore execute permissions to PUBLIC
+GRANT EXECUTE ON FUNCTION ag_catalog.create_graph(name) TO PUBLIC;
+GRANT EXECUTE ON FUNCTION ag_catalog.drop_graph(name, boolean) TO PUBLIC;
+GRANT EXECUTE ON FUNCTION ag_catalog.create_vlabel(cstring, cstring) TO PUBLIC;
+GRANT EXECUTE ON FUNCTION ag_catalog.create_elabel(cstring, cstring) TO PUBLIC;
+
+-- ============================================================================
+-- PART 12: startNode/endNode Permission Tests
+-- ============================================================================
+
+-- Create role with SELECT on base tables but NOT on Person label
+CREATE ROLE security_test_edge_only LOGIN;
+GRANT USAGE ON SCHEMA security_test TO security_test_edge_only;
+GRANT USAGE ON SCHEMA ag_catalog TO security_test_edge_only;
+GRANT SELECT ON ALL TABLES IN SCHEMA ag_catalog TO security_test_edge_only;
+GRANT SELECT ON security_test."KNOWS" TO security_test_edge_only;
+GRANT SELECT ON security_test._ag_label_edge TO security_test_edge_only;
+GRANT SELECT ON security_test._ag_label_vertex TO security_test_edge_only;
+-- Note: NOT granting SELECT on security_test."Person"
+
+SET ROLE security_test_edge_only;
+
+-- Test: endNode fails without SELECT on Person table
+SELECT * FROM cypher('security_test', $$
+ MATCH ()-[e:KNOWS]->()
+ RETURN endNode(e)
+$$) AS (end_vertex agtype);
+
+-- Test: startNode fails without SELECT on Person table
+SELECT * FROM cypher('security_test', $$
+ MATCH ()-[e:KNOWS]->()
+ RETURN startNode(e)
+$$) AS (start_vertex agtype);
+
+RESET ROLE;
+
+-- Grant SELECT on Person and verify success
+GRANT SELECT ON security_test."Person" TO security_test_edge_only;
+
+SET ROLE security_test_edge_only;
+
+-- Test: Should now succeed with SELECT permission
+SELECT * FROM cypher('security_test', $$
+ MATCH ()-[e:KNOWS]->()
+ RETURN startNode(e).name, endNode(e).name
+$$) AS (start_name agtype, end_name agtype);
+
+RESET ROLE;
+
+-- ============================================================================
+-- Cleanup
+-- ============================================================================
+
+RESET ROLE;
+
+-- Drop all owned objects and privileges for each role, then drop the role
+DROP OWNED BY security_test_noread CASCADE;
+DROP ROLE security_test_noread;
+
+DROP OWNED BY security_test_base_only CASCADE;
+DROP ROLE security_test_base_only;
+
+DROP OWNED BY security_test_readonly CASCADE;
+DROP ROLE security_test_readonly;
+
+DROP OWNED BY security_test_insert CASCADE;
+DROP ROLE security_test_insert;
+
+DROP OWNED BY security_test_update CASCADE;
+DROP ROLE security_test_update;
+
+DROP OWNED BY security_test_delete CASCADE;
+DROP ROLE security_test_delete;
+
+DROP OWNED BY security_test_detach_delete CASCADE;
+DROP ROLE security_test_detach_delete;
+
+DROP OWNED BY security_test_full CASCADE;
+DROP ROLE security_test_full;
+
+DROP OWNED BY security_test_person_only CASCADE;
+DROP ROLE security_test_person_only;
+
+DROP OWNED BY security_test_noexec CASCADE;
+DROP ROLE security_test_noexec;
+
+DROP OWNED BY security_test_edge_only CASCADE;
+DROP ROLE security_test_edge_only;
+
+-- Drop test graph
+SELECT drop_graph('security_test', true);
+
+--
+-- Row-Level Security (RLS) Tests
+--
+
+--
+-- Setup: Create test graph, data and roles for RLS tests
+--
+SELECT create_graph('rls_graph');
+
+-- Create test roles
+CREATE ROLE rls_user1 LOGIN;
+CREATE ROLE rls_user2 LOGIN;
+CREATE ROLE rls_admin LOGIN BYPASSRLS; -- Role that bypasses RLS
+
+-- Create base test data FIRST (as superuser) - this creates the label tables
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Person {name: 'Alice', owner: 'rls_user1', department: 'Engineering', level: 1})
+$$) AS (a agtype);
+
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Person {name: 'Bob', owner: 'rls_user2', department: 'Engineering', level: 2})
+$$) AS (a agtype);
+
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Person {name: 'Charlie', owner: 'rls_user1', department: 'Sales', level: 1})
+$$) AS (a agtype);
+
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Person {name: 'Diana', owner: 'rls_user2', department: 'Sales', level: 3})
+$$) AS (a agtype);
+
+-- Create a second vertex label for multi-label tests
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Document {title: 'Public Doc', classification: 'public', owner: 'rls_user1'})
+$$) AS (a agtype);
+
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Document {title: 'Secret Doc', classification: 'secret', owner: 'rls_user2'})
+$$) AS (a agtype);
+
+-- Create edges
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a:Person {name: 'Alice'}), (b:Person {name: 'Bob'})
+ CREATE (a)-[:KNOWS {since: 2020, strength: 'weak'}]->(b)
+$$) AS (a agtype);
+
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a:Person {name: 'Charlie'}), (b:Person {name: 'Diana'})
+ CREATE (a)-[:KNOWS {since: 2021, strength: 'strong'}]->(b)
+$$) AS (a agtype);
+
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a:Person {name: 'Alice'}), (b:Person {name: 'Charlie'})
+ CREATE (a)-[:KNOWS {since: 2022, strength: 'strong'}]->(b)
+$$) AS (a agtype);
+
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a:Person {name: 'Alice'}), (d:Document {title: 'Public Doc'})
+ CREATE (a)-[:AUTHORED]->(d)
+$$) AS (a agtype);
+
+-- Grant permissions AFTER creating tables (so Person, Document, KNOWS, AUTHORED exist)
+GRANT USAGE ON SCHEMA rls_graph TO rls_user1, rls_user2, rls_admin;
+GRANT ALL ON ALL TABLES IN SCHEMA rls_graph TO rls_user1, rls_user2, rls_admin;
+GRANT USAGE ON SCHEMA ag_catalog TO rls_user1, rls_user2, rls_admin;
+GRANT USAGE ON ALL SEQUENCES IN SCHEMA rls_graph TO rls_user1, rls_user2, rls_admin;
+
+-- ============================================================================
+-- PART 1: Vertex SELECT Policies (USING clause)
+-- ============================================================================
+
+-- Enable RLS on Person label
+ALTER TABLE rls_graph."Person" ENABLE ROW LEVEL SECURITY;
+ALTER TABLE rls_graph."Person" FORCE ROW LEVEL SECURITY;
+
+-- 1.1: Basic ownership filtering
+CREATE POLICY person_select_own ON rls_graph."Person"
+ FOR SELECT
+ USING (properties->>'"owner"' = current_user);
+
+-- Test as rls_user1 - should only see Alice and Charlie (owned by rls_user1)
+SET ROLE rls_user1;
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+
+-- Test as rls_user2 - should only see Bob and Diana (owned by rls_user2)
+SET ROLE rls_user2;
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+
+RESET ROLE;
+
+-- 1.2: Default deny - no permissive policies means no access
+DROP POLICY person_select_own ON rls_graph."Person";
+
+-- With no policies, RLS blocks all access
+SET ROLE rls_user1;
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+
+RESET ROLE;
+
+-- ============================================================================
+-- PART 2: Vertex INSERT Policies (WITH CHECK) - CREATE
+-- ============================================================================
+
+-- Allow SELECT for all (so we can verify results)
+CREATE POLICY person_select_all ON rls_graph."Person"
+ FOR SELECT USING (true);
+
+-- 2.1: Basic WITH CHECK - users can only insert rows they own
+CREATE POLICY person_insert_own ON rls_graph."Person"
+ FOR INSERT
+ WITH CHECK (properties->>'"owner"' = current_user);
+
+-- Test as rls_user1 - should succeed (owner matches current_user)
+SET ROLE rls_user1;
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Person {name: 'User1Created', owner: 'rls_user1', department: 'Test', level: 1})
+$$) AS (a agtype);
+
+-- Test as rls_user1 - should FAIL (owner doesn't match current_user)
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Person {name: 'User1Fake', owner: 'rls_user2', department: 'Test', level: 1})
+$$) AS (a agtype);
+
+RESET ROLE;
+
+-- Verify only User1Created was created
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) WHERE p.department = 'Test' RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+
+-- 2.2: Default deny for INSERT - no INSERT policy blocks all inserts
+DROP POLICY person_insert_own ON rls_graph."Person";
+
+SET ROLE rls_user1;
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Person {name: 'ShouldFail', owner: 'rls_user1', department: 'Blocked', level: 1})
+$$) AS (a agtype);
+RESET ROLE;
+
+-- Verify nothing was created in Blocked department
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) WHERE p.department = 'Blocked' RETURN p.name
+$$) AS (name agtype);
+
+-- cleanup
+DROP POLICY person_select_all ON rls_graph."Person";
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) WHERE p.department = 'Test' DELETE p
+$$) AS (a agtype);
+
+-- ============================================================================
+-- PART 3: Vertex UPDATE Policies - SET
+-- ============================================================================
+
+CREATE POLICY person_select_all ON rls_graph."Person"
+ FOR SELECT USING (true);
+
+-- 3.1: USING clause only - filter which rows can be updated
+CREATE POLICY person_update_using ON rls_graph."Person"
+ FOR UPDATE
+ USING (properties->>'"owner"' = current_user);
+
+SET ROLE rls_user1;
+
+-- Should succeed - rls_user1 owns Alice
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'Alice'}) SET p.updated = true RETURN p.name, p.updated
+$$) AS (name agtype, updated agtype);
+
+-- Should silently skip - rls_user1 doesn't own Bob (USING filters it out)
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'Bob'}) SET p.updated = true RETURN p.name, p.updated
+$$) AS (name agtype, updated agtype);
+
+RESET ROLE;
+
+-- Verify Alice was updated, Bob was not
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) WHERE p.name IN ['Alice', 'Bob'] RETURN p.name, p.updated ORDER BY p.name
+$$) AS (name agtype, updated agtype);
+
+-- 3.2: WITH CHECK clause - validate new values
+DROP POLICY person_update_using ON rls_graph."Person";
+
+CREATE POLICY person_update_check ON rls_graph."Person"
+ FOR UPDATE
+ USING (true) -- Can update any row
+ WITH CHECK (properties->>'"owner"' = current_user); -- But new value must keep owner
+
+SET ROLE rls_user1;
+
+-- Should succeed - modifying property but keeping owner
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'Alice'}) SET p.verified = true RETURN p.name, p.verified
+$$) AS (name agtype, verified agtype);
+
+-- Should FAIL - trying to change owner to someone else
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'Alice'}) SET p.owner = 'rls_user2' RETURN p.owner
+$$) AS (owner agtype);
+
+RESET ROLE;
+
+-- Verify owner wasn't changed
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'Alice'}) RETURN p.owner
+$$) AS (owner agtype);
+
+-- 3.3: Both USING and WITH CHECK together
+DROP POLICY person_update_check ON rls_graph."Person";
+
+CREATE POLICY person_update_both ON rls_graph."Person"
+ FOR UPDATE
+ USING (properties->>'"owner"' = current_user)
+ WITH CHECK (properties->>'"owner"' = current_user);
+
+SET ROLE rls_user1;
+
+-- Should succeed - owns Alice, keeping owner
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'Alice'}) SET p.status = 'active' RETURN p.name, p.status
+$$) AS (name agtype, status agtype);
+
+-- Should silently skip - doesn't own Bob (USING filters)
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'Bob'}) SET p.status = 'active' RETURN p.name, p.status
+$$) AS (name agtype, status agtype);
+
+RESET ROLE;
+
+-- ============================================================================
+-- PART 4: Vertex UPDATE Policies - REMOVE
+-- ============================================================================
+
+-- Keep existing update policy, test REMOVE operation
+
+SET ROLE rls_user1;
+
+-- Should succeed - owns Alice
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'Alice'}) REMOVE p.status RETURN p.name, p.status
+$$) AS (name agtype, status agtype);
+
+-- Should silently skip - doesn't own Bob
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'Bob'}) REMOVE p.department RETURN p.name, p.department
+$$) AS (name agtype, dept agtype);
+
+RESET ROLE;
+
+-- Verify Bob still has department
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'Bob'}) RETURN p.department
+$$) AS (dept agtype);
+
+-- cleanup
+DROP POLICY person_select_all ON rls_graph."Person";
+DROP POLICY person_update_both ON rls_graph."Person";
+
+-- ============================================================================
+-- PART 5: Vertex DELETE Policies
+-- ============================================================================
+
+CREATE POLICY person_select_all ON rls_graph."Person"
+ FOR SELECT USING (true);
+
+-- Create test data for delete tests
+CREATE POLICY person_insert_all ON rls_graph."Person"
+ FOR INSERT WITH CHECK (true);
+
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Person {name: 'DeleteTest1', owner: 'rls_user1', department: 'DeleteTest', level: 1})
+$$) AS (a agtype);
+
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Person {name: 'DeleteTest2', owner: 'rls_user2', department: 'DeleteTest', level: 1})
+$$) AS (a agtype);
+
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Person {name: 'DeleteTest3', owner: 'rls_user1', department: 'DeleteTest', level: 1})
+$$) AS (a agtype);
+
+DROP POLICY person_insert_all ON rls_graph."Person";
+
+-- 5.1: Basic USING filtering for DELETE
+CREATE POLICY person_delete_own ON rls_graph."Person"
+ FOR DELETE
+ USING (properties->>'"owner"' = current_user);
+
+SET ROLE rls_user1;
+
+-- Should succeed - owns DeleteTest1
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'DeleteTest1'}) DELETE p
+$$) AS (a agtype);
+
+-- Should silently skip - doesn't own DeleteTest2
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'DeleteTest2'}) DELETE p
+$$) AS (a agtype);
+
+RESET ROLE;
+
+-- Verify DeleteTest1 deleted, DeleteTest2 still exists
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) WHERE p.department = 'DeleteTest' RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+
+-- 5.2: Default deny for DELETE - no policy blocks all deletes
+DROP POLICY person_delete_own ON rls_graph."Person";
+
+SET ROLE rls_user1;
+
+-- Should silently skip - no DELETE policy means default deny
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'DeleteTest3'}) DELETE p
+$$) AS (a agtype);
+
+RESET ROLE;
+
+-- Verify DeleteTest3 still exists
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'DeleteTest3'}) RETURN p.name
+$$) AS (name agtype);
+
+-- cleanup
+DROP POLICY person_select_all ON rls_graph."Person";
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) WHERE p.department = 'DeleteTest' DELETE p
+$$) AS (a agtype);
+
+-- ============================================================================
+-- PART 6: MERGE Policies
+-- ============================================================================
+
+CREATE POLICY person_select_all ON rls_graph."Person"
+ FOR SELECT USING (true);
+
+CREATE POLICY person_insert_own ON rls_graph."Person"
+ FOR INSERT
+ WITH CHECK (properties->>'"owner"' = current_user);
+
+-- 6.1: MERGE creating new vertex - INSERT policy applies
+SET ROLE rls_user1;
+
+-- Should succeed - creating with correct owner
+SELECT * FROM cypher('rls_graph', $$
+ MERGE (p:Person {name: 'MergeNew1', owner: 'rls_user1', department: 'Merge', level: 1})
+ RETURN p.name
+$$) AS (name agtype);
+
+-- Should FAIL - creating with wrong owner
+SELECT * FROM cypher('rls_graph', $$
+ MERGE (p:Person {name: 'MergeNew2', owner: 'rls_user2', department: 'Merge', level: 1})
+ RETURN p.name
+$$) AS (name agtype);
+
+RESET ROLE;
+
+-- 6.2: MERGE matching existing - only SELECT needed
+SET ROLE rls_user1;
+
+-- Should succeed - Alice exists and SELECT allowed
+SELECT * FROM cypher('rls_graph', $$
+ MERGE (p:Person {name: 'Alice'})
+ RETURN p.name, p.owner
+$$) AS (name agtype, owner agtype);
+
+RESET ROLE;
+
+-- Verify only MergeNew1 was created
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) WHERE p.department = 'Merge' RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+
+-- cleanup
+DROP POLICY person_select_all ON rls_graph."Person";
+DROP POLICY person_insert_own ON rls_graph."Person";
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) WHERE p.department = 'Merge' DELETE p
+$$) AS (a agtype);
+
+-- ============================================================================
+-- PART 7: Edge SELECT Policies
+-- ============================================================================
+
+-- Disable vertex RLS, enable edge RLS
+ALTER TABLE rls_graph."Person" DISABLE ROW LEVEL SECURITY;
+ALTER TABLE rls_graph."KNOWS" ENABLE ROW LEVEL SECURITY;
+ALTER TABLE rls_graph."KNOWS" FORCE ROW LEVEL SECURITY;
+
+-- Policy: Only see edges from 2021 or later
+CREATE POLICY knows_select_recent ON rls_graph."KNOWS"
+ FOR SELECT
+ USING ((properties->>'"since"')::int >= 2021);
+
+SET ROLE rls_user1;
+
+-- Should only see 2021 and 2022 edges (not 2020)
+SELECT * FROM cypher('rls_graph', $$
+ MATCH ()-[k:KNOWS]->() RETURN k.since ORDER BY k.since
+$$) AS (since agtype);
+
+RESET ROLE;
+
+-- ============================================================================
+-- PART 8: Edge INSERT Policies (CREATE edge)
+-- ============================================================================
+
+DROP POLICY knows_select_recent ON rls_graph."KNOWS";
+
+CREATE POLICY knows_select_all ON rls_graph."KNOWS"
+ FOR SELECT USING (true);
+
+-- Policy: Can only create edges with strength = 'strong'
+CREATE POLICY knows_insert_strong ON rls_graph."KNOWS"
+ FOR INSERT
+ WITH CHECK (properties->>'"strength"' = 'strong');
+
+SET ROLE rls_user1;
+
+-- Should succeed - strength is 'strong'
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a:Person {name: 'Bob'}), (b:Person {name: 'Diana'})
+ CREATE (a)-[:KNOWS {since: 2023, strength: 'strong'}]->(b)
+$$) AS (a agtype);
+
+-- Should FAIL - strength is 'weak'
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a:Person {name: 'Diana'}), (b:Person {name: 'Alice'})
+ CREATE (a)-[:KNOWS {since: 2023, strength: 'weak'}]->(b)
+$$) AS (a agtype);
+
+RESET ROLE;
+
+-- Verify only strong edge was created
+SELECT * FROM cypher('rls_graph', $$
+ MATCH ()-[k:KNOWS]->() WHERE k.since = 2023 RETURN k.strength ORDER BY k.strength
+$$) AS (strength agtype);
+
+-- cleanup
+DROP POLICY knows_insert_strong ON rls_graph."KNOWS";
+
+-- ============================================================================
+-- PART 9: Edge UPDATE Policies (SET on edge)
+-- ============================================================================
+
+-- Policy: Can only update edges with strength = 'strong'
+CREATE POLICY knows_update_strong ON rls_graph."KNOWS"
+ FOR UPDATE
+ USING (properties->>'"strength"' = 'strong')
+ WITH CHECK (properties->>'"strength"' = 'strong');
+
+SET ROLE rls_user1;
+
+-- Should succeed - edge has strength 'strong'
+SELECT * FROM cypher('rls_graph', $$
+ MATCH ()-[k:KNOWS {since: 2021}]->() SET k.notes = 'updated' RETURN k.since, k.notes
+$$) AS (since agtype, notes agtype);
+
+-- Should silently skip - edge has strength 'weak'
+SELECT * FROM cypher('rls_graph', $$
+ MATCH ()-[k:KNOWS {since: 2020}]->() SET k.notes = 'updated' RETURN k.since, k.notes
+$$) AS (since agtype, notes agtype);
+
+RESET ROLE;
+
+-- Verify only 2021 edge was updated
+SELECT * FROM cypher('rls_graph', $$
+ MATCH ()-[k:KNOWS]->() WHERE k.since IN [2020, 2021] RETURN k.since, k.notes ORDER BY k.since
+$$) AS (since agtype, notes agtype);
+
+-- cleanup
+DROP POLICY knows_select_all ON rls_graph."KNOWS";
+DROP POLICY knows_update_strong ON rls_graph."KNOWS";
+
+-- ============================================================================
+-- PART 10: Edge DELETE Policies
+-- ============================================================================
+
+CREATE POLICY knows_select_all ON rls_graph."KNOWS"
+ FOR SELECT USING (true);
+
+-- Create test edges for delete
+CREATE POLICY knows_insert_all ON rls_graph."KNOWS"
+ FOR INSERT WITH CHECK (true);
+
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a:Person {name: 'Bob'}), (b:Person {name: 'Charlie'})
+ CREATE (a)-[:KNOWS {since: 2018, strength: 'weak'}]->(b)
+$$) AS (a agtype);
+
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a:Person {name: 'Diana'}), (b:Person {name: 'Charlie'})
+ CREATE (a)-[:KNOWS {since: 2019, strength: 'strong'}]->(b)
+$$) AS (a agtype);
+
+DROP POLICY knows_insert_all ON rls_graph."KNOWS";
+
+-- Policy: Can only delete edges with strength = 'weak'
+CREATE POLICY knows_delete_weak ON rls_graph."KNOWS"
+ FOR DELETE
+ USING (properties->>'"strength"' = 'weak');
+
+SET ROLE rls_user1;
+
+-- Should succeed - edge has strength 'weak'
+SELECT * FROM cypher('rls_graph', $$
+ MATCH ()-[k:KNOWS {since: 2018}]->() DELETE k
+$$) AS (a agtype);
+
+-- Should silently skip - edge has strength 'strong'
+SELECT * FROM cypher('rls_graph', $$
+ MATCH ()-[k:KNOWS {since: 2019}]->() DELETE k
+$$) AS (a agtype);
+
+RESET ROLE;
+
+-- Verify 2018 edge deleted, 2019 edge still exists
+SELECT * FROM cypher('rls_graph', $$
+ MATCH ()-[k:KNOWS]->() WHERE k.since IN [2018, 2019] RETURN k.since ORDER BY k.since
+$$) AS (since agtype);
+
+-- cleanup
+DROP POLICY knows_delete_weak ON rls_graph."KNOWS";
+
+-- ============================================================================
+-- PART 11: DETACH DELETE
+-- ============================================================================
+
+-- Re-enable Person RLS
+ALTER TABLE rls_graph."Person" ENABLE ROW LEVEL SECURITY;
+CREATE POLICY person_all ON rls_graph."Person"
+ FOR ALL USING (true) WITH CHECK (true);
+
+-- Create test data with a protected edge
+CREATE POLICY knows_insert_all ON rls_graph."KNOWS"
+ FOR INSERT WITH CHECK (true);
+
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Person {name: 'DetachTest1', owner: 'test', department: 'Detach', level: 1})
+$$) AS (a agtype);
+
+SELECT * FROM cypher('rls_graph', $$
+ CREATE (:Person {name: 'DetachTest2', owner: 'test', department: 'Detach', level: 1})
+$$) AS (a agtype);
+
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a:Person {name: 'DetachTest1'}), (b:Person {name: 'DetachTest2'})
+ CREATE (a)-[:KNOWS {since: 2010, strength: 'protected'}]->(b)
+$$) AS (a agtype);
+
+DROP POLICY knows_insert_all ON rls_graph."KNOWS";
+
+-- Policy: Cannot delete edges with strength = 'protected'
+CREATE POLICY knows_delete_not_protected ON rls_graph."KNOWS"
+ FOR DELETE
+ USING (properties->>'"strength"' != 'protected');
+
+SET ROLE rls_user1;
+
+-- Should ERROR - DETACH DELETE cannot silently skip (would leave dangling edge)
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'DetachTest1'}) DETACH DELETE p
+$$) AS (a agtype);
+
+RESET ROLE;
+
+-- Verify vertex still exists (delete was blocked)
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'DetachTest1'}) RETURN p.name
+$$) AS (name agtype);
+
+-- cleanup
+DROP POLICY person_all ON rls_graph."Person";
+DROP POLICY knows_select_all ON rls_graph."KNOWS";
+DROP POLICY knows_delete_not_protected ON rls_graph."KNOWS";
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) WHERE p.department = 'Detach' DETACH DELETE p
+$$) AS (a agtype);
+
+-- ============================================================================
+-- PART 12: Multiple Labels in Single Query
+-- ============================================================================
+
+-- Enable RLS on Document too
+ALTER TABLE rls_graph."Document" ENABLE ROW LEVEL SECURITY;
+ALTER TABLE rls_graph."Document" FORCE ROW LEVEL SECURITY;
+
+-- Policy: Users see their own Person records
+CREATE POLICY person_own ON rls_graph."Person"
+ FOR SELECT
+ USING (properties->>'"owner"' = current_user);
+
+-- Policy: Users see only public documents
+CREATE POLICY doc_public ON rls_graph."Document"
+ FOR SELECT
+ USING (properties->>'"classification"' = 'public');
+
+SET ROLE rls_user1;
+
+-- Should only see Alice and Charlie (Person) with Public Doc (Document)
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (d:Document) RETURN d.title ORDER BY d.title
+$$) AS (title agtype);
+
+-- Combined query - should respect both policies
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person)-[:AUTHORED]->(d:Document)
+ RETURN p.name, d.title
+$$) AS (person agtype, doc agtype);
+
+RESET ROLE;
+
+-- ============================================================================
+-- PART 13: Permissive vs Restrictive Policies
+-- ============================================================================
+
+DROP POLICY person_own ON rls_graph."Person";
+DROP POLICY doc_public ON rls_graph."Document";
+
+ALTER TABLE rls_graph."Document" DISABLE ROW LEVEL SECURITY;
+ALTER TABLE rls_graph."KNOWS" DISABLE ROW LEVEL SECURITY;
+
+-- 13.1: Multiple permissive policies (OR logic)
+CREATE POLICY person_permissive_own ON rls_graph."Person"
+ AS PERMISSIVE FOR SELECT
+ USING (properties->>'"owner"' = current_user);
+
+CREATE POLICY person_permissive_eng ON rls_graph."Person"
+ AS PERMISSIVE FOR SELECT
+ USING (properties->>'"department"' = 'Engineering');
+
+SET ROLE rls_user1;
+
+-- Should see: Alice (own), Charlie (own), Bob (Engineering)
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) WHERE p.department IN ['Engineering', 'Sales']
+ RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+
+RESET ROLE;
+
+-- 13.2: Add restrictive policy (AND with permissive)
+CREATE POLICY person_restrictive_level ON rls_graph."Person"
+ AS RESTRICTIVE FOR SELECT
+ USING ((properties->>'"level"')::int <= 2);
+
+SET ROLE rls_user1;
+
+-- Should see: Alice (own, level 1), Bob (Engineering, level 2), Charlie (own, level 1)
+-- Diana (level 3) blocked by restrictive
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) RETURN p.name, p.level ORDER BY p.name
+$$) AS (name agtype, level agtype);
+
+RESET ROLE;
+
+-- 13.3: Multiple restrictive policies (all must pass)
+CREATE POLICY person_restrictive_sales ON rls_graph."Person"
+ AS RESTRICTIVE FOR SELECT
+ USING (properties->>'"department"' != 'Sales');
+
+SET ROLE rls_user1;
+
+-- Should see: Alice (own, level 1, not Sales), Bob (Engineering, level 2, not Sales)
+-- Charlie blocked by Sales restriction
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+
+RESET ROLE;
+
+-- ============================================================================
+-- PART 14: BYPASSRLS Role and Superuser Behavior
+-- ============================================================================
+
+DROP POLICY person_permissive_own ON rls_graph."Person";
+DROP POLICY person_permissive_eng ON rls_graph."Person";
+DROP POLICY person_restrictive_level ON rls_graph."Person";
+DROP POLICY person_restrictive_sales ON rls_graph."Person";
+
+-- Restrictive policy that blocks most access
+CREATE POLICY person_very_restrictive ON rls_graph."Person"
+ FOR SELECT
+ USING (properties->>'"name"' = 'Nobody');
+
+-- 14.1: Regular user sees nothing
+SET ROLE rls_user1;
+
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+
+RESET ROLE;
+
+-- 14.2: BYPASSRLS role sees everything
+SET ROLE rls_admin;
+
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+
+RESET ROLE;
+
+-- 14.3: Superuser sees everything (implicit bypass)
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) RETURN p.name ORDER BY p.name
+$$) AS (name agtype);
+
+-- ============================================================================
+-- PART 15: Complex Multi-Operation Queries
+-- ============================================================================
+
+DROP POLICY person_very_restrictive ON rls_graph."Person";
+
+CREATE POLICY person_select_all ON rls_graph."Person"
+ FOR SELECT USING (true);
+
+CREATE POLICY person_insert_own ON rls_graph."Person"
+ FOR INSERT
+ WITH CHECK (properties->>'"owner"' = current_user);
+
+CREATE POLICY person_update_own ON rls_graph."Person"
+ FOR UPDATE
+ USING (properties->>'"owner"' = current_user)
+ WITH CHECK (properties->>'"owner"' = current_user);
+
+-- 15.1: MATCH + CREATE in one query
+SET ROLE rls_user1;
+
+-- Should succeed - creating with correct owner
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a:Person {name: 'Alice'})
+ CREATE (a)-[:KNOWS]->(:Person {name: 'NewFromMatch', owner: 'rls_user1', department: 'Complex', level: 1})
+$$) AS (a agtype);
+
+RESET ROLE;
+
+-- Verify creation
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person {name: 'NewFromMatch'}) RETURN p.name, p.owner
+$$) AS (name agtype, owner agtype);
+
+-- 15.2: MATCH + SET in one query
+SET ROLE rls_user1;
+
+-- Should succeed on Alice (own), skip Bob (not own)
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) WHERE p.name IN ['Alice', 'Bob']
+ SET p.complexTest = true
+ RETURN p.name, p.complexTest
+$$) AS (name agtype, test agtype);
+
+RESET ROLE;
+
+-- Verify only Alice was updated
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (p:Person) WHERE p.name IN ['Alice', 'Bob']
+ RETURN p.name, p.complexTest ORDER BY p.name
+$$) AS (name agtype, test agtype);
+
+-- cleanup
+DROP POLICY IF EXISTS person_select_all ON rls_graph."Person";
+DROP POLICY IF EXISTS person_insert_own ON rls_graph."Person";
+DROP POLICY IF EXISTS person_update_own ON rls_graph."Person";
+
+-- ============================================================================
+-- PART 16: startNode/endNode RLS Enforcement
+-- ============================================================================
+
+ALTER TABLE rls_graph."Person" DISABLE ROW LEVEL SECURITY;
+
+-- Enable RLS on Person with restrictive policy
+ALTER TABLE rls_graph."Person" ENABLE ROW LEVEL SECURITY;
+ALTER TABLE rls_graph."Person" FORCE ROW LEVEL SECURITY;
+
+-- Policy: users can only see their own Person records
+CREATE POLICY person_own ON rls_graph."Person"
+ FOR SELECT
+ USING (properties->>'"owner"' = current_user);
+
+-- Enable edge access for testing
+ALTER TABLE rls_graph."KNOWS" ENABLE ROW LEVEL SECURITY;
+CREATE POLICY knows_all ON rls_graph."KNOWS"
+ FOR SELECT USING (true);
+
+-- 16.1: startNode blocked by RLS - should error
+SET ROLE rls_user1;
+
+-- rls_user1 can see the edge (Alice->Bob) but cannot see Bob (owned by rls_user2)
+-- endNode should error because Bob is blocked by RLS
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a:Person {name: 'Alice'})-[e:KNOWS]->(b)
+ RETURN endNode(e)
+$$) AS (end_vertex agtype);
+
+-- 16.2: endNode blocked by RLS - should error
+-- rls_user1 cannot see Bob, so startNode on an edge starting from Bob should error
+SET ROLE rls_user2;
+
+-- rls_user2 can see Bob but not Alice (owned by rls_user1)
+-- startNode should error because Alice is blocked by RLS
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a)-[e:KNOWS]->(b:Person {name: 'Bob'})
+ RETURN startNode(e)
+$$) AS (start_vertex agtype);
+
+-- 16.3: startNode/endNode succeed when RLS allows access
+SET ROLE rls_user1;
+
+-- Alice->Charlie edge: rls_user1 owns both, should succeed
+SELECT * FROM cypher('rls_graph', $$
+ MATCH (a:Person {name: 'Alice'})-[e:KNOWS]->(c:Person {name: 'Charlie'})
+ RETURN startNode(e).name, endNode(e).name
+$$) AS (start_name agtype, end_name agtype);
+
+RESET ROLE;
+
+-- cleanup
+DROP POLICY person_own ON rls_graph."Person";
+DROP POLICY knows_all ON rls_graph."KNOWS";
+ALTER TABLE rls_graph."KNOWS" DISABLE ROW LEVEL SECURITY;
+
+-- ============================================================================
+-- RLS CLEANUP
+-- ============================================================================
+
+RESET ROLE;
+
+-- Disable RLS on all tables
+ALTER TABLE rls_graph."Person" DISABLE ROW LEVEL SECURITY;
+ALTER TABLE rls_graph."Document" DISABLE ROW LEVEL SECURITY;
+ALTER TABLE rls_graph."KNOWS" DISABLE ROW LEVEL SECURITY;
+
+-- Drop roles
+DROP OWNED BY rls_user1 CASCADE;
+DROP ROLE rls_user1;
+
+DROP OWNED BY rls_user2 CASCADE;
+DROP ROLE rls_user2;
+
+DROP OWNED BY rls_admin CASCADE;
+DROP ROLE rls_admin;
+
+-- Drop test graph
+SELECT drop_graph('rls_graph', true);
diff --git a/src/backend/executor/cypher_create.c b/src/backend/executor/cypher_create.c
index 2091ea29..a90c2a19 100644
--- a/src/backend/executor/cypher_create.c
+++ b/src/backend/executor/cypher_create.c
@@ -19,6 +19,8 @@
#include "postgres.h"
+#include "utils/rls.h"
+
#include "catalog/ag_label.h"
#include "executor/cypher_executor.h"
#include "executor/cypher_utils.h"
@@ -120,6 +122,12 @@ static void begin_cypher_create(CustomScanState *node, EState *estate,
cypher_node->prop_expr_state = ExecInitExpr(cypher_node->prop_expr,
(PlanState *)node);
}
+
+ /* Setup RLS WITH CHECK policies if RLS is enabled */
+ if (check_enable_rls(rel->rd_id, InvalidOid, true) == RLS_ENABLED)
+ {
+ setup_wcos(cypher_node->resultRelInfo, estate, node, CMD_INSERT);
+ }
}
}
diff --git a/src/backend/executor/cypher_delete.c b/src/backend/executor/cypher_delete.c
index 5f9aa561..4766c6e7 100644
--- a/src/backend/executor/cypher_delete.c
+++ b/src/backend/executor/cypher_delete.c
@@ -19,8 +19,11 @@
#include "postgres.h"
-#include "storage/bufmgr.h"
#include "common/hashfn.h"
+#include "miscadmin.h"
+#include "storage/bufmgr.h"
+#include "utils/acl.h"
+#include "utils/rls.h"
#include "catalog/ag_label.h"
#include "executor/cypher_executor.h"
@@ -370,6 +373,16 @@ static void process_delete_list(CustomScanState *node)
ExprContext *econtext = css->css.ss.ps.ps_ExprContext;
TupleTableSlot *scanTupleSlot = econtext->ecxt_scantuple;
EState *estate = node->ss.ps.state;
+ HTAB *qual_cache = NULL;
+ HASHCTL hashctl;
+
+ /* Hash table for caching compiled security quals per label */
+ MemSet(&hashctl, 0, sizeof(hashctl));
+ hashctl.keysize = sizeof(Oid);
+ hashctl.entrysize = sizeof(RLSCacheEntry);
+ hashctl.hcxt = CurrentMemoryContext;
+ qual_cache = hash_create("delete_qual_cache", 8, &hashctl,
+ HASH_ELEM | HASH_BLOBS | HASH_CONTEXT);
foreach(lc, css->delete_data->delete_items)
{
@@ -382,6 +395,7 @@ static void process_delete_list(CustomScanState *node)
char *label_name;
Integer *pos;
int entity_position;
+ Oid relid;
item = lfirst(lc);
@@ -400,6 +414,7 @@ static void process_delete_list(CustomScanState *node)
label_name = pnstrdup(label->val.string.val, label->val.string.len);
resultRelInfo = create_entity_result_rel_info(estate, css->delete_data->graph_name, label_name);
+ relid = RelationGetRelid(resultRelInfo->ri_RelationDesc);
/*
* Setup the scan key to require the id field on-disc to match the
@@ -447,6 +462,36 @@ static void process_delete_list(CustomScanState *node)
continue;
}
+ /* Check RLS security quals (USING policy) before delete */
+ if (check_enable_rls(relid, InvalidOid, true) == RLS_ENABLED)
+ {
+ RLSCacheEntry *entry;
+ bool found;
+
+ /* Get cached security quals and slot for this label */
+ entry = hash_search(qual_cache, &relid, HASH_ENTER, &found);
+ if (!found)
+ {
+ entry->qualExprs = setup_security_quals(resultRelInfo, estate,
+ node, CMD_DELETE);
+ entry->slot = ExecInitExtraTupleSlot(
+ estate, RelationGetDescr(resultRelInfo->ri_RelationDesc),
+ &TTSOpsHeapTuple);
+ entry->withCheckOptions = NIL;
+ entry->withCheckOptionExprs = NIL;
+ }
+
+ ExecStoreHeapTuple(heap_tuple, entry->slot, false);
+
+ /* Silently skip if USING policy filters out this row */
+ if (!check_security_quals(entry->qualExprs, entry->slot, econtext))
+ {
+ table_endscan(scan_desc);
+ destroy_entity_result_rel_info(resultRelInfo);
+ continue;
+ }
+ }
+
/*
* For vertices, we insert the vertex ID in the hashtable
* vertex_id_htab. This hashtable is used later to process
@@ -466,6 +511,9 @@ static void process_delete_list(CustomScanState *node)
table_endscan(scan_desc);
destroy_entity_result_rel_info(resultRelInfo);
}
+
+ /* Clean up the cache */
+ hash_destroy(qual_cache);
}
/*
@@ -489,9 +537,14 @@ static void check_for_connected_edges(CustomScanState *node)
TableScanDesc scan_desc;
HeapTuple tuple;
TupleTableSlot *slot;
+ Oid relid;
+ bool rls_enabled = false;
+ List *qualExprs = NIL;
+ ExprContext *econtext = NULL;
resultRelInfo = create_entity_result_rel_info(estate, graph_name,
label_name);
+ relid = RelationGetRelid(resultRelInfo->ri_RelationDesc);
estate->es_snapshot->curcid = GetCurrentCommandId(false);
estate->es_output_cid = GetCurrentCommandId(false);
scan_desc = table_beginscan(resultRelInfo->ri_RelationDesc,
@@ -500,6 +553,22 @@ static void check_for_connected_edges(CustomScanState *node)
estate, RelationGetDescr(resultRelInfo->ri_RelationDesc),
&TTSOpsHeapTuple);
+ /*
+ * For DETACH DELETE with RLS enabled, compile the security qual
+ * expressions once per label for efficient evaluation.
+ */
+ if (css->delete_data->detach)
+ {
+ /* Setup RLS security quals for this label */
+ if (check_enable_rls(relid, InvalidOid, true) == RLS_ENABLED)
+ {
+ rls_enabled = true;
+ econtext = css->css.ss.ps.ps_ExprContext;
+ qualExprs = setup_security_quals(resultRelInfo, estate, node,
+ CMD_DELETE);
+ }
+ }
+
/* for each row */
while (true)
{
@@ -537,6 +606,34 @@ static void check_for_connected_edges(CustomScanState *node)
{
if (css->delete_data->detach)
{
+ AclResult aclresult;
+
+ /* Check that the user has DELETE permission on the edge table */
+ aclresult = pg_class_aclcheck(relid, GetUserId(), ACL_DELETE);
+ if (aclresult != ACLCHECK_OK)
+ {
+ aclcheck_error(aclresult, OBJECT_TABLE, label_name);
+ }
+
+ /* Check RLS security quals (USING policy) before delete */
+ if (rls_enabled)
+ {
+ /*
+ * For DETACH DELETE, error out if edge RLS check fails.
+ * Unlike normal DELETE which silently skips, we cannot
+ * silently skip edges here as it would leave dangling
+ * edges pointing to deleted vertices.
+ */
+ if (!check_security_quals(qualExprs, slot, econtext))
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+ errmsg("cannot delete edge due to row-level security policy on \"%s\"",
+ label_name),
+ errhint("DETACH DELETE requires permission to delete all connected edges.")));
+ }
+ }
+
delete_entity(estate, resultRelInfo, tuple);
}
else
diff --git a/src/backend/executor/cypher_merge.c b/src/backend/executor/cypher_merge.c
index 9136825a..e0d6c78e 100644
--- a/src/backend/executor/cypher_merge.c
+++ b/src/backend/executor/cypher_merge.c
@@ -19,10 +19,12 @@
#include "postgres.h"
+#include "utils/datum.h"
+#include "utils/rls.h"
+
#include "catalog/ag_label.h"
#include "executor/cypher_executor.h"
#include "executor/cypher_utils.h"
-#include "utils/datum.h"
/*
* The following structure is used to hold a single vertex or edge component
@@ -180,6 +182,12 @@ static void begin_cypher_merge(CustomScanState *node, EState *estate,
cypher_node->prop_expr_state = ExecInitExpr(cypher_node->prop_expr,
(PlanState *)node);
}
+
+ /* Setup RLS WITH CHECK policies if RLS is enabled */
+ if (check_enable_rls(rel->rd_id, InvalidOid, true) == RLS_ENABLED)
+ {
+ setup_wcos(cypher_node->resultRelInfo, estate, node, CMD_INSERT);
+ }
}
/*
diff --git a/src/backend/executor/cypher_set.c b/src/backend/executor/cypher_set.c
index d1837fb1..40cf2b23 100644
--- a/src/backend/executor/cypher_set.c
+++ b/src/backend/executor/cypher_set.c
@@ -19,7 +19,10 @@
#include "postgres.h"
+#include "common/hashfn.h"
+#include "executor/executor.h"
#include "storage/bufmgr.h"
+#include "utils/rls.h"
#include "executor/cypher_executor.h"
#include "executor/cypher_utils.h"
@@ -125,6 +128,13 @@ static HeapTuple update_entity_tuple(ResultRelInfo *resultRelInfo,
ExecConstraints(resultRelInfo, elemTupleSlot, estate);
}
+ /* Check RLS WITH CHECK policies if configured */
+ if (resultRelInfo->ri_WithCheckOptions != NIL)
+ {
+ ExecWithCheckOptions(WCO_RLS_UPDATE_CHECK, resultRelInfo,
+ elemTupleSlot, estate);
+ }
+
result = table_tuple_update(resultRelInfo->ri_RelationDesc,
&tuple->t_self, elemTupleSlot,
cid, estate->es_snapshot,
@@ -355,9 +365,20 @@ static void process_update_list(CustomScanState *node)
EState *estate = css->css.ss.ps.state;
int *luindex = NULL;
int lidx = 0;
+ HTAB *qual_cache = NULL;
+ HASHCTL hashctl;
/* allocate an array to hold the last update index of each 'entity' */
luindex = palloc0(sizeof(int) * scanTupleSlot->tts_nvalid);
+
+ /* Hash table for caching compiled security quals per label */
+ MemSet(&hashctl, 0, sizeof(hashctl));
+ hashctl.keysize = sizeof(Oid);
+ hashctl.entrysize = sizeof(RLSCacheEntry);
+ hashctl.hcxt = CurrentMemoryContext;
+ qual_cache = hash_create("update_qual_cache", 8, &hashctl,
+ HASH_ELEM | HASH_BLOBS | HASH_CONTEXT);
+
/*
* Iterate through the SET items list and store the loop index of each
* 'entity' update. As there is only one entry for each entity, this will
@@ -505,6 +526,38 @@ static void process_update_list(CustomScanState *node)
estate, RelationGetDescr(resultRelInfo->ri_RelationDesc),
&TTSOpsHeapTuple);
+ /* Setup RLS policies if RLS is enabled */
+ if (check_enable_rls(resultRelInfo->ri_RelationDesc->rd_id,
+ InvalidOid, true) == RLS_ENABLED)
+ {
+ Oid relid = RelationGetRelid(resultRelInfo->ri_RelationDesc);
+ RLSCacheEntry *entry;
+ bool found;
+
+ /* Get cached RLS state for this label, or set it up */
+ entry = hash_search(qual_cache, &relid, HASH_ENTER, &found);
+ if (!found)
+ {
+ /* Setup WITH CHECK policies */
+ setup_wcos(resultRelInfo, estate, node, CMD_UPDATE);
+ entry->withCheckOptions = resultRelInfo->ri_WithCheckOptions;
+ entry->withCheckOptionExprs = resultRelInfo->ri_WithCheckOptionExprs;
+
+ /* Setup security quals */
+ entry->qualExprs = setup_security_quals(resultRelInfo, estate,
+ node, CMD_UPDATE);
+ entry->slot = ExecInitExtraTupleSlot(
+ estate, RelationGetDescr(resultRelInfo->ri_RelationDesc),
+ &TTSOpsHeapTuple);
+ }
+ else
+ {
+ /* Use cached WCOs */
+ resultRelInfo->ri_WithCheckOptions = entry->withCheckOptions;
+ resultRelInfo->ri_WithCheckOptionExprs = entry->withCheckOptionExprs;
+ }
+ }
+
/*
* Now that we have the updated properties, create a either a vertex or
* edge Datum for the in-memory update, and setup the tupleTableSlot
@@ -580,8 +633,36 @@ static void process_update_list(CustomScanState *node)
*/
if (HeapTupleIsValid(heap_tuple))
{
- heap_tuple = update_entity_tuple(resultRelInfo, slot, estate,
- heap_tuple);
+ bool should_update = true;
+ Oid relid = RelationGetRelid(resultRelInfo->ri_RelationDesc);
+
+ /* Check RLS security quals (USING policy) before update */
+ if (check_enable_rls(relid, InvalidOid, true) == RLS_ENABLED)
+ {
+ RLSCacheEntry *entry;
+
+ /* Entry was already created earlier when setting up WCOs */
+ entry = hash_search(qual_cache, &relid, HASH_FIND, NULL);
+ if (!entry)
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg("missing RLS cache entry for relation %u",
+ relid)));
+ }
+
+ ExecStoreHeapTuple(heap_tuple, entry->slot, false);
+ should_update = check_security_quals(entry->qualExprs,
+ entry->slot,
+ econtext);
+ }
+
+ /* Silently skip if USING policy filters out this row */
+ if (should_update)
+ {
+ heap_tuple = update_entity_tuple(resultRelInfo, slot, estate,
+ heap_tuple);
+ }
}
/* close the ScanDescription */
table_endscan(scan_desc);
@@ -595,6 +676,10 @@ static void process_update_list(CustomScanState *node)
/* increment loop index */
lidx++;
}
+
+ /* Clean up the cache */
+ hash_destroy(qual_cache);
+
/* free our lookup array */
pfree_if_not_null(luindex);
}
diff --git a/src/backend/executor/cypher_utils.c b/src/backend/executor/cypher_utils.c
index c8d56883..eff82992 100644
--- a/src/backend/executor/cypher_utils.c
+++ b/src/backend/executor/cypher_utils.c
@@ -24,14 +24,36 @@
#include "postgres.h"
+#include "executor/executor.h"
+#include "miscadmin.h"
#include "nodes/makefuncs.h"
#include "parser/parse_relation.h"
+#include "rewrite/rewriteManip.h"
+#include "rewrite/rowsecurity.h"
+#include "utils/acl.h"
+#include "utils/rls.h"
#include "catalog/ag_label.h"
#include "commands/label_commands.h"
#include "executor/cypher_utils.h"
#include "utils/ag_cache.h"
+/* RLS helper function declarations */
+static void get_policies_for_relation(Relation relation, CmdType cmd,
+ Oid user_id, List **permissive_policies,
+ List **restrictive_policies);
+static void add_with_check_options(Relation rel, int rt_index, WCOKind kind,
+ List *permissive_policies,
+ List *restrictive_policies,
+ List **withCheckOptions, bool *hasSubLinks,
+ bool force_using);
+static void add_security_quals(int rt_index, List *permissive_policies,
+ List *restrictive_policies,
+ List **securityQuals, bool *hasSubLinks);
+static void sort_policies_by_name(List *policies);
+static int row_security_policy_cmp(const ListCell *a, const ListCell *b);
+static bool check_role_for_policy(ArrayType *policy_roles, Oid user_id);
+
/*
* Given the graph name and the label name, create a ResultRelInfo for the table
* those two variables represent. Open the Indices too.
@@ -255,6 +277,13 @@ HeapTuple insert_entity_tuple_cid(ResultRelInfo *resultRelInfo,
ExecConstraints(resultRelInfo, elemTupleSlot, estate);
}
+ /* Check RLS WITH CHECK policies if configured */
+ if (resultRelInfo->ri_WithCheckOptions != NIL)
+ {
+ ExecWithCheckOptions(WCO_RLS_INSERT_CHECK, resultRelInfo,
+ elemTupleSlot, estate);
+ }
+
/* Insert the tuple normally */
table_tuple_insert(resultRelInfo->ri_RelationDesc, elemTupleSlot, cid, 0,
NULL);
@@ -268,3 +297,754 @@ HeapTuple insert_entity_tuple_cid(ResultRelInfo *resultRelInfo,
return tuple;
}
+
+/*
+ * setup_wcos
+ *
+ * WithCheckOptions are added during the rewrite phase, but since AGE uses
+ * CMD_SELECT for all queries, WCOs don't get added for CREATE/SET/MERGE
+ * operations. This function compensates by adding WCOs at execution time.
+ *
+ * Based on PostgreSQL's row security implementation in rowsecurity.c
+ */
+void setup_wcos(ResultRelInfo *resultRelInfo, EState *estate,
+ CustomScanState *node, CmdType cmd)
+{
+ List *permissive_policies;
+ List *restrictive_policies;
+ List *withCheckOptions = NIL;
+ List *wcoExprs = NIL;
+ ListCell *lc;
+ Relation rel;
+ Oid user_id;
+ int rt_index;
+ WCOKind wco_kind;
+ bool hasSubLinks = false;
+
+ /* Determine the WCO kind based on command type */
+ if (cmd == CMD_INSERT)
+ {
+ wco_kind = WCO_RLS_INSERT_CHECK;
+ }
+ else if (cmd == CMD_UPDATE)
+ {
+ wco_kind = WCO_RLS_UPDATE_CHECK;
+ }
+ else
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("unexpected command type for setup_wcos")));
+ }
+
+ rel = resultRelInfo->ri_RelationDesc;
+
+ /*
+ * Use rt_index=1 since we're evaluating policies against a single relation.
+ * Policy quals are stored with varno=1, and we set ecxt_scantuple to the
+ * tuple we want to check, so keeping varno=1 is correct.
+ */
+ rt_index = 1;
+ user_id = GetUserId();
+
+ /* Get the policies for the specified command type */
+ get_policies_for_relation(rel, cmd, user_id,
+ &permissive_policies,
+ &restrictive_policies);
+
+ /* Build WithCheckOptions from the policies */
+ add_with_check_options(rel, rt_index, wco_kind,
+ permissive_policies,
+ restrictive_policies,
+ &withCheckOptions,
+ &hasSubLinks,
+ false);
+
+ /* Compile the WCO expressions */
+ foreach(lc, withCheckOptions)
+ {
+ WithCheckOption *wco = lfirst_node(WithCheckOption, lc);
+ ExprState *wcoExpr;
+
+ /* Ensure qual is a List for ExecInitQual */
+ if (!IsA(wco->qual, List))
+ {
+ wco->qual = (Node *) list_make1(wco->qual);
+ }
+
+ wcoExpr = ExecInitQual((List *) wco->qual, (PlanState *) node);
+ wcoExprs = lappend(wcoExprs, wcoExpr);
+ }
+
+ /* Set up the ResultRelInfo with WCOs */
+ resultRelInfo->ri_WithCheckOptions = withCheckOptions;
+ resultRelInfo->ri_WithCheckOptionExprs = wcoExprs;
+}
+
+/*
+ * get_policies_for_relation
+ *
+ * Returns lists of permissive and restrictive policies to be applied to the
+ * specified relation, based on the command type and role.
+ *
+ * This includes any policies added by extensions.
+ *
+ * Copied from PostgreSQL's src/backend/rewrite/rowsecurity.c
+ */
+static void
+get_policies_for_relation(Relation relation, CmdType cmd, Oid user_id,
+ List **permissive_policies,
+ List **restrictive_policies)
+{
+ ListCell *item;
+
+ *permissive_policies = NIL;
+ *restrictive_policies = NIL;
+
+ /* No policies if RLS descriptor is not present */
+ if (relation->rd_rsdesc == NULL)
+ {
+ return;
+ }
+
+ /* First find all internal policies for the relation. */
+ foreach(item, relation->rd_rsdesc->policies)
+ {
+ bool cmd_matches = false;
+ RowSecurityPolicy *policy = (RowSecurityPolicy *) lfirst(item);
+
+ /* Always add ALL policies, if they exist. */
+ if (policy->polcmd == '*')
+ {
+ cmd_matches = true;
+ }
+ else
+ {
+ /* Check whether the policy applies to the specified command type */
+ switch (cmd)
+ {
+ case CMD_SELECT:
+ if (policy->polcmd == ACL_SELECT_CHR)
+ {
+ cmd_matches = true;
+ }
+ break;
+ case CMD_INSERT:
+ if (policy->polcmd == ACL_INSERT_CHR)
+ {
+ cmd_matches = true;
+ }
+ break;
+ case CMD_UPDATE:
+ if (policy->polcmd == ACL_UPDATE_CHR)
+ {
+ cmd_matches = true;
+ }
+ break;
+ case CMD_DELETE:
+ if (policy->polcmd == ACL_DELETE_CHR)
+ {
+ cmd_matches = true;
+ }
+ break;
+ case CMD_MERGE:
+ /*
+ * We do not support a separate policy for MERGE command.
+ * Instead it derives from the policies defined for other
+ * commands.
+ */
+ break;
+ default:
+ elog(ERROR, "unrecognized policy command type %d",
+ (int) cmd);
+ break;
+ }
+ }
+
+ /*
+ * Add this policy to the relevant list of policies if it applies to
+ * the specified role.
+ */
+ if (cmd_matches && check_role_for_policy(policy->roles, user_id))
+ {
+ if (policy->permissive)
+ {
+ *permissive_policies = lappend(*permissive_policies, policy);
+ }
+ else
+ {
+ *restrictive_policies = lappend(*restrictive_policies, policy);
+ }
+ }
+ }
+
+ /*
+ * We sort restrictive policies by name so that any WCOs they generate are
+ * checked in a well-defined order.
+ */
+ sort_policies_by_name(*restrictive_policies);
+
+ /*
+ * Then add any permissive or restrictive policies defined by extensions.
+ * These are simply appended to the lists of internal policies, if they
+ * apply to the specified role.
+ */
+ if (row_security_policy_hook_restrictive)
+ {
+ List *hook_policies =
+ (*row_security_policy_hook_restrictive) (cmd, relation);
+
+ /*
+ * As with built-in restrictive policies, we sort any hook-provided
+ * restrictive policies by name also. Note that we also intentionally
+ * always check all built-in restrictive policies, in name order,
+ * before checking restrictive policies added by hooks, in name order.
+ */
+ sort_policies_by_name(hook_policies);
+
+ foreach(item, hook_policies)
+ {
+ RowSecurityPolicy *policy = (RowSecurityPolicy *) lfirst(item);
+
+ if (check_role_for_policy(policy->roles, user_id))
+ {
+ *restrictive_policies = lappend(*restrictive_policies, policy);
+ }
+ }
+ }
+
+ if (row_security_policy_hook_permissive)
+ {
+ List *hook_policies =
+ (*row_security_policy_hook_permissive) (cmd, relation);
+
+ foreach(item, hook_policies)
+ {
+ RowSecurityPolicy *policy = (RowSecurityPolicy *) lfirst(item);
+
+ if (check_role_for_policy(policy->roles, user_id))
+ {
+ *permissive_policies = lappend(*permissive_policies, policy);
+ }
+ }
+ }
+}
+
+/*
+ * add_with_check_options
+ *
+ * Add WithCheckOptions of the specified kind to check that new records
+ * added by an INSERT or UPDATE are consistent with the specified RLS
+ * policies. Normally new data must satisfy the WITH CHECK clauses from the
+ * policies. If a policy has no explicit WITH CHECK clause, its USING clause
+ * is used instead. In the special case of an UPDATE arising from an
+ * INSERT ... ON CONFLICT DO UPDATE, existing records are first checked using
+ * a WCO_RLS_CONFLICT_CHECK WithCheckOption, which always uses the USING
+ * clauses from RLS policies.
+ *
+ * New WCOs are added to withCheckOptions, and hasSubLinks is set to true if
+ * any of the check clauses added contain sublink subqueries.
+ *
+ * Copied from PostgreSQL's src/backend/rewrite/rowsecurity.c
+ */
+static void
+add_with_check_options(Relation rel,
+ int rt_index,
+ WCOKind kind,
+ List *permissive_policies,
+ List *restrictive_policies,
+ List **withCheckOptions,
+ bool *hasSubLinks,
+ bool force_using)
+{
+ ListCell *item;
+ List *permissive_quals = NIL;
+
+#define QUAL_FOR_WCO(policy) \
+ ( !force_using && \
+ (policy)->with_check_qual != NULL ? \
+ (policy)->with_check_qual : (policy)->qual )
+
+ /*
+ * First collect up the permissive policy clauses, similar to
+ * add_security_quals.
+ */
+ foreach(item, permissive_policies)
+ {
+ RowSecurityPolicy *policy = (RowSecurityPolicy *) lfirst(item);
+ Expr *qual = QUAL_FOR_WCO(policy);
+
+ if (qual != NULL)
+ {
+ permissive_quals = lappend(permissive_quals, copyObject(qual));
+ *hasSubLinks |= policy->hassublinks;
+ }
+ }
+
+ /*
+ * There must be at least one permissive qual found or no rows are allowed
+ * to be added. This is the same as in add_security_quals.
+ *
+ * If there are no permissive_quals then we fall through and return a
+ * single 'false' WCO, preventing all new rows.
+ */
+ if (permissive_quals != NIL)
+ {
+ /*
+ * Add a single WithCheckOption for all the permissive policy clauses,
+ * combining them together using OR. This check has no policy name,
+ * since if the check fails it means that no policy granted permission
+ * to perform the update, rather than any particular policy being
+ * violated.
+ */
+ WithCheckOption *wco;
+
+ wco = makeNode(WithCheckOption);
+ wco->kind = kind;
+ wco->relname = pstrdup(RelationGetRelationName(rel));
+ wco->polname = NULL;
+ wco->cascaded = false;
+
+ if (list_length(permissive_quals) == 1)
+ {
+ wco->qual = (Node *) linitial(permissive_quals);
+ }
+ else
+ {
+ wco->qual = (Node *) makeBoolExpr(OR_EXPR, permissive_quals, -1);
+ }
+
+ ChangeVarNodes(wco->qual, 1, rt_index, 0);
+
+ *withCheckOptions = list_append_unique(*withCheckOptions, wco);
+
+ /*
+ * Now add WithCheckOptions for each of the restrictive policy clauses
+ * (which will be combined together using AND). We use a separate
+ * WithCheckOption for each restrictive policy to allow the policy
+ * name to be included in error reports if the policy is violated.
+ */
+ foreach(item, restrictive_policies)
+ {
+ RowSecurityPolicy *policy = (RowSecurityPolicy *) lfirst(item);
+ Expr *qual = QUAL_FOR_WCO(policy);
+
+ if (qual != NULL)
+ {
+ qual = copyObject(qual);
+ ChangeVarNodes((Node *) qual, 1, rt_index, 0);
+
+ wco = makeNode(WithCheckOption);
+ wco->kind = kind;
+ wco->relname = pstrdup(RelationGetRelationName(rel));
+ wco->polname = pstrdup(policy->policy_name);
+ wco->qual = (Node *) qual;
+ wco->cascaded = false;
+
+ *withCheckOptions = list_append_unique(*withCheckOptions, wco);
+ *hasSubLinks |= policy->hassublinks;
+ }
+ }
+ }
+ else
+ {
+ /*
+ * If there were no policy clauses to check new data, add a single
+ * always-false WCO (a default-deny policy).
+ */
+ WithCheckOption *wco;
+
+ wco = makeNode(WithCheckOption);
+ wco->kind = kind;
+ wco->relname = pstrdup(RelationGetRelationName(rel));
+ wco->polname = NULL;
+ wco->qual = (Node *) makeConst(BOOLOID, -1, InvalidOid,
+ sizeof(bool), BoolGetDatum(false),
+ false, true);
+ wco->cascaded = false;
+
+ *withCheckOptions = lappend(*withCheckOptions, wco);
+ }
+}
+
+/*
+ * sort_policies_by_name
+ *
+ * This is only used for restrictive policies, ensuring that any
+ * WithCheckOptions they generate are applied in a well-defined order.
+ * This is not necessary for permissive policies, since they are all combined
+ * together using OR into a single WithCheckOption check.
+ *
+ * Copied from PostgreSQL's src/backend/rewrite/rowsecurity.c
+ */
+static void
+sort_policies_by_name(List *policies)
+{
+ list_sort(policies, row_security_policy_cmp);
+}
+
+/*
+ * list_sort comparator to sort RowSecurityPolicy entries by name
+ *
+ * Copied from PostgreSQL's src/backend/rewrite/rowsecurity.c
+ */
+static int
+row_security_policy_cmp(const ListCell *a, const ListCell *b)
+{
+ const RowSecurityPolicy *pa = (const RowSecurityPolicy *) lfirst(a);
+ const RowSecurityPolicy *pb = (const RowSecurityPolicy *) lfirst(b);
+
+ /* Guard against NULL policy names from extensions */
+ if (pa->policy_name == NULL)
+ {
+ return pb->policy_name == NULL ? 0 : 1;
+ }
+ if (pb->policy_name == NULL)
+ {
+ return -1;
+ }
+
+ return strcmp(pa->policy_name, pb->policy_name);
+}
+
+/*
+ * check_role_for_policy -
+ * determines if the policy should be applied for the current role
+ *
+ * Copied from PostgreSQL's src/backend/rewrite/rowsecurity.c
+ */
+static bool
+check_role_for_policy(ArrayType *policy_roles, Oid user_id)
+{
+ int i;
+ Oid *roles = (Oid *) ARR_DATA_PTR(policy_roles);
+
+ /* Quick fall-thru for policies applied to all roles */
+ if (roles[0] == ACL_ID_PUBLIC)
+ {
+ return true;
+ }
+
+ for (i = 0; i < ARR_DIMS(policy_roles)[0]; i++)
+ {
+ if (has_privs_of_role(user_id, roles[i]))
+ {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/*
+ * add_security_quals
+ *
+ * Add security quals to enforce the specified RLS policies, restricting
+ * access to existing data in a table. If there are no policies controlling
+ * access to the table, then all access is prohibited --- i.e., an implicit
+ * default-deny policy is used.
+ *
+ * New security quals are added to securityQuals, and hasSubLinks is set to
+ * true if any of the quals added contain sublink subqueries.
+ *
+ * Copied from PostgreSQL's src/backend/rewrite/rowsecurity.c
+ */
+static void
+add_security_quals(int rt_index,
+ List *permissive_policies,
+ List *restrictive_policies,
+ List **securityQuals,
+ bool *hasSubLinks)
+{
+ ListCell *item;
+ List *permissive_quals = NIL;
+ Expr *rowsec_expr;
+
+ /*
+ * First collect up the permissive quals. If we do not find any
+ * permissive policies then no rows are visible (this is handled below).
+ */
+ foreach(item, permissive_policies)
+ {
+ RowSecurityPolicy *policy = (RowSecurityPolicy *) lfirst(item);
+
+ if (policy->qual != NULL)
+ {
+ permissive_quals = lappend(permissive_quals,
+ copyObject(policy->qual));
+ *hasSubLinks |= policy->hassublinks;
+ }
+ }
+
+ /*
+ * We must have permissive quals, always, or no rows are visible.
+ *
+ * If we do not, then we simply return a single 'false' qual which results
+ * in no rows being visible.
+ */
+ if (permissive_quals != NIL)
+ {
+ /*
+ * We now know that permissive policies exist, so we can now add
+ * security quals based on the USING clauses from the restrictive
+ * policies. Since these need to be combined together using AND, we
+ * can just add them one at a time.
+ */
+ foreach(item, restrictive_policies)
+ {
+ RowSecurityPolicy *policy = (RowSecurityPolicy *) lfirst(item);
+ Expr *qual;
+
+ if (policy->qual != NULL)
+ {
+ qual = copyObject(policy->qual);
+ ChangeVarNodes((Node *) qual, 1, rt_index, 0);
+
+ *securityQuals = list_append_unique(*securityQuals, qual);
+ *hasSubLinks |= policy->hassublinks;
+ }
+ }
+
+ /*
+ * Then add a single security qual combining together the USING
+ * clauses from all the permissive policies using OR.
+ */
+ if (list_length(permissive_quals) == 1)
+ {
+ rowsec_expr = (Expr *) linitial(permissive_quals);
+ }
+ else
+ {
+ rowsec_expr = makeBoolExpr(OR_EXPR, permissive_quals, -1);
+ }
+
+ ChangeVarNodes((Node *) rowsec_expr, 1, rt_index, 0);
+ *securityQuals = list_append_unique(*securityQuals, rowsec_expr);
+ }
+ else
+ {
+ /*
+ * A permissive policy must exist for rows to be visible at all.
+ * Therefore, if there were no permissive policies found, return a
+ * single always-false clause.
+ */
+ *securityQuals = lappend(*securityQuals,
+ makeConst(BOOLOID, -1, InvalidOid,
+ sizeof(bool), BoolGetDatum(false),
+ false, true));
+ }
+}
+
+/*
+ * setup_security_quals
+ *
+ * Security quals (USING policies) are added during the rewrite phase, but
+ * since AGE uses CMD_SELECT for all queries, they don't get added for
+ * UPDATE/DELETE operations. This function sets up security quals at
+ * execution time to be evaluated against each tuple before modification.
+ *
+ * Returns a list of compiled ExprState for the security quals.
+ */
+List *
+setup_security_quals(ResultRelInfo *resultRelInfo, EState *estate,
+ CustomScanState *node, CmdType cmd)
+{
+ List *permissive_policies;
+ List *restrictive_policies;
+ List *securityQuals = NIL;
+ List *qualExprs = NIL;
+ ListCell *lc;
+ Relation rel;
+ Oid user_id;
+ int rt_index;
+ bool hasSubLinks = false;
+
+ /* Only UPDATE and DELETE have security quals */
+ if (cmd != CMD_UPDATE && cmd != CMD_DELETE)
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("unexpected command type for setup_security_quals")));
+ }
+
+ rel = resultRelInfo->ri_RelationDesc;
+
+ /* If no RLS policies exist, return empty list */
+ if (rel->rd_rsdesc == NULL)
+ {
+ return NIL;
+ }
+
+ /*
+ * Use rt_index=1 since we're evaluating policies against a single relation.
+ * Policy quals are stored with varno=1, and we set ecxt_scantuple to the
+ * tuple we want to check, so keeping varno=1 is correct.
+ */
+ rt_index = 1;
+ user_id = GetUserId();
+
+ /* Get the policies for the specified command type */
+ get_policies_for_relation(rel, cmd, user_id,
+ &permissive_policies,
+ &restrictive_policies);
+
+ /* Build security quals from the policies */
+ add_security_quals(rt_index, permissive_policies, restrictive_policies,
+ &securityQuals, &hasSubLinks);
+
+ /* Compile the security qual expressions */
+ foreach(lc, securityQuals)
+ {
+ Expr *qual = (Expr *) lfirst(lc);
+ ExprState *qualExpr;
+
+ /* Ensure qual is a List for ExecInitQual */
+ if (!IsA(qual, List))
+ {
+ qual = (Expr *) list_make1(qual);
+ }
+
+ qualExpr = ExecInitQual((List *) qual, (PlanState *) node);
+ qualExprs = lappend(qualExprs, qualExpr);
+ }
+
+ return qualExprs;
+}
+
+/*
+ * check_security_quals
+ *
+ * Evaluate security quals against a tuple. Returns true if all quals pass
+ * (row can be modified), false if any qual fails (row should be silently
+ * skipped).
+ *
+ * This matches PostgreSQL's behavior where USING expressions for UPDATE/DELETE
+ * silently filter rows rather than raising errors.
+ */
+bool
+check_security_quals(List *qualExprs, TupleTableSlot *slot,
+ ExprContext *econtext)
+{
+ ListCell *lc;
+ TupleTableSlot *saved_scantuple;
+ bool result = true;
+
+ if (qualExprs == NIL)
+ {
+ return true;
+ }
+
+ /* Save and set up the scan tuple for expression evaluation */
+ saved_scantuple = econtext->ecxt_scantuple;
+ econtext->ecxt_scantuple = slot;
+
+ foreach(lc, qualExprs)
+ {
+ ExprState *qualExpr = (ExprState *) lfirst(lc);
+
+ if (!ExecQual(qualExpr, econtext))
+ {
+ result = false;
+ break;
+ }
+ }
+
+ econtext->ecxt_scantuple = saved_scantuple;
+ return result;
+}
+
+/*
+ * check_rls_for_tuple
+ *
+ * Check RLS policies for a tuple without needing full executor context.
+ * Used by standalone functions like startNode()/endNode() that access
+ * tables directly.
+ *
+ * Returns true if the tuple passes RLS checks (or if RLS is not enabled),
+ * false if the tuple should be filtered out.
+ */
+bool
+check_rls_for_tuple(Relation rel, HeapTuple tuple, CmdType cmd)
+{
+ List *permissive_policies;
+ List *restrictive_policies;
+ List *securityQuals = NIL;
+ ListCell *lc;
+ Oid user_id;
+ bool hasSubLinks = false;
+ bool result = true;
+ EState *estate;
+ ExprContext *econtext;
+ TupleTableSlot *slot;
+
+ /* If RLS is not enabled, tuple passes */
+ if (check_enable_rls(RelationGetRelid(rel), InvalidOid, true) != RLS_ENABLED)
+ {
+ return true;
+ }
+
+ /* If no RLS policies exist on the relation, tuple passes */
+ if (rel->rd_rsdesc == NULL)
+ {
+ return true;
+ }
+
+ /* Get the policies for the specified command type */
+ user_id = GetUserId();
+ get_policies_for_relation(rel, cmd, user_id,
+ &permissive_policies,
+ &restrictive_policies);
+
+ /* Build security quals from the policies (use rt_index=1) */
+ add_security_quals(1, permissive_policies, restrictive_policies,
+ &securityQuals, &hasSubLinks);
+
+ /* If no quals, tuple passes */
+ if (securityQuals == NIL)
+ {
+ return true;
+ }
+
+ /* Create minimal execution environment */
+ estate = CreateExecutorState();
+ econtext = CreateExprContext(estate);
+
+ /* Create tuple slot and store the tuple */
+ slot = MakeSingleTupleTableSlot(RelationGetDescr(rel), &TTSOpsHeapTuple);
+ ExecStoreHeapTuple(tuple, slot, false);
+ econtext->ecxt_scantuple = slot;
+
+ /* Compile and evaluate each qual */
+ foreach(lc, securityQuals)
+ {
+ Expr *qual = (Expr *) lfirst(lc);
+ ExprState *qualExpr;
+ List *qualList;
+
+ /* ExecPrepareQual expects a List */
+ if (!IsA(qual, List))
+ {
+ qualList = list_make1(qual);
+ }
+ else
+ {
+ qualList = (List *) qual;
+ }
+
+ /* Use ExecPrepareQual for standalone expression evaluation */
+ qualExpr = ExecPrepareQual(qualList, estate);
+
+ if (!ExecQual(qualExpr, econtext))
+ {
+ result = false;
+ break;
+ }
+ }
+
+ /* Clean up */
+ ExecDropSingleTupleTableSlot(slot);
+ FreeExprContext(econtext, true);
+ FreeExecutorState(estate);
+
+ return result;
+}
diff --git a/src/backend/parser/cypher_clause.c b/src/backend/parser/cypher_clause.c
index 515f8c1d..7b636a3d 100644
--- a/src/backend/parser/cypher_clause.c
+++ b/src/backend/parser/cypher_clause.c
@@ -346,6 +346,100 @@ static bool isa_special_VLE_case(cypher_path *path);
static ParseNamespaceItem *find_pnsi(cypher_parsestate *cpstate, char *varname);
static bool has_list_comp_or_subquery(Node *expr, void *context);
+/*
+ * Add required permissions to the RTEPermissionInfo for a relation.
+ * Recursively searches through RTEs including subqueries.
+ */
+static bool
+add_rte_permissions_recurse(List *rtable, List *rteperminfos,
+ Oid relid, AclMode permissions)
+{
+ ListCell *lc;
+
+ /* First check the perminfos at this level */
+ foreach(lc, rteperminfos)
+ {
+ RTEPermissionInfo *perminfo = lfirst(lc);
+
+ if (perminfo->relid == relid)
+ {
+ perminfo->requiredPerms |= permissions;
+ return true;
+ }
+ }
+
+ /* Then recurse into subqueries */
+ foreach(lc, rtable)
+ {
+ RangeTblEntry *rte = lfirst(lc);
+
+ if (rte->rtekind == RTE_SUBQUERY && rte->subquery != NULL)
+ {
+ if (add_rte_permissions_recurse(rte->subquery->rtable,
+ rte->subquery->rteperminfos,
+ relid, permissions))
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+}
+
+/*
+ * Add required permissions to the RTEPermissionInfo for a relation.
+ * Searches through p_rteperminfos and subqueries for a matching relOid
+ * and adds the specified permissions to requiredPerms.
+ */
+static void
+add_rte_permissions(ParseState *pstate, Oid relid, AclMode permissions)
+{
+ add_rte_permissions_recurse(pstate->p_rtable, pstate->p_rteperminfos,
+ relid, permissions);
+}
+
+/*
+ * Add required permissions to the label table for a given entity variable.
+ * Looks up the entity by variable name, extracts its label, and adds
+ * the specified permissions to the corresponding RTEPermissionInfo.
+ */
+static void
+add_entity_permissions(cypher_parsestate *cpstate, char *var_name,
+ AclMode permissions)
+{
+ ParseState *pstate = (ParseState *)cpstate;
+ transform_entity *entity;
+ char *label = NULL;
+ Oid relid;
+
+ entity = find_variable(cpstate, var_name);
+ if (entity == NULL)
+ {
+ return;
+ }
+
+ if (entity->type == ENT_VERTEX)
+ {
+ label = entity->entity.node->label;
+ }
+ else if (entity->type == ENT_EDGE)
+ {
+ label = entity->entity.rel->label;
+ }
+
+ if (label == NULL)
+ {
+ return;
+ }
+
+ relid = get_label_relation(label, cpstate->graph_oid);
+ if (OidIsValid(relid))
+ {
+ add_rte_permissions(pstate, relid, permissions);
+ }
+}
+
/*
* transform a cypher_clause
*/
@@ -1561,6 +1655,9 @@ static List *transform_cypher_delete_item_list(cypher_parsestate *cpstate,
parser_errposition(pstate, col->location)));
}
+ /* Add ACL_DELETE permission to the entity's label table */
+ add_entity_permissions(cpstate, val->sval, ACL_DELETE);
+
add_volatile_wrapper_to_target_entry(query->targetList, resno);
pos = makeInteger(resno);
@@ -1726,6 +1823,9 @@ cypher_update_information *transform_cypher_remove_item_list(
parser_errposition(pstate, set_item->location)));
}
+ /* Add ACL_UPDATE permission to the entity's label table */
+ add_entity_permissions(cpstate, variable_name, ACL_UPDATE);
+
add_volatile_wrapper_to_target_entry(query->targetList,
item->entity_position);
@@ -1903,6 +2003,9 @@ cypher_update_information *transform_cypher_set_item_list(
parser_errposition(pstate, set_item->location)));
}
+ /* Add ACL_UPDATE permission to the entity's label table */
+ add_entity_permissions(cpstate, variable_name, ACL_UPDATE);
+
add_volatile_wrapper_to_target_entry(query->targetList,
item->entity_position);
diff --git a/src/backend/utils/adt/agtype.c b/src/backend/utils/adt/agtype.c
index f2458a30..c552727d 100644
--- a/src/backend/utils/adt/agtype.c
+++ b/src/backend/utils/adt/agtype.c
@@ -44,7 +44,10 @@
#include "libpq/pqformat.h"
#include "miscadmin.h"
#include "parser/parse_coerce.h"
+#include "nodes/nodes.h"
+#include "utils/acl.h"
#include "utils/builtins.h"
+#include "executor/cypher_utils.h"
#include "utils/float.h"
#include "utils/lsyscache.h"
#include "utils/snapmgr.h"
@@ -5625,15 +5628,24 @@ static Datum get_vertex(const char *graph, const char *vertex_label,
HeapTuple tuple;
TupleDesc tupdesc;
Datum id, properties, result;
+ AclResult aclresult;
/* get the specific graph namespace (schema) */
Oid graph_namespace_oid = get_namespace_oid(graph, false);
/* get the specific vertex label table (schema.vertex_label) */
Oid vertex_label_table_oid = get_relname_relid(vertex_label,
- graph_namespace_oid);
+ graph_namespace_oid);
/* get the active snapshot */
Snapshot snapshot = GetActiveSnapshot();
+ /* check for SELECT permission on the table */
+ aclresult = pg_class_aclcheck(vertex_label_table_oid, GetUserId(),
+ ACL_SELECT);
+ if (aclresult != ACLCHECK_OK)
+ {
+ aclcheck_error(aclresult, OBJECT_TABLE, vertex_label);
+ }
+
/* initialize the scan key */
ScanKeyInit(&scan_keys[0], 1, BTEqualStrategyNumber, F_OIDEQ,
Int64GetDatum(graphid));
@@ -5646,11 +5658,24 @@ static Datum get_vertex(const char *graph, const char *vertex_label,
/* bail if the tuple isn't valid */
if (!HeapTupleIsValid(tuple))
{
+ table_endscan(scan_desc);
+ table_close(graph_vertex_label, ShareLock);
ereport(ERROR,
(errcode(ERRCODE_UNDEFINED_TABLE),
errmsg("graphid %lu does not exist", graphid)));
}
+ /* Check RLS policies - error if filtered out */
+ if (!check_rls_for_tuple(graph_vertex_label, tuple, CMD_SELECT))
+ {
+ table_endscan(scan_desc);
+ table_close(graph_vertex_label, ShareLock);
+ ereport(ERROR,
+ (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+ errmsg("access to vertex %lu denied by row-level security policy on \"%s\"",
+ graphid, vertex_label)));
+ }
+
/* get the tupdesc - we don't need to release this one */
tupdesc = RelationGetDescr(graph_vertex_label);
/* bail if the number of columns differs */
diff --git a/src/include/executor/cypher_utils.h b/src/include/executor/cypher_utils.h
index 0798f153..fc406745 100644
--- a/src/include/executor/cypher_utils.h
+++ b/src/include/executor/cypher_utils.h
@@ -21,6 +21,7 @@
#define AG_CYPHER_UTILS_H
#include "access/heapam.h"
+#include "nodes/execnodes.h"
#include "nodes/cypher_nodes.h"
#include "utils/agtype.h"
@@ -127,4 +128,25 @@ HeapTuple insert_entity_tuple_cid(ResultRelInfo *resultRelInfo,
TupleTableSlot *elemTupleSlot,
EState *estate, CommandId cid);
+/* RLS support */
+void setup_wcos(ResultRelInfo *resultRelInfo, EState *estate,
+ CustomScanState *node, CmdType cmd);
+List *setup_security_quals(ResultRelInfo *resultRelInfo, EState *estate,
+ CustomScanState *node, CmdType cmd);
+bool check_security_quals(List *qualExprs, TupleTableSlot *slot,
+ ExprContext *econtext);
+bool check_rls_for_tuple(Relation rel, HeapTuple tuple, CmdType cmd);
+
+/* Hash table entry for caching RLS state per label */
+typedef struct RLSCacheEntry
+{
+ Oid relid; /* hash key */
+ /* Security quals (USING policies) for UPDATE/DELETE */
+ List *qualExprs;
+ TupleTableSlot *slot; /* slot for old tuple (RLS check) */
+ /* WCOs - used only in SET */
+ List *withCheckOptions;
+ List *withCheckOptionExprs;
+} RLSCacheEntry;
+
#endif