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}