Archived
283 lines
8.2 KiB
Rust
283 lines
8.2 KiB
Rust
/// SecretsResolver — loads all required secrets at startup.
|
|
///
|
|
/// The resolver validates that all required secrets are present before the
|
|
/// application starts. If any are missing, startup fails with a clear error
|
|
/// listing what's missing — not a runtime panic deep in the app.
|
|
|
|
use std::collections::HashMap;
|
|
use crate::error::SecretsError;
|
|
use crate::secret::Secret;
|
|
use crate::source::SecretsSource;
|
|
|
|
/// A resolved, validated set of secrets.
|
|
///
|
|
/// Created by SecretsResolver at startup. Once resolved, all secrets are
|
|
/// available and guaranteed to have been present at startup time.
|
|
pub struct ResolvedSecrets {
|
|
values: HashMap<String, Secret<String>>,
|
|
}
|
|
|
|
impl ResolvedSecrets {
|
|
/// Get a secret by key.
|
|
///
|
|
/// Returns None if the key wasn't declared as required.
|
|
/// In normal use you will always have the keys you declared.
|
|
pub fn get(&self, key: &str) -> Option<&Secret<String>> {
|
|
self.values.get(key)
|
|
}
|
|
|
|
/// Get a secret or panic with a clear message.
|
|
///
|
|
/// Use this for secrets that are truly required and were declared in
|
|
/// the resolver — if the resolver passed, this will always succeed.
|
|
pub fn require(&self, key: &str) -> &Secret<String> {
|
|
self.values.get(key).unwrap_or_else(|| {
|
|
panic!(
|
|
"Secret '{}' was not declared as required in SecretsResolver. \
|
|
Declare all required secrets before calling resolve().",
|
|
key
|
|
)
|
|
})
|
|
}
|
|
|
|
/// The number of resolved secrets.
|
|
pub fn len(&self) -> usize {
|
|
self.values.len()
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.values.is_empty()
|
|
}
|
|
}
|
|
|
|
/// Loads and validates all required secrets at startup.
|
|
///
|
|
/// Usage:
|
|
/// ```ignore
|
|
/// let secrets = SecretsResolver::new()
|
|
/// .source(EnvVarSource::new())
|
|
/// .require("jwt.secret_key")
|
|
/// .require("database.password")
|
|
/// .resolve()?;
|
|
///
|
|
/// let jwt_key = secrets.require("jwt.secret_key");
|
|
/// ```
|
|
pub struct SecretsResolver {
|
|
sources: Vec<Box<dyn SecretsSource>>,
|
|
required: Vec<String>,
|
|
}
|
|
|
|
impl SecretsResolver {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
sources: Vec::new(),
|
|
required: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Add a secret source. Sources are tried in order; first success wins.
|
|
pub fn source(mut self, source: Box<dyn SecretsSource>) -> Self {
|
|
self.sources.push(source);
|
|
self
|
|
}
|
|
|
|
/// Declare a required secret key.
|
|
///
|
|
/// All required keys must be present in at least one source.
|
|
/// resolve() fails if any are missing.
|
|
pub fn require(mut self, key: impl Into<String>) -> Self {
|
|
self.required.push(key.into());
|
|
self
|
|
}
|
|
|
|
/// Declare multiple required secret keys.
|
|
pub fn require_all(mut self, keys: &[&str]) -> Self {
|
|
for key in keys {
|
|
self.required.push(key.to_string());
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Load all required secrets and validate they are all present.
|
|
///
|
|
/// Returns an error listing ALL missing secrets — not just the first one —
|
|
/// so you can fix them all in one go.
|
|
pub fn resolve(self) -> Result<ResolvedSecrets, SecretsError> {
|
|
let mut values = HashMap::new();
|
|
let mut missing = Vec::new();
|
|
|
|
for key in &self.required {
|
|
match self.fetch_from_sources(key) {
|
|
Ok(secret) => {
|
|
values.insert(key.clone(), secret);
|
|
}
|
|
Err(_) => {
|
|
missing.push(key.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
if !missing.is_empty() {
|
|
return Err(SecretsError::MissingRequired { keys: missing });
|
|
}
|
|
|
|
Ok(ResolvedSecrets { values })
|
|
}
|
|
|
|
/// Attempt to fetch a key from sources in order.
|
|
fn fetch_from_sources(&self, key: &str) -> Result<Secret<String>, SecretsError> {
|
|
for source in &self.sources {
|
|
if let Ok(secret) = source.get(key) {
|
|
return Ok(secret);
|
|
}
|
|
}
|
|
Err(SecretsError::NotFoundInAnySource {
|
|
key: key.to_string(),
|
|
})
|
|
}
|
|
|
|
/// Resolve without requiring all keys — useful when you want
|
|
/// to load whatever is available.
|
|
pub fn resolve_optional(self) -> ResolvedSecrets {
|
|
let mut values = HashMap::new();
|
|
for key in &self.required {
|
|
if let Ok(secret) = self.fetch_from_sources(key) {
|
|
values.insert(key.clone(), secret);
|
|
}
|
|
}
|
|
ResolvedSecrets { values }
|
|
}
|
|
}
|
|
|
|
impl Default for SecretsResolver {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::source::InMemorySource;
|
|
|
|
fn make_source(pairs: &[(&str, &str)]) -> InMemorySource {
|
|
let mut src = InMemorySource::new();
|
|
for (k, v) in pairs {
|
|
src.insert(*k, *v);
|
|
}
|
|
src
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_all_present() {
|
|
let src = make_source(&[("jwt.key", "secret"), ("db.password", "pass123")]);
|
|
let secrets = SecretsResolver::new()
|
|
.source(Box::new(src))
|
|
.require("jwt.key")
|
|
.require("db.password")
|
|
.resolve()
|
|
.unwrap();
|
|
|
|
assert_eq!(secrets.require("jwt.key").expose(), "secret");
|
|
assert_eq!(secrets.require("db.password").expose(), "pass123");
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_missing_fails_with_list() {
|
|
let src = make_source(&[("jwt.key", "secret")]);
|
|
let result = SecretsResolver::new()
|
|
.source(Box::new(src))
|
|
.require("jwt.key")
|
|
.require("db.password") // missing
|
|
.require("stripe.key") // also missing
|
|
.resolve();
|
|
|
|
match result {
|
|
Err(SecretsError::MissingRequired { keys }) => {
|
|
assert!(keys.contains(&"db.password".to_string()));
|
|
assert!(keys.contains(&"stripe.key".to_string()));
|
|
assert_eq!(keys.len(), 2);
|
|
}
|
|
_ => panic!("expected MissingRequired error"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_optional_skips_missing() {
|
|
let src = make_source(&[("jwt.key", "secret")]);
|
|
let secrets = SecretsResolver::new()
|
|
.source(Box::new(src))
|
|
.require("jwt.key")
|
|
.require("missing.key")
|
|
.resolve_optional();
|
|
|
|
assert!(secrets.get("jwt.key").is_some());
|
|
assert!(secrets.get("missing.key").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn multiple_sources_fallback() {
|
|
let mut primary = InMemorySource::new();
|
|
primary.insert("jwt.key", "from-primary");
|
|
let mut secondary = InMemorySource::new();
|
|
secondary.insert("db.password", "from-secondary");
|
|
|
|
let secrets = SecretsResolver::new()
|
|
.source(Box::new(primary))
|
|
.source(Box::new(secondary))
|
|
.require("jwt.key")
|
|
.require("db.password")
|
|
.resolve()
|
|
.unwrap();
|
|
|
|
assert_eq!(secrets.require("jwt.key").expose(), "from-primary");
|
|
assert_eq!(secrets.require("db.password").expose(), "from-secondary");
|
|
}
|
|
|
|
#[test]
|
|
fn first_source_wins() {
|
|
let mut s1 = InMemorySource::new();
|
|
s1.insert("key", "value-1");
|
|
let mut s2 = InMemorySource::new();
|
|
s2.insert("key", "value-2");
|
|
|
|
let secrets = SecretsResolver::new()
|
|
.source(Box::new(s1))
|
|
.source(Box::new(s2))
|
|
.require("key")
|
|
.resolve()
|
|
.unwrap();
|
|
|
|
assert_eq!(secrets.require("key").expose(), "value-1");
|
|
}
|
|
|
|
#[test]
|
|
fn require_all() {
|
|
let src = make_source(&[("a", "1"), ("b", "2"), ("c", "3")]);
|
|
let secrets = SecretsResolver::new()
|
|
.source(Box::new(src))
|
|
.require_all(&["a", "b", "c"])
|
|
.resolve()
|
|
.unwrap();
|
|
|
|
assert_eq!(secrets.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn resolved_len() {
|
|
let src = make_source(&[("k", "v")]);
|
|
let secrets = SecretsResolver::new()
|
|
.source(Box::new(src))
|
|
.require("k")
|
|
.resolve()
|
|
.unwrap();
|
|
assert_eq!(secrets.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn resolved_is_empty() {
|
|
let resolved = ResolvedSecrets { values: HashMap::new() };
|
|
assert!(resolved.is_empty());
|
|
}
|
|
}
|