Skip to content

Commit 5ad8f5a

Browse files
committed
sql/rls: exempt foreign key propagation from RLS enforcement
Previously, RLS policies were applied during foreign key propagation, triggered by ON DELETE or ON UPDATE actions specified in foreign key definitions. This behavior was incorrect because foreign key maintenance operations must not be blocked by RLS to ensure data integrity. Postgres exempts such operations, and we now match that behavior. Fixes #145333 Epic: CRDB-11724 Release note: none
1 parent 0229d90 commit 5ad8f5a

File tree

5 files changed

+358
-8
lines changed

5 files changed

+358
-8
lines changed

pkg/sql/logictest/testdata/logic_test/row_level_security

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1955,6 +1955,92 @@ DROP TABLE parent;
19551955
statement ok
19561956
DROP USER fk_user;
19571957

1958+
# Test FK propagation with RLS enabled on child table.
1959+
subtest fk_cascade
1960+
1961+
statement ok
1962+
CREATE TABLE customers (
1963+
id INT PRIMARY KEY,
1964+
name TEXT
1965+
);
1966+
1967+
statement ok
1968+
CREATE TABLE orders (
1969+
id INT PRIMARY KEY,
1970+
customer_id INT REFERENCES customers(id) ON UPDATE CASCADE ON DELETE SET NULL
1971+
);
1972+
1973+
statement ok
1974+
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
1975+
1976+
statement ok
1977+
INSERT INTO customers VALUES (1, 'bob');
1978+
1979+
statement ok
1980+
INSERT INTO orders VALUES (1000, 1), (1001, 1);
1981+
1982+
statement ok
1983+
CREATE USER u1;
1984+
1985+
statement ok
1986+
GRANT ALL ON orders, customers TO u1;
1987+
1988+
statement ok
1989+
SET ROLE u1;
1990+
1991+
# Verify u1 cannot ready anything from orders
1992+
query II
1993+
SELECT id, customer_id FROM orders
1994+
----
1995+
1996+
# Update the customer ID. This should succeed and cascade to orders.
1997+
query IT
1998+
UPDATE customers SET id = 2 WHERE id = 1 RETURNING id, name
1999+
----
2000+
2 bob
2001+
2002+
statement ok
2003+
RESET ROLE
2004+
2005+
query II
2006+
SELECT id, customer_id FROM orders ORDER BY id
2007+
----
2008+
1000 2
2009+
1001 2
2010+
2011+
statement ok
2012+
SET ROLE u1;
2013+
2014+
# Delete the customer. This should set customer_id in orders to NULL.
2015+
query IT
2016+
DELETE FROM customers WHERE id = 2 RETURNING id, name
2017+
----
2018+
2 bob
2019+
2020+
# Try to validate oders as u1, but invisible due to RLS
2021+
query IT
2022+
SELECT id, customer_id FROM orders ORDER BY id
2023+
----
2024+
2025+
statement ok
2026+
RESET ROLE;
2027+
2028+
# validate as the root user, should see the cascaded update
2029+
query II
2030+
SELECT id, customer_id FROM orders ORDER BY id
2031+
----
2032+
1000 NULL
2033+
1001 NULL
2034+
2035+
statement ok
2036+
DROP TABLE orders;
2037+
2038+
statement ok
2039+
DROP TABLE customers;
2040+
2041+
statement ok
2042+
DROP USER u1;
2043+
19582044
# Ensure CHECK constraints can work alongside RLS policies
19592045
subtest check_constraint
19602046

pkg/sql/opt/optbuilder/fk_cascade.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -527,7 +527,8 @@ func (cb *onDeleteSetBuilder) Build(
527527
// against the parent we are cascading from. Need to investigate in which
528528
// cases this is safe (e.g. other cascades could have messed with the parent
529529
// table in the meantime).
530-
mb.buildUpdate(nil /* returning */)
530+
// The exempt policy is used for RLS to maintain data integrity.
531+
mb.buildUpdate(nil /* returning */, cat.PolicyScopeExempt)
531532
return mb.outScope.expr
532533
})
533534
}
@@ -783,7 +784,8 @@ func (cb *onUpdateCascadeBuilder) Build(
783784
// Cascades can fire triggers on the child table.
784785
mb.buildRowLevelBeforeTriggers(tree.TriggerEventUpdate, true /* cascade */)
785786

786-
mb.buildUpdate(nil /* returning */)
787+
// The exempt policy is used for RLS to maintain data integrity.
788+
mb.buildUpdate(nil /* returning */, cat.PolicyScopeExempt)
787789
return mb.outScope.expr
788790
})
789791
}

