use async_trait::async_trait;
use secrecy::{ExposeSecret, SecretString};
use super::control_plane::ResourceId;
use super::{BackendFailure, BackendKind, Capabilities, FailureCategory};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SecretBackend {
#[default]
EncryptedPostgres,
External,
}
impl SecretBackend {
pub const fn kind(self) -> BackendKind {
match self {
Self::EncryptedPostgres => BackendKind::Postgres,
Self::External => BackendKind::ExternalSecretManager,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct KekRef(pub String);
impl std::fmt::Display for KekRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SecretRef {
pub id: ResourceId,
pub version: u32,
}
impl std::fmt::Display for SecretRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "secret:{}@{}", self.id.0, self.version)
}
}
#[derive(Clone)]
pub struct SecretMaterial(SecretString);
impl SecretMaterial {
pub fn new(plaintext: String) -> Self {
Self(SecretString::from(plaintext))
}
pub fn expose(&self) -> &str {
self.0.expose_secret()
}
pub fn is_empty(&self) -> bool {
self.0.expose_secret().is_empty()
}
}
impl From<SecretString> for SecretMaterial {
fn from(secret: SecretString) -> Self {
Self(secret)
}
}
impl std::fmt::Debug for SecretMaterial {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SecretMaterial(<redacted>)")
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SecretError {
#[error("secret store `{backend}` unavailable: {message}")]
Unavailable {
backend: &'static str,
message: String,
},
#[error("secret {0} is not stored")]
NotFound(SecretRef),
#[error("secret {reference} could not be unwrapped under KEK `{kek}`")]
Unwrap { reference: SecretRef, kek: KekRef },
#[error("invalid secret request: {0}")]
Invalid(String),
#[error("secret store `{backend}` refused the operation: {message}")]
Denied {
backend: &'static str,
message: String,
},
}
impl BackendFailure for SecretError {
fn category(&self) -> FailureCategory {
match self {
Self::Unavailable { .. } => FailureCategory::Unavailable,
Self::NotFound(_) => FailureCategory::NotFound,
Self::Unwrap { .. } => FailureCategory::Corrupt,
Self::Invalid(_) => FailureCategory::Invalid,
Self::Denied { .. } => FailureCategory::Denied,
}
}
}
#[async_trait]
pub trait SecretStore: Send + Sync {
fn name(&self) -> &'static str;
fn capabilities(&self) -> Capabilities;
async fn store(&self, material: SecretMaterial) -> Result<SecretRef, SecretError>;
async fn rotate(
&self,
reference: &SecretRef,
material: SecretMaterial,
) -> Result<SecretRef, SecretError>;
async fn resolve(&self, reference: &SecretRef) -> Result<SecretMaterial, SecretError>;
async fn exists(&self, reference: &SecretRef) -> Result<bool, SecretError>;
}
#[cfg(test)]
mod tests {
use super::super::{Capability, fakes::InMemorySecrets};
use super::*;
#[test]
fn material_is_not_debuggable() {
let material = SecretMaterial::new("sk-live-do-not-log".to_owned());
let rendered = format!("{material:?}");
assert_eq!(rendered, "SecretMaterial(<redacted>)");
assert!(!rendered.contains("sk-live"));
#[derive(Debug)]
struct Holder {
#[allow(dead_code)]
material: SecretMaterial,
}
assert!(!format!("{:?}", Holder { material }).contains("sk-live"));
}
#[test]
fn references_are_opaque_and_versioned() {
let reference = SecretRef {
id: ResourceId("0191f0a1-credential".to_owned()),
version: 3,
};
let rendered = reference.to_string();
assert_eq!(rendered, "secret:0191f0a1-credential@3");
assert!(format!("{reference:?}").contains("0191f0a1-credential"));
assert!(!rendered.contains("sk-"));
}
#[test]
fn error_messages_never_carry_material() {
let reference = SecretRef {
id: ResourceId("cred".to_owned()),
version: 1,
};
let errors = [
SecretError::NotFound(reference.clone()),
SecretError::Unwrap {
reference,
kek: KekRef("AXOND_KEK".to_owned()),
},
SecretError::Invalid("empty material".to_owned()),
];
for error in errors {
assert!(!error.to_string().contains("sk-live-do-not-log"));
}
}
#[tokio::test]
async fn stored_material_round_trips_through_a_reference() {
let store = InMemorySecrets::new();
let reference = store
.store(SecretMaterial::new("sk-live-1".to_owned()))
.await
.expect("store");
assert!(store.exists(&reference).await.unwrap());
assert_eq!(
store.resolve(&reference).await.unwrap().expose(),
"sk-live-1"
);
}
#[tokio::test]
async fn rotation_keeps_earlier_versions_resolvable() {
let store = InMemorySecrets::new();
let first = store
.store(SecretMaterial::new("sk-live-1".to_owned()))
.await
.unwrap();
let second = store
.rotate(&first, SecretMaterial::new("sk-live-2".to_owned()))
.await
.unwrap();
assert_eq!(second.id, first.id);
assert_eq!(second.version, first.version + 1);
assert_eq!(store.resolve(&first).await.unwrap().expose(), "sk-live-1");
assert_eq!(store.resolve(&second).await.unwrap().expose(), "sk-live-2");
}
#[tokio::test]
async fn empty_material_is_rejected_rather_than_stored() {
let store = InMemorySecrets::new();
let error = store
.store(SecretMaterial::new(String::new()))
.await
.expect_err("empty material is unusable");
assert_eq!(error.category(), FailureCategory::Invalid);
}
#[tokio::test]
async fn an_unwrappable_secret_is_corrupt_not_unavailable() {
let store = InMemorySecrets::new();
let reference = store
.store(SecretMaterial::new("sk-live-1".to_owned()))
.await
.unwrap();
store.break_kek();
let error = store.resolve(&reference).await.expect_err("KEK is wrong");
assert_eq!(error.category(), FailureCategory::Corrupt);
assert!(!error.retryable());
assert!(store.exists(&reference).await.unwrap());
}
#[tokio::test]
async fn a_missing_reference_is_distinguishable_from_an_outage() {
let store = InMemorySecrets::new();
let unknown = SecretRef {
id: ResourceId("absent".to_owned()),
version: 1,
};
assert_eq!(
store.resolve(&unknown).await.unwrap_err().category(),
FailureCategory::NotFound
);
store.set_unavailable(true);
let outage = store.resolve(&unknown).await.expect_err("outage");
assert_eq!(outage.category(), FailureCategory::Unavailable);
assert!(outage.retryable());
}
#[test]
fn selectable_backends_wrap_or_delegate() {
assert_eq!(SecretBackend::default(), SecretBackend::EncryptedPostgres);
assert_eq!(
SecretBackend::EncryptedPostgres.kind(),
BackendKind::Postgres
);
assert_eq!(
SecretBackend::External.kind(),
BackendKind::ExternalSecretManager
);
let responsibility =
super::super::responsibility("SecretStore").expect("declared responsibility");
for backend in [SecretBackend::EncryptedPostgres, SecretBackend::External] {
assert!(responsibility.permits(backend.kind()));
}
assert!(!responsibility.permits(BackendKind::Redis));
}
#[tokio::test]
async fn encrypted_postgres_declares_envelope_encryption() {
let store = InMemorySecrets::new();
assert!(store.capabilities().has(Capability::EnvelopeEncryption));
assert!(!store.capabilities().has(Capability::ExternalKeyManagement));
}
}