Skip to main content

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}