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}