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#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct RlsContext {
63    /// The operator (vendor) this context is scoped to.
64    /// Empty string means no operator scope.
65    pub operator_id: String,
66
67    /// The agent (reseller) this context is scoped to.
68    /// Empty string means no agent scope.
69    pub agent_id: String,
70
71    /// When true, the current user is a platform super admin
72    /// and should bypass tenant isolation.
73    ///
74    /// This field is private — external code must use `bypasses_rls()`.
75    /// Only `super_admin(token)` can set this to true, and that requires
76    /// a `SuperAdminToken` which emits an audit log on creation.
77    is_super_admin: bool,
78}
79
80impl RlsContext {
81    /// Create a context scoped to a specific operator.
82    pub fn operator(operator_id: &str) -> Self {
83        Self {
84            operator_id: operator_id.to_string(),
85            agent_id: String::new(),
86            is_super_admin: false,
87        }
88    }
89
90    /// Create a context scoped to a specific agent (reseller).
91    pub fn agent(agent_id: &str) -> Self {
92        Self {
93            operator_id: String::new(),
94            agent_id: agent_id.to_string(),
95            is_super_admin: false,
96        }
97    }
98
99    /// Create a context scoped to both operator and agent.
100    /// Used when an agent is acting on behalf of an operator.
101    pub fn operator_and_agent(operator_id: &str, agent_id: &str) -> Self {
102        Self {
103            operator_id: operator_id.to_string(),
104            agent_id: agent_id.to_string(),
105            is_super_admin: false,
106        }
107    }
108
109    /// Create a super admin context that bypasses tenant isolation.
110    ///
111    /// Requires a `SuperAdminToken` — which can only be created via
112    /// `SuperAdminToken::issue()` with mandatory audit logging.
113    ///
114    /// Uses nil UUID for operator/agent IDs to avoid `''::uuid` cast errors
115    /// in PostgreSQL RLS policies (PostgreSQL doesn't short-circuit OR).
116    pub fn super_admin(_token: SuperAdminToken) -> Self {
117        Self {
118            operator_id: "00000000-0000-0000-0000-000000000000".to_string(),
119            agent_id: "00000000-0000-0000-0000-000000000000".to_string(),
120            is_super_admin: true,
121        }
122    }
123
124    /// Create an empty context (no tenant, no super admin).
125    ///
126    /// Used for system-level operations that must not operate within
127    /// any tenant scope (startup introspection, migrations, health checks).
128    pub fn empty() -> Self {
129        Self {
130            operator_id: String::new(),
131            agent_id: String::new(),
132            is_super_admin: false,
133        }
134    }
135
136    /// Returns true if this context has an operator scope.
137    pub fn has_operator(&self) -> bool {
138        !self.operator_id.is_empty()
139    }
140
141    /// Returns true if this context has an agent scope.
142    pub fn has_agent(&self) -> bool {
143        !self.agent_id.is_empty()
144    }
145
146    /// Returns true if this context bypasses tenant isolation.
147    pub fn bypasses_rls(&self) -> bool {
148        self.is_super_admin
149    }
150}
151
152impl std::fmt::Display for RlsContext {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        if self.is_super_admin {
155            write!(f, "RlsContext(super_admin)")
156        } else if !self.operator_id.is_empty() && !self.agent_id.is_empty() {
157            write!(f, "RlsContext(op={}, ag={})", self.operator_id, self.agent_id)
158        } else if !self.operator_id.is_empty() {
159            write!(f, "RlsContext(op={})", self.operator_id)
160        } else if !self.agent_id.is_empty() {
161            write!(f, "RlsContext(ag={})", self.agent_id)
162        } else {
163            write!(f, "RlsContext(none)")
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn test_operator_context() {
174        let ctx = RlsContext::operator("op-123");
175        assert_eq!(ctx.operator_id, "op-123");
176        assert!(ctx.agent_id.is_empty());
177        assert!(!ctx.bypasses_rls());
178        assert!(ctx.has_operator());
179        assert!(!ctx.has_agent());
180    }
181
182    #[test]
183    fn test_agent_context() {
184        let ctx = RlsContext::agent("ag-456");
185        assert!(ctx.operator_id.is_empty());
186        assert_eq!(ctx.agent_id, "ag-456");
187        assert!(ctx.has_agent());
188        assert!(!ctx.has_operator());
189    }
190
191    #[test]
192    fn test_super_admin() {
193        let token = SuperAdminToken::issue();
194        let ctx = RlsContext::super_admin(token);
195        assert!(ctx.bypasses_rls());
196    }
197
198    #[test]
199    fn test_operator_and_agent() {
200        let ctx = RlsContext::operator_and_agent("op-1", "ag-2");
201        assert!(ctx.has_operator());
202        assert!(ctx.has_agent());
203        assert!(!ctx.bypasses_rls());
204    }
205
206    #[test]
207    fn test_display() {
208        let token = SuperAdminToken::issue();
209        assert_eq!(RlsContext::super_admin(token).to_string(), "RlsContext(super_admin)");
210        assert_eq!(RlsContext::operator("x").to_string(), "RlsContext(op=x)");
211        assert_eq!(RlsContext::agent("y").to_string(), "RlsContext(ag=y)");
212        assert_eq!(
213            RlsContext::operator_and_agent("x", "y").to_string(),
214            "RlsContext(op=x, ag=y)"
215        );
216    }
217
218    #[test]
219    fn test_equality() {
220        let a = RlsContext::operator("op-1");
221        let b = RlsContext::operator("op-1");
222        let c = RlsContext::operator("op-2");
223        assert_eq!(a, b);
224        assert_ne!(a, c);
225    }
226
227    #[test]
228    fn test_empty_context() {
229        let ctx = RlsContext::empty();
230        assert!(!ctx.has_operator());
231        assert!(!ctx.has_agent());
232        assert!(!ctx.bypasses_rls());
233    }
234
235    #[test]
236    fn test_super_admin_token_cannot_be_forged() {
237        // SuperAdminToken { _private: () } — the private field prevents
238        // external construction. This test documents the intent.
239        let token = SuperAdminToken::issue();
240        let ctx = RlsContext::super_admin(token);
241        assert!(ctx.bypasses_rls());
242    }
243}