use async_trait::async_trait;
use base64::Engine;
use std::collections::{BTreeMap, HashSet};
use std::sync::Arc;
use cloacina_workflow::secret::{SecretResolver, SecretResolverError};
use crate::database::universal_types::UniversalUuid;
use crate::security::{SecretError, SecretStore};
pub const KEK_ENV_VAR: &str = "CLOACINA_SECRET_KEK";
#[derive(Debug, Clone)]
pub enum SecretAllow {
All,
List(HashSet<String>),
}
impl SecretAllow {
#[cfg(feature = "constructors-wasm")]
pub fn from_grants(grants: &crate::registry::loader::grants::ResolvedGrants) -> Self {
SecretAllow::List(grants.secrets.iter().cloned().collect())
}
fn permits(&self, name: &str) -> bool {
match self {
SecretAllow::All => true,
SecretAllow::List(set) => set.contains(name),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum SecretResolverConfigError {
#[error("environment variable {0} is not set")]
MissingEnv(&'static str),
#[error("{0} must be base64 or hex encoding of exactly 32 bytes")]
InvalidKek(&'static str),
}
#[derive(Clone)]
pub struct SecretStoreResolver {
store: SecretStore,
org_id: UniversalUuid,
kek: Vec<u8>,
allow: SecretAllow,
}
impl std::fmt::Debug for SecretStoreResolver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SecretStoreResolver")
.field("org_id", &self.org_id)
.field("kek", &"<redacted>")
.field("allow", &self.allow)
.finish()
}
}
impl SecretStoreResolver {
pub fn new(store: SecretStore, org_id: UniversalUuid, kek: Vec<u8>) -> Self {
Self {
store,
org_id,
kek,
allow: SecretAllow::All,
}
}
pub fn new_gated(
store: SecretStore,
org_id: UniversalUuid,
kek: Vec<u8>,
allow: SecretAllow,
) -> Self {
Self {
store,
org_id,
kek,
allow,
}
}
#[cfg(feature = "constructors-wasm")]
pub fn from_grants(
store: SecretStore,
org_id: UniversalUuid,
kek: Vec<u8>,
grants: &crate::registry::loader::grants::ResolvedGrants,
) -> Self {
Self::new_gated(store, org_id, kek, SecretAllow::from_grants(grants))
}
pub fn into_arc(self) -> Arc<dyn SecretResolver> {
Arc::new(self)
}
pub fn parse_kek(raw: &str) -> Result<Vec<u8>, SecretResolverConfigError> {
let raw = raw.trim();
if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(raw) {
if bytes.len() == 32 {
return Ok(bytes);
}
}
if let Ok(bytes) = hex::decode(raw) {
if bytes.len() == 32 {
return Ok(bytes);
}
}
Err(SecretResolverConfigError::InvalidKek(KEK_ENV_VAR))
}
pub fn kek_from_env() -> Result<Vec<u8>, SecretResolverConfigError> {
let raw = std::env::var(KEK_ENV_VAR)
.map_err(|_| SecretResolverConfigError::MissingEnv(KEK_ENV_VAR))?;
Self::parse_kek(&raw)
}
pub fn from_env(
store: SecretStore,
org_id: UniversalUuid,
) -> Result<Option<Self>, SecretResolverConfigError> {
match std::env::var(KEK_ENV_VAR) {
Err(_) => Ok(None),
Ok(raw) => {
let kek = Self::parse_kek(&raw)?;
Ok(Some(Self::new(store, org_id, kek)))
}
}
}
}
#[async_trait]
impl SecretResolver for SecretStoreResolver {
async fn resolve(&self, name: &str) -> Result<BTreeMap<String, String>, SecretResolverError> {
if !self.allow.permits(name) {
tracing::warn!(
secret.name = %name,
org_id = %self.org_id,
"secret resolution DENIED: name not in the holder's secrets grant"
);
return Err(SecretResolverError::NotGranted(name.to_string()));
}
tracing::debug!(
secret.name = %name,
org_id = %self.org_id,
"secret resolution allowed by grant; resolving"
);
self.store
.resolve_secret(self.org_id, name, &self.kek)
.await
.map_err(|e| match e {
SecretError::NotFound(n) => SecretResolverError::NotFound(n),
other => SecretResolverError::Backend(other.to_string()),
})
}
}
#[cfg(all(test, feature = "sqlite"))]
mod tests {
use super::*;
use crate::dal::unified::DAL;
use crate::database::Database;
use cloacina_workflow::Context;
async fn unique_dal() -> DAL {
let url = format!(
"file:secret_resolver_test_{}?mode=memory&cache=shared",
uuid::Uuid::new_v4()
);
let db = Database::new(&url, "", 5);
db.run_migrations().await.expect("migrations");
DAL::new(db)
}
fn kek() -> Vec<u8> {
vec![7u8; 32]
}
#[test]
fn test_parse_kek_accepts_base64_and_hex_32_bytes() {
let raw = [3u8; 32];
let b64 = base64::engine::general_purpose::STANDARD.encode(raw);
assert_eq!(SecretStoreResolver::parse_kek(&b64).unwrap(), raw.to_vec());
let hexed = hex::encode(raw);
assert_eq!(
SecretStoreResolver::parse_kek(&hexed).unwrap(),
raw.to_vec()
);
}
#[test]
fn test_parse_kek_rejects_wrong_length() {
let short = base64::engine::general_purpose::STANDARD.encode([1u8; 16]);
assert!(SecretStoreResolver::parse_kek(&short).is_err());
}
#[tokio::test]
async fn test_resolver_resolves_stored_secret_through_context() {
let dal = unique_dal().await;
let store = SecretStore::new(dal);
let org = UniversalUuid::new_v4();
let mut fields = BTreeMap::new();
fields.insert("password".to_string(), "resolver-secret".to_string());
store
.create_secret(org, "db_prod", &fields, &kek())
.await
.unwrap();
let resolver = SecretStoreResolver::new(store, org, kek()).into_arc();
let ctx = Context::<serde_json::Value>::new().with_secret_resolver(resolver);
let resolved = ctx.secret("db_prod").await.unwrap();
assert_eq!(resolved.get("password").unwrap(), "resolver-secret");
let json = ctx.to_json().unwrap();
assert!(!json.contains("resolver-secret"), "secret leaked: {json}");
}
#[tokio::test]
async fn test_resolver_missing_secret_maps_to_not_found() {
let dal = unique_dal().await;
let store = SecretStore::new(dal);
let org = UniversalUuid::new_v4();
let resolver = SecretStoreResolver::new(store, org, kek()).into_arc();
let ctx = Context::<serde_json::Value>::new().with_secret_resolver(resolver);
assert!(matches!(
ctx.secret("absent").await.unwrap_err(),
cloacina_workflow::SecretAccessError::NotFound(_)
));
}
#[tokio::test]
async fn test_gated_resolver_allows_granted_denies_ungranted() {
let dal = unique_dal().await;
let store = SecretStore::new(dal);
let org = UniversalUuid::new_v4();
let mut db_fields = BTreeMap::new();
db_fields.insert("password".to_string(), "granted-value".to_string());
store
.create_secret(org, "db_prod", &db_fields, &kek())
.await
.unwrap();
let mut other_fields = BTreeMap::new();
other_fields.insert("token".to_string(), "ungranted-value".to_string());
store
.create_secret(org, "other", &other_fields, &kek())
.await
.unwrap();
let allow = SecretAllow::List(HashSet::from(["db_prod".to_string()]));
let resolver = SecretStoreResolver::new_gated(store, org, kek(), allow);
let resolved = resolver.resolve("db_prod").await.unwrap();
assert_eq!(resolved.get("password").unwrap(), "granted-value");
assert!(matches!(
resolver.resolve("other").await.unwrap_err(),
SecretResolverError::NotGranted(n) if n == "other"
));
}
#[tokio::test]
async fn test_empty_gated_allow_list_denies_everything() {
let dal = unique_dal().await;
let store = SecretStore::new(dal);
let org = UniversalUuid::new_v4();
let mut fields = BTreeMap::new();
fields.insert("password".to_string(), "v".to_string());
store
.create_secret(org, "db_prod", &fields, &kek())
.await
.unwrap();
let resolver =
SecretStoreResolver::new_gated(store, org, kek(), SecretAllow::List(HashSet::new()));
assert!(matches!(
resolver.resolve("db_prod").await.unwrap_err(),
SecretResolverError::NotGranted(_)
));
}
#[cfg(feature = "constructors-wasm")]
#[tokio::test]
async fn test_from_grants_builds_gate_from_resolved_grants() {
use crate::registry::loader::grants::{translate, GrantSpec};
let dal = unique_dal().await;
let store = SecretStore::new(dal);
let org = UniversalUuid::new_v4();
let mut fields = BTreeMap::new();
fields.insert("password".to_string(), "granted-value".to_string());
store
.create_secret(org, "db_prod", &fields, &kek())
.await
.unwrap();
let grants = translate(&GrantSpec::from_lists(
vec![],
vec![],
vec![],
vec![],
vec!["db_prod".into()],
))
.unwrap();
let resolver = SecretStoreResolver::from_grants(store, org, kek(), &grants);
assert_eq!(
resolver.resolve("db_prod").await.unwrap().get("password"),
Some(&"granted-value".to_string())
);
assert!(matches!(
resolver.resolve("nope").await.unwrap_err(),
SecretResolverError::NotGranted(_)
));
}
#[tokio::test]
async fn test_trusted_resolver_resolves_any_name_in_tenant() {
let dal = unique_dal().await;
let store = SecretStore::new(dal);
let org = UniversalUuid::new_v4();
for name in ["db_prod", "stripe", "anything_else"] {
let mut fields = BTreeMap::new();
fields.insert("k".to_string(), format!("v-{name}"));
store
.create_secret(org, name, &fields, &kek())
.await
.unwrap();
}
let resolver = SecretStoreResolver::new(store, org, kek());
for name in ["db_prod", "stripe", "anything_else"] {
assert_eq!(
resolver.resolve(name).await.unwrap().get("k"),
Some(&format!("v-{name}"))
);
}
}
#[tokio::test]
async fn test_grant_does_not_cross_tenant_scope() {
let dal = unique_dal().await;
let store = SecretStore::new(dal);
let org_a = UniversalUuid::new_v4();
let org_b = UniversalUuid::new_v4();
let mut fields = BTreeMap::new();
fields.insert("password".to_string(), "a-only".to_string());
store
.create_secret(org_a, "db_prod", &fields, &kek())
.await
.unwrap();
let allow = SecretAllow::List(HashSet::from(["db_prod".to_string()]));
let resolver = SecretStoreResolver::new_gated(store, org_b, kek(), allow);
assert!(matches!(
resolver.resolve("db_prod").await.unwrap_err(),
SecretResolverError::NotFound(_)
));
}
#[tokio::test]
async fn test_resolver_wrong_kek_maps_to_backend_error() {
let dal = unique_dal().await;
let store = SecretStore::new(dal);
let org = UniversalUuid::new_v4();
let mut fields = BTreeMap::new();
fields.insert("password".to_string(), "resolver-secret".to_string());
store
.create_secret(org, "db_prod", &fields, &kek())
.await
.unwrap();
let resolver = SecretStoreResolver::new(store, org, vec![9u8; 32]).into_arc();
let ctx = Context::<serde_json::Value>::new().with_secret_resolver(resolver);
assert!(matches!(
ctx.secret("db_prod").await.unwrap_err(),
cloacina_workflow::SecretAccessError::Backend(_)
));
}
}