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: crate::storage::query::ast::PolicyAction,
33) -> Option<crate::storage::query::ast::Filter> {
34 rls_policy_filter_for_kind(
35 runtime,
36 table,
37 action,
38 crate::storage::query::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: crate::storage::query::ast::PolicyAction,
51 kind: crate::storage::query::ast::PolicyTargetKind,
52) -> Option<crate::storage::query::ast::Filter> {
53 use crate::storage::query::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<crate::storage::query::ast::Filter>>,
87 entity: &crate::storage::unified::entity::UnifiedEntity,
88) -> bool {
89 use crate::storage::query::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<crate::storage::query::ast::Filter>>,
128 entity: &crate::storage::unified::entity::UnifiedEntity,
129) -> bool {
130 use crate::storage::query::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: crate::storage::query::ast::TableQuery,
186) -> Option<crate::storage::query::ast::TableQuery> {
187 use crate::storage::query::ast::{Filter, PolicyAction};
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 =
193 runtime.matching_rls_policies(&table.table, role_str.as_deref(), PolicyAction::Select);
194
195 if policies.is_empty() {
196 // RLS enabled + no policy match = deny everything. Signal the
197 // caller to short-circuit with an empty result set.
198 return None;
199 }
200
201 // Combine policy predicates with OR (PG's permissive default).
202 let combined = policies
203 .into_iter()
204 .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
205 .expect("policies non-empty");
206
207 // AND into the caller's existing predicate. The predicate may live
208 // in `where_expr` rather than `filter`: `resolve_table_expr_subqueries`
209 // nulls `filter` whenever `where_expr` is present (the case for a
210 // view body rewritten into `SELECT … WHERE …`). Folding only into
211 // `filter` here would silently drop that `where_expr` predicate at
212 // eval time because `effective_table_filter` prefers `filter` —
213 // e.g. `WITHIN TENANT … SELECT * FROM <view>` would apply the
214 // tenant policy but lose the view's own WHERE (#635).
215 use crate::storage::query::sql_lowering::{expr_to_filter, filter_to_expr};
216 let had_where_expr = table.where_expr.is_some();
217 let existing = table
218 .filter
219 .take()
220 .or_else(|| table.where_expr.as_ref().map(expr_to_filter));
221 let new_filter = match existing {
222 Some(existing) => Filter::And(Box::new(existing), Box::new(combined)),
223 None => combined,
224 };
225 // Keep `where_expr` in lock-step with the merged `filter` so
226 // whichever the executor consults sees the full predicate.
227 if had_where_expr {
228 table.where_expr = Some(filter_to_expr(&new_filter));
229 }
230 table.filter = Some(new_filter);
231 Some(table)
232}
233
234/// Apply per-table RLS to a `JoinQuery` by folding each side's policy
235/// predicate into the join's outer filter. Walking the merged record
236/// at the join layer (rather than mutating the per-side scan filter)
237/// keeps the planner's strategy choice and per-side index selection
238/// undisturbed — the policy predicate uses the qualified `t.col` form
239/// that resolves cleanly against the merged record's keys.
240///
241/// Returns `None` when any leaf has RLS enabled and no policy admits
242/// the caller — the join short-circuits to an empty result.
243pub(crate) fn inject_rls_into_join(
244 runtime: &RedDBRuntime,
245 frame: &dyn super::statement_frame::ReadFrame,
246 mut join: crate::storage::query::ast::JoinQuery,
247) -> Option<crate::storage::query::ast::JoinQuery> {
248 use crate::storage::query::ast::Filter;
249
250 let mut policy_filters: Vec<Filter> = Vec::new();
251 if !collect_join_side_policy(runtime, frame, join.left.as_ref(), &mut policy_filters) {
252 return None;
253 }
254 if !collect_join_side_policy(runtime, frame, join.right.as_ref(), &mut policy_filters) {
255 return None;
256 }
257
258 if policy_filters.is_empty() {
259 return Some(join);
260 }
261
262 let combined = policy_filters
263 .into_iter()
264 .reduce(|acc, f| Filter::And(Box::new(acc), Box::new(f)))
265 .expect("policy_filters non-empty");
266
267 join.filter = Some(match join.filter.take() {
268 Some(existing) => Filter::And(Box::new(existing), Box::new(combined)),
269 None => combined,
270 });
271
272 Some(join)
273}
274
275/// For each `Table` leaf reachable through nested joins, append the
276/// RLS-policy filter (combined with OR across that side's matching
277/// policies) into `out`. Returns `false` when a side has RLS enabled
278/// but no policy admits the caller — the join must short-circuit.
279fn collect_join_side_policy(
280 runtime: &RedDBRuntime,
281 frame: &dyn super::statement_frame::ReadFrame,
282 expr: &crate::storage::query::ast::QueryExpr,
283 out: &mut Vec<crate::storage::query::ast::Filter>,
284) -> bool {
285 use crate::storage::query::ast::{Filter, PolicyAction, QueryExpr};
286 match expr {
287 QueryExpr::Table(t) => {
288 if !runtime.inner.rls_enabled_tables.read().contains(&t.table) {
289 return true;
290 }
291 let role = frame.identity().map(|(_, role)| role);
292 let role_str = role.map(|r| r.as_str().to_string());
293 let policies =
294 runtime.matching_rls_policies(&t.table, role_str.as_deref(), PolicyAction::Select);
295 if policies.is_empty() {
296 return false;
297 }
298 let combined = policies
299 .into_iter()
300 .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
301 .expect("policies non-empty");
302 out.push(combined);
303 true
304 }
305 QueryExpr::Join(inner) => {
306 collect_join_side_policy(runtime, frame, inner.left.as_ref(), out)
307 && collect_join_side_policy(runtime, frame, inner.right.as_ref(), out)
308 }
309 _ => true,
310 }
311}
312
313/// Foreign-table post-scan filter application (Phase 3.2.2 PG parity).
314///
315/// Phase 3.2 FDW wrappers don't advertise filter pushdown, so the runtime
316/// applies `WHERE` / `ORDER BY` / `LIMIT` / `OFFSET` after the wrapper
317/// materialises all rows. Projections are best-effort — when the query
318/// lists explicit columns we keep only those; a `SELECT *` keeps every
319/// wrapper-emitted field verbatim.
320///
321/// When a wrapper later opts into pushdown (`supports_pushdown = true`)
322/// the runtime will pass the compiled filter down instead of post-filtering.
323pub(crate) fn apply_foreign_table_filters(
324 records: Vec<crate::storage::query::unified::UnifiedRecord>,
325 query: &crate::storage::query::ast::TableQuery,
326) -> crate::storage::query::unified::UnifiedResult {
327 use crate::storage::query::sql_lowering::{
328 effective_table_filter, effective_table_projections,
329 };
330 use crate::storage::query::unified::UnifiedResult;
331
332 let filter = effective_table_filter(query);
333 let projections = effective_table_projections(query);
334
335 // Step 1 — WHERE. Reuse the cross-store evaluator so the semantics
336 // match native-collection queries (same operators, same NULL handling).
337 let mut filtered: Vec<_> = records
338 .into_iter()
339 .filter(|record| match &filter {
340 Some(f) => {
341 super::join_filter::evaluate_runtime_filter_with_db(None, record, f, None, None)
342 }
343 None => true,
344 })
345 .collect();
346
347 // Step 2 — LIMIT / OFFSET. Applied after filter to match SQL semantics.
348 if let Some(offset) = query.offset {
349 let offset = offset as usize;
350 if offset >= filtered.len() {
351 filtered.clear();
352 } else {
353 filtered.drain(0..offset);
354 }
355 }
356 if let Some(limit) = query.limit {
357 filtered.truncate(limit as usize);
358 }
359
360 // Step 3 — columns list. `SELECT *` (no explicit projections) keeps
361 // the wrapper's column set; an explicit list trims to those names.
362 let columns: Vec<String> = if projections.is_empty() {
363 filtered
364 .first()
365 .map(|r| r.column_names().iter().map(|k| k.to_string()).collect())
366 .unwrap_or_default()
367 } else {
368 projections
369 .iter()
370 .map(super::join_filter::projection_name)
371 .collect()
372 };
373
374 let mut result = UnifiedResult::empty();
375 result.columns = columns;
376 result.records = filtered;
377 result
378}
379
380impl RedDBRuntime {
381 /// Access the shared `ForeignTableRegistry` (Phase 3.2 PG parity).
382 ///
383 /// Callers use this to check whether a table name is a registered
384 /// foreign table (`registry.is_foreign_table(name)`) and, if so, to
385 /// scan it (`registry.scan(name)`). The read-path rewriter consults
386 /// this before dispatching into native-collection lookup.
387 pub fn foreign_tables(&self) -> Arc<crate::storage::fdw::ForeignTableRegistry> {
388 Arc::clone(&self.inner.foreign_tables)
389 }
390
391 /// Is Row-Level Security enabled for this table? (Phase 2.5 PG parity)
392 pub fn is_rls_enabled(&self, table: &str) -> bool {
393 self.inner.rls_enabled_tables.read().contains(table)
394 }
395
396 /// Collect the USING predicates that apply to this `(table, role, action)`.
397 ///
398 /// Returned filters should be OR-combined (a row passes RLS when *any*
399 /// matching policy accepts it) and then AND-ed into the query's WHERE.
400 /// When the table has RLS disabled this returns an empty Vec — callers
401 /// can fast-path back to the unfiltered read.
402 pub fn matching_rls_policies(
403 &self,
404 table: &str,
405 role: Option<&str>,
406 action: crate::storage::query::ast::PolicyAction,
407 ) -> Vec<crate::storage::query::ast::Filter> {
408 // Default kind = Table preserves the pre-Phase-2.5.5 behaviour:
409 // callers that don't name a kind only see Table-scoped
410 // policies (which is what execute SELECT / UPDATE / DELETE
411 // expect).
412 self.matching_rls_policies_for_kind(
413 table,
414 role,
415 action,
416 crate::storage::query::ast::PolicyTargetKind::Table,
417 )
418 }
419
420 /// Kind-aware variant used by cross-model scans (Phase 2.5.5).
421 ///
422 /// Graph scans request `Nodes` / `Edges`, vector ANN requests
423 /// `Vectors`, queue consumers request `Messages`, and timeseries
424 /// range scans request `Points`. Policies tagged with a
425 /// different kind are skipped so a graph-scoped policy doesn't
426 /// accidentally gate a table SELECT on the same collection.
427 pub fn matching_rls_policies_for_kind(
428 &self,
429 table: &str,
430 role: Option<&str>,
431 action: crate::storage::query::ast::PolicyAction,
432 kind: crate::storage::query::ast::PolicyTargetKind,
433 ) -> Vec<crate::storage::query::ast::Filter> {
434 if !self.is_rls_enabled(table) {
435 return Vec::new();
436 }
437 let policies = self.inner.rls_policies.read();
438 policies
439 .iter()
440 .filter_map(|((t, _), p)| {
441 if t != table {
442 return None;
443 }
444 // Kind gate — Table policies also apply to every
445 // other kind *iff* the policy predicate evaluates
446 // against entity fields that exist uniformly; the
447 // caller's kind filter is the stricter check, so
448 // match literally. Auto-tenancy policies stamp
449 // Table and the caller passes the concrete kind —
450 // we allow Table policies to apply cross-kind for
451 // backwards compat.
452 if p.target_kind != kind
453 && p.target_kind != crate::storage::query::ast::PolicyTargetKind::Table
454 {
455 return None;
456 }
457 // Action gate — `None` means "ALL" actions.
458 if let Some(a) = p.action {
459 if a != action {
460 return None;
461 }
462 }
463 // Role gate — `None` means "any role".
464 if let Some(p_role) = p.role.as_deref() {
465 match role {
466 Some(r) if r == p_role => {}
467 _ => return None,
468 }
469 }
470 Some((*p.using).clone())
471 })
472 .collect()
473 }
474}