Skip to main content

auth_framework/config/
config_manager.rs

1//! Advanced configuration management using the `config` crate.
2//!
3//! This module provides flexible configuration loading from multiple sources:
4//! - Configuration files (TOML, YAML, JSON, RON, INI)
5//! - Environment variables
6//! - Command line arguments (when integrated with clap)
7//! - Include directives for modular configuration files
8//!
9//! The configuration system is designed to be easily integrated into parent applications
10//! while providing sensible defaults for standalone use.
11
12use crate::errors::{AuthError, Result};
13use config::{Config, Environment, File, FileFormat};
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::path::Path;
17
18/// Comprehensive configuration manager for the auth framework
19#[derive(Debug, Clone)]
20pub struct ConfigManager {
21    /// The underlying config builder
22    config: Config,
23    /// Configuration source paths for reference
24    sources: Vec<String>,
25    /// Environment variable prefix
26    env_prefix: String,
27}
28
29/// Configuration builder for easy integration into parent applications
30#[derive(Debug, Clone)]
31pub struct ConfigBuilder {
32    /// Configuration sources in order of priority (later sources override earlier ones)
33    sources: Vec<ConfigSource>,
34    /// Environment variable prefix
35    env_prefix: String,
36    /// Whether to include default auth-framework config files
37    include_defaults: bool,
38    /// Custom configuration file search paths
39    search_paths: Vec<String>,
40}
41
42/// Represents a configuration source
43#[derive(Debug, Clone)]
44pub enum ConfigSource {
45    /// Configuration file (path, format, required)
46    File {
47        path: String,
48        format: FileFormat,
49        required: bool,
50    },
51    /// Environment variables with prefix
52    Environment { prefix: String },
53    /// Direct configuration values
54    Values(HashMap<String, config::Value>),
55    /// Include another configuration directory
56    IncludeDir { path: String, pattern: String },
57}
58
59/// Settings that can be used by parent applications to configure auth-framework
60#[derive(Debug, Clone, Serialize, Deserialize, Default)]
61pub struct AuthFrameworkSettings {
62    /// Main auth framework configuration
63    #[serde(flatten)]
64    pub auth: super::AuthConfig,
65
66    /// Threat intelligence configuration
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub threat_intelligence: Option<crate::threat_intelligence::ThreatIntelConfig>,
69
70    /// Session configuration
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub session: Option<SessionSettings>,
73
74    /// Additional custom settings for extensibility
75    #[serde(flatten)]
76    pub custom: HashMap<String, serde_json::Value>,
77}
78
79/// Session-specific configuration settings
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct SessionSettings {
82    /// Maximum number of concurrent sessions per user
83    pub max_concurrent_sessions: Option<u32>,
84
85    /// Session cleanup interval in seconds
86    pub cleanup_interval: Option<u64>,
87
88    /// Enable session device tracking
89    pub enable_device_tracking: Option<bool>,
90
91    /// Session cookie settings
92    pub cookie: Option<SessionCookieSettings>,
93}
94
95/// Session cookie configuration
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct SessionCookieSettings {
98    /// Cookie name
99    pub name: Option<String>,
100
101    /// Cookie domain
102    pub domain: Option<String>,
103
104    /// Cookie path
105    pub path: Option<String>,
106
107    /// Cookie max age in seconds
108    pub max_age: Option<u64>,
109
110    /// Whether cookie is HTTP only
111    pub http_only: Option<bool>,
112}
113
114impl Default for ConfigBuilder {
115    fn default() -> Self {
116        Self::new()
117    }
118}
119
120impl ConfigBuilder {
121    /// Create a new configuration builder with sensible defaults
122    pub fn new() -> Self {
123        Self {
124            sources: Vec::new(),
125            env_prefix: "AUTH_FRAMEWORK".to_string(),
126            include_defaults: true,
127            search_paths: vec![
128                ".".to_string(),
129                "./config".to_string(),
130                "/etc/auth-framework".to_string(),
131                dirs::config_dir()
132                    .map(|d| d.join("auth-framework").to_string_lossy().to_string())
133                    .unwrap_or_else(|| "./config".to_string()),
134            ],
135        }
136    }
137
138    /// Set the environment variable prefix
139    pub fn with_env_prefix(mut self, prefix: impl Into<String>) -> Self {
140        self.env_prefix = prefix.into();
141        self
142    }
143
144    /// Disable loading of default auth-framework configuration files
145    pub fn without_defaults(mut self) -> Self {
146        self.include_defaults = false;
147        self
148    }
149
150    /// Add a configuration file source
151    pub fn add_file<P: AsRef<Path>>(mut self, path: P, required: bool) -> Self {
152        let path_str = path.as_ref().to_string_lossy().to_string();
153        let format = Self::detect_format(&path_str);
154
155        self.sources.push(ConfigSource::File {
156            path: path_str,
157            format,
158            required,
159        });
160        self
161    }
162
163    /// Add a configuration file with explicit format
164    pub fn add_file_with_format<P: AsRef<Path>>(
165        mut self,
166        path: P,
167        format: FileFormat,
168        required: bool,
169    ) -> Self {
170        self.sources.push(ConfigSource::File {
171            path: path.as_ref().to_string_lossy().to_string(),
172            format,
173            required,
174        });
175        self
176    }
177
178    /// Add environment variables as a source
179    pub fn add_env_source(mut self, prefix: impl Into<String>) -> Self {
180        self.sources.push(ConfigSource::Environment {
181            prefix: prefix.into(),
182        });
183        self
184    }
185
186    /// Add direct configuration values
187    pub fn add_values(mut self, values: HashMap<String, config::Value>) -> Self {
188        self.sources.push(ConfigSource::Values(values));
189        self
190    }
191
192    /// Add a directory include source (loads all matching files)
193    pub fn add_include_dir(mut self, path: impl Into<String>, pattern: impl Into<String>) -> Self {
194        self.sources.push(ConfigSource::IncludeDir {
195            path: path.into(),
196            pattern: pattern.into(),
197        });
198        self
199    }
200
201    /// Add a search path for configuration files
202    pub fn add_search_path(mut self, path: impl Into<String>) -> Self {
203        self.search_paths.push(path.into());
204        self
205    }
206
207    /// Build the configuration manager
208    pub fn build(self) -> Result<ConfigManager> {
209        let mut config = Config::builder();
210        let mut sources = Vec::new();
211
212        // Add default auth-framework configuration files if requested
213        if self.include_defaults {
214            // Look for auth-framework configuration files in search paths
215            for search_path in &self.search_paths {
216                for filename in &[
217                    "auth-framework.toml",
218                    "auth-framework.yaml",
219                    "auth-framework.yml",
220                    "auth-framework.json",
221                    "auth.toml",
222                    "auth.yaml",
223                    "auth.yml",
224                    "auth.json",
225                ] {
226                    let path = Path::new(search_path).join(filename);
227                    if path.exists() {
228                        let format = Self::detect_format(&path.to_string_lossy());
229                        config = config
230                            .add_source(File::from(path.clone()).format(format).required(false));
231                        sources.push(path.to_string_lossy().to_string());
232                    }
233                }
234            }
235        }
236
237        // Add user-specified sources in order
238        for source in self.sources {
239            match source {
240                ConfigSource::File {
241                    path,
242                    format,
243                    required,
244                } => {
245                    config = config.add_source(File::new(&path, format).required(required));
246                    sources.push(path);
247                }
248                ConfigSource::Environment { prefix } => {
249                    config = config.add_source(
250                        Environment::with_prefix(&prefix)
251                            .prefix_separator("_")
252                            .separator("__"),
253                    );
254                    sources.push(format!("env:{}", prefix));
255                }
256                ConfigSource::Values(values) => {
257                    for (key, value) in values {
258                        config = config.set_override(&key, value).map_err(|e| {
259                            AuthError::config(format!("Failed to set override: {e}"))
260                        })?;
261                    }
262                    sources.push("values:override".to_string());
263                }
264                ConfigSource::IncludeDir { path, pattern } => {
265                    // Load all matching files from the directory
266                    if let Ok(entries) = std::fs::read_dir(&path) {
267                        let mut files: Vec<_> = entries
268                            .filter_map(|entry| entry.ok())
269                            .filter(|entry| entry.file_name().to_string_lossy().contains(&pattern))
270                            .collect();
271
272                        // Sort for consistent loading order
273                        files.sort_by_key(|e| e.file_name());
274
275                        for entry in files {
276                            let file_path = entry.path();
277                            let format = Self::detect_format(&file_path.to_string_lossy());
278                            config = config.add_source(
279                                File::from(file_path.clone()).format(format).required(false),
280                            );
281                            sources.push(file_path.to_string_lossy().to_string());
282                        }
283                    }
284                }
285            }
286        }
287
288        // Always add the main environment source last (highest priority)
289        config = config.add_source(
290            Environment::with_prefix(&self.env_prefix)
291                .prefix_separator("_")
292                .separator("__"),
293        );
294        sources.push(format!("env:{}", self.env_prefix));
295
296        let built_config = config
297            .build()
298            .map_err(|e| AuthError::config(format!("Failed to build configuration: {e}")))?;
299
300        Ok(ConfigManager {
301            config: built_config,
302            sources,
303            env_prefix: self.env_prefix,
304        })
305    }
306
307    /// Detect file format from extension
308    fn detect_format(path: &str) -> FileFormat {
309        let path = Path::new(path);
310        match path.extension().and_then(|s| s.to_str()) {
311            Some("toml") => FileFormat::Toml,
312            Some("yaml") | Some("yml") => FileFormat::Yaml,
313            Some("json") => FileFormat::Json,
314            Some("ron") => FileFormat::Ron,
315            Some("ini") => FileFormat::Ini,
316            _ => FileFormat::Toml, // Default to TOML
317        }
318    }
319}
320
321impl ConfigManager {
322    /// Create a new configuration manager with default settings
323    pub fn new() -> Result<Self> {
324        ConfigBuilder::new().build()
325    }
326
327    /// Create a configuration manager for a specific application
328    pub fn for_application(app_name: &str) -> Result<Self> {
329        ConfigBuilder::new()
330            .with_env_prefix(format!("{}_AUTH_FRAMEWORK", app_name.to_uppercase()))
331            .add_file(format!("{}.toml", app_name), false)
332            .add_file(format!("config/{}.toml", app_name), false)
333            .build()
334    }
335
336    /// Get the auth framework settings
337    pub fn get_auth_settings(&self) -> Result<AuthFrameworkSettings> {
338        self.config
339            .clone()
340            .try_deserialize::<AuthFrameworkSettings>()
341            .map_err(|e| AuthError::config(format!("Failed to deserialize auth settings: {e}")))
342    }
343
344    /// Get a specific configuration section
345    pub fn get_section<T>(&self, section: &str) -> Result<T>
346    where
347        T: for<'de> Deserialize<'de>,
348    {
349        self.config
350            .get::<T>(section)
351            .map_err(|e| AuthError::config(format!("Failed to get section '{}': {e}", section)))
352    }
353
354    /// Get a configuration value by key
355    pub fn get<T>(&self, key: &str) -> Result<T>
356    where
357        T: for<'de> Deserialize<'de>,
358    {
359        self.config
360            .get::<T>(key)
361            .map_err(|e| AuthError::config(format!("Failed to get key '{}': {e}", key)))
362    }
363
364    /// Get a configuration value with a default
365    pub fn get_or_default<T>(&self, key: &str, default: T) -> T
366    where
367        T: for<'de> Deserialize<'de>,
368    {
369        self.config.get::<T>(key).unwrap_or(default)
370    }
371
372    /// Check if a key exists in the configuration
373    pub fn has_key(&self, key: &str) -> bool {
374        self.config.get::<config::Value>(key).is_ok()
375    }
376
377    /// Get all keys with a specific prefix
378    pub fn get_keys_with_prefix(&self, prefix: &str) -> Vec<String> {
379        // Try to deserialize the prefix as a table to enumerate its keys
380        if let Ok(table) = self.config.get_table(prefix) {
381            table.keys().map(|k| format!("{}.{}", prefix, k)).collect()
382        } else {
383            Vec::new()
384        }
385    }
386
387    /// Get configuration sources used
388    pub fn sources(&self) -> &[String] {
389        &self.sources
390    }
391
392    /// Get the environment prefix
393    pub fn env_prefix(&self) -> &str {
394        &self.env_prefix
395    }
396
397    /// Validate the configuration
398    pub fn validate(&self) -> Result<()> {
399        let auth_config = self.get_auth_settings()?;
400        auth_config.auth.validate()
401    }
402
403    /// Create a nested configuration manager for a subsection
404    pub fn section(&self, section: &str) -> Result<ConfigManager> {
405        let section_config = self
406            .config
407            .get::<HashMap<String, config::Value>>(section)
408            .map_err(|e| AuthError::config(format!("Failed to get section '{}': {e}", section)))?;
409
410        let mut config_builder = Config::builder();
411        for (key, value) in section_config {
412            config_builder = config_builder
413                .set_override(&key, value)
414                .map_err(|e| AuthError::config(format!("Failed to set override: {e}")))?;
415        }
416
417        let built_config = config_builder
418            .build()
419            .map_err(|e| AuthError::config(format!("Failed to build section config: {e}")))?;
420
421        Ok(ConfigManager {
422            config: built_config,
423            sources: vec![format!("section:{}", section)],
424            env_prefix: format!("{}_{}", self.env_prefix, section.to_uppercase()),
425        })
426    }
427
428    /// Merge with another configuration (other takes precedence)
429    pub fn merge(self, other: ConfigManager) -> Result<ConfigManager> {
430        let mut sources = self.sources;
431        sources.extend(other.sources);
432
433        // For simplicity, we'll use the other's config as the primary
434        // In a real implementation, we'd properly merge the configurations
435        Ok(ConfigManager {
436            config: other.config,
437            sources,
438            env_prefix: other.env_prefix,
439        })
440    }
441
442    /// Export the current configuration to a specific format
443    pub fn export_to_string(&self, format: FileFormat) -> Result<String> {
444        let settings = self.get_auth_settings()?;
445
446        match format {
447            FileFormat::Toml => toml::to_string_pretty(&settings)
448                .map_err(|e| AuthError::config(format!("Failed to serialize to TOML: {e}"))),
449            FileFormat::Yaml => serde_yaml::to_string(&settings)
450                .map_err(|e| AuthError::config(format!("Failed to serialize to YAML: {e}"))),
451            FileFormat::Json => serde_json::to_string_pretty(&settings)
452                .map_err(|e| AuthError::config(format!("Failed to serialize to JSON: {e}"))),
453            _ => Err(AuthError::config("Unsupported export format")),
454        }
455    }
456}
457
458impl Default for ConfigManager {
459    /// Creates a `ConfigManager` with default settings.
460    ///
461    /// # Panics
462    /// Panics if the default configuration cannot be built (e.g. config files are
463    /// malformed). Prefer `ConfigManager::new()` which returns `Result` for
464    /// graceful error handling in production code.
465    fn default() -> Self {
466        Self::new().expect(
467            "ConfigManager::default(): failed to build default configuration. \
468             Use ConfigManager::new() for Result-based error handling.",
469        )
470    }
471}
472
473impl Default for SessionSettings {
474    fn default() -> Self {
475        Self {
476            max_concurrent_sessions: Some(5),
477            cleanup_interval: Some(3600), // 1 hour
478            enable_device_tracking: Some(true),
479            cookie: Some(SessionCookieSettings::default()),
480        }
481    }
482}
483
484impl Default for SessionCookieSettings {
485    fn default() -> Self {
486        Self {
487            name: Some("auth_session".to_string()),
488            domain: None,
489            path: Some("/".to_string()),
490            max_age: Some(86400), // 24 hours
491            http_only: Some(true),
492        }
493    }
494}
495
496/// Helper trait for easy integration into parent application configurations
497pub trait ConfigIntegration {
498    /// Get the auth framework configuration section
499    fn auth_framework(&self) -> Option<&AuthFrameworkSettings>;
500
501    /// Get the auth framework configuration section (mutable)
502    fn auth_framework_mut(&mut self) -> Option<&mut AuthFrameworkSettings>;
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    #[test]
510    fn test_config_builder_basic() {
511        let config = ConfigBuilder::new()
512            .with_env_prefix("TEST")
513            .build()
514            .expect("Failed to build config");
515
516        assert_eq!(config.env_prefix(), "TEST");
517    }
518
519    #[test]
520    fn test_config_manager_default() {
521        // Since ConfigManager::new() tries to load from files/env which may not exist,
522        // we'll test the default settings directly instead
523        let settings = AuthFrameworkSettings::default();
524
525        // Should have default values
526        assert!(!settings.auth.enable_multi_factor);
527        assert_eq!(settings.auth.token_lifetime.as_secs(), 3600); // 1 hour
528        assert_eq!(settings.auth.issuer, "auth-framework");
529    }
530
531    #[test]
532    fn test_application_specific_config() {
533        let config = ConfigManager::for_application("myapp").expect("Failed to create app config");
534
535        assert_eq!(config.env_prefix(), "MYAPP_AUTH_FRAMEWORK");
536    }
537
538    #[test]
539    fn test_config_sources() {
540        let config = ConfigBuilder::new()
541            .add_file("nonexistent.toml", false)
542            .add_env_source("TEST")
543            .build()
544            .expect("Failed to build config");
545
546        let sources = config.sources();
547        assert!(!sources.is_empty());
548    }
549}