Skip to main content

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