use std::sync::{Arc, RwLock};
use super::config::{HostPattern, SecretsConfig};
#[derive(Clone)]
pub struct SecretsHandle {
inner: Arc<RwLock<Arc<SecretsConfig>>>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SecretsUpdateError {
#[error("no secret named {name} is configured")]
UnknownSecret {
name: String,
},
#[error("secret {name}: at least one allowed host is required")]
MissingAllowedHosts {
name: String,
},
}
impl SecretsHandle {
pub fn new(config: SecretsConfig) -> Self {
Self {
inner: Arc::new(RwLock::new(Arc::new(config))),
}
}
pub fn load(&self) -> Arc<SecretsConfig> {
self.inner.read().expect("secrets lock poisoned").clone()
}
pub fn rotate_value(&self, name: &str, value: String) -> Result<(), SecretsUpdateError> {
self.update(name, |entry| {
entry.value = zeroize::Zeroizing::new(value);
})
}
pub fn remove(&self, name: &str) {
let mut guard = self.inner.write().expect("secrets lock poisoned");
if !guard.secrets.iter().any(|entry| entry.env_var == name) {
return;
}
let mut config = (**guard).clone();
config.secrets.retain(|entry| entry.env_var != name);
*guard = Arc::new(config);
}
pub fn set_allowed_hosts(
&self,
name: &str,
hosts: &[String],
) -> Result<(), SecretsUpdateError> {
if hosts.is_empty() {
return Err(SecretsUpdateError::MissingAllowedHosts {
name: name.to_string(),
});
}
let hosts: Vec<HostPattern> = hosts.iter().map(|host| HostPattern::parse(host)).collect();
self.update(name, |entry| entry.allowed_hosts = hosts)
}
fn update(
&self,
name: &str,
mutate: impl FnOnce(&mut super::config::SecretEntry),
) -> Result<(), SecretsUpdateError> {
let mut guard = self.inner.write().expect("secrets lock poisoned");
let mut config = (**guard).clone();
let entry = config
.secrets
.iter_mut()
.find(|entry| entry.env_var == name)
.ok_or_else(|| SecretsUpdateError::UnknownSecret {
name: name.to_string(),
})?;
mutate(entry);
*guard = Arc::new(config);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::super::config::{SecretEntry, SecretInjection};
use super::*;
fn config_with_secret(name: &str, value: &str) -> SecretsConfig {
SecretsConfig {
secrets: vec![SecretEntry {
env_var: name.to_string(),
value: zeroize::Zeroizing::new(value.to_string()),
source: None,
placeholder: format!("$MSB_{name}"),
allowed_hosts: vec![HostPattern::Exact("api.example.com".into())],
injection: SecretInjection::default(),
on_violation: None,
require_tls_identity: true,
}],
..SecretsConfig::default()
}
}
#[test]
fn rotate_swaps_value_for_future_loads() {
let handle = SecretsHandle::new(config_with_secret("API_KEY", "old"));
let before = handle.load();
handle.rotate_value("API_KEY", "new".into()).unwrap();
assert_eq!(before.secrets[0].value.as_str(), "old");
assert_eq!(handle.load().secrets[0].value.as_str(), "new");
assert_eq!(handle.load().secrets[0].placeholder, "$MSB_API_KEY");
}
#[test]
fn rotate_unknown_secret_is_an_error() {
let handle = SecretsHandle::new(config_with_secret("API_KEY", "old"));
assert_eq!(
handle.rotate_value("MISSING", "new".into()),
Err(SecretsUpdateError::UnknownSecret {
name: "MISSING".into()
})
);
}
#[test]
fn remove_drops_entry_and_is_idempotent() {
let handle = SecretsHandle::new(config_with_secret("API_KEY", "old"));
handle.remove("API_KEY");
handle.remove("API_KEY");
assert!(handle.load().secrets.is_empty());
}
#[test]
fn set_allowed_hosts_replaces_patterns() {
let handle = SecretsHandle::new(config_with_secret("API_KEY", "old"));
handle
.set_allowed_hosts("API_KEY", &["*.example.org".into(), "one.test".into()])
.unwrap();
assert_eq!(
handle.load().secrets[0].allowed_hosts,
vec![
HostPattern::Wildcard("*.example.org".into()),
HostPattern::Exact("one.test".into()),
]
);
}
#[test]
fn set_allowed_hosts_rejects_empty_list() {
let handle = SecretsHandle::new(config_with_secret("API_KEY", "old"));
assert_eq!(
handle.set_allowed_hosts("API_KEY", &[]),
Err(SecretsUpdateError::MissingAllowedHosts {
name: "API_KEY".into()
})
);
assert_eq!(handle.load().secrets[0].allowed_hosts.len(), 1);
}
}