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
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 `app.current_tenant_id` session variable
28/// - **Qdrant**: Filters vector searches by tenant metadata
29pub mod tenant;
30
31/// An opaque token that authorizes RLS bypass.
32///
33/// Create via one of the named constructors:
34/// - [`SuperAdminToken::for_system_process`] — cron, startup, reference-data
35/// - [`SuperAdminToken::for_webhook`] — inbound callbacks
36/// - [`SuperAdminToken::for_auth`] — login, register, token refresh
37///
38/// External code cannot fabricate this token — it has a private field
39/// and no public field constructor.
40///
41/// # Usage
42/// ```ignore
43/// let token = SuperAdminToken::for_system_process("cron::cleanup");
44/// let ctx = RlsContext::super_admin(token);
45/// assert!(ctx.bypasses_rls());
46/// ```
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct SuperAdminToken {
49    _private: (),
50}
51
52impl SuperAdminToken {
53    /// Issue a token for a system/background process.
54    ///
55    /// Use for cron jobs, startup introspection, and public reference-data
56    /// endpoints (vessel types, locations, currency) that need cross-tenant
57    /// reads but have no user session.
58    ///
59    /// The `_reason` parameter documents intent at the call site
60    /// (e.g. `"cron::check_expired_holds"`). Drivers like `qail-pg`
61    /// may log it via tracing.
62    pub fn for_system_process(_reason: &str) -> Self {
63        Self { _private: () }
64    }
65
66    /// Issue a token for an inbound webhook or gateway trigger.
67    ///
68    /// Use for Meta WhatsApp callbacks, Xendit payment callbacks,
69    /// and gateway event triggers that are authenticated via shared
70    /// secret (`X-Trigger-Secret`) rather than JWT.
71    pub fn for_webhook(_source: &str) -> Self {
72        Self { _private: () }
73    }
74
75    /// Issue a token for an authentication operation.
76    ///
77    /// Use for login, register, token refresh, and admin-claims
78    /// resolution — operations that necessarily run before (or
79    /// outside) a tenant scope is known.
80    pub fn for_auth(_operation: &str) -> Self {
81        Self { _private: () }
82    }
83}
84
85/// RLS context carrying tenant identity for data isolation.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct RlsContext {
88    /// The unified tenant ID — the primary identity for data isolation.
89    /// Empty string means no tenant scope.
90    pub tenant_id: String,
91
92    /// Legacy: The operator (vendor) this context is scoped to.
93    /// Set to the same value as tenant_id during the transition period.
94    pub operator_id: String,
95
96    /// Legacy: The agent (reseller) this context is scoped to.
97    /// Set to the same value as tenant_id during the transition period.
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
109impl RlsContext {
110    /// Create a context scoped to a specific tenant (the unified identity).
111    pub fn tenant(tenant_id: &str) -> Self {
112        Self {
113            tenant_id: tenant_id.to_string(),
114            operator_id: tenant_id.to_string(), // backward compat
115            agent_id: tenant_id.to_string(),    // backward compat
116            is_super_admin: false,
117        }
118    }
119
120    /// Create a context scoped to a specific operator.
121    /// Legacy — use `tenant()` for new code.
122    pub fn operator(operator_id: &str) -> Self {
123        Self {
124            tenant_id: operator_id.to_string(),
125            operator_id: operator_id.to_string(),
126            agent_id: String::new(),
127            is_super_admin: false,
128        }
129    }
130
131    /// Create a context scoped to a specific agent (reseller).
132    /// Legacy — use `tenant()` for new code.
133    pub fn agent(agent_id: &str) -> Self {
134        Self {
135            tenant_id: agent_id.to_string(),
136            operator_id: String::new(),
137            agent_id: agent_id.to_string(),
138            is_super_admin: false,
139        }
140    }
141
142    /// Create a context scoped to both operator and agent.
143    /// Legacy — use `tenant()` for new code.
144    pub fn operator_and_agent(operator_id: &str, agent_id: &str) -> Self {
145        Self {
146            tenant_id: operator_id.to_string(), // primary identity
147            operator_id: operator_id.to_string(),
148            agent_id: agent_id.to_string(),
149            is_super_admin: false,
150        }
151    }
152
153    /// Create a super admin context that bypasses tenant isolation.
154    ///
155    /// Requires a `SuperAdminToken` — which can only be created via
156    /// named constructors (`for_system_process`, `for_webhook`, `for_auth`).
157    ///
158    /// Uses nil UUID for all IDs to avoid `''::uuid` cast errors
159    /// in PostgreSQL RLS policies (PostgreSQL doesn't short-circuit OR).
160    pub fn super_admin(_token: SuperAdminToken) -> Self {
161        let nil = "00000000-0000-0000-0000-000000000000".to_string();
162        Self {
163            tenant_id: nil.clone(),
164            operator_id: nil.clone(),
165            agent_id: nil,
166            is_super_admin: true,
167        }
168    }
169
170    /// Create an empty context (no tenant, no super admin).
171    ///
172    /// Used for system-level operations that must not operate within
173    /// any tenant scope (startup introspection, migrations, health checks).
174    pub fn empty() -> Self {
175        Self {
176            tenant_id: String::new(),
177            operator_id: String::new(),
178            agent_id: String::new(),
179            is_super_admin: false,
180        }
181    }
182
183    /// Returns true if this context has a tenant scope.
184    pub fn has_tenant(&self) -> bool {
185        !self.tenant_id.is_empty()
186    }
187
188    /// Returns true if this context has an operator scope.
189    pub fn has_operator(&self) -> bool {
190        !self.operator_id.is_empty()
191    }
192
193    /// Returns true if this context has an agent scope.
194    pub fn has_agent(&self) -> bool {
195        !self.agent_id.is_empty()
196    }
197
198    /// Returns true if this context bypasses tenant isolation.
199    pub fn bypasses_rls(&self) -> bool {
200        self.is_super_admin
201    }
202}
203
204impl std::fmt::Display for RlsContext {
205    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206        if self.is_super_admin {
207            write!(f, "RlsContext(super_admin)")
208        } else if !self.tenant_id.is_empty() {
209            write!(f, "RlsContext(tenant={})", self.tenant_id)
210        } else {
211            write!(f, "RlsContext(none)")
212        }
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn test_tenant_context() {
222        let ctx = RlsContext::tenant("t-123");
223        assert_eq!(ctx.tenant_id, "t-123");
224        assert_eq!(ctx.operator_id, "t-123"); // backward compat
225        assert_eq!(ctx.agent_id, "t-123"); // backward compat
226        assert!(!ctx.bypasses_rls());
227        assert!(ctx.has_tenant());
228    }
229
230    #[test]
231    fn test_operator_context_sets_tenant() {
232        let ctx = RlsContext::operator("op-123");
233        assert_eq!(ctx.tenant_id, "op-123");
234        assert_eq!(ctx.operator_id, "op-123");
235        assert!(ctx.agent_id.is_empty());
236        assert!(!ctx.bypasses_rls());
237        assert!(ctx.has_operator());
238    }
239
240    #[test]
241    fn test_agent_context_sets_tenant() {
242        let ctx = RlsContext::agent("ag-456");
243        assert_eq!(ctx.tenant_id, "ag-456");
244        assert!(ctx.operator_id.is_empty());
245        assert_eq!(ctx.agent_id, "ag-456");
246        assert!(ctx.has_agent());
247    }
248
249    #[test]
250    fn test_super_admin_via_named_constructors() {
251        let token = SuperAdminToken::for_system_process("test");
252        let ctx = RlsContext::super_admin(token);
253        assert!(ctx.bypasses_rls());
254
255        let token = SuperAdminToken::for_webhook("test");
256        let ctx = RlsContext::super_admin(token);
257        assert!(ctx.bypasses_rls());
258
259        let token = SuperAdminToken::for_auth("test");
260        let ctx = RlsContext::super_admin(token);
261        assert!(ctx.bypasses_rls());
262    }
263
264    #[test]
265    fn test_operator_and_agent() {
266        let ctx = RlsContext::operator_and_agent("op-1", "ag-2");
267        assert_eq!(ctx.tenant_id, "op-1"); // primary identity = operator
268        assert!(ctx.has_operator());
269        assert!(ctx.has_agent());
270        assert!(!ctx.bypasses_rls());
271    }
272
273    #[test]
274    fn test_display() {
275        let token = SuperAdminToken::for_system_process("test_display");
276        assert_eq!(
277            RlsContext::super_admin(token).to_string(),
278            "RlsContext(super_admin)"
279        );
280        assert_eq!(RlsContext::tenant("x").to_string(), "RlsContext(tenant=x)");
281        assert_eq!(
282            RlsContext::operator("x").to_string(),
283            "RlsContext(tenant=x)"
284        );
285    }
286
287    #[test]
288    fn test_equality() {
289        let a = RlsContext::tenant("t-1");
290        let b = RlsContext::tenant("t-1");
291        let c = RlsContext::tenant("t-2");
292        assert_eq!(a, b);
293        assert_ne!(a, c);
294    }
295
296    #[test]
297    fn test_empty_context() {
298        let ctx = RlsContext::empty();
299        assert!(!ctx.has_tenant());
300        assert!(!ctx.has_operator());
301        assert!(!ctx.has_agent());
302        assert!(!ctx.bypasses_rls());
303    }
304
305    #[test]
306    fn test_for_system_process() {
307        let token = SuperAdminToken::for_system_process("cron::check_expired_holds");
308        let ctx = RlsContext::super_admin(token);
309        assert!(ctx.bypasses_rls());
310    }
311
312    #[test]
313    fn test_for_webhook() {
314        let token = SuperAdminToken::for_webhook("xendit_callback");
315        let ctx = RlsContext::super_admin(token);
316        assert!(ctx.bypasses_rls());
317    }
318
319    #[test]
320    fn test_for_auth() {
321        let token = SuperAdminToken::for_auth("login");
322        let ctx = RlsContext::super_admin(token);
323        assert!(ctx.bypasses_rls());
324    }
325
326    #[test]
327    fn test_all_constructors_produce_equal_tokens() {
328        let a = SuperAdminToken::for_system_process("a");
329        let b = SuperAdminToken::for_webhook("b");
330        let c = SuperAdminToken::for_auth("c");
331        // All tokens are structurally identical
332        assert_eq!(a, b);
333        assert_eq!(b, c);
334    }
335}