use crate::{Mecha10Error, Result};
use serde::{de::DeserializeOwned, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
mod environment;
mod redis;
mod vault;
pub use environment::EnvironmentBackend;
pub use redis::RedisBackend;
pub use vault::VaultBackend;
#[async_trait::async_trait]
pub trait SecretBackend: Send + Sync {
async fn get(&self, key: &str) -> Result<String>;
async fn set(&self, key: &str, value: &str) -> Result<()> {
let _ = (key, value);
Err(Mecha10Error::Other(
"This backend does not support writing secrets".to_string(),
))
}
async fn delete(&self, key: &str) -> Result<()> {
let _ = key;
Err(Mecha10Error::Other(
"This backend does not support deleting secrets".to_string(),
))
}
async fn list(&self) -> Result<Vec<String>> {
Err(Mecha10Error::Other(
"This backend does not support listing secrets".to_string(),
))
}
}
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct SecretsConfig {
pub backend: String,
#[serde(flatten)]
pub config: HashMap<String, serde_json::Value>,
#[serde(default = "default_cache_ttl")]
pub cache_ttl_secs: u64,
}
fn default_cache_ttl() -> u64 {
300
}
impl Default for SecretsConfig {
fn default() -> Self {
Self {
backend: "environment".to_string(),
config: HashMap::new(),
cache_ttl_secs: default_cache_ttl(),
}
}
}
struct CacheEntry {
value: String,
expires_at: Instant,
}
pub struct SecretsManager {
backend: Arc<dyn SecretBackend>,
cache: Arc<RwLock<HashMap<String, CacheEntry>>>,
cache_ttl: Duration,
}
impl SecretsManager {
pub fn new(backend: Arc<dyn SecretBackend>, cache_ttl: Duration) -> Self {
Self {
backend,
cache: Arc::new(RwLock::new(HashMap::new())),
cache_ttl,
}
}
pub async fn from_config(config: SecretsConfig) -> Result<Self> {
let backend: Arc<dyn SecretBackend> = match config.backend.as_str() {
"environment" => {
let prefix = config
.config
.get("prefix")
.and_then(|v| v.as_str())
.unwrap_or("MECHA10_SECRET");
Arc::new(EnvironmentBackend::new(prefix))
}
"vault" => {
let mount_path = config
.config
.get("mount_path")
.and_then(|v| v.as_str())
.ok_or_else(|| {
Mecha10Error::Configuration("Vault backend requires 'mount_path' config".to_string())
})?;
Arc::new(VaultBackend::from_env(mount_path).await?)
}
"redis" => {
let prefix = config
.config
.get("prefix")
.and_then(|v| v.as_str())
.unwrap_or("mecha10:secrets");
Arc::new(RedisBackend::from_env(prefix).await?)
}
_ => {
return Err(Mecha10Error::Configuration(format!(
"Unknown secrets backend: {}",
config.backend
)));
}
};
Ok(Self::new(backend, Duration::from_secs(config.cache_ttl_secs)))
}
pub async fn from_env() -> Result<Self> {
Ok(Self::new(
Arc::new(EnvironmentBackend::default()),
Duration::from_secs(300),
))
}
pub async fn get(&self, key: &str) -> Result<String> {
{
let cache = self.cache.read().await;
if let Some(entry) = cache.get(key) {
if entry.expires_at > Instant::now() {
return Ok(entry.value.clone());
}
}
}
let value = self.backend.get(key).await?;
{
let mut cache = self.cache.write().await;
cache.insert(
key.to_string(),
CacheEntry {
value: value.clone(),
expires_at: Instant::now() + self.cache_ttl,
},
);
}
Ok(value)
}
pub async fn get_as<T>(&self, key: &str) -> Result<T>
where
T: DeserializeOwned + std::str::FromStr,
T::Err: std::fmt::Display,
{
let value = self.get(key).await?;
if let Ok(parsed) = serde_json::from_str(&value) {
return Ok(parsed);
}
value.parse().map_err(|e| {
Mecha10Error::Configuration(format!(
"Failed to parse secret '{}' as {}: {}",
key,
std::any::type_name::<T>(),
e
))
})
}
pub async fn set(&self, key: &str, value: &str) -> Result<()> {
self.backend.set(key, value).await?;
let mut cache = self.cache.write().await;
cache.remove(key);
Ok(())
}
pub async fn delete(&self, key: &str) -> Result<()> {
self.backend.delete(key).await?;
let mut cache = self.cache.write().await;
cache.remove(key);
Ok(())
}
pub async fn list(&self) -> Result<Vec<String>> {
self.backend.list().await
}
pub async fn clear_cache(&self) {
let mut cache = self.cache.write().await;
cache.clear();
}
}