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