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//!
9//! # Example
10//!
11//! ```
12//! use qail_core::rls::{RlsContext, SuperAdminToken};
13//!
14//! // Operator context — scopes data to a single operator
15//! let ctx = RlsContext::operator("550e8400-e29b-41d4-a716-446655440000");
16//! assert_eq!(ctx.operator_id, "550e8400-e29b-41d4-a716-446655440000");
17//!
18//! // Super admin — bypasses tenant isolation (requires token)
19//! let token = SuperAdminToken::issue();
20//! let admin = RlsContext::super_admin(token);
21//! assert!(admin.bypasses_rls());
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**: *(removed — native cache replaces Redis)*
30pub mod tenant;
31
32/// An opaque token that authorizes RLS bypass.
33///
34/// This type can only be created by calling `SuperAdminToken::issue()`,
35/// which emits a structured audit log. External code cannot fabricate
36/// this token — it has a private field and no public constructor.
37///
38/// # Usage
39/// ```ignore
40/// let token = SuperAdminToken::issue();
41/// let ctx = RlsContext::super_admin(token);
42/// assert!(ctx.bypasses_rls());
43/// ```
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct SuperAdminToken {
46 _private: (),
47}
48
49impl SuperAdminToken {
50 /// Issue a super admin token.
51 ///
52 /// This is the ONLY way to create a `SuperAdminToken`.
53 /// Callers are responsible for audit logging (the gateway's auth
54 /// module emits structured logs when this token is used to create
55 /// an RLS context).
56 pub fn issue() -> Self {
57 Self { _private: () }
58 }
59}
60
61/// RLS context carrying tenant identity for data isolation.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct RlsContext {
64 /// The operator (vendor) this context is scoped to.
65 /// Empty string means no operator scope.
66 pub operator_id: String,
67
68 /// The agent (reseller) this context is scoped to.
69 /// Empty string means no agent scope.
70 pub agent_id: String,
71
72 /// When true, the current user is a platform super admin
73 /// and should bypass tenant isolation.
74 ///
75 /// This field is private — external code must use `bypasses_rls()`.
76 /// Only `super_admin(token)` can set this to true, and that requires
77 /// a `SuperAdminToken` which emits an audit log on creation.
78 is_super_admin: bool,
79}
80
81impl RlsContext {
82 /// Create a context scoped to a specific operator.
83 pub fn operator(operator_id: &str) -> Self {
84 Self {
85 operator_id: operator_id.to_string(),
86 agent_id: String::new(),
87 is_super_admin: false,
88 }
89 }
90
91 /// Create a context scoped to a specific agent (reseller).
92 pub fn agent(agent_id: &str) -> Self {
93 Self {
94 operator_id: String::new(),
95 agent_id: agent_id.to_string(),
96 is_super_admin: false,
97 }
98 }
99
100 /// Create a context scoped to both operator and agent.
101 /// Used when an agent is acting on behalf of an operator.
102 pub fn operator_and_agent(operator_id: &str, agent_id: &str) -> Self {
103 Self {
104 operator_id: operator_id.to_string(),
105 agent_id: agent_id.to_string(),
106 is_super_admin: false,
107 }
108 }
109
110 /// Create a super admin context that bypasses tenant isolation.
111 ///
112 /// Requires a `SuperAdminToken` — which can only be created via
113 /// `SuperAdminToken::issue()` with mandatory audit logging.
114 ///
115 /// Uses nil UUID for operator/agent IDs to avoid `''::uuid` cast errors
116 /// in PostgreSQL RLS policies (PostgreSQL doesn't short-circuit OR).
117 pub fn super_admin(_token: SuperAdminToken) -> Self {
118 Self {
119 operator_id: "00000000-0000-0000-0000-000000000000".to_string(),
120 agent_id: "00000000-0000-0000-0000-000000000000".to_string(),
121 is_super_admin: true,
122 }
123 }
124
125 /// Create an empty context (no tenant, no super admin).
126 ///
127 /// Used for system-level operations that must not operate within
128 /// any tenant scope (startup introspection, migrations, health checks).
129 pub fn empty() -> Self {
130 Self {
131 operator_id: String::new(),
132 agent_id: String::new(),
133 is_super_admin: false,
134 }
135 }
136
137 /// Returns true if this context has an operator scope.
138 pub fn has_operator(&self) -> bool {
139 !self.operator_id.is_empty()
140 }
141
142 /// Returns true if this context has an agent scope.
143 pub fn has_agent(&self) -> bool {
144 !self.agent_id.is_empty()
145 }
146
147 /// Returns true if this context bypasses tenant isolation.
148 pub fn bypasses_rls(&self) -> bool {
149 self.is_super_admin
150 }
151}
152
153impl std::fmt::Display for RlsContext {
154 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155 if self.is_super_admin {
156 write!(f, "RlsContext(super_admin)")
157 } else if !self.operator_id.is_empty() && !self.agent_id.is_empty() {
158 write!(f, "RlsContext(op={}, ag={})", self.operator_id, self.agent_id)
159 } else if !self.operator_id.is_empty() {
160 write!(f, "RlsContext(op={})", self.operator_id)
161 } else if !self.agent_id.is_empty() {
162 write!(f, "RlsContext(ag={})", self.agent_id)
163 } else {
164 write!(f, "RlsContext(none)")
165 }
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn test_operator_context() {
175 let ctx = RlsContext::operator("op-123");
176 assert_eq!(ctx.operator_id, "op-123");
177 assert!(ctx.agent_id.is_empty());
178 assert!(!ctx.bypasses_rls());
179 assert!(ctx.has_operator());
180 assert!(!ctx.has_agent());
181 }
182
183 #[test]
184 fn test_agent_context() {
185 let ctx = RlsContext::agent("ag-456");
186 assert!(ctx.operator_id.is_empty());
187 assert_eq!(ctx.agent_id, "ag-456");
188 assert!(ctx.has_agent());
189 assert!(!ctx.has_operator());
190 }
191
192 #[test]
193 fn test_super_admin() {
194 let token = SuperAdminToken::issue();
195 let ctx = RlsContext::super_admin(token);
196 assert!(ctx.bypasses_rls());
197 }
198
199 #[test]
200 fn test_operator_and_agent() {
201 let ctx = RlsContext::operator_and_agent("op-1", "ag-2");
202 assert!(ctx.has_operator());
203 assert!(ctx.has_agent());
204 assert!(!ctx.bypasses_rls());
205 }
206
207 #[test]
208 fn test_display() {
209 let token = SuperAdminToken::issue();
210 assert_eq!(RlsContext::super_admin(token).to_string(), "RlsContext(super_admin)");
211 assert_eq!(RlsContext::operator("x").to_string(), "RlsContext(op=x)");
212 assert_eq!(RlsContext::agent("y").to_string(), "RlsContext(ag=y)");
213 assert_eq!(
214 RlsContext::operator_and_agent("x", "y").to_string(),
215 "RlsContext(op=x, ag=y)"
216 );
217 }
218
219 #[test]
220 fn test_equality() {
221 let a = RlsContext::operator("op-1");
222 let b = RlsContext::operator("op-1");
223 let c = RlsContext::operator("op-2");
224 assert_eq!(a, b);
225 assert_ne!(a, c);
226 }
227
228 #[test]
229 fn test_empty_context() {
230 let ctx = RlsContext::empty();
231 assert!(!ctx.has_operator());
232 assert!(!ctx.has_agent());
233 assert!(!ctx.bypasses_rls());
234 }
235
236 #[test]
237 fn test_super_admin_token_cannot_be_forged() {
238 // SuperAdminToken { _private: () } — the private field prevents
239 // external construction. This test documents the intent.
240 let token = SuperAdminToken::issue();
241 let ctx = RlsContext::super_admin(token);
242 assert!(ctx.bypasses_rls());
243 }
244}