Skip to main content

hyperi_rustlib/secrets/
mod.rs

1// Project:   hyperi-rustlib
2// File:      src/secrets/mod.rs
3// Purpose:   Secrets management with multi-provider support and caching
4// Language:  Rust
5//
6// License:   FSL-1.1-ALv2
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Secrets management with multi-provider support and resilient caching.
10//!
11//! Provides a unified interface for loading certificates, credentials, and other
12//! sensitive data from multiple sources with automatic caching for resilience.
13//!
14//! ## Providers
15//!
16//! - **File**: Local filesystem (always available)
17//! - **OpenBao/Vault**: HashiCorp Vault API (requires `secrets-vault` feature)
18//! - **AWS Secrets Manager**: AWS SDK (requires `secrets-aws` feature)
19//!
20//! ## Features
21//!
22//! - Multi-provider support with unified API
23//! - Local disk cache with TTL for resilience
24//! - Stale cache fallback when providers are unavailable
25//! - Background refresh for proactive secret renewal
26//! - Rotation callbacks for application notification
27//!
28//! ## Example
29//!
30//! ```rust,no_run
31//! use hyperi_rustlib::secrets::{SecretsManager, SecretsConfig, SecretSource};
32//!
33//! #[tokio::main]
34//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
35//!     // Simple file-based usage
36//!     let secrets = SecretsManager::new(SecretsConfig::default())?;
37//!     let cert = secrets.get_file("/etc/ssl/cert.pem").await?;
38//!
39//!     // With named sources
40//!     let config = SecretsConfig {
41//!         sources: vec![
42//!             ("tls_cert".into(), SecretSource::File { path: "/etc/ssl/cert.pem".into() }),
43//!         ].into_iter().collect(),
44//!         ..Default::default()
45//!     };
46//!     let secrets = SecretsManager::new(config)?;
47//!     let cert = secrets.get("tls_cert").await?;
48//!
49//!     Ok(())
50//! }
51//! ```
52
53mod cache;
54mod crypto;
55mod error;
56mod provider;
57mod types;
58
59#[cfg(feature = "secrets-vault")]
60mod vault;
61
62#[cfg(feature = "secrets-aws")]
63mod aws;
64
65pub use cache::SecretCache;
66pub use error::{SecretsError, SecretsResult};
67pub use provider::{FileProvider, SecretProvider};
68pub use types::{
69    CacheConfig, RotationEvent, SecretMetadata, SecretSource, SecretValue, SecretsConfig,
70};
71
72#[cfg(feature = "secrets-vault")]
73pub use vault::{OpenBaoAuth, OpenBaoConfig, OpenBaoProvider};
74
75#[cfg(feature = "secrets-aws")]
76pub use aws::{AwsConfig, AwsProvider};
77
78use std::collections::HashMap;
79use std::sync::Arc;
80
81use parking_lot::RwLock;
82use tokio::sync::broadcast;
83use tracing::{debug, info, warn};
84
85/// Secrets manager that coordinates providers and caching.
86pub struct SecretsManager {
87    config: SecretsConfig,
88    cache: Arc<RwLock<SecretCache>>,
89    file_provider: FileProvider,
90    #[cfg(feature = "secrets-vault")]
91    vault_provider: Option<OpenBaoProvider>,
92    #[cfg(feature = "secrets-aws")]
93    aws_provider: Option<AwsProvider>,
94    rotation_tx: broadcast::Sender<RotationEvent>,
95}
96
97impl SecretsManager {
98    /// Create a new secrets manager from configuration.
99    ///
100    /// # Errors
101    ///
102    /// Returns an error if provider initialization fails.
103    pub fn new(config: SecretsConfig) -> SecretsResult<Self> {
104        let cache = SecretCache::new(&config.cache)?;
105
106        #[cfg(feature = "secrets-vault")]
107        let vault_provider = config
108            .openbao
109            .as_ref()
110            .map(OpenBaoProvider::new)
111            .transpose()?;
112
113        #[cfg(feature = "secrets-aws")]
114        let aws_provider = config.aws.as_ref().map(AwsProvider::new).transpose()?;
115
116        let (rotation_tx, _) = broadcast::channel(16);
117
118        Ok(Self {
119            config,
120            cache: Arc::new(RwLock::new(cache)),
121            file_provider: FileProvider::new(),
122            #[cfg(feature = "secrets-vault")]
123            vault_provider,
124            #[cfg(feature = "secrets-aws")]
125            aws_provider,
126            rotation_tx,
127        })
128    }
129
130    /// Get a secret by name (from configured sources).
131    ///
132    /// Looks up the named source in configuration and fetches from the appropriate provider.
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if the secret cannot be fetched.
137    pub async fn get(&self, name: &str) -> SecretsResult<SecretValue> {
138        let source = self
139            .config
140            .sources
141            .get(name)
142            .ok_or_else(|| SecretsError::NotFound(format!("unknown secret source: {name}")))?
143            .clone();
144
145        self.get_from_source(name, &source).await
146    }
147
148    /// Get a secret directly from a file path.
149    ///
150    /// This bypasses the configured sources and reads directly from the filesystem.
151    ///
152    /// # Errors
153    ///
154    /// Returns an error if the file cannot be read.
155    pub async fn get_file(&self, path: &str) -> SecretsResult<SecretValue> {
156        // Use path as cache key
157        let cache_key = format!("file:{path}");
158
159        // Check cache first
160        if let Some(cached) = self.cache.read().get(&cache_key) {
161            debug!(path = %path, "Secret loaded from cache");
162            #[cfg(feature = "metrics")]
163            metrics::counter!("dfe_secrets_cache_hits_total").increment(1);
164            return Ok(cached);
165        }
166
167        #[cfg(feature = "metrics")]
168        metrics::counter!("dfe_secrets_cache_misses_total").increment(1);
169
170        // Fetch from file
171        let value = self.file_provider.get(path).await?;
172
173        #[cfg(feature = "metrics")]
174        metrics::counter!("dfe_secrets_fetch_total").increment(1);
175
176        // Update cache
177        if let Err(e) = self.cache.write().set(&cache_key, &value) {
178            warn!(error = %e, "Failed to cache secret");
179        }
180
181        Ok(value)
182    }
183
184    /// Get a secret from a specific source.
185    async fn get_from_source(
186        &self,
187        cache_key: &str,
188        source: &SecretSource,
189    ) -> SecretsResult<SecretValue> {
190        // Check cache first
191        if let Some(cached) = self.cache.read().get(cache_key) {
192            debug!(key = %cache_key, "Secret loaded from cache");
193            #[cfg(feature = "metrics")]
194            metrics::counter!("dfe_secrets_cache_hits_total").increment(1);
195            return Ok(cached);
196        }
197
198        #[cfg(feature = "metrics")]
199        metrics::counter!("dfe_secrets_cache_misses_total").increment(1);
200
201        // Fetch from provider
202        let result = match source {
203            SecretSource::File { path } => self.file_provider.get(path).await,
204
205            #[cfg(feature = "secrets-vault")]
206            SecretSource::OpenBao { path, key } => {
207                let provider = self
208                    .vault_provider
209                    .as_ref()
210                    .ok_or_else(|| SecretsError::ProviderNotConfigured("openbao".into()))?;
211                provider.get(path, key).await
212            }
213
214            #[cfg(feature = "secrets-aws")]
215            SecretSource::Aws { secret_id, key } => {
216                let provider = self
217                    .aws_provider
218                    .as_ref()
219                    .ok_or_else(|| SecretsError::ProviderNotConfigured("aws".into()))?;
220                provider.get(secret_id, key.as_deref()).await
221            }
222
223            #[cfg(not(feature = "secrets-vault"))]
224            SecretSource::OpenBao { .. } => {
225                return Err(SecretsError::ProviderNotConfigured(
226                    "openbao (enable secrets-vault feature)".into(),
227                ));
228            }
229
230            #[cfg(not(feature = "secrets-aws"))]
231            SecretSource::Aws { .. } => {
232                return Err(SecretsError::ProviderNotConfigured(
233                    "aws (enable secrets-aws feature)".into(),
234                ));
235            }
236        };
237
238        #[cfg(feature = "metrics")]
239        metrics::counter!("dfe_secrets_fetch_total").increment(1);
240
241        match result {
242            Ok(value) => {
243                // Update cache
244                if let Err(e) = self.cache.write().set(cache_key, &value) {
245                    warn!(key = %cache_key, error = %e, "Failed to cache secret");
246                }
247                Ok(value)
248            }
249            Err(e) => {
250                // Try stale cache on provider failure
251                if let Some(stale) = self.cache.read().get_stale(cache_key) {
252                    warn!(
253                        key = %cache_key,
254                        error = %e,
255                        "Provider failed, using stale cached secret"
256                    );
257                    return Ok(stale);
258                }
259                Err(e)
260            }
261        }
262    }
263
264    /// Subscribe to rotation events.
265    ///
266    /// Returns a receiver that will receive events when secrets are rotated.
267    #[must_use]
268    pub fn subscribe_rotations(&self) -> broadcast::Receiver<RotationEvent> {
269        self.rotation_tx.subscribe()
270    }
271
272    /// Refresh all configured secrets from their providers.
273    ///
274    /// This is useful for proactive refresh before TTL expiry.
275    ///
276    /// # Errors
277    ///
278    /// Returns an error if any secret refresh fails (but continues with others).
279    pub async fn refresh_all(&self) -> SecretsResult<()> {
280        let mut errors = Vec::new();
281
282        for (name, source) in &self.config.sources {
283            // Get old version for rotation detection
284            let old_version = self
285                .cache
286                .read()
287                .get(name)
288                .and_then(|v| v.metadata.version.clone());
289
290            match self.get_from_source(name, source).await {
291                Ok(new_value) => {
292                    // Check for rotation
293                    if let Some(ref new_version) = new_value.metadata.version
294                        && old_version.as_ref() != Some(new_version)
295                    {
296                        let event = RotationEvent {
297                            name: name.clone(),
298                            old_version,
299                            new_version: new_version.clone(),
300                            rotated_at: std::time::SystemTime::now(),
301                        };
302                        let _ = self.rotation_tx.send(event);
303                        info!(name = %name, new_version = %new_version, "Secret rotated");
304                    }
305                }
306                Err(e) => {
307                    warn!(name = %name, error = %e, "Failed to refresh secret");
308                    errors.push(format!("{name}: {e}"));
309                }
310            }
311        }
312
313        if errors.is_empty() {
314            Ok(())
315        } else {
316            Err(SecretsError::RefreshFailed(errors.join("; ")))
317        }
318    }
319
320    /// Check health of all configured providers.
321    ///
322    /// Returns a map of provider names to their health status.
323    pub async fn health_check(&self) -> HashMap<String, bool> {
324        let mut health = HashMap::new();
325
326        // File provider is always healthy
327        health.insert("file".into(), true);
328
329        #[cfg(feature = "secrets-vault")]
330        if let Some(ref provider) = self.vault_provider {
331            health.insert("openbao".into(), provider.health_check().await.is_ok());
332        }
333
334        #[cfg(feature = "secrets-aws")]
335        if let Some(ref provider) = self.aws_provider {
336            health.insert("aws".into(), provider.health_check().await.is_ok());
337        }
338
339        health
340    }
341
342    /// Clear all cached secrets.
343    pub fn clear_cache(&self) {
344        self.cache.write().clear();
345    }
346
347    /// Get cache statistics.
348    #[must_use]
349    pub fn cache_stats(&self) -> CacheStats {
350        self.cache.read().stats()
351    }
352}
353
354/// Cache statistics.
355#[derive(Debug, Clone, Default)]
356pub struct CacheStats {
357    /// Number of entries in memory cache.
358    pub memory_entries: usize,
359    /// Number of entries in disk cache.
360    pub disk_entries: usize,
361    /// Total cache hits.
362    pub hits: u64,
363    /// Total cache misses.
364    pub misses: u64,
365    /// Total stale hits (fallback).
366    pub stale_hits: u64,
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn test_secrets_config_default() {
375        let config = SecretsConfig::default();
376        assert!(config.cache.enabled);
377        assert_eq!(config.cache.ttl_secs, 3600);
378        assert!(config.sources.is_empty());
379    }
380
381    #[test]
382    fn test_secrets_manager_new() {
383        let manager = SecretsManager::new(SecretsConfig::default());
384        assert!(manager.is_ok());
385    }
386
387    #[tokio::test]
388    async fn test_file_provider_missing_file() {
389        let manager = SecretsManager::new(SecretsConfig::default()).unwrap();
390        let result = manager.get_file("/nonexistent/path/secret.txt").await;
391        assert!(result.is_err());
392    }
393
394    #[tokio::test]
395    async fn test_file_provider_read_file() {
396        let temp_dir = tempfile::tempdir().unwrap();
397        let secret_path = temp_dir.path().join("test-secret.txt");
398        std::fs::write(&secret_path, "my-secret-value").unwrap();
399
400        let manager = SecretsManager::new(SecretsConfig::default()).unwrap();
401        let result = manager.get_file(secret_path.to_str().unwrap()).await;
402
403        assert!(result.is_ok());
404        let value = result.unwrap();
405        assert_eq!(value.as_str().unwrap(), "my-secret-value");
406    }
407
408    #[tokio::test]
409    async fn test_named_source_file() {
410        let temp_dir = tempfile::tempdir().unwrap();
411        let secret_path = temp_dir.path().join("api-key.txt");
412        std::fs::write(&secret_path, "super-secret-key").unwrap();
413
414        let config = SecretsConfig {
415            sources: [(
416                "api_key".into(),
417                SecretSource::File {
418                    path: secret_path.to_str().unwrap().into(),
419                },
420            )]
421            .into_iter()
422            .collect(),
423            ..Default::default()
424        };
425
426        let manager = SecretsManager::new(config).unwrap();
427        let value = manager.get("api_key").await.unwrap();
428        assert_eq!(value.as_str().unwrap(), "super-secret-key");
429    }
430
431    #[tokio::test]
432    async fn test_unknown_source() {
433        let manager = SecretsManager::new(SecretsConfig::default()).unwrap();
434        let result = manager.get("nonexistent").await;
435        assert!(matches!(result, Err(SecretsError::NotFound(_))));
436    }
437
438    #[tokio::test]
439    async fn test_health_check() {
440        let manager = SecretsManager::new(SecretsConfig::default()).unwrap();
441        let health = manager.health_check().await;
442        assert!(health.get("file").copied().unwrap_or(false));
443    }
444}