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

@@ -17,6 +17,14 @@ All notable changes to NORA will be documented in this file.
- Environment variables: `NORA_RATE_LIMIT_{AUTH|UPLOAD|GENERAL}_{RPS|BURST}`
- Rate limit configuration logged at startup
#### Secrets Provider Architecture
- Trait-based secrets management (`SecretsProvider` trait)
- ENV provider as default (12-Factor App pattern)
- Protected secrets with `zeroize` (memory zeroed on drop)
- Redacted Debug impl prevents secret leakage in logs
- New config section `[secrets]` with `provider` and `clear_env` options
- Foundation for future AWS Secrets Manager, Vault, K8s integration
### Changed
- Rate limiting functions now accept `&RateLimitConfig` parameter
- Improved error messages with `.expect()` instead of `.unwrap()`

15
Cargo.lock generated
View File

@@ -1230,6 +1230,7 @@ dependencies = [
"utoipa-swagger-ui",
"uuid",
"wiremock",
"zeroize",
]
[[package]]
@@ -2955,6 +2956,20 @@ name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zerotrie"

View File

@@ -111,6 +111,8 @@ nora migrate --from local --to s3
| `NORA_RATE_LIMIT_UPLOAD_BURST` | 500 | Upload burst size |
| `NORA_RATE_LIMIT_GENERAL_RPS` | 100 | General requests per second |
| `NORA_RATE_LIMIT_GENERAL_BURST` | 200 | General burst size |
| `NORA_SECRETS_PROVIDER` | env | Secrets provider (`env`) |
| `NORA_SECRETS_CLEAR_ENV` | false | Clear env vars after reading |
### config.toml
@@ -137,6 +139,12 @@ upload_burst = 500
# Balanced limits for general API endpoints
general_rps = 100
general_burst = 200
[secrets]
# Provider: env (default), aws-secrets, vault, k8s (coming soon)
provider = "env"
# Clear environment variables after reading (security hardening)
clear_env = false
```
## Endpoints

View File

@@ -42,6 +42,7 @@ thiserror = "2"
tower_governor = "0.8"
governor = "0.10"
parking_lot = "0.12"
zeroize = { version = "1.8", features = ["derive"] }
[dev-dependencies]
tempfile = "3"

View File

@@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
pub use crate::secrets::SecretsConfig;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub server: ServerConfig,
@@ -16,6 +18,8 @@ pub struct Config {
pub auth: AuthConfig,
#[serde(default)]
pub rate_limit: RateLimitConfig,
#[serde(default)]
pub secrets: SecretsConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -315,6 +319,14 @@ impl Config {
self.rate_limit.general_burst = v;
}
}
// Secrets config
if let Ok(val) = env::var("NORA_SECRETS_PROVIDER") {
self.secrets.provider = val;
}
if let Ok(val) = env::var("NORA_SECRETS_CLEAR_ENV") {
self.secrets.clear_env = val.to_lowercase() == "true" || val == "1";
}
}
}
@@ -336,6 +348,7 @@ impl Default for Config {
pypi: PypiConfig::default(),
auth: AuthConfig::default(),
rate_limit: RateLimitConfig::default(),
secrets: SecretsConfig::default(),
}
}
}

View File

@@ -11,6 +11,7 @@ mod openapi;
mod rate_limit;
mod registry;
mod request_id;
mod secrets;
mod storage;
mod tokens;
mod ui;
@@ -190,6 +191,25 @@ async fn run_server(config: Config, storage: Storage) {
"Rate limiting configured"
);
// Initialize secrets provider
let secrets_provider = match secrets::create_secrets_provider(&config.secrets) {
Ok(provider) => {
info!(
provider = provider.provider_name(),
clear_env = config.secrets.clear_env,
"Secrets provider initialized"
);
Some(provider)
}
Err(e) => {
warn!(error = %e, "Failed to initialize secrets provider, using defaults");
None
}
};
// Store secrets provider for future use (S3 credentials, etc.)
let _secrets = secrets_provider;
// Load auth if enabled
let auth = if config.auth.enabled {
let path = Path::new(&config.auth.htpasswd_file);

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");
}
}

View File

@@ -0,0 +1,166 @@
#![allow(dead_code)] // Foundational code for future S3/Vault integration
//! Secrets management for NORA
//!
//! Provides a trait-based architecture for secrets providers:
//! - `env` - Environment variables (default, 12-Factor App)
//! - `aws-secrets` - AWS Secrets Manager (v0.4.0+)
//! - `vault` - HashiCorp Vault (v0.5.0+)
//! - `k8s` - Kubernetes Secrets (v0.4.0+)
//!
//! # Example
//!
//! ```rust,ignore
//! use nora::secrets::{create_secrets_provider, SecretsConfig};
//!
//! let config = SecretsConfig::default(); // Uses ENV provider
//! let provider = create_secrets_provider(&config)?;
//!
//! let api_key = provider.get_secret("API_KEY").await?;
//! println!("Got secret (redacted): {:?}", api_key);
//! ```
mod env;
pub mod protected;
pub use env::EnvProvider;
#[allow(unused_imports)]
pub use protected::{ProtectedString, S3Credentials};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Secrets provider error
#[derive(Debug, Error)]
pub enum SecretsError {
#[error("Secret not found: {0}")]
NotFound(String),
#[error("Provider error: {0}")]
Provider(String),
#[error("Configuration error: {0}")]
Config(String),
#[error("Unsupported provider: {0}")]
UnsupportedProvider(String),
}
/// Secrets provider trait
///
/// Implement this trait to add new secrets backends.
#[async_trait]
pub trait SecretsProvider: Send + Sync {
/// Get a secret by key (required)
async fn get_secret(&self, key: &str) -> Result<ProtectedString, SecretsError>;
/// Get a secret by key (optional, returns None if not found)
async fn get_secret_optional(&self, key: &str) -> Option<ProtectedString> {
self.get_secret(key).await.ok()
}
/// Get provider name for logging
fn provider_name(&self) -> &'static str;
}
/// Secrets configuration
///
/// # Example config.toml
///
/// ```toml
/// [secrets]
/// provider = "env"
/// clear_env = false
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretsConfig {
/// Provider type: "env", "aws-secrets", "vault", "k8s"
#[serde(default = "default_provider")]
pub provider: String,
/// Clear environment variables after reading (for env provider)
#[serde(default)]
pub clear_env: bool,
}
fn default_provider() -> String {
"env".to_string()
}
impl Default for SecretsConfig {
fn default() -> Self {
Self {
provider: default_provider(),
clear_env: false,
}
}
}
/// Create a secrets provider based on configuration
///
/// Currently supports:
/// - `env` - Environment variables (default)
///
/// Future versions will add:
/// - `aws-secrets` - AWS Secrets Manager
/// - `vault` - HashiCorp Vault
/// - `k8s` - Kubernetes Secrets
pub fn create_secrets_provider(
config: &SecretsConfig,
) -> Result<Box<dyn SecretsProvider>, SecretsError> {
match config.provider.as_str() {
"env" => {
let mut provider = EnvProvider::new();
if config.clear_env {
provider = provider.with_clear_after_read();
}
Ok(Box::new(provider))
}
// Future providers:
// "aws-secrets" => { ... }
// "vault" => { ... }
// "k8s" => { ... }
other => Err(SecretsError::UnsupportedProvider(other.to_string())),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = SecretsConfig::default();
assert_eq!(config.provider, "env");
assert!(!config.clear_env);
}
#[test]
fn test_create_env_provider() {
let config = SecretsConfig::default();
let provider = create_secrets_provider(&config).unwrap();
assert_eq!(provider.provider_name(), "env");
}
#[test]
fn test_create_unsupported_provider() {
let config = SecretsConfig {
provider: "unknown".to_string(),
clear_env: false,
};
let result = create_secrets_provider(&config);
assert!(matches!(result, Err(SecretsError::UnsupportedProvider(_))));
}
#[test]
fn test_config_from_toml() {
let toml = r#"
provider = "env"
clear_env = true
"#;
let config: SecretsConfig = toml::from_str(toml).unwrap();
assert_eq!(config.provider, "env");
assert!(config.clear_env);
}
}

