Skip to main content

symbi_runtime/secrets/
mod.rs

1//! Symbiont Secure Secrets Integration
2//!
3//! This module provides secure secrets management functionality for the Symbiont runtime,
4//! supporting multiple backend types including HashiCorp Vault and file-based storage.
5
6pub mod auditing;
7pub mod config;
8pub mod file_backend;
9pub mod vault_backend;
10
11use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use thiserror::Error;
15
16/// Errors that can occur during secrets operations
17#[derive(Debug, Error, Clone, Serialize, Deserialize)]
18pub enum SecretError {
19    /// Secret not found
20    #[error("Secret not found: {key}")]
21    NotFound { key: String },
22
23    /// Authentication failed with the secrets backend
24    #[error("Authentication failed: {message}")]
25    AuthenticationFailed { message: String },
26
27    /// Network or connection error
28    #[error("Connection error: {message}")]
29    ConnectionError { message: String },
30
31    /// Permission denied accessing secret
32    #[error("Permission denied accessing secret: {key}")]
33    PermissionDenied { key: String },
34
35    /// Backend-specific error
36    #[error("Backend error: {message}")]
37    BackendError { message: String },
38
39    /// Configuration error
40    #[error("Configuration error: {message}")]
41    ConfigurationError { message: String },
42
43    /// Parsing or deserialization error
44    #[error("Parse error: {message}")]
45    ParseError { message: String },
46
47    /// Timeout error
48    #[error("Operation timed out: {message}")]
49    Timeout { message: String },
50
51    /// Invalid secret key format
52    #[error("Invalid secret key format: {key}")]
53    InvalidKeyFormat { key: String },
54
55    /// Backend is unavailable
56    #[error("Backend unavailable: {backend}")]
57    BackendUnavailable { backend: String },
58
59    /// Rate limit exceeded
60    #[error("Rate limit exceeded: {message}")]
61    RateLimitExceeded { message: String },
62
63    /// Secret value is invalid or corrupted
64    #[error("Invalid secret value: {reason}")]
65    InvalidSecretValue { reason: String },
66
67    /// Encryption/decryption error
68    #[error("Crypto error: {message}")]
69    CryptoError { message: String },
70
71    /// IO error during file operations
72    #[error("IO error: {message}")]
73    IoError { message: String },
74
75    /// Operation not supported by this backend
76    #[error("Operation not supported by backend: {operation}")]
77    UnsupportedOperation { operation: String },
78
79    /// Audit logging failed in strict mode — operation blocked
80    #[error("Audit logging failed (strict mode): {message}")]
81    AuditFailed { message: String },
82}
83
84/// A secret value retrieved from a secrets backend
85#[derive(Clone, Serialize, Deserialize)]
86pub struct Secret {
87    /// The secret key/name
88    pub key: String,
89    /// The secret value (sensitive data)
90    pub value: String,
91    /// Optional metadata about the secret
92    pub metadata: Option<HashMap<String, String>>,
93    /// Timestamp when the secret was created/last modified
94    pub created_at: Option<String>,
95    /// Version of the secret (for versioned backends)
96    pub version: Option<String>,
97}
98
99impl Secret {
100    /// Create a new secret
101    pub fn new(key: String, value: String) -> Self {
102        Self {
103            key,
104            value,
105            metadata: None,
106            created_at: None,
107            version: None,
108        }
109    }
110
111    /// Create a new secret with metadata
112    pub fn with_metadata(key: String, value: String, metadata: HashMap<String, String>) -> Self {
113        Self {
114            key,
115            value,
116            metadata: Some(metadata),
117            created_at: None,
118            version: None,
119        }
120    }
121
122    /// Get the secret value
123    pub fn value(&self) -> &str {
124        &self.value
125    }
126
127    /// Get metadata for a specific key
128    pub fn get_metadata(&self, key: &str) -> Option<&String> {
129        self.metadata.as_ref()?.get(key)
130    }
131}
132
133impl std::fmt::Debug for Secret {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        f.debug_struct("Secret")
136            .field("key", &self.key)
137            .field("value", &"[REDACTED]")
138            .field("metadata", &self.metadata)
139            .field("created_at", &self.created_at)
140            .field("version", &self.version)
141            .finish()
142    }
143}
144
145/// Trait for secrets backend implementations
146#[async_trait]
147pub trait SecretStore: Send + Sync {
148    /// Retrieve a secret by key
149    ///
150    /// # Arguments
151    /// * `key` - The secret key to retrieve
152    ///
153    /// # Returns
154    /// * `Ok(Secret)` - The secret if found
155    /// * `Err(SecretError)` - Error if secret not found or other failure
156    async fn get_secret(&self, key: &str) -> Result<Secret, SecretError>;
157
158    /// List all available secret keys
159    ///
160    /// # Returns
161    /// * `Ok(Vec<String>)` - List of secret keys
162    /// * `Err(SecretError)` - Error if operation fails
163    async fn list_secrets(&self) -> Result<Vec<String>, SecretError>;
164}
165
166/// Metadata for a stored secret, including TTL for rotation.
167#[derive(Debug, Clone)]
168pub struct SecretMetadata {
169    /// When the secret was created.
170    pub created_at: std::time::SystemTime,
171    /// When the secret expires (None = never).
172    pub expires_at: Option<std::time::SystemTime>,
173    /// How long before expiry to start warning about rotation.
174    pub rotation_hint: Option<std::time::Duration>,
175}
176
177impl SecretMetadata {
178    /// Check if this secret has expired.
179    pub fn is_expired(&self) -> bool {
180        if let Some(expires) = self.expires_at {
181            std::time::SystemTime::now() > expires
182        } else {
183            false
184        }
185    }
186
187    /// Check if this secret should be rotated (within rotation hint window).
188    pub fn needs_rotation(&self) -> bool {
189        if let (Some(expires), Some(hint)) = (self.expires_at, self.rotation_hint) {
190            if let Ok(remaining) = expires.duration_since(std::time::SystemTime::now()) {
191                return remaining < hint;
192            }
193        }
194        false
195    }
196}
197
198/// Retrieve a secret with expiry checking.
199/// Returns an error if the secret has expired; logs a warning if rotation is due.
200pub async fn get_secret_checked(
201    store: &dyn SecretStore,
202    key: &str,
203    metadata: Option<&SecretMetadata>,
204) -> Result<Secret, SecretError> {
205    if let Some(meta) = metadata {
206        if meta.is_expired() {
207            return Err(SecretError::BackendError {
208                message: format!("Secret '{key}' has expired"),
209            });
210        }
211        if meta.needs_rotation() {
212            tracing::warn!(
213                secret = key,
214                "Secret is approaching expiry and should be rotated"
215            );
216        }
217    }
218    store.get_secret(key).await
219}
220
221/// Result type for secrets operations
222pub type SecretResult<T> = Result<T, SecretError>;
223
224// Re-export config types, backends, and auditing
225pub use auditing::*;
226pub use config::*;
227pub use file_backend::FileSecretStore;
228pub use vault_backend::VaultSecretStore;
229
230/// Create a new SecretStore instance based on configuration
231///
232/// # Arguments
233/// * `config` - The secrets configuration specifying the backend type and settings
234/// * `agent_id` - The agent ID for agent-specific secret namespacing (used by Vault backend)
235///
236/// # Returns
237/// * `Ok(Box<dyn SecretStore + Send + Sync>)` - The configured secret store
238/// * `Err(SecretError)` - Error if backend initialization fails
239pub async fn new_secret_store(
240    config: &SecretsConfig,
241    agent_id: &str,
242) -> Result<Box<dyn SecretStore + Send + Sync>, SecretError> {
243    // Create audit sink from configuration
244    let audit_sink = auditing::create_audit_sink(&config.common.audit);
245
246    match &config.backend {
247        SecretsBackend::File(file_config) => {
248            let store = FileSecretStore::new(file_config.clone(), audit_sink, agent_id.to_string())
249                .await
250                .map_err(|e| SecretError::ConfigurationError {
251                    message: format!("Failed to initialize file backend: {}", e),
252                })?;
253            Ok(Box::new(store))
254        }
255        SecretsBackend::Vault(vault_config) => {
256            let store =
257                VaultSecretStore::new(vault_config.clone(), agent_id.to_string(), audit_sink)
258                    .await
259                    .map_err(|e| SecretError::ConfigurationError {
260                        message: format!("Failed to initialize vault backend: {}", e),
261                    })?;
262            Ok(Box::new(store))
263        }
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn test_secret_creation() {
273        let secret = Secret::new("test_key".to_string(), "test_value".to_string());
274        assert_eq!(secret.key, "test_key");
275        assert_eq!(secret.value(), "test_value");
276        assert!(secret.metadata.is_none());
277    }
278
279    #[test]
280    fn test_secret_with_metadata() {
281        let mut metadata = HashMap::new();
282        metadata.insert("description".to_string(), "Test secret".to_string());
283
284        let secret =
285            Secret::with_metadata("test_key".to_string(), "test_value".to_string(), metadata);
286
287        assert_eq!(secret.key, "test_key");
288        assert_eq!(secret.value(), "test_value");
289        assert_eq!(
290            secret.get_metadata("description"),
291            Some(&"Test secret".to_string())
292        );
293    }
294
295    #[test]
296    fn test_secret_error_display() {
297        let error = SecretError::NotFound {
298            key: "missing_key".to_string(),
299        };
300        assert!(error.to_string().contains("Secret not found: missing_key"));
301    }
302
303    #[test]
304    fn test_secret_metadata_not_expired() {
305        let meta = SecretMetadata {
306            created_at: std::time::SystemTime::now(),
307            expires_at: Some(std::time::SystemTime::now() + std::time::Duration::from_secs(3600)),
308            rotation_hint: None,
309        };
310        assert!(!meta.is_expired());
311    }
312
313    #[test]
314    fn test_secret_metadata_expired() {
315        let meta = SecretMetadata {
316            created_at: std::time::SystemTime::now() - std::time::Duration::from_secs(7200),
317            expires_at: Some(std::time::SystemTime::now() - std::time::Duration::from_secs(1)),
318            rotation_hint: None,
319        };
320        assert!(meta.is_expired());
321    }
322
323    #[test]
324    fn test_secret_metadata_no_expiry() {
325        let meta = SecretMetadata {
326            created_at: std::time::SystemTime::now(),
327            expires_at: None,
328            rotation_hint: None,
329        };
330        assert!(!meta.is_expired());
331        assert!(!meta.needs_rotation());
332    }
333
334    #[test]
335    fn test_secret_metadata_needs_rotation() {
336        // Expires in 5 minutes, rotation hint is 10 minutes => needs rotation
337        let meta = SecretMetadata {
338            created_at: std::time::SystemTime::now() - std::time::Duration::from_secs(3600),
339            expires_at: Some(std::time::SystemTime::now() + std::time::Duration::from_secs(300)),
340            rotation_hint: Some(std::time::Duration::from_secs(600)),
341        };
342        assert!(!meta.is_expired());
343        assert!(meta.needs_rotation());
344    }
345
346    #[test]
347    fn test_secret_metadata_no_rotation_needed() {
348        // Expires in 2 hours, rotation hint is 10 minutes => no rotation needed
349        let meta = SecretMetadata {
350            created_at: std::time::SystemTime::now(),
351            expires_at: Some(std::time::SystemTime::now() + std::time::Duration::from_secs(7200)),
352            rotation_hint: Some(std::time::Duration::from_secs(600)),
353        };
354        assert!(!meta.is_expired());
355        assert!(!meta.needs_rotation());
356    }
357}