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: 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}