use std::collections::HashMap;
use std::path::PathBuf;
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
use super::error::SecretsResult;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct SecretsConfig {
pub cache: CacheConfig,
#[cfg(feature = "secrets-vault")]
pub openbao: Option<super::OpenBaoConfig>,
#[cfg(feature = "secrets-aws")]
pub aws: Option<super::AwsConfig>,
#[cfg(not(feature = "secrets-vault"))]
#[serde(skip)]
pub openbao: Option<()>,
#[cfg(not(feature = "secrets-aws"))]
#[serde(skip)]
pub aws: Option<()>,
pub sources: HashMap<String, SecretSource>,
}
impl SecretsConfig {
#[must_use]
pub fn from_cascade() -> Self {
#[cfg(feature = "config")]
{
if let Some(cfg) = crate::config::try_get()
&& let Ok(secrets) = cfg.unmarshal_key_registered::<Self>("secrets")
{
return secrets;
}
}
Self::default()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CacheConfig {
pub enabled: bool,
pub directory: Option<PathBuf>,
pub ttl_secs: u64,
pub stale_grace_secs: u64,
pub refresh_interval_secs: u64,
pub refresh_jitter_secs: u64,
pub encryption_key: Option<crate::SensitiveString>,
#[serde(default)]
pub allow_plaintext_disk_cache: bool,
pub dir_mode: Option<u32>,
pub file_mode: Option<u32>,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
enabled: true,
directory: None, ttl_secs: 3600, stale_grace_secs: 86400, refresh_interval_secs: 1800, refresh_jitter_secs: 300, encryption_key: None,
allow_plaintext_disk_cache: false,
dir_mode: Some(0o700),
file_mode: Some(0o600),
}
}
}
impl CacheConfig {
pub fn validate(&self, is_production: bool) -> Result<(), String> {
if is_production
&& self.enabled
&& self.encryption_key.is_none()
&& self.allow_plaintext_disk_cache
{
return Err(
"secrets cache: allow_plaintext_disk_cache=true is not permitted in \
production -- configure an encryption_key for the disk cache, or leave \
it memory-only (allow_plaintext_disk_cache=false)"
.to_string(),
);
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "provider", rename_all = "snake_case")]
pub enum SecretSource {
File {
path: String,
},
OpenBao {
path: String,
key: String,
},
Aws {
secret_id: String,
key: Option<String>,
},
}
#[derive(Clone)]
pub struct SecretValue {
pub data: Vec<u8>,
pub fetched_at: SystemTime,
pub metadata: SecretMetadata,
}
impl std::fmt::Debug for SecretValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SecretValue")
.field("data", &"[REDACTED]")
.field("fetched_at", &self.fetched_at)
.field("metadata", &self.metadata)
.finish()
}
}
impl SecretValue {
#[must_use]
pub fn new(data: Vec<u8>) -> Self {
Self {
data,
fetched_at: SystemTime::now(),
metadata: SecretMetadata::default(),
}
}
#[must_use]
pub fn with_metadata(data: Vec<u8>, metadata: SecretMetadata) -> Self {
Self {
data,
fetched_at: SystemTime::now(),
metadata,
}
}
pub fn as_str(&self) -> SecretsResult<&str> {
std::str::from_utf8(&self.data)
.map_err(|e| super::error::SecretsError::InvalidData(format!("not valid UTF-8: {e}")))
}
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
&self.data
}
#[must_use]
pub fn is_expired(&self, ttl_secs: u64) -> bool {
self.fetched_at
.elapsed()
.map_or(true, |d| d.as_secs() >= ttl_secs)
}
#[must_use]
pub fn is_within_grace(&self, ttl_secs: u64, grace_secs: u64) -> bool {
self.fetched_at
.elapsed()
.is_ok_and(|d| d.as_secs() <= ttl_secs + grace_secs)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecretMetadata {
pub version: Option<String>,
pub source_path: Option<String>,
pub provider: Option<String>,
}
#[derive(Debug, Clone)]
pub struct RotationEvent {
pub name: String,
pub old_version: Option<String>,
pub new_version: String,
pub rotated_at: SystemTime,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct CacheEntry {
pub data: String,
pub fetched_at_secs: u64,
pub metadata: SecretMetadata,
}
impl CacheEntry {
pub fn from_value(value: &SecretValue) -> Self {
use base64::Engine;
Self {
data: base64::engine::general_purpose::STANDARD.encode(&value.data),
fetched_at_secs: value
.fetched_at
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs()),
metadata: value.metadata.clone(),
}
}
pub fn to_value(&self) -> SecretsResult<SecretValue> {
use base64::Engine;
let data = base64::engine::general_purpose::STANDARD
.decode(&self.data)
.map_err(|e| super::error::SecretsError::CacheError(format!("invalid base64: {e}")))?;
let fetched_at =
SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(self.fetched_at_secs);
Ok(SecretValue {
data,
fetched_at,
metadata: self.metadata.clone(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_config_validate_rejects_plaintext_disk_in_prod() {
let cfg = CacheConfig {
allow_plaintext_disk_cache: true,
encryption_key: None,
..Default::default()
};
assert!(
cfg.validate(true).is_err(),
"prod must reject plaintext disk"
);
assert!(
cfg.validate(false).is_ok(),
"non-prod allows plaintext disk"
);
let safe = CacheConfig::default();
assert!(safe.validate(true).is_ok());
assert!(safe.validate(false).is_ok());
let encrypted = CacheConfig {
allow_plaintext_disk_cache: true,
encryption_key: Some(crate::SensitiveString::from("0123456789abcdef")),
..Default::default()
};
assert!(
encrypted.validate(true).is_ok(),
"an encryption_key satisfies prod validation"
);
}
#[test]
fn test_secret_value_new() {
let value = SecretValue::new(b"test-secret".to_vec());
assert_eq!(value.as_bytes(), b"test-secret");
assert_eq!(value.as_str().unwrap(), "test-secret");
}
#[test]
fn test_secret_value_expiry() {
let value = SecretValue::new(b"test".to_vec());
assert!(!value.is_expired(3600));
assert!(value.is_within_grace(3600, 86400));
}
#[test]
fn test_cache_entry_roundtrip() {
let value = SecretValue::new(b"secret-data".to_vec());
let entry = CacheEntry::from_value(&value);
let restored = entry.to_value().unwrap();
assert_eq!(restored.data, value.data);
}
#[test]
fn test_secret_source_file_serialization() {
let source = SecretSource::File {
path: "/etc/ssl/cert.pem".to_string(),
};
let json = serde_json::to_string(&source).unwrap();
assert!(json.contains("\"provider\":\"file\""));
}
#[test]
fn test_cache_config_default() {
let config = CacheConfig::default();
assert!(config.enabled);
assert_eq!(config.ttl_secs, 3600);
assert_eq!(config.stale_grace_secs, 86400);
assert!(config.encryption_key.is_none());
}
}