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
76pub(crate) fn entity_passes_rls_action(
77    runtime: &RedDBRuntime,
78    collection: &str,
79    action: reddb_rql::ast::PolicyAction,
80    entity: &crate::storage::unified::entity::UnifiedEntity,
81) -> bool {
82    if current_auth_identity().is_none() || !rls_is_enabled(runtime, collection) {
83        return true;
84    }
85    let kind = entity_policy_target_kind(runtime, collection, entity);
86    let Some(filter) = rls_policy_filter_for_kind(runtime, collection, action, kind) else {
87        return false;
88    };
89    crate::runtime::query_exec::evaluate_entity_filter_with_db(
90        Some(&runtime.inner.db),
91        entity,
92        &filter,
93        collection,
94        collection,
95    )
96}
97
98fn entity_policy_target_kind(
99    runtime: &RedDBRuntime,
100    collection: &str,
101    entity: &crate::storage::unified::entity::UnifiedEntity,
102) -> reddb_rql::ast::PolicyTargetKind {
103    use crate::storage::unified::entity::EntityKind;
104    use reddb_rql::ast::PolicyTargetKind;
105
106    match &entity.kind {
107        EntityKind::GraphNode(_) => PolicyTargetKind::Nodes,
108        EntityKind::GraphEdge(_) => PolicyTargetKind::Edges,
109        EntityKind::Vector { .. } => PolicyTargetKind::Vectors,
110        EntityKind::TimeSeriesPoint(_) => PolicyTargetKind::Points,
111        EntityKind::QueueMessage { .. } => PolicyTargetKind::Messages,
112        EntityKind::TableRow { .. }
113            if runtime
114                .db()
115                .collection_contract_arc(collection)
116                .is_some_and(|contract| {
117                    contract.declared_model == crate::catalog::CollectionModel::Document
118                }) =>
119        {
120            PolicyTargetKind::Documents
121        }
122        EntityKind::TableRow { .. } => PolicyTargetKind::Table,
123    }
124}
125
126pub(crate) fn kv_watch_event_passes_rls(
127    runtime: &RedDBRuntime,
128    event: &crate::replication::cdc::KvWatchEvent,
129) -> bool {
130    use crate::storage::unified::entity::{EntityData, EntityKind, RowData, UnifiedEntity};
131    use crate::storage::EntityId;
132    use reddb_rql::ast::PolicyAction;
133    use reddb_types::Value;
134    use std::sync::Arc;
135
136    if current_auth_identity().is_none() || !rls_is_enabled(runtime, &event.collection) {
137        return true;
138    }
139
140    let table: Arc<str> = Arc::from(event.collection.as_str());
141    let visible = |value: &crate::json::Value| {
142        let Ok(value) = crate::application::entity::json_to_storage_value(value) else {
143            return false;
144        };
145        let entity = UnifiedEntity::new(
146            EntityId::new(0),
147            EntityKind::TableRow {
148                table: Arc::clone(&table),
149                row_id: 0,
150            },
151            EntityData::Row(RowData::with_names(
152                vec![Value::text(event.key.clone()), value],
153                vec!["key".to_string(), "value".to_string()],
154            )),
155        );
156        entity_passes_rls_action(runtime, &event.collection, PolicyAction::Select, &entity)
157    };
158
159    let mut values = event.before.iter().chain(event.after.iter());
160    let Some(first) = values.next() else {
161        return false;
162    };
163    visible(first) && values.all(visible)
164}
165
166/// Per-entity gate used by the graph materialiser for `GraphNode`
167/// entities. RLS is checked against the source collection with
168/// `kind = Nodes`, which `matching_rls_policies_for_kind` resolves to
169/// either `Nodes`-targeted policies or legacy `Table`-targeted ones
170/// (for back-compat with auto-tenancy declarations). Cached per
171/// collection so big graphs only resolve the policy chain once.
172pub(crate) fn node_passes_rls(
173    runtime: &RedDBRuntime,
174    collection: &str,
175    role: Option<&str>,
176    cache: &mut std::collections::HashMap<String, Option<reddb_rql::ast::Filter>>,
177    entity: &crate::storage::unified::entity::UnifiedEntity,
178) -> bool {
179    use reddb_rql::ast::{Filter, PolicyAction, PolicyTargetKind};
180
181    if !runtime.inner.rls_enabled_tables.read().contains(collection) {
182        return true;
183    }
184    let filter = cache.entry(collection.to_string()).or_insert_with(|| {
185        let policies = runtime.matching_rls_policies_for_kind(
186            collection,
187            role,
188            PolicyAction::Select,
189            PolicyTargetKind::Nodes,
190        );
191        if policies.is_empty() {
192            None
193        } else {
194            policies
195                .into_iter()
196                .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
197        }
198    });
199    let Some(filter) = filter else {
200        return false;
201    };
202    crate::runtime::query_exec::evaluate_entity_filter_with_db(
203        Some(&runtime.inner.db),
204        entity,
205        filter,
206        collection,
207        collection,
208    )
209}
210
211/// Edge counterpart of `node_passes_rls`. Same caching strategy with
212/// `kind = Edges`.
213pub(crate) fn edge_passes_rls(
214    runtime: &RedDBRuntime,
215    collection: &str,
216    role: Option<&str>,
217    cache: &mut std::collections::HashMap<String, Option<reddb_rql::ast::Filter>>,
218    entity: &crate::storage::unified::entity::UnifiedEntity,
219) -> bool {
220    use reddb_rql::ast::{Filter, PolicyAction, PolicyTargetKind};
221
222    if !runtime.inner.rls_enabled_tables.read().contains(collection) {
223        return true;
224    }
225    let filter = cache.entry(collection.to_string()).or_insert_with(|| {
226        let policies = runtime.matching_rls_policies_for_kind(
227            collection,
228            role,
229            PolicyAction::Select,
230            PolicyTargetKind::Edges,
231        );
232        if policies.is_empty() {
233            None
234        } else {
235            policies
236                .into_iter()
237                .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
238        }
239    });
240    let Some(filter) = filter else {
241        return false;
242    };
243    crate::runtime::query_exec::evaluate_entity_filter_with_db(
244        Some(&runtime.inner.db),
245        entity,
246        filter,
247        collection,
248        collection,
249    )
250}
251
252/// RLS policy injection (Phase 2.5.2 PG parity).
253///
254/// Fetch every matching policy for the current thread-local role and
255/// fold them into the query's filter. Semantics mirror PostgreSQL:
256///
257/// * Multiple policies on the same table combine with **OR** — a row is
258///   visible if *any* policy admits it.
259/// * The combined policy predicate is **AND**-ed into the caller's
260///   existing `WHERE` clause so explicit predicates continue to trim
261///   the policy-allowed set.
262/// * No matching policies + RLS enabled = zero rows (PG's
263///   restrictive-default). Callers get `None` and return an empty
264///   `UnifiedResult` without ever dispatching the scan.
265///
266/// This runs only when `RuntimeInner::rls_enabled_tables` already
267/// contains the table name — callers gate the hot path upfront to
268/// avoid the lock acquisition on tables without RLS.
269///
270/// Returns `None` when no policy admits the current role; returns
271/// `Some(mutated_table)` with policy filters folded in otherwise.
272pub(crate) fn inject_rls_filters(
273    runtime: &RedDBRuntime,
274    frame: &dyn super::statement_frame::ReadFrame,
275    mut table: reddb_rql::ast::TableQuery,
276) -> Option<reddb_rql::ast::TableQuery> {
277    use reddb_rql::ast::Filter;
278
279    // `None` role falls through to policies with no `TO role` clause.
280    let role = frame.identity().map(|(_, role)| role);
281    let role_str = role.map(|r| r.as_str().to_string());
282    let policies = runtime.matching_select_rls_policies(&table.table, role_str.as_deref());
283
284    if policies.is_empty() {
285        // RLS enabled + no policy match = deny everything. Signal the
286        // caller to short-circuit with an empty result set.
287        return None;
288    }
289
290    // Combine policy predicates with OR (PG's permissive default).
291    let combined = policies
292        .into_iter()
293        .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
294        .expect("policies non-empty");
295
296    // AND into the caller's existing predicate. The predicate may live
297    // in `where_expr` rather than `filter`: `resolve_table_expr_subqueries`
298    // nulls `filter` whenever `where_expr` is present (the case for a
299    // view body rewritten into `SELECT … WHERE …`). Folding only into
300    // `filter` here would silently drop that `where_expr` predicate at
301    // eval time because `effective_table_filter` prefers `filter` —
302    // e.g. `WITHIN TENANT … SELECT * FROM <view>` would apply the
303    // tenant policy but lose the view's own WHERE (#635).
304    use reddb_rql::sql_lowering::{expr_to_filter, filter_to_expr};
305    let had_where_expr = table.where_expr.is_some();
306    let existing = table
307        .filter
308        .take()
309        .or_else(|| table.where_expr.as_ref().map(expr_to_filter));
310    let new_filter = match existing {
311        Some(existing) => Filter::And(Box::new(existing), Box::new(combined)),
312        None => combined,
313    };
314    // Keep `where_expr` in lock-step with the merged `filter` so
315    // whichever the executor consults sees the full predicate.
316    if had_where_expr {
317        table.where_expr = Some(filter_to_expr(&new_filter));
318    }
319    table.filter = Some(new_filter);
320    Some(table)
321}
322
323/// Apply per-table RLS to a `JoinQuery` by folding each side's policy
324/// predicate into the join's outer filter. Walking the merged record
325/// at the join layer (rather than mutating the per-side scan filter)
326/// keeps the planner's strategy choice and per-side index selection
327/// undisturbed — the policy predicate uses the qualified `t.col` form
328/// that resolves cleanly against the merged record's keys.
329///
330/// Returns `None` when any leaf has RLS enabled and no policy admits
331/// the caller — the join short-circuits to an empty result.
332pub(crate) fn inject_rls_into_join(
333    runtime: &RedDBRuntime,
334    frame: &dyn super::statement_frame::ReadFrame,
335    mut join: reddb_rql::ast::JoinQuery,
336) -> Option<reddb_rql::ast::JoinQuery> {
337    use reddb_rql::ast::Filter;
338
339    let mut policy_filters: Vec<Filter> = Vec::new();
340    if !collect_join_side_policy(runtime, frame, join.left.as_ref(), &mut policy_filters) {
341        return None;
342    }
343    if !collect_join_side_policy(runtime, frame, join.right.as_ref(), &mut policy_filters) {
344        return None;
345    }
346
347    if policy_filters.is_empty() {
348        return Some(join);
349    }
350
351    let combined = policy_filters
352        .into_iter()
353        .reduce(|acc, f| Filter::And(Box::new(acc), Box::new(f)))
354        .expect("policy_filters non-empty");
355
356    join.filter = Some(match join.filter.take() {
357        Some(existing) => Filter::And(Box::new(existing), Box::new(combined)),
358        None => combined,
359    });
360
361    Some(join)
362}
363
364/// For each `Table` leaf reachable through nested joins, append the
365/// RLS-policy filter (combined with OR across that side's matching
366/// policies) into `out`. Returns `false` when a side has RLS enabled
367/// but no policy admits the caller — the join must short-circuit.
368fn collect_join_side_policy(
369    runtime: &RedDBRuntime,
370    frame: &dyn super::statement_frame::ReadFrame,
371    expr: &reddb_rql::ast::QueryExpr,
372    out: &mut Vec<reddb_rql::ast::Filter>,
373) -> bool {
374    use reddb_rql::ast::{Filter, QueryExpr};
375    match expr {
376        QueryExpr::Table(t) => {
377            if !runtime.inner.rls_enabled_tables.read().contains(&t.table) {
378                return true;
379            }
380            let role = frame.identity().map(|(_, role)| role);
381            let role_str = role.map(|r| r.as_str().to_string());
382            let policies = runtime.matching_select_rls_policies(&t.table, role_str.as_deref());
383            if policies.is_empty() {
384                return false;
385            }
386            let combined = policies
387                .into_iter()
388                .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
389                .expect("policies non-empty");
390            out.push(combined);
391            true
392        }
393        QueryExpr::Join(inner) => {
394            collect_join_side_policy(runtime, frame, inner.left.as_ref(), out)
395                && collect_join_side_policy(runtime, frame, inner.right.as_ref(), out)
396        }
397        _ => true,
398    }
399}
400
401/// Foreign-table post-scan filter application (Phase 3.2.2 PG parity).
402///
403/// Phase 3.2 FDW wrappers don't advertise filter pushdown, so the runtime
404/// applies `WHERE` / `ORDER BY` / `LIMIT` / `OFFSET` after the wrapper
405/// materialises all rows. Projections are best-effort — when the query
406/// lists explicit columns we keep only those; a `SELECT *` keeps every
407/// wrapper-emitted field verbatim.
408///
409/// When a wrapper later opts into pushdown (`supports_pushdown = true`)
410/// the runtime will pass the compiled filter down instead of post-filtering.
411pub(crate) fn apply_foreign_table_filters(
412    records: Vec<crate::storage::query::unified::UnifiedRecord>,
413    query: &reddb_rql::ast::TableQuery,
414) -> crate::storage::query::unified::UnifiedResult {
415    use crate::storage::query::unified::UnifiedResult;
416    use reddb_rql::sql_lowering::{effective_table_filter, effective_table_projections};
417
418    let filter = effective_table_filter(query);
419    let projections = effective_table_projections(query);
420
421    // Step 1 — WHERE. Reuse the cross-store evaluator so the semantics
422    // match native-collection queries (same operators, same NULL handling).
423    let mut filtered: Vec<_> = records
424        .into_iter()
425        .filter(|record| match &filter {
426            Some(f) => {
427                super::join_filter::evaluate_runtime_filter_with_db(None, record, f, None, None)
428            }
429            None => true,
430        })
431        .collect();
432
433    // Step 2 — LIMIT / OFFSET. Applied after filter to match SQL semantics.
434    if let Some(offset) = query.offset {
435        let offset = offset as usize;
436        if offset >= filtered.len() {
437            filtered.clear();
438        } else {
439            filtered.drain(0..offset);
440        }
441    }
442    if let Some(limit) = query.limit {
443        filtered.truncate(limit as usize);
444    }
445
446    // Step 3 — columns list. `SELECT *` (no explicit projections) keeps
447    // the wrapper's column set; an explicit list trims to those names.
448    let columns: Vec<String> = if projections.is_empty() {
449        filtered
450            .first()
451            .map(|r| r.column_names().iter().map(|k| k.to_string()).collect())
452            .unwrap_or_default()
453    } else {
454        projections
455            .iter()
456            .map(super::join_filter::projection_name)
457            .collect()
458    };
459
460    let mut result = UnifiedResult::empty();
461    result.columns = columns;
462    result.records = filtered;
463    result
464}
465
466impl RedDBRuntime {
467    /// SELECT and JOIN use the relational AST for both tables and documents.
468    /// Resolve the policy kind from the declared collection model.
469    fn matching_select_rls_policies(
470        &self,
471        table: &str,
472        role: Option<&str>,
473    ) -> Vec<reddb_rql::ast::Filter> {
474        use reddb_rql::ast::{PolicyAction, PolicyTargetKind};
475
476        let kind = if self
477            .db()
478            .collection_contract_arc(table)
479            .is_some_and(|contract| {
480                contract.declared_model == crate::catalog::CollectionModel::Document
481            }) {
482            PolicyTargetKind::Documents
483        } else {
484            PolicyTargetKind::Table
485        };
486        self.matching_rls_policies_for_kind(table, role, PolicyAction::Select, kind)
487    }
488
489    /// Access the shared `ForeignTableRegistry` (Phase 3.2 PG parity).
490    ///
491    /// Callers use this to check whether a table name is a registered
492    /// foreign table (`registry.is_foreign_table(name)`) and, if so, to
493    /// scan it (`registry.scan(name)`). The read-path rewriter consults
494    /// this before dispatching into native-collection lookup.
495    pub fn foreign_tables(&self) -> Arc<crate::storage::fdw::ForeignTableRegistry> {
496        Arc::clone(&self.inner.foreign_tables)
497    }
498
499    /// Is Row-Level Security enabled for this table? (Phase 2.5 PG parity)
500    pub fn is_rls_enabled(&self, table: &str) -> bool {
501        self.inner.rls_enabled_tables.read().contains(table)
502    }
503
504    /// Collect the USING predicates that apply to this `(table, role, action)`.
505    ///
506    /// Returned filters should be OR-combined (a row passes RLS when *any*
507    /// matching policy accepts it) and then AND-ed into the query's WHERE.
508    /// When the table has RLS disabled this returns an empty Vec — callers
509    /// can fast-path back to the unfiltered read.
510    pub fn matching_rls_policies(
511        &self,
512        table: &str,
513        role: Option<&str>,
514        action: reddb_rql::ast::PolicyAction,
515    ) -> Vec<reddb_rql::ast::Filter> {
516        // Default kind = Table preserves the pre-Phase-2.5.5 behaviour:
517        // callers that don't name a kind only see Table-scoped
518        // policies (which is what execute SELECT / UPDATE / DELETE
519        // expect).
520        self.matching_rls_policies_for_kind(
521            table,
522            role,
523            action,
524            reddb_rql::ast::PolicyTargetKind::Table,
525        )
526    }
527
528    /// Kind-aware variant used by cross-model scans (Phase 2.5.5).
529    ///
530    /// Graph scans request `Nodes` / `Edges`, vector ANN requests
531    /// `Vectors`, queue consumers request `Messages`, and timeseries
532    /// range scans request `Points`. Policies tagged with a
533    /// different kind are skipped so a graph-scoped policy doesn't
534    /// accidentally gate a table SELECT on the same collection.
535    pub fn matching_rls_policies_for_kind(
536        &self,
537        table: &str,
538        role: Option<&str>,
539        action: reddb_rql::ast::PolicyAction,
540        kind: reddb_rql::ast::PolicyTargetKind,
541    ) -> Vec<reddb_rql::ast::Filter> {
542        if !self.is_rls_enabled(table) {
543            return Vec::new();
544        }
545        let policies = self.inner.rls_policies.read();
546        policies
547            .iter()
548            .filter_map(|((t, _), p)| {
549                if t != table {
550                    return None;
551                }
552                // Kind gate — Table policies also apply to every
553                // other kind *iff* the policy predicate evaluates
554                // against entity fields that exist uniformly; the
555                // caller's kind filter is the stricter check, so
556                // match literally. Auto-tenancy policies stamp
557                // Table and the caller passes the concrete kind —
558                // we allow Table policies to apply cross-kind for
559                // backwards compat.
560                if p.target_kind != kind && p.target_kind != reddb_rql::ast::PolicyTargetKind::Table
561                {
562                    return None;
563                }
564                // Action gate — `None` means "ALL" actions.
565                if let Some(a) = p.action {
566                    if a != action {
567                        return None;
568                    }
569                }
570                // Role gate — `None` means "any role".
571                if let Some(p_role) = p.role.as_deref() {
572                    match role {
573                        Some(r) if r == p_role => {}
574                        _ => return None,
575                    }
576                }
577                Some((*p.using).clone())
578            })
579            .collect()
580    }
581}