xjp_oidc/
tenant.rs

1//! Multi-tenant support for OIDC SDK
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5
6/// Tenant resolution strategy
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8pub enum TenantMode {
9    /// Single tenant mode - no tenant resolution needed
10    Single,
11    /// Use query parameter for tenant resolution (e.g., ?tenant_id=tenant)
12    QueryParam,
13    /// Use client_id association for tenant resolution
14    ClientId,
15}
16
17impl Default for TenantMode {
18    fn default() -> Self {
19        TenantMode::Single
20    }
21}
22
23/// Tenant configuration for multi-tenant setups
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25pub struct TenantConfig {
26    /// Tenant mode - how to resolve tenant
27    pub mode: TenantMode,
28    /// Tenant identifier (slug or id)
29    pub tenant: Option<String>,
30}
31
32/// Tenant resolution with priority support
33#[derive(Debug, Clone, Default)]
34pub struct TenantResolution {
35    /// Tenant resolved from client_id
36    pub client_id_tenant: Option<String>,
37    /// Admin override header value
38    pub admin_override_tenant: Option<String>,
39    /// Default tenant fallback
40    pub default_tenant: Option<String>,
41}
42
43impl TenantResolution {
44    /// Resolve tenant based on priority: client_id -> admin_override -> default
45    pub fn resolve(&self) -> Option<String> {
46        self.client_id_tenant
47            .as_ref()
48            .or(self.admin_override_tenant.as_ref())
49            .or(self.default_tenant.as_ref())
50            .cloned()
51    }
52    
53    /// Get the effective tenant ID as string
54    pub fn tenant_id(&self) -> String {
55        self.resolve().unwrap_or_else(|| "default".to_string())
56    }
57}
58
59impl TenantConfig {
60    /// Create a new single tenant configuration
61    pub fn single() -> Self {
62        Self {
63            mode: TenantMode::Single,
64            tenant: None,
65        }
66    }
67
68
69    /// Create a new query parameter-based tenant configuration
70    pub fn query_param(tenant: String) -> Self {
71        Self {
72            mode: TenantMode::QueryParam,
73            tenant: Some(tenant),
74        }
75    }
76
77    /// Create a new client_id-based tenant configuration
78    pub fn client_id(client_id: String) -> Self {
79        Self {
80            mode: TenantMode::ClientId,
81            tenant: Some(client_id),
82        }
83    }
84
85    /// Apply tenant configuration to a URL
86    pub fn apply_to_url(&self, url: &str) -> Result<String, String> {
87        match &self.mode {
88            TenantMode::Single => Ok(url.to_string()),
89            TenantMode::QueryParam => {
90                if let Some(tenant) = &self.tenant {
91                    let separator = if url.contains('?') { "&" } else { "?" };
92                    Ok(format!("{}{}tenant_id={}", url, separator, tenant))
93                } else {
94                    Err("Tenant identifier required for query param mode".to_string())
95                }
96            }
97            TenantMode::ClientId => {
98                if let Some(client_id) = &self.tenant {
99                    let separator = if url.contains('?') { "&" } else { "?" };
100                    Ok(format!("{}{}client_id={}", url, separator, client_id))
101                } else {
102                    Err("Client ID required for client_id mode".to_string())
103                }
104            }
105        }
106    }
107
108
109    /// Check if tenant configuration is valid
110    pub fn validate(&self) -> Result<(), String> {
111        match &self.mode {
112            TenantMode::Single => Ok(()),
113            TenantMode::QueryParam => {
114                if self.tenant.is_none() {
115                    Err("Query param mode requires tenant".to_string())
116                } else {
117                    Ok(())
118                }
119            }
120            TenantMode::ClientId => {
121                if self.tenant.is_none() {
122                    Err("Client ID mode requires client_id".to_string())
123                } else {
124                    Ok(())
125                }
126            }
127        }
128    }
129}
130
131impl fmt::Display for TenantConfig {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        match &self.mode {
134            TenantMode::Single => write!(f, "Single tenant"),
135            TenantMode::QueryParam => {
136                if let Some(tenant) = &self.tenant {
137                    write!(f, "Query param: tenant_id={}", tenant)
138                } else {
139                    write!(f, "Query param (unconfigured)")
140                }
141            }
142            TenantMode::ClientId => write!(f, "Client ID based"),
143        }
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn test_single_tenant() {
153        let config = TenantConfig::single();
154        assert_eq!(config.mode, TenantMode::Single);
155        assert!(config.validate().is_ok());
156    }
157
158
159    #[test]
160    fn test_query_param_tenant() {
161        let config = TenantConfig::query_param("xiaojinpro".to_string());
162        assert_eq!(config.mode, TenantMode::QueryParam);
163        assert!(config.validate().is_ok());
164
165        let url = "https://auth.xiaojinpro.com/test";
166        let result = config.apply_to_url(url).unwrap();
167        assert_eq!(result, "https://auth.xiaojinpro.com/test?tenant_id=xiaojinpro");
168
169        let url_with_params = "https://auth.xiaojinpro.com/test?foo=bar";
170        let result = config.apply_to_url(url_with_params).unwrap();
171        assert_eq!(result, "https://auth.xiaojinpro.com/test?foo=bar&tenant_id=xiaojinpro");
172    }
173
174    #[test]
175    fn test_client_id_tenant() {
176        let config = TenantConfig::client_id("xjp-web".to_string());
177        assert_eq!(config.mode, TenantMode::ClientId);
178        assert!(config.validate().is_ok());
179
180        let url = "https://auth.xiaojinpro.com/.well-known/openid-configuration";
181        let result = config.apply_to_url(url).unwrap();
182        assert_eq!(result, "https://auth.xiaojinpro.com/.well-known/openid-configuration?client_id=xjp-web");
183
184        let url_with_params = "https://auth.xiaojinpro.com/.well-known/openid-configuration?foo=bar";
185        let result = config.apply_to_url(url_with_params).unwrap();
186        assert_eq!(result, "https://auth.xiaojinpro.com/.well-known/openid-configuration?foo=bar&client_id=xjp-web");
187    }
188
189    #[test]
190    fn test_client_id_tenant_validation() {
191        let mut config = TenantConfig {
192            mode: TenantMode::ClientId,
193            tenant: None,
194        };
195        
196        // Should fail without client_id
197        assert!(config.validate().is_err());
198        
199        // Should pass with client_id
200        config.tenant = Some("xjp-web".to_string());
201        assert!(config.validate().is_ok());
202    }
203    
204    #[test]
205    fn test_tenant_resolution_priority() {
206        let resolution = TenantResolution {
207            client_id_tenant: Some("client-tenant".to_string()),
208            admin_override_tenant: Some("admin-tenant".to_string()),
209            default_tenant: Some("default-tenant".to_string()),
210        };
211        
212        // Should resolve to client_id first
213        assert_eq!(resolution.resolve(), Some("client-tenant".to_string()));
214        
215        // With no client_id, should resolve to admin override
216        let resolution = TenantResolution {
217            client_id_tenant: None,
218            admin_override_tenant: Some("admin-tenant".to_string()),
219            default_tenant: Some("default-tenant".to_string()),
220        };
221        assert_eq!(resolution.resolve(), Some("admin-tenant".to_string()));
222        
223        // With no client_id or admin, should resolve to default
224        let resolution = TenantResolution {
225            client_id_tenant: None,
226            admin_override_tenant: None,
227            default_tenant: Some("default-tenant".to_string()),
228        };
229        assert_eq!(resolution.resolve(), Some("default-tenant".to_string()));
230        
231        // With nothing, should return None
232        let resolution = TenantResolution::default();
233        assert_eq!(resolution.resolve(), None);
234        assert_eq!(resolution.tenant_id(), "default");
235    }
236}