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_operator_id', ...)` session variables
7//! - **qail-qdrant**: metadata filter `{ operator_id: "..." }` on vector search
8//!
9//! # Example
10//!
11//! ```
12//! use qail_core::rls::{RlsContext, SuperAdminToken};
13//!
14//! // Operator context — scopes data to a single operator
15//! let ctx = RlsContext::operator("550e8400-e29b-41d4-a716-446655440000");
16//! assert_eq!(ctx.operator_id, "550e8400-e29b-41d4-a716-446655440000");
17//!
18//! // Super admin — bypasses tenant isolation (requires token)
19//! let token = SuperAdminToken::issue();
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 session variables referenced by RLS policies
28/// - **Qdrant**: Filters vector searches by tenant metadata
29/// - **Redis**: *(removed — native cache replaces Redis)*
30pub mod tenant;
31
32/// An opaque token that authorizes RLS bypass.
33///
34/// This type can only be created by calling `SuperAdminToken::issue()`,
35/// which emits a structured audit log. External code cannot fabricate
36/// this token — it has a private field and no public constructor.
37///
38/// # Usage
39/// ```ignore
40/// let token = SuperAdminToken::issue();
41/// let ctx = RlsContext::super_admin(token);
42/// assert!(ctx.bypasses_rls());
43/// ```
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct SuperAdminToken {
46    _private: (),
47}
48
49impl SuperAdminToken {
50    /// Issue a super admin token.
51    ///
52    /// This is the ONLY way to create a `SuperAdminToken`.
53    /// Callers are responsible for audit logging (the gateway's auth
54    /// module emits structured logs when this token is used to create
55    /// an RLS context).
56    pub fn issue() -> Self {
57        Self { _private: () }
58    }
59}
60
61/// RLS context carrying tenant identity for data isolation.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct RlsContext {
64    /// The operator (vendor) this context is scoped to.
65    /// Empty string means no operator scope.
66    pub operator_id: String,
67
68    /// The agent (reseller) this context is scoped to.
69    /// Empty string means no agent scope.
70    pub agent_id: String,
71
72    /// When true, the current user is a platform super admin
73    /// and should bypass tenant isolation.
74    ///
75    /// This field is private — external code must use `bypasses_rls()`.
76    /// Only `super_admin(token)` can set this to true, and that requires
77    /// a `SuperAdminToken` which emits an audit log on creation.
78    is_super_admin: bool,
79}
80
81impl RlsContext {
82    /// Create a context scoped to a specific operator.
83    pub fn operator(operator_id: &str) -> Self {
84        Self {
85            operator_id: operator_id.to_string(),
86            agent_id: String::new(),
87            is_super_admin: false,
88        }
89    }
90
91    /// Create a context scoped to a specific agent (reseller).
92    pub fn agent(agent_id: &str) -> Self {
93        Self {
94            operator_id: String::new(),
95            agent_id: agent_id.to_string(),
96            is_super_admin: false,
97        }
98    }
99
100    /// Create a context scoped to both operator and agent.
101    /// Used when an agent is acting on behalf of an operator.
102    pub fn operator_and_agent(operator_id: &str, agent_id: &str) -> Self {
103        Self {
104            operator_id: operator_id.to_string(),
105            agent_id: agent_id.to_string(),
106            is_super_admin: false,
107        }
108    }
109
110    /// Create a super admin context that bypasses tenant isolation.
111    ///
112    /// Requires a `SuperAdminToken` — which can only be created via
113    /// `SuperAdminToken::issue()` with mandatory audit logging.
114    ///
115    /// Uses nil UUID for operator/agent IDs to avoid `''::uuid` cast errors
116    /// in PostgreSQL RLS policies (PostgreSQL doesn't short-circuit OR).
117    pub fn super_admin(_token: SuperAdminToken) -> Self {
118        Self {
119            operator_id: "00000000-0000-0000-0000-000000000000".to_string(),
120            agent_id: "00000000-0000-0000-0000-000000000000".to_string(),
121            is_super_admin: true,
122        }
123    }
124
125    /// Create an empty context (no tenant, no super admin).
126    ///
127    /// Used for system-level operations that must not operate within
128    /// any tenant scope (startup introspection, migrations, health checks).
129    pub fn empty() -> Self {
130        Self {
131            operator_id: String::new(),
132            agent_id: String::new(),
133            is_super_admin: false,
134        }
135    }
136
137    /// Returns true if this context has an operator scope.
138    pub fn has_operator(&self) -> bool {
139        !self.operator_id.is_empty()
140    }
141
142    /// Returns true if this context has an agent scope.
143    pub fn has_agent(&self) -> bool {
144        !self.agent_id.is_empty()
145    }
146
147    /// Returns true if this context bypasses tenant isolation.
148    pub fn bypasses_rls(&self) -> bool {
149        self.is_super_admin
150    }
151}
152
153impl std::fmt::Display for RlsContext {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        if self.is_super_admin {
156            write!(f, "RlsContext(super_admin)")
157        } else if !self.operator_id.is_empty() && !self.agent_id.is_empty() {
158            write!(f, "RlsContext(op={}, ag={})", self.operator_id, self.agent_id)
159        } else if !self.operator_id.is_empty() {
160            write!(f, "RlsContext(op={})", self.operator_id)
161        } else if !self.agent_id.is_empty() {
162            write!(f, "RlsContext(ag={})", self.agent_id)
163        } else {
164            write!(f, "RlsContext(none)")
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn test_operator_context() {
175        let ctx = RlsContext::operator("op-123");
176        assert_eq!(ctx.operator_id, "op-123");
177        assert!(ctx.agent_id.is_empty());
178        assert!(!ctx.bypasses_rls());
179        assert!(ctx.has_operator());
180        assert!(!ctx.has_agent());
181    }
182
183    #[test]
184    fn test_agent_context() {
185        let ctx = RlsContext::agent("ag-456");
186        assert!(ctx.operator_id.is_empty());
187        assert_eq!(ctx.agent_id, "ag-456");
188        assert!(ctx.has_agent());
189        assert!(!ctx.has_operator());
190    }
191
192    #[test]
193    fn test_super_admin() {
194        let token = SuperAdminToken::issue();
195        let ctx = RlsContext::super_admin(token);
196        assert!(ctx.bypasses_rls());
197    }
198
199    #[test]
200    fn test_operator_and_agent() {
201        let ctx = RlsContext::operator_and_agent("op-1", "ag-2");
202        assert!(ctx.has_operator());
203        assert!(ctx.has_agent());
204        assert!(!ctx.bypasses_rls());
205    }
206
207    #[test]
208    fn test_display() {
209        let token = SuperAdminToken::issue();
210        assert_eq!(RlsContext::super_admin(token).to_string(), "RlsContext(super_admin)");
211        assert_eq!(RlsContext::operator("x").to_string(), "RlsContext(op=x)");
212        assert_eq!(RlsContext::agent("y").to_string(), "RlsContext(ag=y)");
213        assert_eq!(
214            RlsContext::operator_and_agent("x", "y").to_string(),
215            "RlsContext(op=x, ag=y)"
216        );
217    }
218
219    #[test]
220    fn test_equality() {
221        let a = RlsContext::operator("op-1");
222        let b = RlsContext::operator("op-1");
223        let c = RlsContext::operator("op-2");
224        assert_eq!(a, b);
225        assert_ne!(a, c);
226    }
227
228    #[test]
229    fn test_empty_context() {
230        let ctx = RlsContext::empty();
231        assert!(!ctx.has_operator());
232        assert!(!ctx.has_agent());
233        assert!(!ctx.bypasses_rls());
234    }
235
236    #[test]
237    fn test_super_admin_token_cannot_be_forged() {
238        // SuperAdminToken { _private: () } — the private field prevents
239        // external construction. This test documents the intent.
240        let token = SuperAdminToken::issue();
241        let ctx = RlsContext::super_admin(token);
242        assert!(ctx.bypasses_rls());
243    }
244}