feat: add secrets provider architecture

Trait-based secrets management for secure credential handling:
- SecretsProvider trait for pluggable backends
- EnvProvider as default (12-Factor App pattern)
- ProtectedString with zeroize (memory zeroed on drop)
- Redacted Debug impl prevents secret leakage in logs
- S3Credentials struct for future AWS S3 integration
- Config: [secrets] section with provider and clear_env options

Foundation for AWS Secrets Manager, Vault, K8s (v0.4.0+)
This commit is contained in:
2026-01-30 10:02:58 +00:00
parent 3265e217e7
commit ee4e01467a
9 changed files with 508 additions and 0 deletions

View File

@@ -0,0 +1,125 @@
//! Environment variables secrets provider
//!
//! Reads secrets from environment variables. This is the default provider
//! following 12-Factor App principles.
use std::env;
use super::{SecretsError, SecretsProvider};
use crate::secrets::protected::ProtectedString;
use async_trait::async_trait;
/// Environment variables secrets provider
///
/// Reads secrets from environment variables.
/// Optionally clears variables after reading for extra security.
#[derive(Debug, Clone)]
pub struct EnvProvider {
/// Clear environment variables after reading
clear_after_read: bool,
}
impl EnvProvider {
/// Create a new environment provider
pub fn new() -> Self {
Self {
clear_after_read: false,
}
}
/// Create a provider that clears env vars after reading
///
/// This prevents secrets from being visible in `/proc/<pid>/environ`
pub fn with_clear_after_read(mut self) -> Self {
self.clear_after_read = true;
self
}
}
impl Default for EnvProvider {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl SecretsProvider for EnvProvider {
async fn get_secret(&self, key: &str) -> Result<ProtectedString, SecretsError> {
let value = env::var(key).map_err(|_| SecretsError::NotFound(key.to_string()))?;
if self.clear_after_read {
env::remove_var(key);
}
Ok(ProtectedString::new(value))
}
async fn get_secret_optional(&self, key: &str) -> Option<ProtectedString> {
env::var(key).ok().map(|v| {
if self.clear_after_read {
env::remove_var(key);
}
ProtectedString::new(v)
})
}
fn provider_name(&self) -> &'static str {
"env"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_get_secret_exists() {
env::set_var("TEST_SECRET_123", "secret-value");
let provider = EnvProvider::new();
let secret = provider.get_secret("TEST_SECRET_123").await.unwrap();
assert_eq!(secret.expose(), "secret-value");
env::remove_var("TEST_SECRET_123");
}
#[tokio::test]
async fn test_get_secret_not_found() {
let provider = EnvProvider::new();
let result = provider.get_secret("NONEXISTENT_VAR_XYZ").await;
assert!(matches!(result, Err(SecretsError::NotFound(_))));
}
#[tokio::test]
async fn test_get_secret_optional_exists() {
env::set_var("TEST_OPTIONAL_123", "optional-value");
let provider = EnvProvider::new();
let secret = provider.get_secret_optional("TEST_OPTIONAL_123").await;
assert!(secret.is_some());
assert_eq!(secret.unwrap().expose(), "optional-value");
env::remove_var("TEST_OPTIONAL_123");
}
#[tokio::test]
async fn test_get_secret_optional_not_found() {
let provider = EnvProvider::new();
let secret = provider.get_secret_optional("NONEXISTENT_OPTIONAL_XYZ").await;
assert!(secret.is_none());
}
#[tokio::test]
async fn test_clear_after_read() {
env::set_var("TEST_CLEAR_123", "to-be-cleared");
let provider = EnvProvider::new().with_clear_after_read();
let secret = provider.get_secret("TEST_CLEAR_123").await.unwrap();
assert_eq!(secret.expose(), "to-be-cleared");
// Variable should be cleared
assert!(env::var("TEST_CLEAR_123").is_err());
}
#[test]
fn test_provider_name() {
let provider = EnvProvider::new();
assert_eq!(provider.provider_name(), "env");
}
}