reddb_server/runtime/rls_injection.rs
1//! Row-Level-Security policy injection & foreign-table filtering.
2//!
3//! Extracted verbatim from `impl_core.rs` (impl_core slice 4/10, issue #1625).
4//! Houses the RLS injection family that PRD #1619 keeps out of the central
5//! dispatch file:
6//!
7//! - **Policy-filter free fns** — `rls_policy_filter`,
8//! `rls_policy_filter_for_kind`, `rls_is_enabled`, `node_passes_rls`,
9//! `edge_passes_rls`, the query-rewrite injectors (`inject_rls_filters`,
10//! `inject_rls_into_join`, `collect_join_side_policy`), and
11//! `apply_foreign_table_filters`.
12//! - **Runtime accessor methods** — `is_rls_enabled`, `matching_rls_policies`,
13//! `matching_rls_policies_for_kind`, and the coupled FDW accessor
14//! `foreign_tables`.
15//!
16//! Names, signatures and visibility are preserved so the central dispatch and
17//! sibling-file callers (including `crate::runtime::impl_core::rls_*` paths)
18//! need no edits; `impl_core` re-exports the externally-referenced fns.
19use super::execution_context::current_auth_identity;
20use super::*;
21
22/// Combine matching RLS policies for a table + action into a single
23/// `Filter` suitable for AND-ing into a caller's `WHERE` clause.
24///
25/// Returns `None` when RLS is disabled or no policy admits the caller's
26/// role — callers use that to short-circuit the mutation (for DELETE /
27/// UPDATE we simply skip the operation, which PG expresses as "no rows
28/// match the policy + predicate combination").
29pub(crate) fn rls_policy_filter(
30 runtime: &RedDBRuntime,
31 table: &str,
32 action: reddb_rql::ast::PolicyAction,
33) -> Option<reddb_rql::ast::Filter> {
34 rls_policy_filter_for_kind(
35 runtime,
36 table,
37 action,
38 reddb_rql::ast::PolicyTargetKind::Table,
39 )
40}
41
42/// Kind-aware policy filter combiner (Phase 2.5.5 RLS universal).
43/// Graph / vector / queue / timeseries scans pass the concrete kind;
44/// policies targeting other kinds are ignored. Legacy Table-scoped
45/// policies still apply cross-kind — callers register auto-tenancy
46/// policies as Table today.
47pub(crate) fn rls_policy_filter_for_kind(
48 runtime: &RedDBRuntime,
49 table: &str,
50 action: reddb_rql::ast::PolicyAction,
51 kind: reddb_rql::ast::PolicyTargetKind,
52) -> Option<reddb_rql::ast::Filter> {
53 use reddb_rql::ast::Filter;
54
55 if !runtime.inner.rls_enabled_tables.read().contains(table) {
56 return None;
57 }
58 let role = current_auth_identity().map(|(_, role)| role);
59 let role_str = role.map(|r| r.as_str().to_string());
60 let policies = runtime.matching_rls_policies_for_kind(table, role_str.as_deref(), action, kind);
61 if policies.is_empty() {
62 return None;
63 }
64 policies
65 .into_iter()
66 .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
67}
68
69/// Returns true when the table has RLS enforcement enabled. Convenience
70/// shortcut so DML paths can gate the AND-combine work without reaching
71/// into `runtime.inner.rls_enabled_tables` directly.
72pub(crate) fn rls_is_enabled(runtime: &RedDBRuntime, table: &str) -> bool {
73 runtime.inner.rls_enabled_tables.read().contains(table)
74}
75
76/// Per-entity gate used by the graph materialiser for `GraphNode`
77/// entities. RLS is checked against the source collection with
78/// `kind = Nodes`, which `matching_rls_policies_for_kind` resolves to
79/// either `Nodes`-targeted policies or legacy `Table`-targeted ones
80/// (for back-compat with auto-tenancy declarations). Cached per
81/// collection so big graphs only resolve the policy chain once.
82pub(crate) fn node_passes_rls(
83 runtime: &RedDBRuntime,
84 collection: &str,
85 role: Option<&str>,
86 cache: &mut std::collections::HashMap<String, Option<reddb_rql::ast::Filter>>,
87 entity: &crate::storage::unified::entity::UnifiedEntity,
88) -> bool {
89 use reddb_rql::ast::{Filter, PolicyAction, PolicyTargetKind};
90
91 if !runtime.inner.rls_enabled_tables.read().contains(collection) {
92 return true;
93 }
94 let filter = cache.entry(collection.to_string()).or_insert_with(|| {
95 let policies = runtime.matching_rls_policies_for_kind(
96 collection,
97 role,
98 PolicyAction::Select,
99 PolicyTargetKind::Nodes,
100 );
101 if policies.is_empty() {
102 None
103 } else {
104 policies
105 .into_iter()
106 .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
107 }
108 });
109 let Some(filter) = filter else {
110 return false;
111 };
112 crate::runtime::query_exec::evaluate_entity_filter_with_db(
113 Some(&runtime.inner.db),
114 entity,
115 filter,
116 collection,
117 collection,
118 )
119}
120
121/// Edge counterpart of `node_passes_rls`. Same caching strategy with
122/// `kind = Edges`.
123pub(crate) fn edge_passes_rls(
124 runtime: &RedDBRuntime,
125 collection: &str,
126 role: Option<&str>,
127 cache: &mut std::collections::HashMap<String, Option<reddb_rql::ast::Filter>>,
128 entity: &crate::storage::unified::entity::UnifiedEntity,
129) -> bool {
130 use reddb_rql::ast::{Filter, PolicyAction, PolicyTargetKind};
131
132 if !runtime.inner.rls_enabled_tables.read().contains(collection) {
133 return true;
134 }
135 let filter = cache.entry(collection.to_string()).or_insert_with(|| {
136 let policies = runtime.matching_rls_policies_for_kind(
137 collection,
138 role,
139 PolicyAction::Select,
140 PolicyTargetKind::Edges,
141 );
142 if policies.is_empty() {
143 None
144 } else {
145 policies
146 .into_iter()
147 .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
148 }
149 });
150 let Some(filter) = filter else {
151 return false;
152 };
153 crate::runtime::query_exec::evaluate_entity_filter_with_db(
154 Some(&runtime.inner.db),
155 entity,
156 filter,
157 collection,
158 collection,
159 )
160}
161
162/// RLS policy injection (Phase 2.5.2 PG parity).
163///
164/// Fetch every matching policy for the current thread-local role and
165/// fold them into the query's filter. Semantics mirror PostgreSQL:
166///
167/// * Multiple policies on the same table combine with **OR** — a row is
168/// visible if *any* policy admits it.
169/// * The combined policy predicate is **AND**-ed into the caller's
170/// existing `WHERE` clause so explicit predicates continue to trim
171/// the policy-allowed set.
172/// * No matching policies + RLS enabled = zero rows (PG's
173/// restrictive-default). Callers get `None` and return an empty
174/// `UnifiedResult` without ever dispatching the scan.
175///
176/// This runs only when `RuntimeInner::rls_enabled_tables` already
177/// contains the table name — callers gate the hot path upfront to
178/// avoid the lock acquisition on tables without RLS.
179///
180/// Returns `None` when no policy admits the current role; returns
181/// `Some(mutated_table)` with policy filters folded in otherwise.
182pub(crate) fn inject_rls_filters(
183 runtime: &RedDBRuntime,
184 frame: &dyn super::statement_frame::ReadFrame,
185 mut table: reddb_rql::ast::TableQuery,
186) -> Option<reddb_rql::ast::TableQuery> {
187 use reddb_rql::ast::Filter;
188
189 // `None` role falls through to policies with no `TO role` clause.
190 let role = frame.identity().map(|(_, role)| role);
191 let role_str = role.map(|r| r.as_str().to_string());
192 let policies = runtime.matching_select_rls_policies(&table.table, role_str.as_deref());
193
194 if policies.is_empty() {
195 // RLS enabled + no policy match = deny everything. Signal the
196 // caller to short-circuit with an empty result set.
197 return None;
198 }
199
200 // Combine policy predicates with OR (PG's permissive default).
201 let combined = policies
202 .into_iter()
203 .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
204 .expect("policies non-empty");
205
206 // AND into the caller's existing predicate. The predicate may live
207 // in `where_expr` rather than `filter`: `resolve_table_expr_subqueries`
208 // nulls `filter` whenever `where_expr` is present (the case for a
209 // view body rewritten into `SELECT … WHERE …`). Folding only into
210 // `filter` here would silently drop that `where_expr` predicate at
211 // eval time because `effective_table_filter` prefers `filter` —
212 // e.g. `WITHIN TENANT … SELECT * FROM <view>` would apply the
213 // tenant policy but lose the view's own WHERE (#635).
214 use reddb_rql::sql_lowering::{expr_to_filter, filter_to_expr};
215 let had_where_expr = table.where_expr.is_some();
216 let existing = table
217 .filter
218 .take()
219 .or_else(|| table.where_expr.as_ref().map(expr_to_filter));
220 let new_filter = match existing {
221 Some(existing) => Filter::And(Box::new(existing), Box::new(combined)),
222 None => combined,
223 };
224 // Keep `where_expr` in lock-step with the merged `filter` so
225 // whichever the executor consults sees the full predicate.
226 if had_where_expr {
227 table.where_expr = Some(filter_to_expr(&new_filter));
228 }
229 table.filter = Some(new_filter);
230 Some(table)
231}
232
233/// Apply per-table RLS to a `JoinQuery` by folding each side's policy
234/// predicate into the join's outer filter. Walking the merged record
235/// at the join layer (rather than mutating the per-side scan filter)
236/// keeps the planner's strategy choice and per-side index selection
237/// undisturbed — the policy predicate uses the qualified `t.col` form
238/// that resolves cleanly against the merged record's keys.
239///
240/// Returns `None` when any leaf has RLS enabled and no policy admits
241/// the caller — the join short-circuits to an empty result.
242pub(crate) fn inject_rls_into_join(
243 runtime: &RedDBRuntime,
244 frame: &dyn super::statement_frame::ReadFrame,
245 mut join: reddb_rql::ast::JoinQuery,
246) -> Option<reddb_rql::ast::JoinQuery> {
247 use reddb_rql::ast::Filter;
248
249 let mut policy_filters: Vec<Filter> = Vec::new();
250 if !collect_join_side_policy(runtime, frame, join.left.as_ref(), &mut policy_filters) {
251 return None;
252 }
253 if !collect_join_side_policy(runtime, frame, join.right.as_ref(), &mut policy_filters) {
254 return None;
255 }
256
257 if policy_filters.is_empty() {
258 return Some(join);
259 }
260
261 let combined = policy_filters
262 .into_iter()
263 .reduce(|acc, f| Filter::And(Box::new(acc), Box::new(f)))
264 .expect("policy_filters non-empty");
265
266 join.filter = Some(match join.filter.take() {
267 Some(existing) => Filter::And(Box::new(existing), Box::new(combined)),
268 None => combined,
269 });
270
271 Some(join)
272}
273
274/// For each `Table` leaf reachable through nested joins, append the
275/// RLS-policy filter (combined with OR across that side's matching
276/// policies) into `out`. Returns `false` when a side has RLS enabled
277/// but no policy admits the caller — the join must short-circuit.
278fn collect_join_side_policy(
279 runtime: &RedDBRuntime,
280 frame: &dyn super::statement_frame::ReadFrame,
281 expr: &reddb_rql::ast::QueryExpr,
282 out: &mut Vec<reddb_rql::ast::Filter>,
283) -> bool {
284 use reddb_rql::ast::{Filter, QueryExpr};
285 match expr {
286 QueryExpr::Table(t) => {
287 if !runtime.inner.rls_enabled_tables.read().contains(&t.table) {
288 return true;
289 }
290 let role = frame.identity().map(|(_, role)| role);
291 let role_str = role.map(|r| r.as_str().to_string());
292 let policies = runtime.matching_select_rls_policies(&t.table, role_str.as_deref());
293 if policies.is_empty() {
294 return false;
295 }
296 let combined = policies
297 .into_iter()
298 .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
299 .expect("policies non-empty");
300 out.push(combined);
301 true
302 }
303 QueryExpr::Join(inner) => {
304 collect_join_side_policy(runtime, frame, inner.left.as_ref(), out)
305 && collect_join_side_policy(runtime, frame, inner.right.as_ref(), out)
306 }
307 _ => true,
308 }
309}
310
311/// Foreign-table post-scan filter application (Phase 3.2.2 PG parity).
312///
313/// Phase 3.2 FDW wrappers don't advertise filter pushdown, so the runtime
314/// applies `WHERE` / `ORDER BY` / `LIMIT` / `OFFSET` after the wrapper
315/// materialises all rows. Projections are best-effort — when the query
316/// lists explicit columns we keep only those; a `SELECT *` keeps every
317/// wrapper-emitted field verbatim.
318///
319/// When a wrapper later opts into pushdown (`supports_pushdown = true`)
320/// the runtime will pass the compiled filter down instead of post-filtering.
321pub(crate) fn apply_foreign_table_filters(
322 records: Vec<crate::storage::query::unified::UnifiedRecord>,
323 query: &reddb_rql::ast::TableQuery,
324) -> crate::storage::query::unified::UnifiedResult {
325 use crate::storage::query::unified::UnifiedResult;
326 use reddb_rql::sql_lowering::{effective_table_filter, effective_table_projections};
327
328 let filter = effective_table_filter(query);
329 let projections = effective_table_projections(query);
330
331 // Step 1 — WHERE. Reuse the cross-store evaluator so the semantics
332 // match native-collection queries (same operators, same NULL handling).
333 let mut filtered: Vec<_> = records
334 .into_iter()
335 .filter(|record| match &filter {
336 Some(f) => {
337 super::join_filter::evaluate_runtime_filter_with_db(None, record, f, None, None)
338 }
339 None => true,
340 })
341 .collect();
342
343 // Step 2 — LIMIT / OFFSET. Applied after filter to match SQL semantics.
344 if let Some(offset) = query.offset {
345 let offset = offset as usize;
346 if offset >= filtered.len() {
347 filtered.clear();
348 } else {
349 filtered.drain(0..offset);
350 }
351 }
352 if let Some(limit) = query.limit {
353 filtered.truncate(limit as usize);
354 }
355
356 // Step 3 — columns list. `SELECT *` (no explicit projections) keeps
357 // the wrapper's column set; an explicit list trims to those names.
358 let columns: Vec<String> = if projections.is_empty() {
359 filtered
360 .first()
361 .map(|r| r.column_names().iter().map(|k| k.to_string()).collect())
362 .unwrap_or_default()
363 } else {
364 projections
365 .iter()
366 .map(super::join_filter::projection_name)
367 .collect()
368 };
369
370 let mut result = UnifiedResult::empty();
371 result.columns = columns;
372 result.records = filtered;
373 result
374}
375
376impl RedDBRuntime {
377 /// SELECT and JOIN use the relational AST for both tables and documents.
378 /// Resolve the policy kind from the declared collection model.
379 fn matching_select_rls_policies(
380 &self,
381 table: &str,
382 role: Option<&str>,
383 ) -> Vec<reddb_rql::ast::Filter> {
384 use reddb_rql::ast::{PolicyAction, PolicyTargetKind};
385
386 let kind = if self
387 .db()
388 .collection_contract_arc(table)
389 .is_some_and(|contract| {
390 contract.declared_model == crate::catalog::CollectionModel::Document
391 }) {
392 PolicyTargetKind::Documents
393 } else {
394 PolicyTargetKind::Table
395 };
396 self.matching_rls_policies_for_kind(table, role, PolicyAction::Select, kind)
397 }
398
399 /// Access the shared `ForeignTableRegistry` (Phase 3.2 PG parity).
400 ///
401 /// Callers use this to check whether a table name is a registered
402 /// foreign table (`registry.is_foreign_table(name)`) and, if so, to
403 /// scan it (`registry.scan(name)`). The read-path rewriter consults
404 /// this before dispatching into native-collection lookup.
405 pub fn foreign_tables(&self) -> Arc<crate::storage::fdw::ForeignTableRegistry> {
406 Arc::clone(&self.inner.foreign_tables)
407 }
408
409 /// Is Row-Level Security enabled for this table? (Phase 2.5 PG parity)
410 pub fn is_rls_enabled(&self, table: &str) -> bool {
411 self.inner.rls_enabled_tables.read().contains(table)
412 }
413
414 /// Collect the USING predicates that apply to this `(table, role, action)`.
415 ///
416 /// Returned filters should be OR-combined (a row passes RLS when *any*
417 /// matching policy accepts it) and then AND-ed into the query's WHERE.
418 /// When the table has RLS disabled this returns an empty Vec — callers
419 /// can fast-path back to the unfiltered read.
420 pub fn matching_rls_policies(
421 &self,
422 table: &str,
423 role: Option<&str>,
424 action: reddb_rql::ast::PolicyAction,
425 ) -> Vec<reddb_rql::ast::Filter> {
426 // Default kind = Table preserves the pre-Phase-2.5.5 behaviour:
427 // callers that don't name a kind only see Table-scoped
428 // policies (which is what execute SELECT / UPDATE / DELETE
429 // expect).
430 self.matching_rls_policies_for_kind(
431 table,
432 role,
433 action,
434 reddb_rql::ast::PolicyTargetKind::Table,
435 )
436 }
437
438 /// Kind-aware variant used by cross-model scans (Phase 2.5.5).
439 ///
440 /// Graph scans request `Nodes` / `Edges`, vector ANN requests
441 /// `Vectors`, queue consumers request `Messages`, and timeseries
442 /// range scans request `Points`. Policies tagged with a
443 /// different kind are skipped so a graph-scoped policy doesn't
444 /// accidentally gate a table SELECT on the same collection.
445 pub fn matching_rls_policies_for_kind(
446 &self,
447 table: &str,
448 role: Option<&str>,
449 action: reddb_rql::ast::PolicyAction,
450 kind: reddb_rql::ast::PolicyTargetKind,
451 ) -> Vec<reddb_rql::ast::Filter> {
452 if !self.is_rls_enabled(table) {
453 return Vec::new();
454 }
455 let policies = self.inner.rls_policies.read();
456 policies
457 .iter()
458 .filter_map(|((t, _), p)| {
459 if t != table {
460 return None;
461 }
462 // Kind gate — Table policies also apply to every
463 // other kind *iff* the policy predicate evaluates
464 // against entity fields that exist uniformly; the
465 // caller's kind filter is the stricter check, so
466 // match literally. Auto-tenancy policies stamp
467 // Table and the caller passes the concrete kind —
468 // we allow Table policies to apply cross-kind for
469 // backwards compat.
470 if p.target_kind != kind && p.target_kind != reddb_rql::ast::PolicyTargetKind::Table
471 {
472 return None;
473 }
474 // Action gate — `None` means "ALL" actions.
475 if let Some(a) = p.action {
476 if a != action {
477 return None;
478 }
479 }
480 // Role gate — `None` means "any role".
481 if let Some(p_role) = p.role.as_deref() {
482 match role {
483 Some(r) if r == p_role => {}
484 _ => return None,
485 }
486 }
487 Some((*p.using).clone())
488 })
489 .collect()
490 }
491}