fraiseql_core/tenancy/mod.rs
1//! Multi-tenancy support for FraiseQL
2//!
3//! Provides tenant isolation, context extraction, and row-level security.
4//!
5//! # Architecture
6//!
7//! Tenants are isolated at the data level:
8//! - Each tenant has a unique ID
9//! - Queries automatically include tenant filter (WHERE tenant_id = $1)
10//! - JWT claims carry tenant_id for authorization
11//! - Cross-tenant access is denied
12//!
13//! # Example
14//!
15//! ```ignore
16//! use fraiseql_core::tenancy::TenantContext;
17//! use serde_json::json;
18//!
19//! // Create tenant context
20//! let tenant = TenantContext::new("acme-corp");
21//!
22//! // Or extract from JWT claims
23//! let claims = json!({"tenant_id": "acme-corp", "sub": "user123"});
24//! let tenant = TenantContext::from_jwt_claims(&claims)?;
25//!
26//! // Use in query execution
27//! let executor = Executor::with_tenant(schema, db_pool, tenant)?;
28//! let result = executor.execute("query { users { id name } }").await?;
29//! ```
30
31use std::collections::HashMap;
32
33use chrono::Utc;
34use serde_json::Value as JsonValue;
35
36/// Tenant context for row-level security and data isolation.
37///
38/// Represents a single tenant in a multi-tenant system.
39/// All queries executed with this context will be filtered to only include data
40/// belonging to this tenant.
41#[derive(Debug, Clone)]
42pub struct TenantContext {
43 /// Tenant identifier (e.g., "acme-corp", UUID, or subdomain).
44 id: String,
45
46 /// ISO 8601 formatted creation timestamp.
47 created_at: String,
48
49 /// Optional metadata for the tenant.
50 metadata: HashMap<String, String>,
51}
52
53impl TenantContext {
54 /// Create a new tenant context.
55 ///
56 /// # Arguments
57 ///
58 /// * `id` - Unique tenant identifier
59 ///
60 /// # Example
61 ///
62 /// ```ignore
63 /// let tenant = TenantContext::new("company-123");
64 /// assert_eq!(tenant.id(), "company-123");
65 /// ```
66 #[must_use]
67 pub fn new(id: impl Into<String>) -> Self {
68 Self {
69 id: id.into(),
70 created_at: Utc::now().to_rfc3339(),
71 metadata: HashMap::new(),
72 }
73 }
74
75 /// Get the tenant ID.
76 #[must_use]
77 pub fn id(&self) -> &str {
78 &self.id
79 }
80
81 /// Get the creation timestamp in ISO 8601 format.
82 #[must_use]
83 pub fn created_at_iso8601(&self) -> Option<&str> {
84 if self.created_at.is_empty() {
85 None
86 } else {
87 Some(&self.created_at)
88 }
89 }
90
91 /// Set metadata key-value pair for the tenant.
92 pub fn set_metadata(&mut self, key: impl Into<String>, value: impl Into<String>) {
93 self.metadata.insert(key.into(), value.into());
94 }
95
96 /// Get metadata value by key.
97 #[must_use]
98 pub fn get_metadata(&self, key: &str) -> Option<&str> {
99 self.metadata.get(key).map(String::as_str)
100 }
101
102 /// Get all metadata.
103 #[must_use]
104 pub fn metadata(&self) -> &HashMap<String, String> {
105 &self.metadata
106 }
107
108 /// Create a TenantContext from JWT claims.
109 ///
110 /// Extracts the `tenant_id` from JWT claims and creates a new TenantContext.
111 ///
112 /// # Arguments
113 ///
114 /// * `claims` - JWT claims as JSON object
115 ///
116 /// # Errors
117 ///
118 /// Returns an error if:
119 /// - `tenant_id` claim is missing
120 /// - `tenant_id` is not a string
121 ///
122 /// # Example
123 ///
124 /// ```ignore
125 /// use serde_json::json;
126 /// use fraiseql_core::tenancy::TenantContext;
127 ///
128 /// let claims = json!({
129 /// "sub": "user123",
130 /// "tenant_id": "acme-corp",
131 /// "email": "alice@acme.com"
132 /// });
133 ///
134 /// let tenant = TenantContext::from_jwt_claims(&claims)?;
135 /// assert_eq!(tenant.id(), "acme-corp");
136 /// ```
137 pub fn from_jwt_claims(claims: &JsonValue) -> Result<Self, String> {
138 // Extract tenant_id from claims
139 let tenant_id = claims
140 .get("tenant_id")
141 .and_then(|v| v.as_str())
142 .ok_or_else(|| "Missing or invalid 'tenant_id' claim in JWT".to_string())?;
143
144 Ok(Self::new(tenant_id))
145 }
146
147 /// Generate a WHERE clause for tenant filtering.
148 ///
149 /// Returns a WHERE clause that restricts data to this tenant.
150 /// Can be combined with existing WHERE clauses using AND.
151 ///
152 /// # Example
153 ///
154 /// ```ignore
155 /// let tenant = TenantContext::new("acme-corp");
156 /// let clause = tenant.where_clause(); // "tenant_id = 'acme-corp'"
157 /// ```
158 #[must_use]
159 pub fn where_clause(&self) -> String {
160 format!("tenant_id = '{}'", self.id)
161 }
162
163 /// Generate a parameterized WHERE clause for PostgreSQL.
164 ///
165 /// For use with parameterized queries to prevent SQL injection.
166 ///
167 /// # Arguments
168 ///
169 /// * `param_index` - Parameter placeholder index (1-based for PostgreSQL)
170 ///
171 /// # Example
172 ///
173 /// ```ignore
174 /// let tenant = TenantContext::new("acme-corp");
175 /// let clause = tenant.where_clause_postgresql(1); // "tenant_id = $1"
176 /// ```
177 #[must_use]
178 pub fn where_clause_postgresql(&self, param_index: usize) -> String {
179 format!("tenant_id = ${}", param_index)
180 }
181
182 /// Generate a parameterized WHERE clause for MySQL/SQLite.
183 ///
184 /// For use with parameterized queries to prevent SQL injection.
185 ///
186 /// # Example
187 ///
188 /// ```ignore
189 /// let tenant = TenantContext::new("acme-corp");
190 /// let clause = tenant.where_clause_parameterized(); // "tenant_id = ?"
191 /// ```
192 #[must_use]
193 pub fn where_clause_parameterized(&self) -> String {
194 "tenant_id = ?".to_string()
195 }
196}
197
198// ============================================================================
199// Query Filtering
200// ============================================================================
201
202/// Generates a WHERE clause for tenant filtering.
203///
204/// Returns a WHERE clause that restricts data to a specific tenant.
205/// Can be combined with existing WHERE clauses using AND.
206///
207/// # Example
208///
209/// ```ignore
210/// let tenant = TenantContext::new("acme-corp");
211/// let clause = tenant.where_clause(); // "tenant_id = 'acme-corp'"
212/// ```
213pub fn where_clause(tenant_id: &str) -> String {
214 format!("tenant_id = '{}'", tenant_id)
215}
216
217/// Generates a parameterized WHERE clause for PostgreSQL.
218///
219/// For use with parameterized queries to prevent SQL injection.
220pub fn where_clause_postgresql(param_index: usize) -> String {
221 format!("tenant_id = ${}", param_index)
222}
223
224/// Generates a parameterized WHERE clause for MySQL/SQLite.
225///
226/// For use with parameterized queries to prevent SQL injection.
227pub fn where_clause_parameterized() -> String {
228 "tenant_id = ?".to_string()
229}
230
231// ============================================================================
232// Tests
233// ============================================================================
234
235#[cfg(test)]
236mod tests;
237
238#[cfg(test)]
239mod jwt_extraction_tests;
240
241#[cfg(test)]
242mod query_filter_tests;