mod cache;
mod error;
mod provider;
mod types;
#[cfg(feature = "secrets-vault")]
mod vault;
#[cfg(feature = "secrets-aws")]
mod aws;
pub use cache::SecretCache;
pub use error::{SecretsError, SecretsResult};
pub use provider::{FileProvider, SecretProvider};
pub use types::{
CacheConfig, RotationEvent, SecretMetadata, SecretSource, SecretValue, SecretsConfig,
};
#[cfg(feature = "secrets-vault")]
pub use vault::{OpenBaoAuth, OpenBaoConfig, OpenBaoProvider};
#[cfg(feature = "secrets-aws")]
pub use aws::{AwsConfig, AwsProvider};
use std::collections::HashMap;
use std::sync::Arc;
use parking_lot::RwLock;
use tokio::sync::broadcast;
use tracing::{debug, info, warn};
pub struct SecretsManager {
config: SecretsConfig,
cache: Arc<RwLock<SecretCache>>,
file_provider: FileProvider,
#[cfg(feature = "secrets-vault")]
vault_provider: Option<OpenBaoProvider>,
#[cfg(feature = "secrets-aws")]
aws_provider: Option<AwsProvider>,
rotation_tx: broadcast::Sender<RotationEvent>,
}
impl SecretsManager {
pub fn new(config: SecretsConfig) -> SecretsResult<Self> {
let cache = SecretCache::new(&config.cache)?;
#[cfg(feature = "secrets-vault")]
let vault_provider = config
.openbao
.as_ref()
.map(OpenBaoProvider::new)
.transpose()?;
#[cfg(feature = "secrets-aws")]
let aws_provider = config.aws.as_ref().map(AwsProvider::new).transpose()?;
let (rotation_tx, _) = broadcast::channel(16);
Ok(Self {
config,
cache: Arc::new(RwLock::new(cache)),
file_provider: FileProvider::new(),
#[cfg(feature = "secrets-vault")]
vault_provider,
#[cfg(feature = "secrets-aws")]
aws_provider,
rotation_tx,
})
}
pub async fn get(&self, name: &str) -> SecretsResult<SecretValue> {
let source = self
.config
.sources
.get(name)
.ok_or_else(|| SecretsError::NotFound(format!("unknown secret source: {name}")))?
.clone();
self.get_from_source(name, &source).await
}
pub async fn get_file(&self, path: &str) -> SecretsResult<SecretValue> {
let cache_key = format!("file:{path}");
if let Some(cached) = self.cache.read().get(&cache_key) {
debug!(path = %path, "Secret loaded from cache");
#[cfg(feature = "metrics")]
metrics::counter!("dfe_secrets_cache_hits_total").increment(1);
return Ok(cached);
}
#[cfg(feature = "metrics")]
metrics::counter!("dfe_secrets_cache_misses_total").increment(1);
let value = self.file_provider.get(path).await?;
#[cfg(feature = "metrics")]
metrics::counter!("dfe_secrets_fetch_total").increment(1);
if let Err(e) = self.cache.write().set(&cache_key, &value) {
warn!(error = %e, "Failed to cache secret");
}
Ok(value)
}
async fn get_from_source(
&self,
cache_key: &str,
source: &SecretSource,
) -> SecretsResult<SecretValue> {
if let Some(cached) = self.cache.read().get(cache_key) {
debug!(key = %cache_key, "Secret loaded from cache");
#[cfg(feature = "metrics")]
metrics::counter!("dfe_secrets_cache_hits_total").increment(1);
return Ok(cached);
}
#[cfg(feature = "metrics")]
metrics::counter!("dfe_secrets_cache_misses_total").increment(1);
let result = match source {
SecretSource::File { path } => self.file_provider.get(path).await,
#[cfg(feature = "secrets-vault")]
SecretSource::OpenBao { path, key } => {
let provider = self
.vault_provider
.as_ref()
.ok_or_else(|| SecretsError::ProviderNotConfigured("openbao".into()))?;
provider.get(path, key).await
}
#[cfg(feature = "secrets-aws")]
SecretSource::Aws { secret_id, key } => {
let provider = self
.aws_provider
.as_ref()
.ok_or_else(|| SecretsError::ProviderNotConfigured("aws".into()))?;
provider.get(secret_id, key.as_deref()).await
}
#[cfg(not(feature = "secrets-vault"))]
SecretSource::OpenBao { .. } => {
return Err(SecretsError::ProviderNotConfigured(
"openbao (enable secrets-vault feature)".into(),
));
}
#[cfg(not(feature = "secrets-aws"))]
SecretSource::Aws { .. } => {
return Err(SecretsError::ProviderNotConfigured(
"aws (enable secrets-aws feature)".into(),
));
}
};
#[cfg(feature = "metrics")]
metrics::counter!("dfe_secrets_fetch_total").increment(1);
match result {
Ok(value) => {
if let Err(e) = self.cache.write().set(cache_key, &value) {
warn!(key = %cache_key, error = %e, "Failed to cache secret");
}
Ok(value)
}
Err(e) => {
if let Some(stale) = self.cache.read().get_stale(cache_key) {
warn!(
key = %cache_key,
error = %e,
"Provider failed, using stale cached secret"
);
return Ok(stale);
}
Err(e)
}
}
}
#[must_use]
pub fn subscribe_rotations(&self) -> broadcast::Receiver<RotationEvent> {
self.rotation_tx.subscribe()
}
pub async fn refresh_all(&self) -> SecretsResult<()> {
let mut errors = Vec::new();
for (name, source) in &self.config.sources {
let old_version = self
.cache
.read()
.get(name)
.and_then(|v| v.metadata.version.clone());
match self.get_from_source(name, source).await {
Ok(new_value) => {
if let Some(ref new_version) = new_value.metadata.version
&& old_version.as_ref() != Some(new_version)
{
let event = RotationEvent {
name: name.clone(),
old_version,
new_version: new_version.clone(),
rotated_at: std::time::SystemTime::now(),
};
let _ = self.rotation_tx.send(event);
info!(name = %name, new_version = %new_version, "Secret rotated");
}
}
Err(e) => {
warn!(name = %name, error = %e, "Failed to refresh secret");
errors.push(format!("{name}: {e}"));
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(SecretsError::RefreshFailed(errors.join("; ")))
}
}
pub async fn health_check(&self) -> HashMap<String, bool> {
let mut health = HashMap::new();
health.insert("file".into(), true);
#[cfg(feature = "secrets-vault")]
if let Some(ref provider) = self.vault_provider {
health.insert("openbao".into(), provider.health_check().await.is_ok());
}
#[cfg(feature = "secrets-aws")]
if let Some(ref provider) = self.aws_provider {
health.insert("aws".into(), provider.health_check().await.is_ok());
}
health
}
pub fn clear_cache(&self) {
self.cache.write().clear();
}
#[must_use]
pub fn cache_stats(&self) -> CacheStats {
self.cache.read().stats()
}
}
#[derive(Debug, Clone, Default)]
pub struct CacheStats {
pub memory_entries: usize,
pub disk_entries: usize,
pub hits: u64,
pub misses: u64,
pub stale_hits: u64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_secrets_config_default() {
let config = SecretsConfig::default();
assert!(config.cache.enabled);
assert_eq!(config.cache.ttl_secs, 3600);
assert!(config.sources.is_empty());
}
#[test]
fn test_secrets_manager_new() {
let manager = SecretsManager::new(SecretsConfig::default());
assert!(manager.is_ok());
}
#[tokio::test]
async fn test_file_provider_missing_file() {
let manager = SecretsManager::new(SecretsConfig::default()).unwrap();
let result = manager.get_file("/nonexistent/path/secret.txt").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_file_provider_read_file() {
let temp_dir = tempfile::tempdir().unwrap();
let secret_path = temp_dir.path().join("test-secret.txt");
std::fs::write(&secret_path, "my-secret-value").unwrap();
let manager = SecretsManager::new(SecretsConfig::default()).unwrap();
let result = manager.get_file(secret_path.to_str().unwrap()).await;
assert!(result.is_ok());
let value = result.unwrap();
assert_eq!(value.as_str().unwrap(), "my-secret-value");
}
#[tokio::test]
async fn test_named_source_file() {
let temp_dir = tempfile::tempdir().unwrap();
let secret_path = temp_dir.path().join("api-key.txt");
std::fs::write(&secret_path, "super-secret-key").unwrap();
let config = SecretsConfig {
sources: [(
"api_key".into(),
SecretSource::File {
path: secret_path.to_str().unwrap().into(),
},
)]
.into_iter()
.collect(),
..Default::default()
};
let manager = SecretsManager::new(config).unwrap();
let value = manager.get("api_key").await.unwrap();
assert_eq!(value.as_str().unwrap(), "super-secret-key");
}
#[tokio::test]
async fn test_unknown_source() {
let manager = SecretsManager::new(SecretsConfig::default()).unwrap();
let result = manager.get("nonexistent").await;
assert!(matches!(result, Err(SecretsError::NotFound(_))));
}
#[tokio::test]
async fn test_health_check() {
let manager = SecretsManager::new(SecretsConfig::default()).unwrap();
let health = manager.health_check().await;
assert!(health.get("file").copied().unwrap_or(false));
}
}