pkg/sql/opt/optbuilder/select.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -771,7 +771,8 @@ func (b *Builder) buildScan(
771771
}
772772

773773
// Apply any filters required to enforce RLS policies. This must be done
774-
// after projecting out virtual columns, in case any policies reference them.
774+
// after adding projections for virtual columns, in case any policies
775+
// reference them.
775776
b.addRowLevelSecurityFilter(tabMeta, outScope, policyCommandScope)
776777

777778
if b.trackSchemaDeps {

pkg/sql/opt/optbuilder/testdata/row_level_security

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1311,3 +1311,262 @@ update t
13111311
│ └── c_new:15 + 1 [as=v_comp:16]
13121312
└── projections
13131313
└── (v_comp:16 > 0) AND (v_comp:16 > 5) [as=rls:17]
1314+
1315+
# Tests that verify FK maintenance operations are exempt from RLS checks on the
1316+
# referencing table.
1317+
1318+
exec-ddl
1319+
CREATE TABLE customers (
1320+
id INT PRIMARY KEY,
1321+
name TEXT
1322+
);
1323+
----
1324+
1325+
exec-ddl
1326+
CREATE TABLE orders (
1327+
id INT PRIMARY KEY,
1328+
customer_id INT REFERENCES customers(id) ON UPDATE CASCADE ON DELETE SET NULL
1329+
);
1330+
----
1331+
1332+
exec-ddl
1333+
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
1334+
----
1335+
1336+
# Validate that without any RLS policies, direct reads from orders are denied.
1337+
build
1338+
SELECT id, customer_id FROM orders
1339+
----
1340+
project
1341+
├── columns: id:1!null customer_id:2
1342+
└── select
1343+
├── columns: id:1!null customer_id:2 crdb_internal_mvcc_timestamp:3 tableoid:4
1344+
├── scan orders
1345+
│ └── columns: id:1!null customer_id:2 crdb_internal_mvcc_timestamp:3 tableoid:4
1346+
└── filters
1347+
└── false
1348+
1349+
# Update the customer ID. This should cascade the update to orders, bypassing
1350+
# RLS on orders.
1351+
build-post-queries
1352+
UPDATE customers SET id = 2 WHERE id = 1 RETURNING id, name
1353+
----
1354+
root
1355+
├── update customers
1356+
│ ├── columns: id:1!null name:2
1357+
│ ├── fetch columns: id:5 name:6
1358+
│ ├── update-mapping:
1359+
│ │ └── id_new:9 => id:1
1360+
│ ├── return-mapping:
1361+
│ │ ├── id_new:9 => id:1
1362+
│ │ └── name:6 => name:2
1363+
│ ├── input binding: &1
1364+
│ ├── cascades
1365+
│ │ └── orders_customer_id_fkey
1366+
│ └── project
1367+
│ ├── columns: id_new:9!null id:5!null name:6 crdb_internal_mvcc_timestamp:7 tableoid:8
1368+
│ ├── select
1369+
│ │ ├── columns: id:5!null name:6 crdb_internal_mvcc_timestamp:7 tableoid:8
1370+
│ │ ├── scan customers
1371+
│ │ │ ├── columns: id:5!null name:6 crdb_internal_mvcc_timestamp:7 tableoid:8
1372+
│ │ │ └── flags: avoid-full-scan
1373+
│ │ └── filters
1374+
│ │ └── id:5 = 1
1375+
│ └── projections
1376+
│ └── 2 [as=id_new:9]
1377+
└── cascade
1378+
└── update orders
1379+
├── columns: <none>
1380+
├── fetch columns: orders.id:14 orders.customer_id:15
1381+
├── update-mapping:
1382+
│ └── customer_id_new:19 => orders.customer_id:11
1383+
├── check columns: rls:20
1384+
├── input binding: &2
1385+
├── project
1386+
│ ├── columns: rls:20!null orders.id:14!null orders.customer_id:15!null customer_id_old:18!null customer_id_new:19!null
1387+
│ ├── inner-join (hash)
1388+
│ │ ├── columns: orders.id:14!null orders.customer_id:15!null customer_id_old:18!null customer_id_new:19!null
1389+
│ │ ├── scan orders
1390+
│ │ │ ├── columns: orders.id:14!null orders.customer_id:15
1391+
│ │ │ └── flags: avoid-full-scan disabled not visible index feature
1392+
│ │ ├── select
1393+
│ │ │ ├── columns: customer_id_old:18!null customer_id_new:19!null
1394+
│ │ │ ├── with-scan &1
1395+
│ │ │ │ ├── columns: customer_id_old:18!null customer_id_new:19!null
1396+
│ │ │ │ └── mapping:
1397+
│ │ │ │ ├── customers.id:5 => customer_id_old:18
1398+
│ │ │ │ └── id_new:9 => customer_id_new:19
1399+
│ │ │ └── filters
1400+
│ │ │ └── customer_id_old:18 IS DISTINCT FROM customer_id_new:19
1401+
│ │ └── filters
1402+
│ │ └── orders.customer_id:15 = customer_id_old:18
1403+
│ └── projections
1404+
│ └── true [as=rls:20]
1405+
└── f-k-checks
1406+
└── f-k-checks-item: orders(customer_id) -> customers(id)
1407+
└── anti-join (hash)
1408+
├── columns: customer_id:21!null
1409+
├── with-scan &2
1410+
│ ├── columns: customer_id:21!null
1411+
│ └── mapping:
1412+
│ └── customer_id_new:19 => customer_id:21
1413+
├── scan customers
1414+
│ ├── columns: customers.id:22!null
1415+
│ └── flags: avoid-full-scan disabled not visible index feature
1416+
└── filters
1417+
└── customer_id:21 = customers.id:22
1418+
1419+
# Delete the customer. This should set customer_id in orders to NULL, bypassing
1420+
# RLS on orders.
1421+
build-post-queries
1422+
DELETE FROM customers WHERE id = 2 RETURNING id, name
1423+
----
1424+
root
1425+
├── delete customers
1426+
│ ├── columns: id:1!null name:2
1427+
│ ├── fetch columns: id:5 name:6
1428+
│ ├── return-mapping:
1429+
│ │ ├── id:5 => id:1
1430+
│ │ └── name:6 => name:2
1431+
│ ├── input binding: &1
1432+
│ ├── cascades
1433+
│ │ └── orders_customer_id_fkey
1434+
│ └── select
1435+
│ ├── columns: id:5!null name:6 crdb_internal_mvcc_timestamp:7 tableoid:8
1436+
│ ├── scan customers
1437+
│ │ ├── columns: id:5!null name:6 crdb_internal_mvcc_timestamp:7 tableoid:8
1438+
│ │ └── flags: avoid-full-scan
1439+
│ └── filters
1440+
│ └── id:5 = 2
1441+
└── cascade
1442+
└── update orders
1443+
├── columns: <none>
1444+
├── fetch columns: orders.id:13 customer_id:14
1445+
├── update-mapping:
1446+
│ └── customer_id_new:18 => customer_id:10
1447+
├── check columns: rls:19
1448+
└── project
1449+
├── columns: rls:19!null orders.id:13!null customer_id:14 customer_id_new:18
1450+
├── project
1451+
│ ├── columns: customer_id_new:18 orders.id:13!null customer_id:14
1452+
│ ├── semi-join (hash)
1453+
│ │ ├── columns: orders.id:13!null customer_id:14
1454+
│ │ ├── scan orders
1455+
│ │ │ ├── columns: orders.id:13!null customer_id:14
1456+
│ │ │ └── flags: avoid-full-scan disabled not visible index feature
1457+
│ │ ├── with-scan &1
1458+
│ │ │ ├── columns: id:17!null
1459+
│ │ │ └── mapping:
1460+
│ │ │ └── customers.id:5 => id:17
1461+
│ │ └── filters
1462+
│ │ └── customer_id:14 = id:17
1463+
│ └── projections
1464+
│ └── NULL::INT8 [as=customer_id_new:18]
1465+
└── projections
1466+
└── true [as=rls:19]
1467+
1468+
# Repeat the operations but using different actions for ON UPDATE and ON DELETE
1469+
exec-ddl
1470+
DROP TABLE orders
1471+
----
1472+
1473+
exec-ddl
1474+
CREATE TABLE orders (
1475+
id INT PRIMARY KEY,
1476+
customer_id INT REFERENCES customers(id) ON UPDATE SET NULL ON DELETE CASCADE
1477+
);
1478+
----
1479+
1480+
exec-ddl
1481+
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
1482+
----
1483+
1484+
build-post-queries
1485+
UPDATE customers SET id = 2 WHERE id = 1 RETURNING id, name
1486+
----
1487+
root
1488+
├── update customers
1489+
│ ├── columns: id:1!null name:2
1490+
│ ├── fetch columns: id:5 name:6
1491+
│ ├── update-mapping:
1492+
│ │ └── id_new:9 => id:1
1493+
│ ├── return-mapping:
1494+
│ │ ├── id_new:9 => id:1
1495+
│ │ └── name:6 => name:2
1496+
│ ├── input binding: &1
1497+
│ ├── cascades
1498+
│ │ └── orders_customer_id_fkey
1499+
│ └── project
1500+
│ ├── columns: id_new:9!null id:5!null name:6 crdb_internal_mvcc_timestamp:7 tableoid:8
1501+
│ ├── select
1502+
│ │ ├── columns: id:5!null name:6 crdb_internal_mvcc_timestamp:7 tableoid:8
1503+
│ │ ├── scan customers
1504+
│ │ │ ├── columns: id:5!null name:6 crdb_internal_mvcc_timestamp:7 tableoid:8
1505+
│ │ │ └── flags: avoid-full-scan
1506+
│ │ └── filters
1507+
│ │ └── id:5 = 1
1508+
│ └── projections
1509+
│ └── 2 [as=id_new:9]
1510+
└── cascade
1511+
└── update orders
1512+
├── columns: <none>
1513+
├── fetch columns: orders.id:14 customer_id:15
1514+
├── update-mapping:
1515+
│ └── customer_id_new:20 => customer_id:11
1516+
├── check columns: rls:21
1517+
└── project
1518+
├── columns: rls:21!null orders.id:14!null customer_id:15!null customer_id_old:18!null customer_id_new:19!null customer_id_new:20
1519+
├── project
1520+
│ ├── columns: customer_id_new:20 orders.id:14!null customer_id:15!null customer_id_old:18!null customer_id_new:19!null
1521+
│ ├── inner-join (hash)
1522+
│ │ ├── columns: orders.id:14!null customer_id:15!null customer_id_old:18!null customer_id_new:19!null
1523+
│ │ ├── scan orders
1524+
│ │ │ ├── columns: orders.id:14!null customer_id:15
1525+
│ │ │ └── flags: avoid-full-scan disabled not visible index feature
1526+
│ │ ├── select
1527+
│ │ │ ├── columns: customer_id_old:18!null customer_id_new:19!null
1528+
│ │ │ ├── with-scan &1
1529+
│ │ │ │ ├── columns: customer_id_old:18!null customer_id_new:19!null
1530+
│ │ │ │ └── mapping:
1531+
│ │ │ │ ├── customers.id:5 => customer_id_old:18
1532+
│ │ │ │ └── id_new:9 => customer_id_new:19
1533+
│ │ │ └── filters
1534+
│ │ │ └── customer_id_old:18 IS DISTINCT FROM customer_id_new:19
1535+
│ │ └── filters
1536+
│ │ └── customer_id:15 = customer_id_old:18
1537+
│ └── projections
1538+
│ └── NULL::INT8 [as=customer_id_new:20]
1539+
└── projections
1540+
└── true [as=rls:21]
1541+
1542+
build-post-queries
1543+
DELETE FROM customers WHERE id = 2 RETURNING id, name
1544+
----
1545+
root
1546+
├── delete customers
1547+
│ ├── columns: id:1!null name:2
1548+
│ ├── fetch columns: id:5 name:6
1549+
│ ├── return-mapping:
1550+
│ │ ├── id:5 => id:1
1551+
│ │ └── name:6 => name:2
1552+
│ ├── cascades
1553+
│ │ └── orders_customer_id_fkey
1554+
│ └── select
1555+
│ ├── columns: id:5!null name:6 crdb_internal_mvcc_timestamp:7 tableoid:8
1556+
│ ├── scan customers
1557+
│ │ ├── columns: id:5!null name:6 crdb_internal_mvcc_timestamp:7 tableoid:8
1558+
│ │ └── flags: avoid-full-scan
1559+
│ └── filters
1560+
│ └── id:5 = 2
1561+
└── cascade
1562+
└── delete orders
1563+
├── columns: <none>
1564+
├── fetch columns: orders.id:13 customer_id:14
1565+
└── select
1566+
├── columns: orders.id:13!null customer_id:14!null
1567+
├── scan orders
1568+
│ ├── columns: orders.id:13!null customer_id:14
1569+
│ └── flags: avoid-full-scan disabled not visible index feature
1570+
└── filters
1571+
├── customer_id:14 = 2
1572+
└── customer_id:14 IS DISTINCT FROM CAST(NULL AS INT8)

0 commit comments

Comments
 (0)