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 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    /// Attach an authenticated user ID to an existing tenant/global context.
213    ///
214    /// User scope is orthogonal to tenant/agent scope: PostgreSQL policies can
215    /// use both `app.current_tenant_id` and `app.current_user_id`.
216    pub fn with_user(mut self, user_id: &str) -> Self {
217        self.user_id = user_id.to_string();
218        self
219    }
220
221    /// Returns true if this context has a tenant scope.
222    pub fn has_tenant(&self) -> bool {
223        !self.tenant_id.is_empty()
224    }
225
226    /// Returns true if this context has an agent scope.
227    pub fn has_agent(&self) -> bool {
228        !self.agent_id.is_empty()
229    }
230
231    /// Returns true if this context has a user scope.
232    pub fn has_user(&self) -> bool {
233        !self.user_id.is_empty()
234    }
235
236    /// Returns the user ID for this context (empty if none).
237    pub fn user_id(&self) -> &str {
238        &self.user_id
239    }
240
241    /// Returns true if this context bypasses tenant isolation.
242    pub fn bypasses_rls(&self) -> bool {
243        self.is_super_admin
244    }
245
246    /// Returns true if this context is explicitly scoped to global rows.
247    pub fn is_global(&self) -> bool {
248        self.is_global
249    }
250}
251
252impl std::fmt::Display for RlsContext {
253    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254        if self.is_super_admin {
255            write!(f, "RlsContext(super_admin)")
256        } else if self.is_global {
257            write!(f, "RlsContext(global)")
258        } else if !self.tenant_id.is_empty() {
259            write!(f, "RlsContext(tenant={})", self.tenant_id)
260        } else {
261            write!(f, "RlsContext(none)")
262        }
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn test_tenant_context() {
272        let ctx = RlsContext::tenant("t-123");
273        assert_eq!(ctx.tenant_id, "t-123");
274        assert!(ctx.agent_id.is_empty());
275        assert!(!ctx.bypasses_rls());
276        assert!(ctx.has_tenant());
277    }
278
279    #[test]
280    fn test_agent_context_sets_tenant() {
281        let ctx = RlsContext::agent("ag-456");
282        assert!(ctx.tenant_id.is_empty());
283        assert_eq!(ctx.agent_id, "ag-456");
284        assert!(ctx.has_agent());
285    }
286
287    #[test]
288    fn test_super_admin_via_named_constructors() {
289        let token = SuperAdminToken::for_system_process("test");
290        let ctx = RlsContext::super_admin(token);
291        assert!(ctx.bypasses_rls());
292
293        let token = SuperAdminToken::for_webhook("test");
294        let ctx = RlsContext::super_admin(token);
295        assert!(ctx.bypasses_rls());
296
297        let token = SuperAdminToken::for_auth("test");
298        let ctx = RlsContext::super_admin(token);
299        assert!(ctx.bypasses_rls());
300    }
301
302    #[test]
303    fn test_tenant_and_agent() {
304        let ctx = RlsContext::tenant_and_agent("tenant-1", "ag-2");
305        assert_eq!(ctx.tenant_id, "tenant-1");
306        assert!(ctx.has_agent());
307        assert!(!ctx.bypasses_rls());
308    }
309
310    #[test]
311    fn test_display() {
312        let token = SuperAdminToken::for_system_process("test_display");
313        assert_eq!(
314            RlsContext::super_admin(token).to_string(),
315            "RlsContext(super_admin)"
316        );
317        assert_eq!(RlsContext::tenant("x").to_string(), "RlsContext(tenant=x)");
318    }
319
320    #[test]
321    fn test_equality() {
322        let a = RlsContext::tenant("t-1");
323        let b = RlsContext::tenant("t-1");
324        let c = RlsContext::tenant("t-2");
325        assert_eq!(a, b);
326        assert_ne!(a, c);
327    }
328
329    #[test]
330    fn test_empty_context() {
331        let ctx = RlsContext::empty();
332        assert!(!ctx.has_tenant());
333        assert!(!ctx.has_agent());
334        assert!(!ctx.bypasses_rls());
335        assert!(!ctx.is_global());
336    }
337
338    #[test]
339    fn test_global_context() {
340        let ctx = RlsContext::global();
341        assert!(!ctx.has_tenant());
342        assert!(!ctx.has_agent());
343        assert!(!ctx.bypasses_rls());
344        assert!(ctx.is_global());
345        assert_eq!(ctx.to_string(), "RlsContext(global)");
346    }
347
348    #[test]
349    fn test_for_system_process() {
350        let token = SuperAdminToken::for_system_process("cron::check_expired_holds");
351        let ctx = RlsContext::super_admin(token);
352        assert!(ctx.bypasses_rls());
353    }
354
355    #[test]
356    fn test_for_webhook() {
357        let token = SuperAdminToken::for_webhook("xendit_callback");
358        let ctx = RlsContext::super_admin(token);
359        assert!(ctx.bypasses_rls());
360    }
361
362    #[test]
363    fn test_for_auth() {
364        let token = SuperAdminToken::for_auth("login");
365        let ctx = RlsContext::super_admin(token);
366        assert!(ctx.bypasses_rls());
367    }
368
369    #[test]
370    fn test_all_constructors_produce_equal_tokens() {
371        let a = SuperAdminToken::for_system_process("a");
372        let b = SuperAdminToken::for_webhook("b");
373        let c = SuperAdminToken::for_auth("c");
374        // All tokens are structurally identical
375        assert_eq!(a, b);
376        assert_eq!(b, c);
377    }
378
379    #[test]
380    fn test_user_context() {
381        let ctx = RlsContext::user("550e8400-e29b-41d4-a716-446655440000");
382        assert!(!ctx.has_tenant());
383        assert!(!ctx.has_agent());
384        assert!(!ctx.bypasses_rls());
385        assert!(!ctx.is_global());
386        assert!(ctx.has_user());
387        assert_eq!(ctx.user_id(), "550e8400-e29b-41d4-a716-446655440000");
388    }
389
390    #[test]
391    fn test_with_user_preserves_tenant_scope() {
392        let ctx = RlsContext::tenant("tenant-1").with_user("user-1");
393
394        assert_eq!(ctx.tenant_id, "tenant-1");
395        assert_eq!(ctx.user_id(), "user-1");
396        assert!(ctx.has_tenant());
397        assert!(ctx.has_user());
398        assert!(!ctx.bypasses_rls());
399    }
400
401    #[test]
402    fn test_user_context_display() {
403        let ctx = RlsContext::user("u-123");
404        assert_eq!(ctx.to_string(), "RlsContext(none)");
405        // user context doesn't have tenant, so Display falls through to "none"
406        // (user_id is an orthogonal axis, not a tenant scope)
407    }
408
409    #[test]
410    fn test_other_constructors_have_no_user() {
411        assert!(!RlsContext::tenant("t-1").has_user());
412        assert!(!RlsContext::global().has_user());
413        assert!(!RlsContext::empty().has_user());
414        let token = SuperAdminToken::for_auth("test");
415        assert!(!RlsContext::super_admin(token).has_user());
416    }
417}