Files
notfiles/crates/notsecrets/src/resolver.rs
Joseph O'Brien 1359def673
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled
feat(notsecrets): add multi-provider secret resolution system
Add a config-driven, strongly-typed secret/env resolution system to
notsecrets with 11 provider backends. The system supports explicit
key bindings with typed SecretRef enums and a priority chain fallback.

Tier 1 providers (implemented): Env, 1Password, dotenvx, SOPS, GSM
Tier 2 providers (stubbed): nuenv, direnv, mise, Bitwarden, Vault, dotenvy

New types: Provider, ProviderConfig, SecretRef, SecretsConfig,
SecretsError, SecretSource, EnumerableSecretSource, SecretResolver

Config lives in notsecrets.toml with typed serde deserialization.
ISP split: EnumerableSecretSource for backends that support bulk
enumeration, SecretSource for single-key lookup only.

Includes conformance test suite, 33 new tests (161 workspace total).
notstrap EnvInjector migration deferred to follow-up.
2026-06-06 09:49:05 -04:00

223 lines
8.4 KiB
Rust

use crate::config::{Provider, ProviderConfig, SecretsConfig};
use crate::error::SecretsError;
use crate::ports::{EnumerableSecretSource, SecretSource};
use crate::sources::*;
use std::collections::HashMap;
pub struct SecretResolver {
config: SecretsConfig,
sources: Vec<Box<dyn SecretSource>>,
enumerable: Vec<Box<dyn EnumerableSecretSource>>,
}
impl SecretResolver {
pub fn from_config(config: SecretsConfig) -> Result<Self, SecretsError> {
// Validate: every binding references a provider in the chain
for (key, binding) in &config.secrets {
if !config.providers.contains(&binding.provider()) {
return Err(SecretsError::Config(format!(
"secret '{key}' references provider '{:?}' not in providers list",
binding.provider()
)));
}
}
let mut sources: Vec<Box<dyn SecretSource>> = Vec::new();
let mut enumerable: Vec<Box<dyn EnumerableSecretSource>> = Vec::new();
for provider in &config.providers {
let provider_config = config.provider.get(provider);
match provider {
Provider::Env => {
enumerable.push(Box::new(EnvSource));
sources.push(Box::new(EnvSource));
}
Provider::Op => {
let account = match provider_config {
Some(ProviderConfig::Op { account }) => account.clone(),
_ => {
return Err(SecretsError::Config(
"op provider requires [provider.op] with account".to_string(),
));
}
};
sources.push(Box::new(OpSource::new(account)));
}
Provider::Dotenvx => {
let env_file = match provider_config {
Some(ProviderConfig::Dotenvx { env_file }) => env_file.clone(),
_ => {
return Err(SecretsError::Config(
"dotenvx provider requires [provider.dotenvx] with env_file"
.to_string(),
));
}
};
enumerable.push(Box::new(DotenvxSource::new(env_file.clone())));
sources.push(Box::new(DotenvxSource::new(env_file)));
}
Provider::Sops => {
let file = match provider_config {
Some(ProviderConfig::Sops { file }) => file.clone(),
_ => {
return Err(SecretsError::Config(
"sops provider requires [provider.sops] with file".to_string(),
));
}
};
enumerable.push(Box::new(SopsSource::new(file.clone())));
sources.push(Box::new(SopsSource::new(file)));
}
Provider::Gsm => {
let project = match provider_config {
Some(ProviderConfig::Gsm { project }) => project.clone(),
_ => {
return Err(SecretsError::Config(
"gsm provider requires [provider.gsm] with project".to_string(),
));
}
};
sources.push(Box::new(GsmSource::new(project)));
}
Provider::Nuenv => sources.push(Box::new(NuenvSource)),
Provider::Direnv => sources.push(Box::new(DirenvSource)),
Provider::Mise => sources.push(Box::new(MiseSource)),
Provider::Bitwarden => {
let item_name = match provider_config {
Some(ProviderConfig::Bitwarden { .. }) => "default".to_string(),
_ => "default".to_string(),
};
sources.push(Box::new(BitwardenSource::new(item_name)));
}
Provider::Vault => sources.push(Box::new(VaultSource)),
Provider::Dotenvy => sources.push(Box::new(DotenvySource)),
}
}
Ok(Self {
config,
sources,
enumerable,
})
}
pub fn resolve(&self, key: &str) -> Result<Option<String>, SecretsError> {
// 1. Explicit binding
if let Some(secret_ref) = self.config.secrets.get(key) {
let provider = secret_ref.provider();
if let Some(source) = self.sources.iter().find(|s| s.provider() == provider) {
return source.resolve_ref(key, secret_ref);
}
}
// 2. Priority chain
for source in &self.sources {
if let Some(value) = source.resolve(key)? {
return Ok(Some(value));
}
}
Ok(None)
}
pub fn resolve_all(&self) -> Result<HashMap<String, String>, SecretsError> {
let mut map = HashMap::new();
// 1. Merge enumerable sources (priority order, first wins)
for source in &self.enumerable {
let source_map = source.resolve_all()?;
for (k, v) in source_map {
map.entry(k).or_insert(v);
}
}
// 2. Overlay explicit bindings (always win)
for (key, secret_ref) in &self.config.secrets {
let provider = secret_ref.provider();
if let Some(source) = self.sources.iter().find(|s| s.provider() == provider)
&& let Some(value) = source.resolve_ref(key, secret_ref)?
{
map.insert(key.clone(), value);
}
}
Ok(map)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Provider, SecretRef, SecretsConfig};
fn config_with_env_only() -> SecretsConfig {
SecretsConfig {
providers: vec![Provider::Env],
provider: HashMap::new(),
secrets: HashMap::new(),
}
}
#[test]
fn resolver_from_config_env_only() {
let resolver = SecretResolver::from_config(config_with_env_only());
assert!(resolver.is_ok());
}
#[test]
fn resolver_resolve_env_var() {
unsafe { std::env::set_var("NOTSECRETS_RESOLVER_TEST", "hello") };
let resolver = SecretResolver::from_config(config_with_env_only()).unwrap();
let val = resolver.resolve("NOTSECRETS_RESOLVER_TEST").unwrap();
assert_eq!(val, Some("hello".to_string()));
unsafe { std::env::remove_var("NOTSECRETS_RESOLVER_TEST") };
}
#[test]
fn resolver_resolve_missing_returns_none() {
let resolver = SecretResolver::from_config(config_with_env_only()).unwrap();
let val = resolver.resolve("NOTSECRETS_NONEXISTENT_99999").unwrap();
assert_eq!(val, None);
}
#[test]
fn resolver_binding_overrides_chain() {
unsafe { std::env::set_var("NOTSECRETS_BOUND_TEST", "from_env") };
let mut config = config_with_env_only();
config
.secrets
.insert("NOTSECRETS_BOUND_TEST".to_string(), SecretRef::Env);
let resolver = SecretResolver::from_config(config).unwrap();
let val = resolver.resolve("NOTSECRETS_BOUND_TEST").unwrap();
assert_eq!(val, Some("from_env".to_string()));
unsafe { std::env::remove_var("NOTSECRETS_BOUND_TEST") };
}
#[test]
fn resolver_resolve_all_includes_env() {
unsafe { std::env::set_var("NOTSECRETS_ALL_TEST", "present") };
let resolver = SecretResolver::from_config(config_with_env_only()).unwrap();
let map = resolver.resolve_all().unwrap();
assert_eq!(
map.get("NOTSECRETS_ALL_TEST").map(|s| s.as_str()),
Some("present")
);
unsafe { std::env::remove_var("NOTSECRETS_ALL_TEST") };
}
#[test]
fn resolver_binding_refs_unknown_provider_errors() {
let config = SecretsConfig {
providers: vec![Provider::Env],
provider: HashMap::new(),
secrets: HashMap::from([(
"KEY".to_string(),
SecretRef::Op {
uri: "op://x/y/z".to_string(),
},
)]),
};
let result = SecretResolver::from_config(config);
assert!(result.is_err());
}
}