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