Skip to main content

fraiseql_core/security/
rls_policy.rs

1//! Row-Level Security (RLS) Policy Evaluation
2//!
3//! This module provides the trait for evaluating RLS rules at runtime.
4//!
5//! RLS rules are defined in fraiseql.toml at authoring time and compiled into
6//! schema.compiled.json. At runtime, the executor evaluates these rules using
7//! the `SecurityContext` to determine what rows a user can access.
8//!
9//! # Architecture
10//!
11//! ```text
12//! fraiseql.toml (authoring)
13//!     ├── [[security.policies]]          # Define policies
14//!     └── [[security.rules]]             # Define RLS rules
15//!     ↓
16//! schema.compiled.json (compiled)
17//!     ├── "policies": [...]              # Serialized policies
18//!     └── "rules": [...]                 # Serialized rules
19//!     ↓
20//! Executor.execute_regular_query()       # Runtime
21//!     ├── SecurityContext (user info)
22//!     └── RLSPolicy::evaluate()          # Evaluate rules
23//!     ↓
24//! WHERE clause composition
25//!     └── WhereClause::And([user_where, rls_filter])
26//! ```
27//!
28//! # Example RLS Rules (in fraiseql.toml)
29//!
30//! ```toml
31//! # Users can only read their own posts
32//! [[security.rules]]
33//! name = "own_posts_only"
34//! rule = "user.id == object.author_id"
35//! cacheable = true
36//! cache_ttl_seconds = 300
37//!
38//! # Admins can read everything
39//! [[security.rules]]
40//! name = "admin_can_read_all"
41//! rule = "user.roles includes 'admin'"
42//! cacheable = false
43//! ```
44//!
45//! # Example RLS Policies (in fraiseql.toml)
46//!
47//! ```toml
48//! [[security.policies]]
49//! name = "read_own_posts"
50//! type = "rls"
51//! rules = ["own_posts_only"]
52//! description = "Users can only read their own posts"
53//!
54//! [[security.policies]]
55//! name = "admin_access"
56//! type = "rbac"
57//! roles = ["admin"]
58//! strategy = "any"
59//! description = "Admins have full access"
60//! ```
61
62use std::sync::Arc;
63
64use serde::{Deserialize, Serialize};
65
66use crate::{
67    db::WhereClause,
68    error::{FraiseQLError, Result},
69    security::SecurityContext,
70    utils::clock::{Clock, SystemClock},
71};
72
73/// A WHERE clause that has been evaluated by an RLS policy.
74///
75/// This type is a compile-time guarantee that the WHERE clause was produced
76/// by [`RLSPolicy::evaluate()`] rather than arbitrary user code.
77///
78/// `RlsWhereClause` can only be constructed within `fraiseql-core` via
79/// `RlsWhereClause::new()`, ensuring all instances originate from RLS evaluation.
80///
81/// # Invariant
82///
83/// Any value of this type was produced by an [`RLSPolicy`] implementation
84/// invoked on a [`SecurityContext`], not by arbitrary caller code. This makes
85/// it impossible to accidentally bypass RLS when composing cache keys or
86/// building filtered queries.
87///
88/// # Example
89///
90/// ```no_run
91/// // The executor receives an RlsWhereClause after evaluating the policy.
92/// // It cannot construct one directly — that would be a compile error.
93/// # use fraiseql_core::security::{RLSPolicy, DefaultRLSPolicy, SecurityContext};
94/// # let context: SecurityContext = panic!("example");
95/// let rls = DefaultRLSPolicy::new();
96/// let rls_clause = rls.evaluate(&context, "Post").unwrap();
97/// // rls_clause is Option<RlsWhereClause> — proven to have gone through RLS
98/// ```
99#[derive(Debug, Clone, PartialEq)]
100pub struct RlsWhereClause {
101    inner: WhereClause,
102}
103
104impl RlsWhereClause {
105    /// Construct from an evaluated WHERE clause.
106    ///
107    /// `pub(crate)` — only RLS policy implementations within `fraiseql-core`
108    /// may construct this type. External callers obtain instances through
109    /// [`RLSPolicy::evaluate()`].
110    pub(crate) const fn new(inner: WhereClause) -> Self {
111        Self { inner }
112    }
113
114    /// Borrow the underlying WHERE clause.
115    #[must_use]
116    pub const fn as_where_clause(&self) -> &WhereClause {
117        &self.inner
118    }
119
120    /// Consume this wrapper and return the underlying WHERE clause.
121    #[must_use]
122    pub fn into_where_clause(self) -> WhereClause {
123        self.inner
124    }
125}
126
127/// Cache entry for RLS policy decisions with TTL support
128#[derive(Debug, Clone)]
129pub(crate) struct CacheEntry {
130    /// The cached RLS evaluation result
131    pub(crate) result:     Option<WhereClause>,
132    /// When this cache entry expires (Unix seconds)
133    pub(crate) expires_at: u64,
134}
135
136/// Row-Level Security (RLS) policy for runtime evaluation.
137///
138/// Implementations of this trait evaluate compiled RLS rules with the user's
139/// `SecurityContext` to determine what rows they can access.
140///
141/// # Type Safety
142///
143/// The trait returns `Option<WhereClause>` to support composition:
144/// - `None`: No RLS filter (unrestricted access)
145/// - `Some(clause)`: Filter to apply to the query
146///
147/// The executor composes this with user-provided filters via `WhereClause::And()`.
148pub trait RLSPolicy: Send + Sync {
149    /// Evaluate RLS rules for the given type and security context.
150    ///
151    /// # Arguments
152    ///
153    /// * `context` - Security context with user information and permissions
154    /// * `type_name` - GraphQL type name being accessed (e.g., "Post", "User")
155    ///
156    /// # Returns
157    ///
158    /// - `Ok(Some(clause))`: RLS filter to apply to query (wrapped in [`RlsWhereClause`])
159    /// - `Ok(None)`: No RLS filter (full access)
160    /// - `Err(e)`: Policy evaluation error (access denied)
161    ///
162    /// # Example
163    ///
164    /// ```no_run
165    /// // Requires: a SecurityContext built from authenticated request metadata.
166    /// // See: tests/integration/ for runnable examples.
167    /// # use fraiseql_core::security::{RLSPolicy, DefaultRLSPolicy, SecurityContext};
168    /// # let context: SecurityContext = panic!("example");
169    /// let rls = DefaultRLSPolicy::new();
170    /// // filter is Some(RlsWhereClause) wrapping the evaluated WhereClause
171    /// let filter = rls.evaluate(&context, "Post").unwrap();
172    /// ```
173    ///
174    /// # Errors
175    ///
176    /// Returns `FraiseQLError` if the RLS policy evaluation fails.
177    fn evaluate(
178        &self,
179        context: &SecurityContext,
180        type_name: &str,
181    ) -> Result<Option<RlsWhereClause>>;
182
183    /// Optional: Cache RLS decisions for performance.
184    ///
185    /// The executor may call this to cache policy decisions per user/type
186    /// combination to avoid repeated evaluations.
187    ///
188    /// # Arguments
189    ///
190    /// * `cache_key` - Cache key (typically "`user_id:type_name`")
191    /// * `result` - The policy evaluation result to cache
192    fn cache_result(&self, _cache_key: &str, _result: &Option<WhereClause>) {
193        // Default: no caching. Implementers can override.
194    }
195}
196
197/// Default RLS policy that enforces tenant isolation and owner-based access.
198///
199/// This is a reference implementation showing how to build RLS policies.
200///
201/// Rules:
202/// 1. Multi-tenant: Filter to rows matching user's `tenant_id`
203/// 2. Admin bypass: Admins can access all rows in their tenant
204/// 3. Owner-based: Regular users can only access their own rows (`author_id` == `user_id`)
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct DefaultRLSPolicy {
207    /// Enable multi-tenant isolation
208    pub enable_tenant_isolation: bool,
209    /// Field name for tenant isolation (default: "`tenant_id`")
210    pub tenant_field:            String,
211    /// Field name for owner-based access (default: "`author_id`")
212    pub owner_field:             String,
213}
214
215impl DefaultRLSPolicy {
216    /// Create a new default RLS policy.
217    #[must_use]
218    pub fn new() -> Self {
219        Self {
220            enable_tenant_isolation: true,
221            tenant_field:            "tenant_id".to_string(),
222            owner_field:             "author_id".to_string(),
223        }
224    }
225
226    /// Disable tenant isolation (single-tenant mode).
227    #[must_use]
228    pub const fn with_single_tenant(mut self) -> Self {
229        self.enable_tenant_isolation = false;
230        self
231    }
232
233    /// Set custom tenant field name.
234    #[must_use]
235    pub fn with_tenant_field(mut self, field: String) -> Self {
236        self.tenant_field = field;
237        self
238    }
239
240    /// Set custom owner field name.
241    #[must_use]
242    pub fn with_owner_field(mut self, field: String) -> Self {
243        self.owner_field = field;
244        self
245    }
246}
247
248impl Default for DefaultRLSPolicy {
249    fn default() -> Self {
250        Self::new()
251    }
252}
253
254impl RLSPolicy for DefaultRLSPolicy {
255    fn evaluate(
256        &self,
257        context: &SecurityContext,
258        _type_name: &str,
259    ) -> Result<Option<RlsWhereClause>> {
260        // Admins bypass RLS
261        if context.is_admin() {
262            return Ok(None);
263        }
264
265        let mut filters = vec![];
266
267        // Rule 1: Multi-tenant isolation
268        if self.enable_tenant_isolation {
269            if let Some(ref tenant_id) = context.tenant_id {
270                filters.push(WhereClause::Field {
271                    path:     vec![self.tenant_field.clone()],
272                    operator: crate::db::WhereOperator::Eq,
273                    value:    serde_json::json!(tenant_id.clone()),
274                });
275            }
276        }
277
278        // Rule 2: Owner-based access (users can only access their own rows)
279        filters.push(WhereClause::Field {
280            path:     vec![self.owner_field.clone()],
281            operator: crate::db::WhereOperator::Eq,
282            value:    serde_json::json!(context.user_id.clone()),
283        });
284
285        // Combine all filters with AND and wrap in RlsWhereClause
286        let clause = match filters.len() {
287            0 => return Ok(None),
288            // Reason: `filters.len() == 1` guarantees `.next()` yields `Some`
289            1 => filters.into_iter().next().expect("len checked == 1"),
290            _ => WhereClause::And(filters),
291        };
292        Ok(Some(RlsWhereClause::new(clause)))
293    }
294}
295
296/// No-op RLS policy that allows all access (for testing or fully open APIs).
297#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct NoRLSPolicy;
299
300impl RLSPolicy for NoRLSPolicy {
301    fn evaluate(
302        &self,
303        _context: &SecurityContext,
304        _type_name: &str,
305    ) -> Result<Option<RlsWhereClause>> {
306        Ok(None)
307    }
308}
309
310/// Returns a production `SystemClock` wrapped in `Arc<dyn Clock>`.
311/// Used as the serde `default` for [`CompiledRLSPolicy::clock`].
312fn default_system_clock() -> Arc<dyn Clock> {
313    Arc::new(SystemClock)
314}
315
316/// Custom RLS policy that can be configured from schema.compiled.json
317///
318/// This allows schema authors to define RLS rules without writing Rust code.
319/// Supports caching of policy evaluation results for performance optimization.
320#[derive(Clone, Serialize, Deserialize)]
321pub struct CompiledRLSPolicy {
322    /// RLS rules indexed by type name
323    pub rules_by_type: std::collections::HashMap<String, Vec<RLSRule>>,
324    /// Default RLS rule if no type-specific rule exists
325    pub default_rule:  Option<RLSRule>,
326    /// Cache for policy evaluation results (not serialized)
327    #[serde(skip)]
328    pub(crate) cache:  Arc<parking_lot::RwLock<std::collections::HashMap<String, CacheEntry>>>,
329    /// Clock for cache-expiry checks. Injectable for deterministic testing.
330    #[serde(skip, default = "default_system_clock")]
331    clock:             Arc<dyn Clock>,
332}
333
334impl std::fmt::Debug for CompiledRLSPolicy {
335    #[cfg_attr(test, mutants::skip)]
336    // Reason: diagnostic-only impl — outputs "<cached>" and "<clock>" placeholder
337    // strings that no test asserts on; mutations to these literals cannot be killed.
338    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
339        f.debug_struct("CompiledRLSPolicy")
340            .field("rules_by_type", &self.rules_by_type)
341            .field("default_rule", &self.default_rule)
342            .field("cache", &"<cached>")
343            .field("clock", &"<clock>")
344            .finish()
345    }
346}
347
348impl CompiledRLSPolicy {
349    /// Create a new compiled RLS policy with caching enabled.
350    #[must_use]
351    pub fn new(
352        rules_by_type: std::collections::HashMap<String, Vec<RLSRule>>,
353        default_rule: Option<RLSRule>,
354    ) -> Self {
355        Self::new_with_clock(rules_by_type, default_rule, Arc::new(SystemClock))
356    }
357
358    /// Create a compiled RLS policy with a custom clock for deterministic testing.
359    pub fn new_with_clock(
360        rules_by_type: std::collections::HashMap<String, Vec<RLSRule>>,
361        default_rule: Option<RLSRule>,
362        clock: Arc<dyn Clock>,
363    ) -> Self {
364        Self {
365            rules_by_type,
366            default_rule,
367            cache: Arc::new(parking_lot::RwLock::new(std::collections::HashMap::new())),
368            clock,
369        }
370    }
371}
372
373/// A single RLS rule for a type
374#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct RLSRule {
376    /// Rule name (for debugging)
377    pub name:              String,
378    /// Expression to evaluate (e.g., "user.id == `object.author_id`")
379    pub expression:        String,
380    /// Whether this rule result can be cached
381    pub cacheable:         bool,
382    /// Cache TTL in seconds (if cacheable)
383    pub cache_ttl_seconds: Option<u64>,
384}
385
386impl RLSPolicy for CompiledRLSPolicy {
387    fn evaluate(
388        &self,
389        context: &SecurityContext,
390        type_name: &str,
391    ) -> Result<Option<RlsWhereClause>> {
392        // Admins bypass all RLS (never cache admin access)
393        if context.is_admin() {
394            return Ok(None);
395        }
396
397        // Find rule for type or use default
398        let rule = self
399            .rules_by_type
400            .get(type_name)
401            .and_then(|rules| rules.first())
402            .or(self.default_rule.as_ref());
403
404        if let Some(rule) = rule {
405            // Check cache for cacheable rules
406            let cache_key = if rule.cacheable {
407                Some(format!("{}:{}", context.user_id, type_name))
408            } else {
409                None
410            };
411
412            // Try to retrieve from cache (CacheEntry stores raw WhereClause internally)
413            if let Some(ref key) = cache_key {
414                let cache = self.cache.read();
415                if let Some(entry) = cache.get(key) {
416                    if self.clock.now_secs() < entry.expires_at {
417                        // Re-wrap: the cached clause originated from RLS evaluation
418                        return Ok(entry.result.clone().map(RlsWhereClause::new));
419                    }
420                }
421                drop(cache);
422            }
423
424            // Evaluate the RLS expression and generate WHERE clause
425            let result: Option<WhereClause> = evaluate_rls_expression(&rule.expression, context)?;
426
427            // Cache the raw WhereClause for reuse
428            if let Some(key) = cache_key {
429                if let Some(ttl_secs) = rule.cache_ttl_seconds {
430                    let expires_at = self.clock.now_secs() + ttl_secs;
431                    let entry = CacheEntry {
432                        result: result.clone(),
433                        expires_at,
434                    };
435                    let mut cache = self.cache.write();
436                    cache.insert(key, entry);
437                }
438            }
439
440            Ok(result.map(RlsWhereClause::new))
441        } else {
442            Ok(None)
443        }
444    }
445
446    fn cache_result(&self, cache_key: &str, result: &Option<WhereClause>) {
447        // Direct cache storage with default TTL of 300 seconds
448        let expires_at = self.clock.now_secs() + 300;
449        let entry = CacheEntry {
450            result: result.clone(),
451            expires_at,
452        };
453        let mut cache = self.cache.write();
454        cache.insert(cache_key.to_string(), entry);
455    }
456}
457
458/// Helper function to evaluate RLS expressions
459///
460/// Supports simple expressions like:
461/// - `user.id == object.author_id` - Equality comparison
462/// - `user.roles includes 'admin'` - Role/array membership
463/// - `user.tenant_id == object.tenant_id` - Tenant isolation
464///
465/// In production, consider using:
466/// - Rhai for dynamic expression evaluation
467/// - WASM for sandboxed custom policies
468/// - A domain-specific language (DSL)
469fn evaluate_rls_expression(
470    expression: &str,
471    context: &SecurityContext,
472) -> Result<Option<WhereClause>> {
473    let expr = expression.trim();
474
475    // Pattern 1: Simple equality - "user.id == object.field_name"
476    if let Some(eq_parts) = expr.split_once("==") {
477        let left = eq_parts.0.trim();
478        let right = eq_parts.1.trim();
479
480        // Left side: user.{field}
481        if let Some(user_field) = left.strip_prefix("user.") {
482            let user_value = extract_user_value(user_field, context);
483
484            // Right side: object.{field} or literal
485            if let Some(object_field) = right.strip_prefix("object.") {
486                // Return a field comparison filter
487                return Ok(Some(WhereClause::Field {
488                    path:     vec![object_field.to_string()],
489                    operator: crate::db::WhereOperator::Eq,
490                    value:    user_value.unwrap_or(serde_json::Value::Null),
491                }));
492            } else if serde_json::from_str::<serde_json::Value>(right).is_ok() {
493                // Literal value comparison
494                return Ok(Some(WhereClause::Field {
495                    path:     vec!["_literal_".to_string()],
496                    operator: crate::db::WhereOperator::Eq,
497                    value:    serde_json::json!(user_value),
498                }));
499            }
500        }
501    }
502
503    // Pattern 2: Membership test - "user.roles includes 'admin'"
504    if expr.contains("includes") {
505        if let Some(includes_parts) = expr.split_once("includes") {
506            let left = includes_parts.0.trim();
507            let right = includes_parts.1.trim().trim_matches(|c| c == '\'' || c == '"');
508
509            if left == "user.roles" && context.has_role(right) {
510                // User has the required role - no RLS filter needed
511                return Ok(None);
512            }
513        }
514    }
515
516    // Pattern 3: Tenant isolation - "user.tenant_id == object.tenant_id"
517    if expr.contains("tenant_id") && expr.contains("==") {
518        if let Some(tenant_id) = &context.tenant_id {
519            return Ok(Some(WhereClause::Field {
520                path:     vec!["tenant_id".to_string()],
521                operator: crate::db::WhereOperator::Eq,
522                value:    serde_json::json!(tenant_id),
523            }));
524        }
525    }
526
527    // Unrecognised expression: fail closed to prevent silent cross-tenant access.
528    Err(FraiseQLError::Validation {
529        message: format!("Unrecognised RLS expression: '{expr}'"),
530        path:    None,
531    })
532}
533
534/// Extract a value from user context by field name
535pub(crate) fn extract_user_value(
536    field: &str,
537    context: &SecurityContext,
538) -> Option<serde_json::Value> {
539    match field {
540        "id" | "user_id" => Some(serde_json::json!(context.user_id)),
541        "tenant_id" => context.tenant_id.as_ref().map(|t| serde_json::json!(t)),
542        "roles" => Some(serde_json::json!(context.roles)),
543        custom => context.get_attribute(custom).cloned(),
544    }
545}