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//! - **qail-redis**: key prefix `tenant:{operator_id}:*`
9//!
10//! # Example
11//!
12//! ```
13//! use qail_core::rls::RlsContext;
14//!
15//! // Operator context — scopes data to a single operator
16//! let ctx = RlsContext::operator("550e8400-e29b-41d4-a716-446655440000");
17//! assert_eq!(ctx.operator_id, "550e8400-e29b-41d4-a716-446655440000");
18//!
19//! // Super admin — bypasses tenant isolation
20//! let admin = RlsContext::super_admin();
21//! assert!(admin.is_super_admin);
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**: Prefixes keys with tenant identifier
30pub mod tenant;
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct RlsContext {
33    /// The operator (vendor) this context is scoped to.
34    /// Empty string means no operator scope.
35    pub operator_id: String,
36
37    /// The agent (reseller) this context is scoped to.
38    /// Empty string means no agent scope.
39    pub agent_id: String,
40
41    /// When true, the current user is a platform super admin
42    /// and should bypass tenant isolation.
43    pub is_super_admin: bool,
44}
45
46impl RlsContext {
47    /// Create a context scoped to a specific operator.
48    pub fn operator(operator_id: &str) -> Self {
49        Self {
50            operator_id: operator_id.to_string(),
51            agent_id: String::new(),
52            is_super_admin: false,
53        }
54    }
55
56    /// Create a context scoped to a specific agent (reseller).
57    pub fn agent(agent_id: &str) -> Self {
58        Self {
59            operator_id: String::new(),
60            agent_id: agent_id.to_string(),
61            is_super_admin: false,
62        }
63    }
64
65    /// Create a context scoped to both operator and agent.
66    /// Used when an agent is acting on behalf of an operator.
67    pub fn operator_and_agent(operator_id: &str, agent_id: &str) -> Self {
68        Self {
69            operator_id: operator_id.to_string(),
70            agent_id: agent_id.to_string(),
71            is_super_admin: false,
72        }
73    }
74
75    /// Create a super admin context that bypasses tenant isolation.
76    pub fn super_admin() -> Self {
77        Self {
78            operator_id: String::new(),
79            agent_id: String::new(),
80            is_super_admin: true,
81        }
82    }
83
84    /// Returns true if this context has an operator scope.
85    pub fn has_operator(&self) -> bool {
86        !self.operator_id.is_empty()
87    }
88
89    /// Returns true if this context has an agent scope.
90    pub fn has_agent(&self) -> bool {
91        !self.agent_id.is_empty()
92    }
93
94    /// Returns true if this context bypasses tenant isolation.
95    pub fn bypasses_rls(&self) -> bool {
96        self.is_super_admin
97    }
98}
99
100impl std::fmt::Display for RlsContext {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        if self.is_super_admin {
103            write!(f, "RlsContext(super_admin)")
104        } else if !self.operator_id.is_empty() && !self.agent_id.is_empty() {
105            write!(f, "RlsContext(op={}, ag={})", self.operator_id, self.agent_id)
106        } else if !self.operator_id.is_empty() {
107            write!(f, "RlsContext(op={})", self.operator_id)
108        } else if !self.agent_id.is_empty() {
109            write!(f, "RlsContext(ag={})", self.agent_id)
110        } else {
111            write!(f, "RlsContext(none)")
112        }
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn test_operator_context() {
122        let ctx = RlsContext::operator("op-123");
123        assert_eq!(ctx.operator_id, "op-123");
124        assert!(ctx.agent_id.is_empty());
125        assert!(!ctx.is_super_admin);
126        assert!(ctx.has_operator());
127        assert!(!ctx.has_agent());
128        assert!(!ctx.bypasses_rls());
129    }
130
131    #[test]
132    fn test_agent_context() {
133        let ctx = RlsContext::agent("ag-456");
134        assert!(ctx.operator_id.is_empty());
135        assert_eq!(ctx.agent_id, "ag-456");
136        assert!(ctx.has_agent());
137        assert!(!ctx.has_operator());
138    }
139
140    #[test]
141    fn test_super_admin() {
142        let ctx = RlsContext::super_admin();
143        assert!(ctx.is_super_admin);
144        assert!(ctx.bypasses_rls());
145    }
146
147    #[test]
148    fn test_operator_and_agent() {
149        let ctx = RlsContext::operator_and_agent("op-1", "ag-2");
150        assert!(ctx.has_operator());
151        assert!(ctx.has_agent());
152        assert!(!ctx.bypasses_rls());
153    }
154
155    #[test]
156    fn test_display() {
157        assert_eq!(RlsContext::super_admin().to_string(), "RlsContext(super_admin)");
158        assert_eq!(RlsContext::operator("x").to_string(), "RlsContext(op=x)");
159        assert_eq!(RlsContext::agent("y").to_string(), "RlsContext(ag=y)");
160        assert_eq!(
161            RlsContext::operator_and_agent("x", "y").to_string(),
162            "RlsContext(op=x, ag=y)"
163        );
164    }
165
166    #[test]
167    fn test_equality() {
168        let a = RlsContext::operator("op-1");
169        let b = RlsContext::operator("op-1");
170        let c = RlsContext::operator("op-2");
171        assert_eq!(a, b);
172        assert_ne!(a, c);
173    }
174}