View File

@@ -0,0 +1,152 @@
//! Protected secret types with memory safety
//!
//! Secrets are automatically zeroed on drop and redacted in Debug output.
use std::fmt;
use zeroize::{Zeroize, Zeroizing};
/// A protected secret string that is zeroed on drop
///
/// - Implements Zeroize: memory is overwritten with zeros when dropped
/// - Debug shows `***REDACTED***` instead of actual value
/// - Clone creates a new protected copy
#[derive(Clone, Zeroize)]
#[zeroize(drop)]
pub struct ProtectedString {
inner: String,
}
impl ProtectedString {
/// Create a new protected string
pub fn new(value: String) -> Self {
Self { inner: value }
}
/// Get the secret value (use sparingly!)
pub fn expose(&self) -> &str {
&self.inner
}
/// Consume and return the inner value
pub fn into_inner(self) -> Zeroizing<String> {
Zeroizing::new(self.inner.clone())
}
/// Check if the secret is empty
pub fn is_empty(&self) -> bool {
self.inner.is_empty()
}
}
impl fmt::Debug for ProtectedString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ProtectedString")
.field("value", &"***REDACTED***")
.finish()
}
}
impl fmt::Display for ProtectedString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "***REDACTED***")
}
}
impl From<String> for ProtectedString {
fn from(value: String) -> Self {
Self::new(value)
}
}
impl From<&str> for ProtectedString {
fn from(value: &str) -> Self {
Self::new(value.to_string())
}
}
/// S3 credentials with protected secrets
#[derive(Clone, Zeroize)]
#[zeroize(drop)]
pub struct S3Credentials {
pub access_key_id: String,
#[zeroize(skip)] // access_key_id is not sensitive
pub secret_access_key: ProtectedString,
pub region: Option<String>,
}
impl S3Credentials {
pub fn new(access_key_id: String, secret_access_key: String) -> Self {
Self {
access_key_id,
secret_access_key: ProtectedString::new(secret_access_key),
region: None,
}
}
pub fn with_region(mut self, region: String) -> Self {
self.region = Some(region);
self
}
}
impl fmt::Debug for S3Credentials {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("S3Credentials")
.field("access_key_id", &self.access_key_id)
.field("secret_access_key", &"***REDACTED***")
.field("region", &self.region)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_protected_string_redacted_debug() {
let secret = ProtectedString::new("super-secret-value".to_string());
let debug_output = format!("{:?}", secret);
assert!(debug_output.contains("REDACTED"));
assert!(!debug_output.contains("super-secret-value"));
}
#[test]
fn test_protected_string_redacted_display() {
let secret = ProtectedString::new("super-secret-value".to_string());
let display_output = format!("{}", secret);
assert_eq!(display_output, "***REDACTED***");
}
#[test]
fn test_protected_string_expose() {
let secret = ProtectedString::new("my-secret".to_string());
assert_eq!(secret.expose(), "my-secret");
}
#[test]
fn test_s3_credentials_redacted_debug() {
let creds = S3Credentials::new(
"AKIAIOSFODNN7EXAMPLE".to_string(),
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
);
let debug_output = format!("{:?}", creds);
assert!(debug_output.contains("AKIAIOSFODNN7EXAMPLE"));
assert!(!debug_output.contains("wJalrXUtnFEMI"));
assert!(debug_output.contains("REDACTED"));
}
#[test]
fn test_protected_string_from_str() {
let secret: ProtectedString = "test".into();
assert_eq!(secret.expose(), "test");
}
#[test]
fn test_protected_string_is_empty() {
let empty = ProtectedString::new(String::new());
let non_empty = ProtectedString::new("secret".to_string());
assert!(empty.is_empty());
assert!(!non_empty.is_empty());
}
}