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 agent (reseller) this context is scoped to.
97 /// Empty string means no agent scope.
98 pub agent_id: String,
99
100 /// When true, the current user is a platform super admin
101 /// and should bypass tenant isolation.
102 ///
103 /// This field is private — external code must use `bypasses_rls()`.
104 /// Only `super_admin(token)` can set this to true, and that requires
105 /// a `SuperAdminToken` which emits an audit log on creation.
106 is_super_admin: bool,
107
108 /// When true, the context is explicitly scoped to global/platform rows
109 /// (`tenant_id IS NULL`) rather than tenant-specific rows.
110 is_global: bool,
111
112 /// The authenticated user's UUID for user-scoped DB policies.
113 /// Empty string means no user scope. Set via `RlsContext::user()`.
114 user_id: String,
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 agent_id: String::new(),
123 is_super_admin: false,
124 is_global: false,
125 user_id: String::new(),
126 }
127 }
128
129 /// Create a context scoped to a specific agent (reseller).
130 pub fn agent(agent_id: &str) -> Self {
131 Self {
132 tenant_id: String::new(),
133 agent_id: agent_id.to_string(),
134 is_super_admin: false,
135 is_global: false,
136 user_id: String::new(),
137 }
138 }
139
140 /// Create a context scoped to both tenant and agent.
141 pub fn tenant_and_agent(tenant_id: &str, agent_id: &str) -> Self {
142 Self {
143 tenant_id: tenant_id.to_string(),
144 agent_id: agent_id.to_string(),
145 is_super_admin: false,
146 is_global: false,
147 user_id: String::new(),
148 }
149 }
150
151 /// Create a global context scoped to platform rows (`tenant_id IS NULL`).
152 ///
153 /// This is not a bypass: it applies explicit global scoping in AST injection
154 /// and exposes `app.is_global=true` for policy usage at the database layer.
155 pub fn global() -> Self {
156 Self {
157 tenant_id: String::new(),
158 agent_id: String::new(),
159 is_super_admin: false,
160 is_global: true,
161 user_id: String::new(),
162 }
163 }
164
165 /// Create a super admin context that bypasses tenant isolation.
166 ///
167 /// Requires a `SuperAdminToken` — which can only be created via
168 /// named constructors (`for_system_process`, `for_webhook`, `for_auth`).
169 ///
170 /// Uses nil UUID for all IDs to avoid `''::uuid` cast errors
171 /// in PostgreSQL RLS policies (PostgreSQL doesn't short-circuit OR).
172 pub fn super_admin(_token: SuperAdminToken) -> Self {
173 let nil = "00000000-0000-0000-0000-000000000000".to_string();
174 Self {
175 tenant_id: nil,
176 agent_id: String::new(),
177 is_super_admin: true,
178 is_global: false,
179 user_id: String::new(),
180 }
181 }
182
183 /// Create an empty context (no tenant, no super admin).
184 ///
185 /// Used for system-level operations that must not operate within
186 /// any tenant scope (startup introspection, migrations, health checks).
187 pub fn empty() -> Self {
188 Self {
189 tenant_id: String::new(),
190 agent_id: String::new(),
191 is_super_admin: false,
192 is_global: false,
193 user_id: String::new(),
194 }
195 }
196
197 /// Create a user-scoped context for authenticated end-user operations.
198 ///
199 /// Sets `app.current_user_id` so that DB policies can enforce
200 /// row-level isolation by user (e.g. `user_id = get_current_user_id()`).
201 /// Does NOT bypass tenant isolation or grant super-admin.
202 pub fn user(user_id: &str) -> Self {
203 Self {
204 tenant_id: String::new(),
205 agent_id: String::new(),
206 is_super_admin: false,
207 is_global: false,
208 user_id: user_id.to_string(),
209 }
210 }
211
212 /// Returns true if this context has a tenant scope.
213 pub fn has_tenant(&self) -> bool {
214 !self.tenant_id.is_empty()
215 }
216
217 /// Returns true if this context has an agent scope.
218 pub fn has_agent(&self) -> bool {
219 !self.agent_id.is_empty()
220 }
221
222 /// Returns true if this context has a user scope.
223 pub fn has_user(&self) -> bool {
224 !self.user_id.is_empty()
225 }
226
227 /// Returns the user ID for this context (empty if none).
228 pub fn user_id(&self) -> &str {
229 &self.user_id
230 }
231
232 /// Returns true if this context bypasses tenant isolation.
233 pub fn bypasses_rls(&self) -> bool {
234 self.is_super_admin
235 }
236
237 /// Returns true if this context is explicitly scoped to global rows.
238 pub fn is_global(&self) -> bool {
239 self.is_global
240 }
241}
242
243impl std::fmt::Display for RlsContext {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 if self.is_super_admin {
246 write!(f, "RlsContext(super_admin)")
247 } else if self.is_global {
248 write!(f, "RlsContext(global)")
249 } else if !self.tenant_id.is_empty() {
250 write!(f, "RlsContext(tenant={})", self.tenant_id)
251 } else {
252 write!(f, "RlsContext(none)")
253 }
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn test_tenant_context() {
263 let ctx = RlsContext::tenant("t-123");
264 assert_eq!(ctx.tenant_id, "t-123");
265 assert!(ctx.agent_id.is_empty());
266 assert!(!ctx.bypasses_rls());
267 assert!(ctx.has_tenant());
268 }
269
270 #[test]
271 fn test_agent_context_sets_tenant() {
272 let ctx = RlsContext::agent("ag-456");
273 assert!(ctx.tenant_id.is_empty());
274 assert_eq!(ctx.agent_id, "ag-456");
275 assert!(ctx.has_agent());
276 }
277
278 #[test]
279 fn test_super_admin_via_named_constructors() {
280 let token = SuperAdminToken::for_system_process("test");
281 let ctx = RlsContext::super_admin(token);
282 assert!(ctx.bypasses_rls());
283
284 let token = SuperAdminToken::for_webhook("test");
285 let ctx = RlsContext::super_admin(token);
286 assert!(ctx.bypasses_rls());
287
288 let token = SuperAdminToken::for_auth("test");
289 let ctx = RlsContext::super_admin(token);
290 assert!(ctx.bypasses_rls());
291 }
292
293 #[test]
294 fn test_tenant_and_agent() {
295 let ctx = RlsContext::tenant_and_agent("tenant-1", "ag-2");
296 assert_eq!(ctx.tenant_id, "tenant-1");
297 assert!(ctx.has_agent());
298 assert!(!ctx.bypasses_rls());
299 }
300
301 #[test]
302 fn test_display() {
303 let token = SuperAdminToken::for_system_process("test_display");
304 assert_eq!(
305 RlsContext::super_admin(token).to_string(),
306 "RlsContext(super_admin)"
307 );
308 assert_eq!(RlsContext::tenant("x").to_string(), "RlsContext(tenant=x)");
309 }
310
311 #[test]
312 fn test_equality() {
313 let a = RlsContext::tenant("t-1");
314 let b = RlsContext::tenant("t-1");
315 let c = RlsContext::tenant("t-2");
316 assert_eq!(a, b);
317 assert_ne!(a, c);
318 }
319
320 #[test]
321 fn test_empty_context() {
322 let ctx = RlsContext::empty();
323 assert!(!ctx.has_tenant());
324 assert!(!ctx.has_agent());
325 assert!(!ctx.bypasses_rls());
326 assert!(!ctx.is_global());
327 }
328
329 #[test]
330 fn test_global_context() {
331 let ctx = RlsContext::global();
332 assert!(!ctx.has_tenant());
333 assert!(!ctx.has_agent());
334 assert!(!ctx.bypasses_rls());
335 assert!(ctx.is_global());
336 assert_eq!(ctx.to_string(), "RlsContext(global)");
337 }
338
339 #[test]
340 fn test_for_system_process() {
341 let token = SuperAdminToken::for_system_process("cron::check_expired_holds");
342 let ctx = RlsContext::super_admin(token);
343 assert!(ctx.bypasses_rls());
344 }
345
346 #[test]
347 fn test_for_webhook() {
348 let token = SuperAdminToken::for_webhook("xendit_callback");
349 let ctx = RlsContext::super_admin(token);
350 assert!(ctx.bypasses_rls());
351 }
352
353 #[test]
354 fn test_for_auth() {
355 let token = SuperAdminToken::for_auth("login");
356 let ctx = RlsContext::super_admin(token);
357 assert!(ctx.bypasses_rls());
358 }
359
360 #[test]
361 fn test_all_constructors_produce_equal_tokens() {
362 let a = SuperAdminToken::for_system_process("a");
363 let b = SuperAdminToken::for_webhook("b");
364 let c = SuperAdminToken::for_auth("c");
365 // All tokens are structurally identical
366 assert_eq!(a, b);
367 assert_eq!(b, c);
368 }
369
370 #[test]
371 fn test_user_context() {
372 let ctx = RlsContext::user("550e8400-e29b-41d4-a716-446655440000");
373 assert!(!ctx.has_tenant());
374 assert!(!ctx.has_agent());
375 assert!(!ctx.bypasses_rls());
376 assert!(!ctx.is_global());
377 assert!(ctx.has_user());
378 assert_eq!(ctx.user_id(), "550e8400-e29b-41d4-a716-446655440000");
379 }
380
381 #[test]
382 fn test_user_context_display() {
383 let ctx = RlsContext::user("u-123");
384 assert_eq!(ctx.to_string(), "RlsContext(none)");
385 // user context doesn't have tenant, so Display falls through to "none"
386 // (user_id is an orthogonal axis, not a tenant scope)
387 }
388
389 #[test]
390 fn test_other_constructors_have_no_user() {
391 assert!(!RlsContext::tenant("t-1").has_user());
392 assert!(!RlsContext::global().has_user());
393 assert!(!RlsContext::empty().has_user());
394 let token = SuperAdminToken::for_auth("test");
395 assert!(!RlsContext::super_admin(token).has_user());
396 }
397}