hyperi_rustlib/secrets/
mod.rs1mod 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
85pub 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 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 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 pub async fn get_file(&self, path: &str) -> SecretsResult<SecretValue> {
156 let cache_key = format!("file:{path}");
158
159 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 let value = self.file_provider.get(path).await?;
172
173 #[cfg(feature = "metrics")]
174 metrics::counter!("dfe_secrets_fetch_total").increment(1);
175
176 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 async fn get_from_source(
186 &self,
187 cache_key: &str,
188 source: &SecretSource,
189 ) -> SecretsResult<SecretValue> {
190 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 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 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 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 #[must_use]
268 pub fn subscribe_rotations(&self) -> broadcast::Receiver<RotationEvent> {
269 self.rotation_tx.subscribe()
270 }
271
272 pub async fn refresh_all(&self) -> SecretsResult<()> {
280 let mut errors = Vec::new();
281
282 for (name, source) in &self.config.sources {
283 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 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 pub async fn health_check(&self) -> HashMap<String, bool> {
324 let mut health = HashMap::new();
325
326 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 pub fn clear_cache(&self) {
344 self.cache.write().clear();
345 }
346
347 #[must_use]
349 pub fn cache_stats(&self) -> CacheStats {
350 self.cache.read().stats()
351 }
352}
353
354#[derive(Debug, Clone, Default)]
356pub struct CacheStats {
357 pub memory_entries: usize,
359 pub disk_entries: usize,
361 pub hits: u64,
363 pub misses: u64,
365 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}