use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::{
backend::WhereClause,
error::{FraiseQLError, Result},
security::SecurityContext,
utils::clock::{Clock, SystemClock},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RlsTarget<'a> {
pub query: &'a str,
pub type_name: Option<&'a str>,
pub table: Option<&'a str>,
}
impl<'a> RlsTarget<'a> {
#[must_use]
pub const fn query(query: &'a str, type_name: &'a str) -> Self {
Self {
query,
type_name: Some(type_name),
table: None,
}
}
#[must_use]
pub const fn fact_table(query: &'a str, table: &'a str) -> Self {
Self {
query,
type_name: None,
table: Some(table),
}
}
pub fn keys(&self) -> impl Iterator<Item = &'a str> + '_ {
self.type_name.into_iter().chain(self.table).chain(std::iter::once(self.query))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConstrainedPaths {
Opaque,
Declared(Vec<String>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
pub enum UnmatchedTarget {
#[default]
Deny,
Allow,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RlsWhereClause {
inner: WhereClause,
}
impl RlsWhereClause {
pub(crate) const fn new(inner: WhereClause) -> Self {
Self { inner }
}
#[must_use]
pub const fn as_where_clause(&self) -> &WhereClause {
&self.inner
}
#[must_use]
pub fn into_where_clause(self) -> WhereClause {
self.inner
}
}
#[derive(Debug, Clone)]
pub(crate) struct CacheEntry {
pub(crate) result: Option<WhereClause>,
pub(crate) expires_at: u64,
}
pub trait RLSPolicy: Send + Sync {
fn evaluate(
&self,
context: &SecurityContext,
target: &RlsTarget<'_>,
) -> Result<Option<RlsWhereClause>>;
fn cache_result(&self, _cache_key: &str, _result: &Option<WhereClause>) {
}
fn constrained_paths(&self, _target: &RlsTarget<'_>) -> ConstrainedPaths {
ConstrainedPaths::Opaque
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DefaultRLSPolicy {
pub enable_tenant_isolation: bool,
pub tenant_field: String,
pub owner_field: String,
}
impl DefaultRLSPolicy {
#[must_use]
pub fn new() -> Self {
Self {
enable_tenant_isolation: true,
tenant_field: "tenant_id".to_string(),
owner_field: "author_id".to_string(),
}
}
#[must_use]
pub const fn with_single_tenant(mut self) -> Self {
self.enable_tenant_isolation = false;
self
}
#[must_use]
pub fn with_tenant_field(mut self, field: String) -> Self {
self.tenant_field = field;
self
}
#[must_use]
pub fn with_owner_field(mut self, field: String) -> Self {
self.owner_field = field;
self
}
}
impl Default for DefaultRLSPolicy {
fn default() -> Self {
Self::new()
}
}
impl RLSPolicy for DefaultRLSPolicy {
fn constrained_paths(&self, _target: &RlsTarget<'_>) -> ConstrainedPaths {
let mut paths = Vec::with_capacity(2);
if self.enable_tenant_isolation {
paths.push(self.tenant_field.clone());
}
paths.push(self.owner_field.clone());
ConstrainedPaths::Declared(paths)
}
fn evaluate(
&self,
context: &SecurityContext,
_target: &RlsTarget<'_>,
) -> Result<Option<RlsWhereClause>> {
if context.is_admin() {
return Ok(None);
}
let mut filters = vec![];
if self.enable_tenant_isolation {
if let Some(ref tenant_id) = context.tenant_id {
filters.push(WhereClause::Field {
path: vec![self.tenant_field.clone()],
operator: crate::backend::WhereOperator::Eq,
value: serde_json::json!(tenant_id.clone()),
});
}
}
filters.push(WhereClause::Field {
path: vec![self.owner_field.clone()],
operator: crate::backend::WhereOperator::Eq,
value: serde_json::json!(context.user_id.clone()),
});
let clause = match filters.len() {
0 => return Ok(None),
1 => filters.into_iter().next().expect("len checked == 1"),
_ => WhereClause::And(filters),
};
Ok(Some(RlsWhereClause::new(clause)))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NoRLSPolicy;
impl RLSPolicy for NoRLSPolicy {
fn constrained_paths(&self, _target: &RlsTarget<'_>) -> ConstrainedPaths {
ConstrainedPaths::Declared(Vec::new())
}
fn evaluate(
&self,
_context: &SecurityContext,
_target: &RlsTarget<'_>,
) -> Result<Option<RlsWhereClause>> {
Ok(None)
}
}
fn default_system_clock() -> Arc<dyn Clock> {
Arc::new(SystemClock)
}
#[derive(Clone, Serialize, Deserialize)]
pub struct CompiledRLSPolicy {
pub rules_by_type: std::collections::HashMap<String, Vec<RLSRule>>,
pub default_rule: Option<RLSRule>,
#[serde(default)]
pub on_unmatched: UnmatchedTarget,
#[serde(skip)]
pub(crate) cache: Arc<parking_lot::RwLock<std::collections::HashMap<String, CacheEntry>>>,
#[serde(skip, default = "default_system_clock")]
clock: Arc<dyn Clock>,
}
impl std::fmt::Debug for CompiledRLSPolicy {
#[cfg_attr(test, mutants::skip)]
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CompiledRLSPolicy")
.field("rules_by_type", &self.rules_by_type)
.field("default_rule", &self.default_rule)
.field("on_unmatched", &self.on_unmatched)
.field("cache", &"<cached>")
.field("clock", &"<clock>")
.finish()
}
}
impl CompiledRLSPolicy {
#[must_use]
pub fn new(
rules_by_type: std::collections::HashMap<String, Vec<RLSRule>>,
default_rule: Option<RLSRule>,
) -> Self {
Self::new_with_clock(rules_by_type, default_rule, Arc::new(SystemClock))
}
pub fn new_with_clock(
rules_by_type: std::collections::HashMap<String, Vec<RLSRule>>,
default_rule: Option<RLSRule>,
clock: Arc<dyn Clock>,
) -> Self {
Self {
rules_by_type,
default_rule,
on_unmatched: UnmatchedTarget::Deny,
cache: Arc::new(parking_lot::RwLock::new(std::collections::HashMap::new())),
clock,
}
}
#[must_use]
pub const fn with_unmatched(mut self, on_unmatched: UnmatchedTarget) -> Self {
self.on_unmatched = on_unmatched;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RLSRule {
pub name: String,
pub expression: String,
pub cacheable: bool,
pub cache_ttl_seconds: Option<u64>,
}
impl RLSPolicy for CompiledRLSPolicy {
fn constrained_paths(&self, target: &RlsTarget<'_>) -> ConstrainedPaths {
let rule = target
.keys()
.find_map(|key| self.rules_by_type.get(key).and_then(|r| r.first()))
.or(self.default_rule.as_ref());
ConstrainedPaths::Declared(
rule.map_or_else(Vec::new, |r| rls_expression_paths(&r.expression)),
)
}
fn evaluate(
&self,
context: &SecurityContext,
target: &RlsTarget<'_>,
) -> Result<Option<RlsWhereClause>> {
if context.is_admin() {
return Ok(None);
}
let matched = target.keys().find_map(|key| {
self.rules_by_type.get(key).and_then(|r| r.first()).map(|rule| (key, rule))
});
let (matched_key, rule) = match matched {
Some(found) => found,
None => match self.default_rule.as_ref() {
Some(rule) => (target.query, rule),
None => {
return match self.on_unmatched {
UnmatchedTarget::Allow => Ok(None),
UnmatchedTarget::Deny => Err(FraiseQLError::Authorization {
message: format!(
"no RLS rule matches this read, and the policy has no default \
rule; refusing rather than reading unfiltered (tried {})",
target.keys().collect::<Vec<_>>().join(", ")
),
action: Some("read".to_string()),
resource: Some(target.query.to_string()),
}),
};
},
},
};
{
let type_name = matched_key;
let cache_key = if rule.cacheable {
Some(format!("{}:{}", context.user_id, type_name))
} else {
None
};
if let Some(ref key) = cache_key {
let cache = self.cache.read();
if let Some(entry) = cache.get(key) {
if self.clock.now_secs() < entry.expires_at {
return Ok(entry.result.clone().map(RlsWhereClause::new));
}
}
drop(cache);
}
let result: Option<WhereClause> = evaluate_rls_expression(&rule.expression, context)?;
if let Some(key) = cache_key {
if let Some(ttl_secs) = rule.cache_ttl_seconds {
let expires_at = self.clock.now_secs() + ttl_secs;
let entry = CacheEntry {
result: result.clone(),
expires_at,
};
let mut cache = self.cache.write();
cache.insert(key, entry);
}
}
Ok(result.map(RlsWhereClause::new))
}
}
fn cache_result(&self, cache_key: &str, result: &Option<WhereClause>) {
let expires_at = self.clock.now_secs() + 300;
let entry = CacheEntry {
result: result.clone(),
expires_at,
};
let mut cache = self.cache.write();
cache.insert(cache_key.to_string(), entry);
}
}
fn evaluate_rls_expression(
expression: &str,
context: &SecurityContext,
) -> Result<Option<WhereClause>> {
let expr = expression.trim();
if let Some(eq_parts) = expr.split_once("==") {
let left = eq_parts.0.trim();
let right = eq_parts.1.trim();
if let Some(user_field) = left.strip_prefix("user.") {
let user_value = extract_user_value(user_field, context);
if let Some(object_field) = right.strip_prefix("object.") {
return Ok(Some(WhereClause::Field {
path: vec![object_field.to_string()],
operator: crate::backend::WhereOperator::Eq,
value: user_value.unwrap_or(serde_json::Value::Null),
}));
} else if serde_json::from_str::<serde_json::Value>(right).is_ok() {
return Ok(Some(WhereClause::Field {
path: vec!["_literal_".to_string()],
operator: crate::backend::WhereOperator::Eq,
value: serde_json::json!(user_value),
}));
}
}
}
if expr.contains("includes") {
if let Some(includes_parts) = expr.split_once("includes") {
let left = includes_parts.0.trim();
let right = includes_parts.1.trim().trim_matches(|c| c == '\'' || c == '"');
if left == "user.roles" && context.has_role(right) {
return Ok(None);
}
}
}
if expr.contains("tenant_id") && expr.contains("==") {
if let Some(tenant_id) = &context.tenant_id {
return Ok(Some(WhereClause::Field {
path: vec!["tenant_id".to_string()],
operator: crate::backend::WhereOperator::Eq,
value: serde_json::json!(tenant_id),
}));
}
}
Err(FraiseQLError::Validation {
message: format!("Unrecognised RLS expression: '{expr}'"),
path: None,
})
}
fn rls_expression_paths(expression: &str) -> Vec<String> {
let expr = expression.trim();
if let Some((left, right)) = expr.split_once("==") {
let (left, right) = (left.trim(), right.trim());
if left.starts_with("user.") {
if let Some(object_field) = right.strip_prefix("object.") {
return vec![object_field.to_string()];
}
if serde_json::from_str::<serde_json::Value>(right).is_ok() {
return vec!["_literal_".to_string()];
}
}
}
if expr.contains("tenant_id") && expr.contains("==") {
return vec!["tenant_id".to_string()];
}
Vec::new()
}
pub(crate) fn extract_user_value(
field: &str,
context: &SecurityContext,
) -> Option<serde_json::Value> {
match field {
"id" | "user_id" => Some(serde_json::json!(context.user_id)),
"tenant_id" => context.tenant_id.as_ref().map(|t| serde_json::json!(t)),
"roles" => Some(serde_json::json!(context.roles)),
custom => context.get_attribute(custom).cloned(),
}
}