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