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//! ```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;