use async_trait::async_trait;
use std::collections::HashMap;
use tracing::instrument;
use crate::{Result, RotationResult, Secret, SecretMetadata, SecretRef, SecretsError};
#[async_trait]
pub trait SecretsProvider: Send + Sync {
async fn get_secret(&self, scope: &str, name: &str) -> Result<Secret>;
async fn get_secrets(&self, scope: &str, names: &[&str]) -> Result<HashMap<String, Secret>>;
async fn list_secrets(&self, scope: &str) -> Result<Vec<SecretMetadata>>;
async fn exists(&self, scope: &str, name: &str) -> Result<bool>;
}
#[async_trait]
pub trait SecretsStore: SecretsProvider {
async fn set_secret(&self, scope: &str, name: &str, value: &Secret) -> Result<()>;
async fn delete_secret(&self, scope: &str, name: &str) -> Result<()>;
async fn rotate_secret(
&self,
scope: &str,
name: &str,
value: &Secret,
) -> Result<RotationResult> {
let previous_version = self
.list_secrets(scope)
.await?
.into_iter()
.find(|m| m.name == name)
.map(|m| m.version);
if previous_version.is_none() {
return Err(SecretsError::NotFound {
name: name.to_string(),
});
}
self.set_secret(scope, name, value).await?;
let new_version = self
.list_secrets(scope)
.await?
.into_iter()
.find(|m| m.name == name)
.map(|m| m.version)
.ok_or_else(|| SecretsError::NotFound {
name: name.to_string(),
})?;
Ok(RotationResult {
previous_version,
new_version,
})
}
}
#[async_trait]
impl<T: SecretsProvider + ?Sized> SecretsProvider for std::sync::Arc<T> {
async fn get_secret(&self, scope: &str, name: &str) -> Result<Secret> {
(**self).get_secret(scope, name).await
}
async fn get_secrets(&self, scope: &str, names: &[&str]) -> Result<HashMap<String, Secret>> {
(**self).get_secrets(scope, names).await
}
async fn list_secrets(&self, scope: &str) -> Result<Vec<SecretMetadata>> {
(**self).list_secrets(scope).await
}
async fn exists(&self, scope: &str, name: &str) -> Result<bool> {
(**self).exists(scope, name).await
}
}
#[async_trait]
impl<T: SecretsStore + ?Sized> SecretsStore for std::sync::Arc<T> {
async fn set_secret(&self, scope: &str, name: &str, value: &Secret) -> Result<()> {
(**self).set_secret(scope, name, value).await
}
async fn delete_secret(&self, scope: &str, name: &str) -> Result<()> {
(**self).delete_secret(scope, name).await
}
async fn rotate_secret(
&self,
scope: &str,
name: &str,
value: &Secret,
) -> Result<RotationResult> {
(**self).rotate_secret(scope, name, value).await
}
}
#[async_trait]
pub trait EnvScopeProvider: Send + Sync {
async fn resolve_env_scope(&self, name_or_id: &str) -> Result<String>;
}
pub struct SecretsResolver<P: SecretsProvider> {
provider: P,
scope: String,
env_resolver: Option<std::sync::Arc<dyn EnvScopeProvider>>,
}
impl<P: SecretsProvider> SecretsResolver<P> {
pub fn new(provider: P, scope: impl Into<String>) -> Self {
Self {
provider,
scope: scope.into(),
env_resolver: None,
}
}
#[must_use]
pub fn with_env_resolver(mut self, env_resolver: std::sync::Arc<dyn EnvScopeProvider>) -> Self {
self.env_resolver = Some(env_resolver);
self
}
pub fn provider(&self) -> &P {
&self.provider
}
pub fn scope(&self) -> &str {
&self.scope
}
#[instrument(skip(self), fields(scope = %self.scope))]
pub async fn resolve_value(&self, value: &str) -> Result<String> {
if let Some(rest) = value.strip_prefix("$secret://") {
return self.resolve_secret_url(rest).await;
}
if SecretRef::is_secret_ref(value) {
return self.resolve_s_ref(value).await;
}
Ok(value.to_string())
}
async fn resolve_s_ref(&self, value: &str) -> Result<String> {
let secret_ref = SecretRef::parse(value).ok_or_else(|| SecretsError::InvalidName {
name: value.to_string(),
})?;
let scope = match &secret_ref.service {
Some(service) => format!("{}/{}", self.scope, service),
None => self.scope.clone(),
};
let secret = self.provider.get_secret(&scope, &secret_ref.name).await?;
let secret_value = secret.expose();
match &secret_ref.field {
Some(field) => Self::extract_field(secret_value, field),
None => Ok(secret_value.to_string()),
}
}
async fn resolve_secret_url(&self, rest: &str) -> Result<String> {
let (env_name, after_env) =
rest.split_once('/')
.ok_or_else(|| SecretsError::InvalidName {
name: format!("$secret://{rest}"),
})?;
if env_name.is_empty() {
return Err(SecretsError::InvalidName {
name: format!("$secret://{rest}"),
});
}
let (key, field) = match after_env.split_once('/') {
Some((k, f)) => (k, Some(f.to_string())),
None => (after_env, None),
};
if key.is_empty() {
return Err(SecretsError::InvalidName {
name: format!("$secret://{rest}"),
});
}
let env_resolver = self.env_resolver.as_ref().ok_or_else(|| {
SecretsError::Provider(
"SecretsResolver has no env resolver; `$secret://` not supported".to_string(),
)
})?;
let scope = env_resolver.resolve_env_scope(env_name).await?;
let secret = self.provider.get_secret(&scope, key).await?;
let secret_value = secret.expose();
match field {
Some(f) => Self::extract_field(secret_value, &f),
None => Ok(secret_value.to_string()),
}
}
#[instrument(skip(self, env), fields(scope = %self.scope, env_count = env.len()))]
pub async fn resolve_env(
&self,
env: &HashMap<String, String>,
) -> Result<HashMap<String, String>> {
let mut refs_by_scope: HashMap<String, Vec<(String, SecretRef)>> = HashMap::new();
let mut non_secret_entries: Vec<(String, String)> = Vec::new();
for (key, value) in env {
if SecretRef::is_secret_ref(value) {
if let Some(secret_ref) = SecretRef::parse(value) {
let scope = match &secret_ref.service {
Some(service) => format!("{}/{}", self.scope, service),
None => self.scope.clone(),
};
refs_by_scope
.entry(scope)
.or_default()
.push((key.clone(), secret_ref));
} else {
return Err(SecretsError::InvalidName {
name: value.clone(),
});
}
} else {
non_secret_entries.push((key.clone(), value.clone()));
}
}
let mut secrets_by_scope: HashMap<String, HashMap<String, Secret>> = HashMap::new();
for (scope, refs) in &refs_by_scope {
let names: Vec<&str> = refs
.iter()
.map(|(_, secret_ref)| secret_ref.name.as_str())
.collect();
let unique_names: Vec<&str> = names
.iter()
.copied()
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
let secrets = self.provider.get_secrets(scope, &unique_names).await?;
secrets_by_scope.insert(scope.clone(), secrets);
}
let mut resolved = HashMap::with_capacity(env.len());
for (key, value) in non_secret_entries {
resolved.insert(key, value);
}
for (scope, refs) in refs_by_scope {
let scope_secrets = secrets_by_scope.get(&scope).ok_or_else(|| {
SecretsError::Provider(format!("missing secrets for scope: {scope}"))
})?;
for (env_key, secret_ref) in refs {
let secret =
scope_secrets
.get(&secret_ref.name)
.ok_or_else(|| SecretsError::NotFound {
name: secret_ref.name.clone(),
})?;
let value = match &secret_ref.field {
Some(field) => Self::extract_field(secret.expose(), field)?,
None => secret.expose().to_string(),
};
resolved.insert(env_key, value);
}
}
Ok(resolved)
}
fn extract_field(secret_value: &str, field: &str) -> Result<String> {
let json: serde_json::Value = serde_json::from_str(secret_value)
.map_err(|e| SecretsError::Decryption(e.to_string()))?;
match json.get(field) {
Some(serde_json::Value::String(s)) => Ok(s.clone()),
Some(serde_json::Value::Number(n)) => Ok(n.to_string()),
Some(serde_json::Value::Bool(b)) => Ok(b.to_string()),
Some(serde_json::Value::Null) => Ok(String::new()),
Some(v) => Ok(v.to_string()), None => Err(SecretsError::NotFound {
name: format!("field '{field}' in secret"),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
struct MockProvider {
secrets: Mutex<HashMap<String, HashMap<String, Secret>>>,
}
impl MockProvider {
fn new() -> Self {
Self {
secrets: Mutex::new(HashMap::new()),
}
}
fn add_secret(&self, scope: &str, name: &str, value: &str) {
let mut secrets = self.secrets.lock().unwrap();
secrets
.entry(scope.to_string())
.or_default()
.insert(name.to_string(), Secret::new(value));
}
}
#[async_trait]
impl SecretsProvider for MockProvider {
async fn get_secret(&self, scope: &str, name: &str) -> Result<Secret> {
let secrets = self.secrets.lock().unwrap();
secrets
.get(scope)
.and_then(|s| s.get(name))
.cloned()
.ok_or_else(|| SecretsError::NotFound {
name: name.to_string(),
})
}
async fn get_secrets(
&self,
scope: &str,
names: &[&str],
) -> Result<HashMap<String, Secret>> {
let secrets = self.secrets.lock().unwrap();
let scope_secrets = secrets.get(scope);
let mut result = HashMap::new();
if let Some(scope_secrets) = scope_secrets {
for name in names {
if let Some(secret) = scope_secrets.get(*name) {
result.insert((*name).to_string(), secret.clone());
}
}
}
Ok(result)
}
async fn list_secrets(&self, scope: &str) -> Result<Vec<SecretMetadata>> {
let secrets = self.secrets.lock().unwrap();
Ok(secrets
.get(scope)
.map(|s| s.keys().map(SecretMetadata::new).collect())
.unwrap_or_default())
}
async fn exists(&self, scope: &str, name: &str) -> Result<bool> {
let secrets = self.secrets.lock().unwrap();
Ok(secrets.get(scope).is_some_and(|s| s.contains_key(name)))
}
}
#[tokio::test]
async fn test_resolve_non_secret_value() {
let provider = MockProvider::new();
let resolver = SecretsResolver::new(provider, "test-deployment");
let result = resolver.resolve_value("plain-value").await.unwrap();
assert_eq!(result, "plain-value");
}
#[tokio::test]
async fn test_resolve_secret_value() {
let provider = MockProvider::new();
provider.add_secret("test-deployment", "api-key", "secret-api-key-123");
let resolver = SecretsResolver::new(provider, "test-deployment");
let result = resolver.resolve_value("$S:api-key").await.unwrap();
assert_eq!(result, "secret-api-key-123");
}
#[tokio::test]
async fn test_resolve_service_scoped_secret() {
let provider = MockProvider::new();
provider.add_secret("test-deployment/api", "db-password", "service-specific-pwd");
let resolver = SecretsResolver::new(provider, "test-deployment");
let result = resolver.resolve_value("$S:@api/db-password").await.unwrap();
assert_eq!(result, "service-specific-pwd");
}
#[tokio::test]
async fn test_resolve_secret_with_field() {
let provider = MockProvider::new();
provider.add_secret(
"test-deployment",
"database",
r#"{"host":"localhost","port":5432,"password":"db-secret"}"#,
);
let resolver = SecretsResolver::new(provider, "test-deployment");
let result = resolver
.resolve_value("$S:database/password")
.await
.unwrap();
assert_eq!(result, "db-secret");
let provider = MockProvider::new();
provider.add_secret(
"test-deployment",
"database",
r#"{"host":"localhost","port":5432,"password":"db-secret"}"#,
);
let resolver = SecretsResolver::new(provider, "test-deployment");
let result = resolver.resolve_value("$S:database/port").await.unwrap();
assert_eq!(result, "5432");
}
#[tokio::test]
async fn test_resolve_missing_secret() {
let provider = MockProvider::new();
let resolver = SecretsResolver::new(provider, "test-deployment");
let result = resolver.resolve_value("$S:nonexistent").await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), SecretsError::NotFound { .. }));
}
#[tokio::test]
async fn test_resolve_env() {
let provider = MockProvider::new();
provider.add_secret("test-deployment", "api-key", "secret-key");
provider.add_secret("test-deployment", "db-password", "secret-pwd");
provider.add_secret("test-deployment/worker", "worker-token", "worker-secret");
let resolver = SecretsResolver::new(provider, "test-deployment");
let mut env = HashMap::new();
env.insert("API_KEY".to_string(), "$S:api-key".to_string());
env.insert("DB_PASSWORD".to_string(), "$S:db-password".to_string());
env.insert(
"WORKER_TOKEN".to_string(),
"$S:@worker/worker-token".to_string(),
);
env.insert("PLAIN_VAR".to_string(), "plain-value".to_string());
let resolved_env = resolver.resolve_env(&env).await.unwrap();
assert_eq!(resolved_env.get("API_KEY").unwrap(), "secret-key");
assert_eq!(resolved_env.get("DB_PASSWORD").unwrap(), "secret-pwd");
assert_eq!(resolved_env.get("WORKER_TOKEN").unwrap(), "worker-secret");
assert_eq!(resolved_env.get("PLAIN_VAR").unwrap(), "plain-value");
}
#[tokio::test]
async fn test_resolve_env_with_missing_secret() {
let provider = MockProvider::new();
provider.add_secret("test-deployment", "exists", "value");
let resolver = SecretsResolver::new(provider, "test-deployment");
let mut env = HashMap::new();
env.insert("EXISTS".to_string(), "$S:exists".to_string());
env.insert("MISSING".to_string(), "$S:does-not-exist".to_string());
let result = resolver.resolve_env(&env).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_provider_exists() {
let provider = MockProvider::new();
provider.add_secret("scope", "exists", "value");
assert!(provider.exists("scope", "exists").await.unwrap());
assert!(!provider.exists("scope", "missing").await.unwrap());
assert!(!provider.exists("other-scope", "exists").await.unwrap());
}
#[tokio::test]
async fn test_provider_list_secrets() {
let provider = MockProvider::new();
provider.add_secret("scope", "secret1", "value1");
provider.add_secret("scope", "secret2", "value2");
provider.add_secret("other", "secret3", "value3");
let list = provider.list_secrets("scope").await.unwrap();
assert_eq!(list.len(), 2);
let names: Vec<&str> = list.iter().map(|m| m.name.as_str()).collect();
assert!(names.contains(&"secret1"));
assert!(names.contains(&"secret2"));
}
#[tokio::test]
async fn test_resolver_accessors() {
let provider = MockProvider::new();
let resolver = SecretsResolver::new(provider, "my-scope");
assert_eq!(resolver.scope(), "my-scope");
let _ = resolver.provider();
}
type MockStoreData = Mutex<HashMap<String, HashMap<String, (Secret, u32)>>>;
struct MockStore {
data: MockStoreData,
}
impl MockStore {
fn new() -> Self {
Self {
data: Mutex::new(HashMap::new()),
}
}
}
#[async_trait]
impl SecretsProvider for MockStore {
async fn get_secret(&self, scope: &str, name: &str) -> Result<Secret> {
let data = self.data.lock().unwrap();
data.get(scope)
.and_then(|s| s.get(name))
.map(|(secret, _)| secret.clone())
.ok_or_else(|| SecretsError::NotFound {
name: name.to_string(),
})
}
async fn get_secrets(
&self,
scope: &str,
names: &[&str],
) -> Result<HashMap<String, Secret>> {
let data = self.data.lock().unwrap();
let mut result = HashMap::new();
if let Some(scope_data) = data.get(scope) {
for name in names {
if let Some((secret, _)) = scope_data.get(*name) {
result.insert((*name).to_string(), secret.clone());
}
}
}
Ok(result)
}
async fn list_secrets(&self, scope: &str) -> Result<Vec<SecretMetadata>> {
let data = self.data.lock().unwrap();
Ok(data
.get(scope)
.map(|s| {
s.iter()
.map(|(name, (_, version))| {
let mut meta = SecretMetadata::new(name);
meta.version = *version;
meta
})
.collect()
})
.unwrap_or_default())
}
async fn exists(&self, scope: &str, name: &str) -> Result<bool> {
let data = self.data.lock().unwrap();
Ok(data.get(scope).is_some_and(|s| s.contains_key(name)))
}
}
#[async_trait]
impl SecretsStore for MockStore {
async fn set_secret(&self, scope: &str, name: &str, value: &Secret) -> Result<()> {
let mut data = self.data.lock().unwrap();
let scope_data = data.entry(scope.to_string()).or_default();
let next_version = scope_data
.get(name)
.map_or(1, |(_, version)| version.saturating_add(1));
scope_data.insert(name.to_string(), (value.clone(), next_version));
Ok(())
}
async fn delete_secret(&self, scope: &str, name: &str) -> Result<()> {
let mut data = self.data.lock().unwrap();
let scope_data = data.get_mut(scope).ok_or_else(|| SecretsError::NotFound {
name: name.to_string(),
})?;
scope_data
.remove(name)
.ok_or_else(|| SecretsError::NotFound {
name: name.to_string(),
})?;
Ok(())
}
}
#[tokio::test]
async fn test_rotate_secret_default_impl() {
let store = MockStore::new();
let scope = "test-scope";
let name = "test-key";
store
.set_secret(scope, name, &Secret::new("v1"))
.await
.unwrap();
let result = store
.rotate_secret(scope, name, &Secret::new("v2"))
.await
.unwrap();
assert_eq!(result.previous_version, Some(1));
assert_eq!(result.new_version, 2);
let current = store.get_secret(scope, name).await.unwrap();
assert_eq!(current.expose(), "v2");
}
#[tokio::test]
async fn test_rotate_secret_missing_returns_not_found() {
let store = MockStore::new();
let result = store
.rotate_secret("scope", "does-not-exist", &Secret::new("v1"))
.await;
assert!(matches!(result, Err(SecretsError::NotFound { .. })));
}
struct MockEnvScope {
map: HashMap<String, String>,
}
impl MockEnvScope {
fn new(pairs: &[(&str, &str)]) -> Self {
Self {
map: pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect(),
}
}
}
#[async_trait]
impl EnvScopeProvider for MockEnvScope {
async fn resolve_env_scope(&self, name_or_id: &str) -> Result<String> {
self.map
.get(name_or_id)
.cloned()
.ok_or_else(|| SecretsError::NotFound {
name: format!("env:{name_or_id}"),
})
}
}
#[tokio::test]
async fn test_secret_url_resolves_via_env_resolver() {
let provider = MockProvider::new();
provider.add_secret("env:abc", "PWD", "xyz");
let env_resolver = std::sync::Arc::new(MockEnvScope::new(&[("bootstrap", "env:abc")]));
let resolver =
SecretsResolver::new(provider, "ignored-scope").with_env_resolver(env_resolver);
let result = resolver
.resolve_value("$secret://bootstrap/PWD")
.await
.unwrap();
assert_eq!(result, "xyz");
}
#[tokio::test]
async fn test_secret_url_without_env_resolver_errors() {
let provider = MockProvider::new();
provider.add_secret("env:abc", "PWD", "xyz");
let resolver = SecretsResolver::new(provider, "ignored-scope");
let err = resolver
.resolve_value("$secret://bootstrap/PWD")
.await
.unwrap_err();
match err {
SecretsError::Provider(msg) => {
assert!(
msg.contains("$secret://"),
"expected error to mention `$secret://`, got: {msg}"
);
}
other => panic!("expected SecretsError::Provider, got {other:?}"),
}
}
#[tokio::test]
async fn test_secret_url_with_json_field_extraction() {
let provider = MockProvider::new();
provider.add_secret(
"env:abc",
"database",
r#"{"host":"localhost","port":5432,"password":"db-secret"}"#,
);
let env_resolver = std::sync::Arc::new(MockEnvScope::new(&[("bootstrap", "env:abc")]));
let resolver =
SecretsResolver::new(provider, "ignored-scope").with_env_resolver(env_resolver);
let pwd = resolver
.resolve_value("$secret://bootstrap/database/password")
.await
.unwrap();
assert_eq!(pwd, "db-secret");
let port = resolver
.resolve_value("$secret://bootstrap/database/port")
.await
.unwrap();
assert_eq!(port, "5432");
}
#[tokio::test]
async fn test_secret_url_malformed_missing_key_errors() {
let provider = MockProvider::new();
let env_resolver = std::sync::Arc::new(MockEnvScope::new(&[("bootstrap", "env:abc")]));
let resolver =
SecretsResolver::new(provider, "ignored-scope").with_env_resolver(env_resolver);
let err = resolver
.resolve_value("$secret://bootstrap")
.await
.unwrap_err();
assert!(matches!(err, SecretsError::InvalidName { .. }));
let err = resolver
.resolve_value("$secret://bootstrap/")
.await
.unwrap_err();
assert!(matches!(err, SecretsError::InvalidName { .. }));
}
#[tokio::test]
async fn test_secret_url_unknown_env_propagates_not_found() {
let provider = MockProvider::new();
let env_resolver = std::sync::Arc::new(MockEnvScope::new(&[("bootstrap", "env:abc")]));
let resolver =
SecretsResolver::new(provider, "ignored-scope").with_env_resolver(env_resolver);
let err = resolver
.resolve_value("$secret://unknown-env/PWD")
.await
.unwrap_err();
assert!(matches!(err, SecretsError::NotFound { .. }));
}
}