Skip to main content

qail_core/rls/
mod.rs

1//! Row-Level Security (RLS) Context for Multi-Tenant SaaS
2//!
3//! Provides a shared tenant context that all Qail drivers can use
4//! for data isolation. Each driver implements isolation differently:
5//!
6//! - **qail-pg**: `set_config('app.current_operator_id', ...)` session variables
7//! - **qail-qdrant**: metadata filter `{ operator_id: "..." }` on vector search
8//! - **qail-redis**: key prefix `tenant:{operator_id}:*`
9//!
10//! # Example
11//!
12//! ```
13//! use qail_core::rls::RlsContext;
14//!
15//! // Operator context — scopes data to a single operator
16//! let ctx = RlsContext::operator("550e8400-e29b-41d4-a716-446655440000");
17//! assert_eq!(ctx.operator_id, "550e8400-e29b-41d4-a716-446655440000");
18//!
19//! // Super admin — bypasses tenant isolation
20//! let admin = RlsContext::super_admin();
21//! assert!(admin.is_super_admin);
22//! ```
23
24/// Tenant context for multi-tenant data isolation.
25///
26/// Each driver uses this context to scope operations to a specific tenant:
27/// - **PostgreSQL**: Sets session variables referenced by RLS policies
28/// - **Qdrant**: Filters vector searches by tenant metadata
29/// - **Redis**: Prefixes keys with tenant identifier
30
31pub mod tenant;
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct RlsContext {
34    /// The operator (vendor) this context is scoped to.
35    /// Empty string means no operator scope.
36    pub operator_id: String,
37
38    /// The agent (reseller) this context is scoped to.
39    /// Empty string means no agent scope.
40    pub agent_id: String,
41
42    /// When true, the current user is a platform super admin
43    /// and should bypass tenant isolation.
44    pub is_super_admin: bool,
45}
46
47impl RlsContext {
48    /// Create a context scoped to a specific operator.
49    pub fn operator(operator_id: &str) -> Self {
50        Self {
51            operator_id: operator_id.to_string(),
52            agent_id: String::new(),
53            is_super_admin: false,
54        }
55    }
56
57    /// Create a context scoped to a specific agent (reseller).
58    pub fn agent(agent_id: &str) -> Self {
59        Self {
60            operator_id: String::new(),
61            agent_id: agent_id.to_string(),
62            is_super_admin: false,
63        }
64    }
65
66    /// Create a context scoped to both operator and agent.
67    /// Used when an agent is acting on behalf of an operator.
68    pub fn operator_and_agent(operator_id: &str, agent_id: &str) -> Self {
69        Self {
70            operator_id: operator_id.to_string(),
71            agent_id: agent_id.to_string(),
72            is_super_admin: false,
73        }
74    }
75
76    /// Create a super admin context that bypasses tenant isolation.
77    pub fn super_admin() -> Self {
78        Self {
79            operator_id: String::new(),
80            agent_id: String::new(),
81            is_super_admin: true,
82        }
83    }
84
85    /// Returns true if this context has an operator scope.
86    pub fn has_operator(&self) -> bool {
87        !self.operator_id.is_empty()
88    }
89
90    /// Returns true if this context has an agent scope.
91    pub fn has_agent(&self) -> bool {
92        !self.agent_id.is_empty()
93    }
94
95    /// Returns true if this context bypasses tenant isolation.
96    pub fn bypasses_rls(&self) -> bool {
97        self.is_super_admin
98    }
99}
100
101impl std::fmt::Display for RlsContext {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        if self.is_super_admin {
104            write!(f, "RlsContext(super_admin)")
105        } else if !self.operator_id.is_empty() && !self.agent_id.is_empty() {
106            write!(f, "RlsContext(op={}, ag={})", self.operator_id, self.agent_id)
107        } else if !self.operator_id.is_empty() {
108            write!(f, "RlsContext(op={})", self.operator_id)
109        } else if !self.agent_id.is_empty() {
110            write!(f, "RlsContext(ag={})", self.agent_id)
111        } else {
112            write!(f, "RlsContext(none)")
113        }
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn test_operator_context() {
123        let ctx = RlsContext::operator("op-123");
124        assert_eq!(ctx.operator_id, "op-123");
125        assert!(ctx.agent_id.is_empty());
126        assert!(!ctx.is_super_admin);
127        assert!(ctx.has_operator());
128        assert!(!ctx.has_agent());
129        assert!(!ctx.bypasses_rls());
130    }
131
132    #[test]
133    fn test_agent_context() {
134        let ctx = RlsContext::agent("ag-456");
135        assert!(ctx.operator_id.is_empty());
136        assert_eq!(ctx.agent_id, "ag-456");
137        assert!(ctx.has_agent());
138        assert!(!ctx.has_operator());
139    }
140
141    #[test]
142    fn test_super_admin() {
143        let ctx = RlsContext::super_admin();
144        assert!(ctx.is_super_admin);
145        assert!(ctx.bypasses_rls());
146    }
147
148    #[test]
149    fn test_operator_and_agent() {
150        let ctx = RlsContext::operator_and_agent("op-1", "ag-2");
151        assert!(ctx.has_operator());
152        assert!(ctx.has_agent());
153        assert!(!ctx.bypasses_rls());
154    }
155
156    #[test]
157    fn test_display() {
158        assert_eq!(RlsContext::super_admin().to_string(), "RlsContext(super_admin)");
159        assert_eq!(RlsContext::operator("x").to_string(), "RlsContext(op=x)");
160        assert_eq!(RlsContext::agent("y").to_string(), "RlsContext(ag=y)");
161        assert_eq!(
162            RlsContext::operator_and_agent("x", "y").to_string(),
163            "RlsContext(op=x, ag=y)"
164        );
165    }
166
167    #[test]
168    fn test_equality() {
169        let a = RlsContext::operator("op-1");
170        let b = RlsContext::operator("op-1");
171        let c = RlsContext::operator("op-2");
172        assert_eq!(a, b);
173        assert_ne!(a, c);
174    }
175}