Skip to main content

symbi_runtime/secrets/
config.rs

1//! Configuration structures for secrets backends
2//!
3//! This module defines the configuration structures that can be deserialized
4//! from symbiont.toml for different secrets backend types.
5
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8use std::time::Duration;
9
10/// Configuration for secrets management
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct SecretsConfig {
13    /// The secrets backend configuration
14    #[serde(flatten)]
15    pub backend: SecretsBackend,
16    /// Common configuration options
17    #[serde(default)]
18    pub common: CommonSecretsConfig,
19}
20
21/// Enumeration of supported secrets backends
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(tag = "type", rename_all = "lowercase")]
24pub enum SecretsBackend {
25    /// HashiCorp Vault backend
26    Vault(VaultConfig),
27    /// File-based secrets backend
28    File(FileConfig),
29}
30
31/// Common configuration options for all secrets backends
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct CommonSecretsConfig {
34    /// Timeout for secrets operations (in seconds)
35    #[serde(default = "default_timeout")]
36    pub timeout_seconds: u64,
37    /// Maximum number of retry attempts
38    #[serde(default = "default_max_retries")]
39    pub max_retries: u32,
40    /// Enable caching of secrets
41    #[serde(default = "default_enable_cache")]
42    pub enable_cache: bool,
43    /// Cache TTL in seconds
44    #[serde(default = "default_cache_ttl")]
45    pub cache_ttl_seconds: u64,
46    /// Audit configuration for secret operations
47    pub audit: Option<super::auditing::AuditConfig>,
48}
49
50impl Default for CommonSecretsConfig {
51    fn default() -> Self {
52        Self {
53            timeout_seconds: default_timeout(),
54            max_retries: default_max_retries(),
55            enable_cache: default_enable_cache(),
56            cache_ttl_seconds: default_cache_ttl(),
57            audit: None,
58        }
59    }
60}
61
62/// Configuration for HashiCorp Vault backend
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct VaultConfig {
65    /// Vault server URL
66    pub url: String,
67    /// Authentication method configuration
68    pub auth: VaultAuthConfig,
69    /// Vault namespace (optional)
70    pub namespace: Option<String>,
71    /// Default mount path for KV secrets engine
72    #[serde(default = "default_vault_mount")]
73    pub mount_path: String,
74    /// API version for KV secrets engine (v1 or v2)
75    #[serde(default = "default_vault_api_version")]
76    pub api_version: String,
77    /// TLS configuration
78    #[serde(default)]
79    pub tls: VaultTlsConfig,
80    /// Connection pool settings
81    #[serde(default)]
82    pub connection: VaultConnectionConfig,
83}
84
85/// Vault authentication configuration
86#[derive(Clone, Serialize, Deserialize)]
87#[serde(tag = "method", rename_all = "lowercase")]
88pub enum VaultAuthConfig {
89    /// Token-based authentication
90    Token {
91        /// Vault token (can be from environment variable)
92        token: String,
93    },
94    /// AppRole authentication
95    AppRole {
96        /// Role ID
97        role_id: String,
98        /// Secret ID
99        secret_id: String,
100        /// Mount path for AppRole auth
101        #[serde(default = "default_approle_mount")]
102        mount_path: String,
103    },
104    /// Kubernetes authentication
105    Kubernetes {
106        /// Service account token path
107        #[serde(default = "default_k8s_token_path")]
108        token_path: String,
109        /// Kubernetes role
110        role: String,
111        /// Mount path for Kubernetes auth
112        #[serde(default = "default_k8s_mount")]
113        mount_path: String,
114    },
115    /// AWS IAM authentication
116    Aws {
117        /// AWS region
118        region: String,
119        /// Vault role name
120        role: String,
121        /// Mount path for AWS auth
122        #[serde(default = "default_aws_mount")]
123        mount_path: String,
124    },
125}
126
127impl std::fmt::Debug for VaultAuthConfig {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        match self {
130            VaultAuthConfig::Token { .. } => f
131                .debug_struct("VaultAuthConfig::Token")
132                .field("token", &"[REDACTED]")
133                .finish(),
134            VaultAuthConfig::AppRole {
135                role_id,
136                mount_path,
137                ..
138            } => f
139                .debug_struct("VaultAuthConfig::AppRole")
140                .field("role_id", role_id)
141                .field("secret_id", &"[REDACTED]")
142                .field("mount_path", mount_path)
143                .finish(),
144            VaultAuthConfig::Kubernetes {
145                token_path,
146                role,
147                mount_path,
148            } => f
149                .debug_struct("VaultAuthConfig::Kubernetes")
150                .field("token_path", token_path)
151                .field("role", role)
152                .field("mount_path", mount_path)
153                .finish(),
154            VaultAuthConfig::Aws {
155                region,
156                role,
157                mount_path,
158            } => f
159                .debug_struct("VaultAuthConfig::Aws")
160                .field("region", region)
161                .field("role", role)
162                .field("mount_path", mount_path)
163                .finish(),
164        }
165    }
166}
167
168// Display must never print credential-bearing fields, so mirror the Debug
169// behaviour. Without an explicit impl, some logging crates fall back to
170// auto-generated Display via `Debug`, but some call sites format enums with
171// `{}` expecting a short human label; provide one that never leaks.
172impl std::fmt::Display for VaultAuthConfig {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        match self {
175            VaultAuthConfig::Token { .. } => write!(f, "VaultAuthConfig::Token(redacted)"),
176            VaultAuthConfig::AppRole { role_id, .. } => {
177                write!(f, "VaultAuthConfig::AppRole(role_id={})", role_id)
178            }
179            VaultAuthConfig::Kubernetes { role, .. } => {
180                write!(f, "VaultAuthConfig::Kubernetes(role={})", role)
181            }
182            VaultAuthConfig::Aws { region, role, .. } => {
183                write!(f, "VaultAuthConfig::Aws(region={}, role={})", region, role)
184            }
185        }
186    }
187}
188
189#[cfg(test)]
190mod vault_auth_redaction_tests {
191    use super::*;
192
193    #[test]
194    fn debug_redacts_token() {
195        let cfg = VaultAuthConfig::Token {
196            token: "s.VERY_SECRET_TOKEN_1234".to_string(),
197        };
198        let rendered = format!("{:?}", cfg);
199        assert!(!rendered.contains("VERY_SECRET_TOKEN"));
200        assert!(rendered.contains("[REDACTED]"));
201    }
202
203    #[test]
204    fn debug_redacts_approle_secret_id() {
205        let cfg = VaultAuthConfig::AppRole {
206            role_id: "pub-role".to_string(),
207            secret_id: "THIS_IS_VERY_SECRET".to_string(),
208            mount_path: "approle".to_string(),
209        };
210        let rendered = format!("{:?}", cfg);
211        assert!(!rendered.contains("THIS_IS_VERY_SECRET"));
212        assert!(rendered.contains("pub-role"));
213        assert!(rendered.contains("[REDACTED]"));
214    }
215
216    #[test]
217    fn display_does_not_leak_token() {
218        let cfg = VaultAuthConfig::Token {
219            token: "s.DISPLAY_SHOULD_NOT_PRINT_THIS".to_string(),
220        };
221        let rendered = format!("{}", cfg);
222        assert!(!rendered.contains("DISPLAY_SHOULD_NOT_PRINT_THIS"));
223    }
224
225    #[test]
226    fn display_does_not_leak_approle_secret() {
227        let cfg = VaultAuthConfig::AppRole {
228            role_id: "pub-role".to_string(),
229            secret_id: "SECRET_ID_DO_NOT_LEAK".to_string(),
230            mount_path: "approle".to_string(),
231        };
232        let rendered = format!("{}", cfg);
233        assert!(!rendered.contains("SECRET_ID_DO_NOT_LEAK"));
234        assert!(rendered.contains("pub-role"));
235    }
236}
237
238/// Vault TLS configuration
239#[derive(Debug, Clone, Serialize, Deserialize, Default)]
240pub struct VaultTlsConfig {
241    /// **DEPRECATED**. Historically allowed turning off Vault TLS
242    /// verification; now refused unconditionally by the Vault backend.
243    ///
244    /// The field is retained so existing TOML/YAML configs that still
245    /// contain `tls.skip_verify = false` keep parsing; any `true` value is
246    /// rejected at backend initialisation with an instruction to configure
247    /// `ca_cert` instead. A future release will remove the field entirely.
248    #[serde(default)]
249    pub skip_verify: bool,
250    /// Path to CA certificate file
251    pub ca_cert: Option<PathBuf>,
252    /// Path to client certificate file
253    pub client_cert: Option<PathBuf>,
254    /// Path to client private key file
255    pub client_key: Option<PathBuf>,
256}
257
258/// Vault connection configuration
259#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct VaultConnectionConfig {
261    /// Maximum number of connections in the pool
262    #[serde(default = "default_max_connections")]
263    pub max_connections: usize,
264    /// Connection timeout in seconds
265    #[serde(default = "default_connection_timeout")]
266    pub connection_timeout_seconds: u64,
267    /// Request timeout in seconds
268    #[serde(default = "default_request_timeout")]
269    pub request_timeout_seconds: u64,
270}
271
272impl Default for VaultConnectionConfig {
273    fn default() -> Self {
274        Self {
275            max_connections: default_max_connections(),
276            connection_timeout_seconds: default_connection_timeout(),
277            request_timeout_seconds: default_request_timeout(),
278        }
279    }
280}
281
282/// Configuration for file-based secrets backend
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct FileConfig {
285    /// Path to the secrets file or directory
286    pub path: PathBuf,
287    /// File format for secrets storage
288    #[serde(default = "default_file_format")]
289    pub format: FileFormat,
290    /// Encryption configuration
291    #[serde(default)]
292    pub encryption: FileEncryptionConfig,
293    /// File permissions (Unix only)
294    pub permissions: Option<u32>,
295    /// Watch for file changes and reload
296    #[serde(default)]
297    pub watch_for_changes: bool,
298    /// Backup configuration
299    #[serde(default)]
300    pub backup: FileBackupConfig,
301}
302
303/// Supported file formats for secrets storage
304#[derive(Debug, Clone, Serialize, Deserialize)]
305#[serde(rename_all = "lowercase")]
306pub enum FileFormat {
307    /// JSON format
308    Json,
309    /// YAML format
310    Yaml,
311    /// TOML format
312    Toml,
313    /// Plain text (key=value pairs)
314    Env,
315}
316
317/// File encryption configuration
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct FileEncryptionConfig {
320    /// Enable encryption of secrets file
321    #[serde(default)]
322    pub enabled: bool,
323    /// Encryption algorithm
324    #[serde(default = "default_encryption_algorithm")]
325    pub algorithm: String,
326    /// Key derivation function
327    #[serde(default = "default_kdf")]
328    pub kdf: String,
329    /// Key provider configuration
330    #[serde(default)]
331    pub key: FileKeyConfig,
332}
333
334/// Configuration for encryption key retrieval
335#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct FileKeyConfig {
337    /// Key provider type
338    #[serde(default = "default_key_provider")]
339    pub provider: String,
340    /// Environment variable containing encryption key (for 'env' provider)
341    pub env_var: Option<String>,
342    /// Keychain service name (for 'os_keychain' provider)
343    pub service: Option<String>,
344    /// Keychain account name (for 'os_keychain' provider)
345    pub account: Option<String>,
346    /// Path to key file (for 'file' provider)
347    pub file_path: Option<PathBuf>,
348}
349
350impl Default for FileKeyConfig {
351    fn default() -> Self {
352        Self {
353            provider: default_key_provider(),
354            env_var: None,
355            service: None,
356            account: None,
357            file_path: None,
358        }
359    }
360}
361
362impl Default for FileEncryptionConfig {
363    fn default() -> Self {
364        Self {
365            enabled: false,
366            algorithm: default_encryption_algorithm(),
367            kdf: default_kdf(),
368            key: FileKeyConfig::default(),
369        }
370    }
371}
372
373/// File backup configuration
374#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct FileBackupConfig {
376    /// Enable automatic backups
377    #[serde(default)]
378    pub enabled: bool,
379    /// Directory for backup files
380    pub backup_dir: Option<PathBuf>,
381    /// Maximum number of backup files to keep
382    #[serde(default = "default_max_backups")]
383    pub max_backups: usize,
384    /// Create backup before modifications
385    #[serde(default = "default_backup_before_write")]
386    pub backup_before_write: bool,
387}
388
389impl Default for FileBackupConfig {
390    fn default() -> Self {
391        Self {
392            enabled: false,
393            backup_dir: None,
394            max_backups: default_max_backups(),
395            backup_before_write: default_backup_before_write(),
396        }
397    }
398}
399
400// Default value functions
401fn default_timeout() -> u64 {
402    30
403}
404fn default_max_retries() -> u32 {
405    3
406}
407fn default_enable_cache() -> bool {
408    true
409}
410fn default_cache_ttl() -> u64 {
411    300
412}
413fn default_vault_mount() -> String {
414    "secret".to_string()
415}
416fn default_vault_api_version() -> String {
417    "v2".to_string()
418}
419fn default_approle_mount() -> String {
420    "approle".to_string()
421}
422fn default_k8s_token_path() -> String {
423    "/var/run/secrets/kubernetes.io/serviceaccount/token".to_string()
424}
425fn default_k8s_mount() -> String {
426    "kubernetes".to_string()
427}
428fn default_aws_mount() -> String {
429    "aws".to_string()
430}
431fn default_max_connections() -> usize {
432    10
433}
434fn default_connection_timeout() -> u64 {
435    10
436}
437fn default_request_timeout() -> u64 {
438    30
439}
440fn default_file_format() -> FileFormat {
441    FileFormat::Json
442}
443fn default_encryption_algorithm() -> String {
444    "AES-256-GCM".to_string()
445}
446fn default_kdf() -> String {
447    "PBKDF2".to_string()
448}
449fn default_key_provider() -> String {
450    "env".to_string()
451}
452fn default_max_backups() -> usize {
453    5
454}
455fn default_backup_before_write() -> bool {
456    true
457}
458
459impl SecretsConfig {
460    /// Create a Vault configuration with token authentication
461    pub fn vault_with_token(url: String, token: String) -> Self {
462        Self {
463            backend: SecretsBackend::Vault(VaultConfig {
464                url,
465                auth: VaultAuthConfig::Token { token },
466                namespace: None,
467                mount_path: default_vault_mount(),
468                api_version: default_vault_api_version(),
469                tls: VaultTlsConfig::default(),
470                connection: VaultConnectionConfig::default(),
471            }),
472            common: CommonSecretsConfig::default(),
473        }
474    }
475
476    /// Create a file-based configuration with JSON format
477    pub fn file_json(path: PathBuf) -> Self {
478        Self {
479            backend: SecretsBackend::File(FileConfig {
480                path,
481                format: FileFormat::Json,
482                encryption: FileEncryptionConfig::default(),
483                permissions: Some(0o600),
484                watch_for_changes: false,
485                backup: FileBackupConfig::default(),
486            }),
487            common: CommonSecretsConfig::default(),
488        }
489    }
490
491    /// Get the backend type as a string
492    pub fn backend_type(&self) -> &'static str {
493        match &self.backend {
494            SecretsBackend::Vault(_) => "vault",
495            SecretsBackend::File(_) => "file",
496        }
497    }
498
499    /// Get timeout as Duration
500    pub fn timeout(&self) -> Duration {
501        Duration::from_secs(self.common.timeout_seconds)
502    }
503
504    /// Get cache TTL as Duration
505    pub fn cache_ttl(&self) -> Duration {
506        Duration::from_secs(self.common.cache_ttl_seconds)
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    #[test]
515    fn test_vault_config_creation() {
516        let config = SecretsConfig::vault_with_token(
517            "https://vault.example.com".to_string(),
518            "hvs.token123".to_string(),
519        );
520
521        assert_eq!(config.backend_type(), "vault");
522        if let SecretsBackend::Vault(vault_config) = &config.backend {
523            assert_eq!(vault_config.url, "https://vault.example.com");
524            if let VaultAuthConfig::Token { token } = &vault_config.auth {
525                assert_eq!(token, "hvs.token123");
526            } else {
527                panic!("Expected token auth");
528            }
529        } else {
530            panic!("Expected vault backend");
531        }
532    }
533
534    #[test]
535    fn test_file_config_creation() {
536        let path = PathBuf::from("/etc/secrets/app.json");
537        let config = SecretsConfig::file_json(path.clone());
538
539        assert_eq!(config.backend_type(), "file");
540        if let SecretsBackend::File(file_config) = &config.backend {
541            assert_eq!(file_config.path, path);
542            assert!(matches!(file_config.format, FileFormat::Json));
543        } else {
544            panic!("Expected file backend");
545        }
546    }
547
548    #[test]
549    fn test_common_config_defaults() {
550        let config = CommonSecretsConfig::default();
551        assert_eq!(config.timeout_seconds, 30);
552        assert_eq!(config.max_retries, 3);
553        assert!(config.enable_cache);
554        assert_eq!(config.cache_ttl_seconds, 300);
555    }
556
557    #[test]
558    fn test_timeout_conversion() {
559        let config = SecretsConfig::file_json(PathBuf::from("/test"));
560        assert_eq!(config.timeout(), Duration::from_secs(30));
561        assert_eq!(config.cache_ttl(), Duration::from_secs(300));
562    }
563}