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//! ```no_run
16//! // Requires: a live database adapter and compiled schema.
17//! // See: tests/integration/ for runnable examples.
18//! use fraiseql_core::tenancy::TenantContext;
19//! use serde_json::json;
20//!
21//! // Create tenant context
22//! let tenant = TenantContext::new("acme-corp");
23//!
24//! // Or extract from JWT claims
25//! let claims = json!({"tenant_id": "acme-corp", "sub": "user123"});
26//! let tenant = TenantContext::from_jwt_claims(&claims).unwrap();
27//!
28//! // Use in query execution (executor setup not shown — requires live DB)
29//! // let result = executor.execute("query { users { id name } }").await?;
30//! ```
31
32use std::collections::HashMap;
33
34use chrono::Utc;
35use serde_json::Value as JsonValue;
36
37/// Tenant context for row-level security and data isolation.
38///
39/// Represents a single tenant in a multi-tenant system.
40/// All queries executed with this context will be filtered to only include data
41/// belonging to this tenant.
42#[derive(Debug, Clone)]
43pub struct TenantContext {
44 /// Tenant identifier (e.g., "acme-corp", UUID, or subdomain).
45 id: String,
46
47 /// ISO 8601 formatted creation timestamp.
48 created_at: String,
49
50 /// Optional metadata for the tenant.
51 metadata: HashMap<String, String>,
52}
53
54impl TenantContext {
55 /// Create a new tenant context.
56 ///
57 /// # Arguments
58 ///
59 /// * `id` - Unique tenant identifier
60 ///
61 /// # Example
62 ///
63 /// ```rust
64 /// # use fraiseql_core::tenancy::TenantContext;
65 /// let tenant = TenantContext::new("company-123");
66 /// assert_eq!(tenant.id(), "company-123");
67 /// ```
68 #[must_use]
69 pub fn new(id: impl Into<String>) -> Self {
70 Self {
71 id: id.into(),
72 created_at: Utc::now().to_rfc3339(),
73 metadata: HashMap::new(),
74 }
75 }
76
77 /// Get the tenant ID.
78 #[must_use]
79 pub fn id(&self) -> &str {
80 &self.id
81 }
82
83 /// Get the creation timestamp in ISO 8601 format.
84 #[must_use]
85 pub fn created_at_iso8601(&self) -> Option<&str> {
86 if self.created_at.is_empty() {
87 None
88 } else {
89 Some(&self.created_at)
90 }
91 }
92
93 /// Set metadata key-value pair for the tenant.
94 pub fn set_metadata(&mut self, key: impl Into<String>, value: impl Into<String>) {
95 self.metadata.insert(key.into(), value.into());
96 }
97
98 /// Get metadata value by key.
99 #[must_use]
100 pub fn get_metadata(&self, key: &str) -> Option<&str> {
101 self.metadata.get(key).map(String::as_str)
102 }
103
104 /// Get all metadata.
105 #[must_use]
106 pub const fn metadata(&self) -> &HashMap<String, String> {
107 &self.metadata
108 }
109
110 /// Create a `TenantContext` from JWT claims.
111 ///
112 /// Extracts the `tenant_id` from JWT claims and creates a new `TenantContext`.
113 ///
114 /// # Arguments
115 ///
116 /// * `claims` - JWT claims as JSON object
117 ///
118 /// # Errors
119 ///
120 /// Returns an error if:
121 /// - `tenant_id` claim is missing
122 /// - `tenant_id` is not a string
123 ///
124 /// # Example
125 ///
126 /// ```rust
127 /// use serde_json::json;
128 /// use fraiseql_core::tenancy::TenantContext;
129 ///
130 /// let claims = json!({
131 /// "sub": "user123",
132 /// "tenant_id": "acme-corp",
133 /// "email": "alice@acme.com"
134 /// });
135 ///
136 /// let tenant = TenantContext::from_jwt_claims(&claims).unwrap();
137 /// assert_eq!(tenant.id(), "acme-corp");
138 /// ```
139 /// # Errors
140 ///
141 /// Returns a `String` error if the `tenant_id` claim is missing or not a string.
142 pub fn from_jwt_claims(claims: &JsonValue) -> Result<Self, String> {
143 // Extract tenant_id from claims
144 let tenant_id = claims
145 .get("tenant_id")
146 .and_then(|v| v.as_str())
147 .ok_or_else(|| "Missing or invalid 'tenant_id' claim in JWT".to_string())?;
148
149 Ok(Self::new(tenant_id))
150 }
151
152 /// Generate a WHERE clause for tenant filtering.
153 ///
154 /// Returns a WHERE clause that restricts data to this tenant.
155 /// Can be combined with existing WHERE clauses using AND.
156 ///
157 /// # Panics
158 ///
159 /// Panics if the tenant ID contains characters outside the safe set
160 /// (`[A-Za-z0-9._-]`). Tenant IDs are validated at context creation
161 /// so this should never trigger in practice.
162 ///
163 /// # Security
164 ///
165 /// Prefer [`where_clause_postgresql`] or [`where_clause_parameterized`]
166 /// for production query execution. This method embeds the tenant ID
167 /// directly into SQL and is only safe because the ID is strictly validated.
168 ///
169 /// # Example
170 ///
171 /// ```rust
172 /// # use fraiseql_core::tenancy::TenantContext;
173 /// let tenant = TenantContext::new("acme-corp");
174 /// let clause = tenant.where_clause(); // "tenant_id = 'acme-corp'"
175 /// assert_eq!(clause, "tenant_id = 'acme-corp'");
176 /// ```
177 #[must_use]
178 pub fn where_clause(&self) -> String {
179 validate_tenant_id_for_interpolation(&self.id);
180 format!("tenant_id = '{}'", self.id)
181 }
182
183 /// Generate a parameterized WHERE clause for PostgreSQL.
184 ///
185 /// For use with parameterized queries to prevent SQL injection.
186 ///
187 /// # Arguments
188 ///
189 /// * `param_index` - Parameter placeholder index (1-based for PostgreSQL)
190 ///
191 /// # Example
192 ///
193 /// ```rust
194 /// # use fraiseql_core::tenancy::TenantContext;
195 /// let tenant = TenantContext::new("acme-corp");
196 /// let clause = tenant.where_clause_postgresql(1); // "tenant_id = $1"
197 /// assert_eq!(clause, "tenant_id = $1");
198 /// ```
199 #[must_use]
200 pub fn where_clause_postgresql(&self, param_index: usize) -> String {
201 format!("tenant_id = ${}", param_index)
202 }
203
204 /// Generate a parameterized WHERE clause for MySQL/SQLite.
205 ///
206 /// For use with parameterized queries to prevent SQL injection.
207 ///
208 /// # Example
209 ///
210 /// ```rust
211 /// # use fraiseql_core::tenancy::TenantContext;
212 /// let tenant = TenantContext::new("acme-corp");
213 /// let clause = tenant.where_clause_parameterized(); // "tenant_id = ?"
214 /// assert_eq!(clause, "tenant_id = ?");
215 /// ```
216 #[must_use]
217 pub fn where_clause_parameterized(&self) -> String {
218 "tenant_id = ?".to_string()
219 }
220}
221
222// ============================================================================
223// Query Filtering
224// ============================================================================
225
226/// Validate that a tenant ID is safe to interpolate directly into SQL.
227///
228/// Allows only `[A-Za-z0-9._-]` to prevent SQL injection. Panics on
229/// violation so callers catch programming errors at development time.
230///
231/// Production code should use the parameterized WHERE clause helpers instead.
232fn validate_tenant_id_for_interpolation(tenant_id: &str) {
233 assert!(
234 !tenant_id.is_empty()
235 && tenant_id.chars().all(|c| c.is_alphanumeric() || matches!(c, '.' | '_' | '-')),
236 "SECURITY: tenant_id '{tenant_id}' contains characters that are unsafe for SQL interpolation. \
237 Use where_clause_postgresql() or where_clause_parameterized() instead."
238 );
239}
240
241/// Generates a WHERE clause for tenant filtering.
242///
243/// Returns a WHERE clause that restricts data to a specific tenant.
244/// Can be combined with existing WHERE clauses using AND.
245///
246/// # Panics
247///
248/// Panics if `tenant_id` contains characters outside `[A-Za-z0-9._-]`.
249/// Use [`where_clause_postgresql`] or [`where_clause_parameterized`] for
250/// production code where tenant IDs come from external input.
251///
252/// # Example
253///
254/// ```rust
255/// # use fraiseql_core::tenancy::where_clause;
256/// let clause = where_clause("acme-corp"); // "tenant_id = 'acme-corp'"
257/// assert_eq!(clause, "tenant_id = 'acme-corp'");
258/// ```
259pub fn where_clause(tenant_id: &str) -> String {
260 validate_tenant_id_for_interpolation(tenant_id);
261 format!("tenant_id = '{}'", tenant_id)
262}
263
264/// Generates a parameterized WHERE clause for PostgreSQL.
265///
266/// For use with parameterized queries to prevent SQL injection.
267pub fn where_clause_postgresql(param_index: usize) -> String {
268 format!("tenant_id = ${}", param_index)
269}
270
271/// Generates a parameterized WHERE clause for MySQL/SQLite.
272///
273/// For use with parameterized queries to prevent SQL injection.
274pub fn where_clause_parameterized() -> String {
275 "tenant_id = ?".to_string()
276}
277
278// ============================================================================
279// Tests
280// ============================================================================
281
282#[cfg(test)]
283mod tests;
284
285#[cfg(test)]
286mod jwt_extraction_tests;
287
288#[cfg(test)]
289mod query_filter_tests;