Skip to main content

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;