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_tenant_id', ...)` session variables
7//! - **qail-qdrant**: metadata filter `{ tenant_id: "..." }` on vector search
8//!
9//! # Example
10//!
11//! ```
12//! use qail_core::rls::{RlsContext, SuperAdminToken};
13//!
14//! // Tenant context — scopes data to a single tenant
15//! let ctx = RlsContext::tenant("550e8400-e29b-41d4-a716-446655440000");
16//! assert_eq!(ctx.tenant_id, "550e8400-e29b-41d4-a716-446655440000");
17//!
18//! // Super admin — bypasses tenant isolation (requires named constructor)
19//! let token = SuperAdminToken::for_system_process("example");
20//! let admin = RlsContext::super_admin(token);
21//! assert!(admin.bypasses_rls());
22//!
23//! // Global context — scopes to platform rows (tenant_id IS NULL)
24//! let global = RlsContext::global();
25//! assert!(global.is_global());
26//! ```
27
28/// Tenant context for multi-tenant data isolation.
29///
30/// Each driver uses this context to scope operations to a specific tenant:
31/// - **PostgreSQL**: Sets `app.current_tenant_id` session variable
32/// - **Qdrant**: Filters vector searches by tenant metadata
33pub mod tenant;
34
35/// An opaque token that authorizes RLS bypass.
36///
37/// Create via one of the named constructors:
38/// - [`SuperAdminToken::for_system_process`] — cron, startup, cross-tenant internals
39/// - [`SuperAdminToken::for_webhook`] — inbound callbacks
40/// - [`SuperAdminToken::for_auth`] — login, register, token refresh
41///
42/// External code cannot fabricate this token — it has a private field
43/// and no public field constructor.
44///
45/// # Usage
46/// ```ignore
47/// let token = SuperAdminToken::for_system_process("cron::cleanup");
48/// let ctx = RlsContext::super_admin(token);
49/// assert!(ctx.bypasses_rls());
50/// ```
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct SuperAdminToken {
53 _private: (),
54}
55
56impl SuperAdminToken {
57 /// Issue a token for a system/background process.
58 ///
59 /// Use for cron jobs, startup introspection, and internal cross-tenant
60 /// maintenance paths. For shared/public reference data, prefer
61 /// [`RlsContext::global()`] instead of bypass.
62 ///
63 /// The `_reason` parameter documents intent at the call site
64 /// (e.g. `"cron::check_expired_holds"`). Drivers like `qail-pg`
65 /// may log it via tracing.
66 pub fn for_system_process(_reason: &str) -> Self {
67 Self { _private: () }
68 }
69
70 /// Issue a token for an inbound webhook or gateway trigger.
71 ///
72 /// Use for Meta WhatsApp callbacks, Xendit payment callbacks,
73 /// and gateway event triggers that are authenticated via shared
74 /// secret (`X-Trigger-Secret`) rather than JWT.
75 pub fn for_webhook(_source: &str) -> Self {
76 Self { _private: () }
77 }
78
79 /// Issue a token for an authentication operation.
80 ///
81 /// Use for login, register, token refresh, and admin-claims
82 /// resolution — operations that necessarily run before (or
83 /// outside) a tenant scope is known.
84 pub fn for_auth(_operation: &str) -> Self {
85 Self { _private: () }
86 }
87}
88
89/// RLS context carrying tenant identity for data isolation.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct RlsContext {
92 /// The unified tenant ID — the primary identity for data isolation.
93 /// Empty string means no tenant scope.
94 pub tenant_id: String,
95
96 /// Legacy: The operator (vendor) this context is scoped to.
97 /// Set to the same value as tenant_id during the transition period.
98 pub operator_id: String,
99
100 /// Legacy: The agent (reseller) this context is scoped to.
101 /// Set to the same value as tenant_id during the transition period.
102 pub agent_id: String,
103
104 /// When true, the current user is a platform super admin
105 /// and should bypass tenant isolation.
106 ///
107 /// This field is private — external code must use `bypasses_rls()`.
108 /// Only `super_admin(token)` can set this to true, and that requires
109 /// a `SuperAdminToken` which emits an audit log on creation.
110 is_super_admin: bool,
111
112 /// When true, the context is explicitly scoped to global/platform rows
113 /// (`tenant_id IS NULL`) rather than tenant-specific rows.
114 is_global: bool,
115}
116
117impl RlsContext {
118 /// Create a context scoped to a specific tenant (the unified identity).
119 pub fn tenant(tenant_id: &str) -> Self {
120 Self {
121 tenant_id: tenant_id.to_string(),
122 operator_id: tenant_id.to_string(), // backward compat
123 agent_id: tenant_id.to_string(), // backward compat
124 is_super_admin: false,
125 is_global: false,
126 }
127 }
128
129 /// Create a context scoped to a specific operator.
130 /// Legacy — use `tenant()` for new code.
131 pub fn operator(operator_id: &str) -> Self {
132 Self {
133 tenant_id: operator_id.to_string(),
134 operator_id: operator_id.to_string(),
135 agent_id: String::new(),
136 is_super_admin: false,
137 is_global: false,
138 }
139 }
140
141 /// Create a context scoped to a specific agent (reseller).
142 /// Legacy — use `tenant()` for new code.
143 pub fn agent(agent_id: &str) -> Self {
144 Self {
145 tenant_id: agent_id.to_string(),
146 operator_id: String::new(),
147 agent_id: agent_id.to_string(),
148 is_super_admin: false,
149 is_global: false,
150 }
151 }
152
153 /// Create a context scoped to both operator and agent.
154 /// Legacy — use `tenant()` for new code.
155 pub fn operator_and_agent(operator_id: &str, agent_id: &str) -> Self {
156 Self {
157 tenant_id: operator_id.to_string(), // primary identity
158 operator_id: operator_id.to_string(),
159 agent_id: agent_id.to_string(),
160 is_super_admin: false,
161 is_global: false,
162 }
163 }
164
165 /// Create a global context scoped to platform rows (`tenant_id IS NULL`).
166 ///
167 /// This is not a bypass: it applies explicit global scoping in AST injection
168 /// and exposes `app.is_global=true` for policy usage at the database layer.
169 pub fn global() -> Self {
170 Self {
171 tenant_id: String::new(),
172 operator_id: String::new(),
173 agent_id: String::new(),
174 is_super_admin: false,
175 is_global: true,
176 }
177 }
178
179 /// Create a super admin context that bypasses tenant isolation.
180 ///
181 /// Requires a `SuperAdminToken` — which can only be created via
182 /// named constructors (`for_system_process`, `for_webhook`, `for_auth`).
183 ///
184 /// Uses nil UUID for all IDs to avoid `''::uuid` cast errors
185 /// in PostgreSQL RLS policies (PostgreSQL doesn't short-circuit OR).
186 pub fn super_admin(_token: SuperAdminToken) -> Self {
187 let nil = "00000000-0000-0000-0000-000000000000".to_string();
188 Self {
189 tenant_id: nil.clone(),
190 operator_id: nil.clone(),
191 agent_id: nil,
192 is_super_admin: true,
193 is_global: false,
194 }
195 }
196
197 /// Create an empty context (no tenant, no super admin).
198 ///
199 /// Used for system-level operations that must not operate within
200 /// any tenant scope (startup introspection, migrations, health checks).
201 pub fn empty() -> Self {
202 Self {
203 tenant_id: String::new(),
204 operator_id: String::new(),
205 agent_id: String::new(),
206 is_super_admin: false,
207 is_global: false,
208 }
209 }
210
211 /// Returns true if this context has a tenant scope.
212 pub fn has_tenant(&self) -> bool {
213 !self.tenant_id.is_empty()
214 }
215
216 /// Returns true if this context has an operator scope.
217 pub fn has_operator(&self) -> bool {
218 !self.operator_id.is_empty()
219 }
220
221 /// Returns true if this context has an agent scope.
222 pub fn has_agent(&self) -> bool {
223 !self.agent_id.is_empty()
224 }
225
226 /// Returns true if this context bypasses tenant isolation.
227 pub fn bypasses_rls(&self) -> bool {
228 self.is_super_admin
229 }
230
231 /// Returns true if this context is explicitly scoped to global rows.
232 pub fn is_global(&self) -> bool {
233 self.is_global
234 }
235}
236
237impl std::fmt::Display for RlsContext {
238 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239 if self.is_super_admin {
240 write!(f, "RlsContext(super_admin)")
241 } else if self.is_global {
242 write!(f, "RlsContext(global)")
243 } else if !self.tenant_id.is_empty() {
244 write!(f, "RlsContext(tenant={})", self.tenant_id)
245 } else {
246 write!(f, "RlsContext(none)")
247 }
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn test_tenant_context() {
257 let ctx = RlsContext::tenant("t-123");
258 assert_eq!(ctx.tenant_id, "t-123");
259 assert_eq!(ctx.operator_id, "t-123"); // backward compat
260 assert_eq!(ctx.agent_id, "t-123"); // backward compat
261 assert!(!ctx.bypasses_rls());
262 assert!(ctx.has_tenant());
263 }
264
265 #[test]
266 fn test_operator_context_sets_tenant() {
267 let ctx = RlsContext::operator("op-123");
268 assert_eq!(ctx.tenant_id, "op-123");
269 assert_eq!(ctx.operator_id, "op-123");
270 assert!(ctx.agent_id.is_empty());
271 assert!(!ctx.bypasses_rls());
272 assert!(ctx.has_operator());
273 }
274
275 #[test]
276 fn test_agent_context_sets_tenant() {
277 let ctx = RlsContext::agent("ag-456");
278 assert_eq!(ctx.tenant_id, "ag-456");
279 assert!(ctx.operator_id.is_empty());
280 assert_eq!(ctx.agent_id, "ag-456");
281 assert!(ctx.has_agent());
282 }
283
284 #[test]
285 fn test_super_admin_via_named_constructors() {
286 let token = SuperAdminToken::for_system_process("test");
287 let ctx = RlsContext::super_admin(token);
288 assert!(ctx.bypasses_rls());
289
290 let token = SuperAdminToken::for_webhook("test");
291 let ctx = RlsContext::super_admin(token);
292 assert!(ctx.bypasses_rls());
293
294 let token = SuperAdminToken::for_auth("test");
295 let ctx = RlsContext::super_admin(token);
296 assert!(ctx.bypasses_rls());
297 }
298
299 #[test]
300 fn test_operator_and_agent() {
301 let ctx = RlsContext::operator_and_agent("op-1", "ag-2");
302 assert_eq!(ctx.tenant_id, "op-1"); // primary identity = operator
303 assert!(ctx.has_operator());
304 assert!(ctx.has_agent());
305 assert!(!ctx.bypasses_rls());
306 }
307
308 #[test]
309 fn test_display() {
310 let token = SuperAdminToken::for_system_process("test_display");
311 assert_eq!(
312 RlsContext::super_admin(token).to_string(),
313 "RlsContext(super_admin)"
314 );
315 assert_eq!(RlsContext::tenant("x").to_string(), "RlsContext(tenant=x)");
316 assert_eq!(
317 RlsContext::operator("x").to_string(),
318 "RlsContext(tenant=x)"
319 );
320 }
321
322 #[test]
323 fn test_equality() {
324 let a = RlsContext::tenant("t-1");
325 let b = RlsContext::tenant("t-1");
326 let c = RlsContext::tenant("t-2");
327 assert_eq!(a, b);
328 assert_ne!(a, c);
329 }
330
331 #[test]
332 fn test_empty_context() {
333 let ctx = RlsContext::empty();
334 assert!(!ctx.has_tenant());
335 assert!(!ctx.has_operator());
336 assert!(!ctx.has_agent());
337 assert!(!ctx.bypasses_rls());
338 assert!(!ctx.is_global());
339 }
340
341 #[test]
342 fn test_global_context() {
343 let ctx = RlsContext::global();
344 assert!(!ctx.has_tenant());
345 assert!(!ctx.has_operator());
346 assert!(!ctx.has_agent());
347 assert!(!ctx.bypasses_rls());
348 assert!(ctx.is_global());
349 assert_eq!(ctx.to_string(), "RlsContext(global)");
350 }
351
352 #[test]
353 fn test_for_system_process() {
354 let token = SuperAdminToken::for_system_process("cron::check_expired_holds");
355 let ctx = RlsContext::super_admin(token);
356 assert!(ctx.bypasses_rls());
357 }
358
359 #[test]
360 fn test_for_webhook() {
361 let token = SuperAdminToken::for_webhook("xendit_callback");
362 let ctx = RlsContext::super_admin(token);
363 assert!(ctx.bypasses_rls());
364 }
365
366 #[test]
367 fn test_for_auth() {
368 let token = SuperAdminToken::for_auth("login");
369 let ctx = RlsContext::super_admin(token);
370 assert!(ctx.bypasses_rls());
371 }
372
373 #[test]
374 fn test_all_constructors_produce_equal_tokens() {
375 let a = SuperAdminToken::for_system_process("a");
376 let b = SuperAdminToken::for_webhook("b");
377 let c = SuperAdminToken::for_auth("c");
378 // All tokens are structurally identical
379 assert_eq!(a, b);
380 assert_eq!(b, c);
381 }
382}