mockforge_core/multi_tenant/
registry.rs

1//! Multi-tenant workspace support for MockForge
2//!
3//! This module provides infrastructure for hosting multiple isolated workspaces
4//! in a single MockForge instance, enabling namespace separation and tenant isolation.
5
6use crate::request_logger::CentralizedRequestLogger;
7use crate::routing::RouteRegistry;
8use crate::workspace::{EntityId, Workspace};
9use crate::{Error, Result};
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::sync::{Arc, RwLock};
14
15/// Multi-tenant configuration
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
18pub struct MultiTenantConfig {
19    /// Enable multi-tenant mode
20    pub enabled: bool,
21    /// Routing strategy (path-based, port-based, or both)
22    pub routing_strategy: RoutingStrategy,
23    /// Workspace path prefix (e.g., "/workspace" or "/w")
24    pub workspace_prefix: String,
25    /// Default workspace ID (used when no workspace specified in request)
26    pub default_workspace: String,
27    /// Maximum number of workspaces allowed
28    pub max_workspaces: Option<usize>,
29    /// Workspace-specific port mappings (for port-based routing)
30    #[serde(default)]
31    pub workspace_ports: HashMap<String, u16>,
32    /// Enable workspace auto-discovery from config directory
33    pub auto_discover: bool,
34    /// Configuration directory for workspace configs
35    pub config_directory: Option<String>,
36}
37
38impl Default for MultiTenantConfig {
39    fn default() -> Self {
40        Self {
41            enabled: false,
42            routing_strategy: RoutingStrategy::Path,
43            workspace_prefix: "/workspace".to_string(),
44            default_workspace: "default".to_string(),
45            max_workspaces: None,
46            workspace_ports: HashMap::new(),
47            auto_discover: false,
48            config_directory: None,
49        }
50    }
51}
52
53/// Routing strategy for multi-tenant workspaces
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
55#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
56#[serde(rename_all = "lowercase")]
57pub enum RoutingStrategy {
58    /// Path-based routing: /workspace/{id}/path
59    Path,
60    /// Port-based routing: different port per workspace
61    Port,
62    /// Both path and port-based routing
63    Both,
64}
65
66/// Tenant workspace wrapper with isolated resources
67#[derive(Debug, Clone)]
68pub struct TenantWorkspace {
69    /// Workspace metadata and configuration
70    pub workspace: Workspace,
71    /// Workspace-specific route registry
72    pub route_registry: Arc<RwLock<RouteRegistry>>,
73    /// Last access timestamp
74    pub last_accessed: DateTime<Utc>,
75    /// Whether this workspace is enabled
76    pub enabled: bool,
77    /// Workspace-specific statistics
78    pub stats: WorkspaceStats,
79}
80
81/// Statistics for a tenant workspace
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct WorkspaceStats {
84    /// Total number of requests handled
85    pub total_requests: u64,
86    /// Total number of active routes
87    pub active_routes: usize,
88    /// Last request timestamp
89    pub last_request_at: Option<DateTime<Utc>>,
90    /// Created at timestamp
91    pub created_at: DateTime<Utc>,
92    /// Average response time in milliseconds
93    pub avg_response_time_ms: f64,
94}
95
96impl Default for WorkspaceStats {
97    fn default() -> Self {
98        Self {
99            total_requests: 0,
100            active_routes: 0,
101            last_request_at: None,
102            created_at: Utc::now(),
103            avg_response_time_ms: 0.0,
104        }
105    }
106}
107
108/// Multi-tenant workspace registry for managing multiple isolated workspaces
109#[derive(Debug, Clone)]
110pub struct MultiTenantWorkspaceRegistry {
111    /// Tenant workspaces indexed by ID
112    workspaces: Arc<RwLock<HashMap<EntityId, TenantWorkspace>>>,
113    /// Default workspace ID
114    default_workspace_id: EntityId,
115    /// Configuration
116    config: MultiTenantConfig,
117    /// Global request logger (for aggregated logging)
118    global_logger: Arc<CentralizedRequestLogger>,
119}
120
121impl MultiTenantWorkspaceRegistry {
122    /// Create a new multi-tenant workspace registry
123    pub fn new(config: MultiTenantConfig) -> Self {
124        let default_workspace_id = config.default_workspace.clone();
125
126        Self {
127            workspaces: Arc::new(RwLock::new(HashMap::new())),
128            default_workspace_id,
129            config,
130            global_logger: Arc::new(CentralizedRequestLogger::new(10000)), // Keep last 10000 requests
131        }
132    }
133
134    /// Create with default configuration
135    pub fn with_default_workspace(workspace_name: String) -> Self {
136        let config = MultiTenantConfig {
137            default_workspace: "default".to_string(),
138            ..Default::default()
139        };
140
141        let mut registry = Self::new(config);
142
143        // Create and register default workspace
144        let default_workspace = Workspace::new(workspace_name);
145        let _ = registry.register_workspace("default".to_string(), default_workspace);
146
147        registry
148    }
149
150    /// Register a new workspace
151    pub fn register_workspace(&mut self, workspace_id: String, workspace: Workspace) -> Result<()> {
152        // Check max workspaces limit
153        if let Some(max) = self.config.max_workspaces {
154            let current_count = self
155                .workspaces
156                .read()
157                .map_err(|e| Error::generic(format!("Failed to read workspaces: {}", e)))?
158                .len();
159
160            if current_count >= max {
161                return Err(Error::generic(format!(
162                    "Maximum number of workspaces ({}) exceeded",
163                    max
164                )));
165            }
166        }
167
168        let tenant_workspace = TenantWorkspace {
169            workspace,
170            route_registry: Arc::new(RwLock::new(RouteRegistry::new())),
171            last_accessed: Utc::now(),
172            enabled: true,
173            stats: WorkspaceStats::default(),
174        };
175
176        self.workspaces
177            .write()
178            .map_err(|e| Error::generic(format!("Failed to write workspaces: {}", e)))?
179            .insert(workspace_id, tenant_workspace);
180
181        Ok(())
182    }
183
184    /// Get a workspace by ID
185    pub fn get_workspace(&self, workspace_id: &str) -> Result<TenantWorkspace> {
186        let workspaces = self
187            .workspaces
188            .read()
189            .map_err(|e| Error::generic(format!("Failed to read workspaces: {}", e)))?;
190
191        workspaces
192            .get(workspace_id)
193            .cloned()
194            .ok_or_else(|| Error::generic(format!("Workspace '{}' not found", workspace_id)))
195    }
196
197    /// Get the default workspace
198    pub fn get_default_workspace(&self) -> Result<TenantWorkspace> {
199        self.get_workspace(&self.default_workspace_id)
200    }
201
202    /// Update workspace
203    pub fn update_workspace(&mut self, workspace_id: &str, workspace: Workspace) -> Result<()> {
204        let mut workspaces = self
205            .workspaces
206            .write()
207            .map_err(|e| Error::generic(format!("Failed to write workspaces: {}", e)))?;
208
209        if let Some(tenant_workspace) = workspaces.get_mut(workspace_id) {
210            tenant_workspace.workspace = workspace;
211            Ok(())
212        } else {
213            Err(Error::generic(format!("Workspace '{}' not found", workspace_id)))
214        }
215    }
216
217    /// Remove a workspace
218    pub fn remove_workspace(&mut self, workspace_id: &str) -> Result<()> {
219        // Prevent removing default workspace
220        if workspace_id == self.default_workspace_id {
221            return Err(Error::generic("Cannot remove default workspace".to_string()));
222        }
223
224        self.workspaces
225            .write()
226            .map_err(|e| Error::generic(format!("Failed to write workspaces: {}", e)))?
227            .remove(workspace_id)
228            .ok_or_else(|| Error::generic(format!("Workspace '{}' not found", workspace_id)))?;
229
230        Ok(())
231    }
232
233    /// List all workspaces
234    pub fn list_workspaces(&self) -> Result<Vec<(String, TenantWorkspace)>> {
235        let workspaces = self
236            .workspaces
237            .read()
238            .map_err(|e| Error::generic(format!("Failed to read workspaces: {}", e)))?;
239
240        Ok(workspaces.iter().map(|(id, ws)| (id.clone(), ws.clone())).collect())
241    }
242
243    /// Get workspace by ID or default
244    pub fn resolve_workspace(&self, workspace_id: Option<&str>) -> Result<TenantWorkspace> {
245        if let Some(id) = workspace_id {
246            self.get_workspace(id)
247        } else {
248            self.get_default_workspace()
249        }
250    }
251
252    /// Update workspace last accessed time
253    pub fn touch_workspace(&mut self, workspace_id: &str) -> Result<()> {
254        let mut workspaces = self
255            .workspaces
256            .write()
257            .map_err(|e| Error::generic(format!("Failed to write workspaces: {}", e)))?;
258
259        if let Some(tenant_workspace) = workspaces.get_mut(workspace_id) {
260            tenant_workspace.last_accessed = Utc::now();
261            Ok(())
262        } else {
263            Err(Error::generic(format!("Workspace '{}' not found", workspace_id)))
264        }
265    }
266
267    /// Update workspace statistics
268    pub fn update_workspace_stats(
269        &mut self,
270        workspace_id: &str,
271        response_time_ms: f64,
272    ) -> Result<()> {
273        let mut workspaces = self
274            .workspaces
275            .write()
276            .map_err(|e| Error::generic(format!("Failed to write workspaces: {}", e)))?;
277
278        if let Some(tenant_workspace) = workspaces.get_mut(workspace_id) {
279            tenant_workspace.stats.total_requests += 1;
280            tenant_workspace.stats.last_request_at = Some(Utc::now());
281
282            // Update average response time using running average
283            let n = tenant_workspace.stats.total_requests as f64;
284            tenant_workspace.stats.avg_response_time_ms =
285                ((tenant_workspace.stats.avg_response_time_ms * (n - 1.0)) + response_time_ms) / n;
286
287            Ok(())
288        } else {
289            Err(Error::generic(format!("Workspace '{}' not found", workspace_id)))
290        }
291    }
292
293    /// Get workspace count
294    pub fn workspace_count(&self) -> Result<usize> {
295        let workspaces = self
296            .workspaces
297            .read()
298            .map_err(|e| Error::generic(format!("Failed to read workspaces: {}", e)))?;
299
300        Ok(workspaces.len())
301    }
302
303    /// Check if workspace exists
304    pub fn workspace_exists(&self, workspace_id: &str) -> bool {
305        self.workspaces.read().map(|ws| ws.contains_key(workspace_id)).unwrap_or(false)
306    }
307
308    /// Enable/disable a workspace
309    pub fn set_workspace_enabled(&mut self, workspace_id: &str, enabled: bool) -> Result<()> {
310        let mut workspaces = self
311            .workspaces
312            .write()
313            .map_err(|e| Error::generic(format!("Failed to write workspaces: {}", e)))?;
314
315        if let Some(tenant_workspace) = workspaces.get_mut(workspace_id) {
316            tenant_workspace.enabled = enabled;
317            Ok(())
318        } else {
319            Err(Error::generic(format!("Workspace '{}' not found", workspace_id)))
320        }
321    }
322
323    /// Get the global request logger
324    pub fn global_logger(&self) -> &Arc<CentralizedRequestLogger> {
325        &self.global_logger
326    }
327
328    /// Get configuration
329    pub fn config(&self) -> &MultiTenantConfig {
330        &self.config
331    }
332
333    /// Extract workspace ID from request path
334    pub fn extract_workspace_id_from_path(&self, path: &str) -> Option<String> {
335        if !self.config.enabled {
336            return None;
337        }
338
339        let prefix = &self.config.workspace_prefix;
340
341        // Check if path starts with workspace prefix
342        if !path.starts_with(prefix) {
343            return None;
344        }
345
346        // Extract workspace ID from path: /workspace/{id}/...
347        let remaining = &path[prefix.len()..];
348
349        // Skip leading slash
350        let remaining = remaining.strip_prefix('/').unwrap_or(remaining);
351
352        // Get first path segment (workspace ID)
353        remaining.split('/').next().filter(|id| !id.is_empty()).map(|id| id.to_string())
354    }
355
356    /// Strip workspace prefix from path
357    pub fn strip_workspace_prefix(&self, path: &str, workspace_id: &str) -> String {
358        if !self.config.enabled {
359            return path.to_string();
360        }
361
362        let prefix = format!("{}/{}", self.config.workspace_prefix, workspace_id);
363
364        if path.starts_with(&prefix) {
365            let remaining = &path[prefix.len()..];
366            if remaining.is_empty() {
367                "/".to_string()
368            } else {
369                remaining.to_string()
370            }
371        } else {
372            path.to_string()
373        }
374    }
375}
376
377impl TenantWorkspace {
378    /// Create a new tenant workspace
379    pub fn new(workspace: Workspace) -> Self {
380        Self {
381            workspace,
382            route_registry: Arc::new(RwLock::new(RouteRegistry::new())),
383            last_accessed: Utc::now(),
384            enabled: true,
385            stats: WorkspaceStats::default(),
386        }
387    }
388
389    /// Get workspace ID
390    pub fn id(&self) -> &str {
391        &self.workspace.id
392    }
393
394    /// Get workspace name
395    pub fn name(&self) -> &str {
396        &self.workspace.name
397    }
398
399    /// Get route registry
400    pub fn route_registry(&self) -> &Arc<RwLock<RouteRegistry>> {
401        &self.route_registry
402    }
403
404    /// Get workspace statistics
405    pub fn stats(&self) -> &WorkspaceStats {
406        &self.stats
407    }
408
409    /// Rebuild route registry from workspace routes
410    pub fn rebuild_routes(&mut self) -> Result<()> {
411        let routes = self.workspace.get_routes();
412
413        let mut registry = self
414            .route_registry
415            .write()
416            .map_err(|e| Error::generic(format!("Failed to write route registry: {}", e)))?;
417
418        // Clear existing routes
419        *registry = RouteRegistry::new();
420
421        // Add all routes from workspace
422        for route in routes {
423            registry.add_http_route(route)?;
424        }
425
426        // Update stats - count total number of routes
427        self.stats.active_routes = self.workspace.requests.len()
428            + self.workspace.folders.iter().map(Self::count_folder_requests).sum::<usize>();
429
430        Ok(())
431    }
432
433    /// Count requests in a folder recursively
434    fn count_folder_requests(folder: &crate::workspace::Folder) -> usize {
435        folder.requests.len()
436            + folder.folders.iter().map(Self::count_folder_requests).sum::<usize>()
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn test_multi_tenant_config_default() {
446        let config = MultiTenantConfig::default();
447        assert!(!config.enabled);
448        assert_eq!(config.routing_strategy, RoutingStrategy::Path);
449        assert_eq!(config.workspace_prefix, "/workspace");
450        assert_eq!(config.default_workspace, "default");
451    }
452
453    #[test]
454    fn test_multi_tenant_registry_creation() {
455        let config = MultiTenantConfig::default();
456        let registry = MultiTenantWorkspaceRegistry::new(config);
457        assert_eq!(registry.workspace_count().unwrap(), 0);
458    }
459
460    #[test]
461    fn test_register_workspace() {
462        let config = MultiTenantConfig::default();
463        let mut registry = MultiTenantWorkspaceRegistry::new(config);
464
465        let workspace = Workspace::new("Test Workspace".to_string());
466        registry.register_workspace("test".to_string(), workspace).unwrap();
467
468        assert_eq!(registry.workspace_count().unwrap(), 1);
469        assert!(registry.workspace_exists("test"));
470    }
471
472    #[test]
473    fn test_max_workspaces_limit() {
474        let config = MultiTenantConfig {
475            max_workspaces: Some(2),
476            ..Default::default()
477        };
478
479        let mut registry = MultiTenantWorkspaceRegistry::new(config);
480
481        // Register first workspace
482        registry
483            .register_workspace("ws1".to_string(), Workspace::new("WS1".to_string()))
484            .unwrap();
485
486        // Register second workspace
487        registry
488            .register_workspace("ws2".to_string(), Workspace::new("WS2".to_string()))
489            .unwrap();
490
491        // Third should fail
492        let result =
493            registry.register_workspace("ws3".to_string(), Workspace::new("WS3".to_string()));
494        assert!(result.is_err());
495    }
496
497    #[test]
498    fn test_extract_workspace_id_from_path() {
499        let config = MultiTenantConfig {
500            enabled: true,
501            ..Default::default()
502        };
503
504        let registry = MultiTenantWorkspaceRegistry::new(config);
505
506        // Test valid path
507        let workspace_id =
508            registry.extract_workspace_id_from_path("/workspace/project-a/api/users");
509        assert_eq!(workspace_id, Some("project-a".to_string()));
510
511        // Test path without workspace
512        let workspace_id = registry.extract_workspace_id_from_path("/api/users");
513        assert_eq!(workspace_id, None);
514
515        // Test root workspace path
516        let workspace_id = registry.extract_workspace_id_from_path("/workspace/test");
517        assert_eq!(workspace_id, Some("test".to_string()));
518    }
519
520    #[test]
521    fn test_strip_workspace_prefix() {
522        let config = MultiTenantConfig {
523            enabled: true,
524            ..Default::default()
525        };
526
527        let registry = MultiTenantWorkspaceRegistry::new(config);
528
529        // Test stripping prefix
530        let stripped =
531            registry.strip_workspace_prefix("/workspace/project-a/api/users", "project-a");
532        assert_eq!(stripped, "/api/users");
533
534        // Test path without prefix
535        let stripped = registry.strip_workspace_prefix("/api/users", "project-a");
536        assert_eq!(stripped, "/api/users");
537
538        // Test root path
539        let stripped = registry.strip_workspace_prefix("/workspace/project-a", "project-a");
540        assert_eq!(stripped, "/");
541    }
542
543    #[test]
544    fn test_workspace_stats_update() {
545        let config = MultiTenantConfig::default();
546        let mut registry = MultiTenantWorkspaceRegistry::new(config);
547
548        let workspace = Workspace::new("Test Workspace".to_string());
549        registry.register_workspace("test".to_string(), workspace).unwrap();
550
551        // Update stats with response time
552        registry.update_workspace_stats("test", 100.0).unwrap();
553
554        let tenant_ws = registry.get_workspace("test").unwrap();
555        assert_eq!(tenant_ws.stats.total_requests, 1);
556        assert_eq!(tenant_ws.stats.avg_response_time_ms, 100.0);
557
558        // Update again with different response time
559        registry.update_workspace_stats("test", 200.0).unwrap();
560
561        let tenant_ws = registry.get_workspace("test").unwrap();
562        assert_eq!(tenant_ws.stats.total_requests, 2);
563        assert_eq!(tenant_ws.stats.avg_response_time_ms, 150.0);
564    }
565
566    #[test]
567    fn test_cannot_remove_default_workspace() {
568        let config = MultiTenantConfig {
569            default_workspace: "default".to_string(),
570            ..Default::default()
571        };
572
573        let mut registry = MultiTenantWorkspaceRegistry::new(config);
574
575        registry
576            .register_workspace("default".to_string(), Workspace::new("Default".to_string()))
577            .unwrap();
578
579        // Try to remove default workspace
580        let result = registry.remove_workspace("default");
581        assert!(result.is_err());
582    }
583}