use async_trait::async_trait;
use secrecy::{ExposeSecret, SecretString};
use super::{BackendFailure, BackendKind, Capabilities, FailureCategory};
use crate::desired_state::secrets::{
ForbiddenTransition, LifecycleTransition, SecretLifecycle, SecretOwner, SecretRef,
};
#[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(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, Copy, PartialEq, Eq)]
pub struct SecretDescriptor {
pub reference: SecretRef,
pub owner: SecretOwner,
pub lifecycle: SecretLifecycle,
}
impl SecretDescriptor {
pub const fn permits_resolution(&self) -> bool {
self.lifecycle.permits_resolution()
}
}
impl std::fmt::Display for SecretDescriptor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} ({}, {})", self.reference, self.owner, self.lifecycle)
}
}
#[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("secret {reference} does not belong to {owner}")]
Ownership {
reference: SecretRef,
owner: SecretOwner,
},
#[error("secret {reference} is {state} and cannot be resolved")]
Lifecycle {
reference: SecretRef,
state: SecretLifecycle,
},
#[error("secret {reference} cannot be moved: {source}")]
Transition {
reference: SecretRef,
#[source]
source: ForbiddenTransition,
},
#[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(_) | Self::Ownership { .. } => FailureCategory::NotFound,
Self::Unwrap { .. } => FailureCategory::Corrupt,
Self::Invalid(_) => FailureCategory::Invalid,
Self::Lifecycle { .. } | Self::Transition { .. } | Self::Denied { .. } => {
FailureCategory::Denied
}
}
}
}
#[async_trait]
pub trait SecretResolver: Send + Sync {
fn name(&self) -> &'static str;
fn capabilities(&self) -> Capabilities;
async fn resolve(
&self,
owner: SecretOwner,
reference: &SecretRef,
) -> Result<SecretMaterial, SecretError>;
async fn exists(&self, owner: SecretOwner, reference: &SecretRef) -> Result<bool, SecretError>;
}
#[async_trait]
pub trait SecretStore: SecretResolver {
async fn stage(
&self,
owner: SecretOwner,
material: SecretMaterial,
) -> Result<SecretDescriptor, SecretError>;
async fn rotate(
&self,
owner: SecretOwner,
reference: &SecretRef,
material: SecretMaterial,
) -> Result<SecretDescriptor, SecretError>;
async fn transition(
&self,
owner: SecretOwner,
reference: &SecretRef,
next: SecretLifecycle,
) -> Result<LifecycleTransition, SecretError>;
async fn describe(
&self,
owner: SecretOwner,
reference: &SecretRef,
) -> Result<SecretDescriptor, SecretError>;
}
#[cfg(test)]
mod tests {
use super::super::{Capability, fakes::InMemorySecrets};
use super::*;
use crate::desired_state::fixtures::{project_id, secret_id, tenant_id};
fn owner() -> SecretOwner {
SecretOwner::tenant(tenant_id(1))
}
async fn active(store: &InMemorySecrets, owner: SecretOwner, plaintext: &str) -> SecretRef {
let staged = store
.stage(owner, SecretMaterial::new(plaintext.to_owned()))
.await
.expect("store");
store
.transition(owner, &staged.reference, SecretLifecycle::Active)
.await
.expect("staged material can be put in service");
staged.reference
}
#[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 secret = secret_id(1);
let reference = SecretRef::first(secret);
let rendered = reference.to_string();
assert_eq!(rendered, format!("{secret}@v1"));
assert!(format!("{reference:?}").contains(&secret.uuid().to_string()));
assert!(!rendered.contains("sk-"));
let descriptor = SecretDescriptor {
reference,
owner: owner(),
lifecycle: SecretLifecycle::Active,
};
assert_eq!(
descriptor.to_string(),
format!("{secret}@v1 ({}, active)", owner())
);
assert!(!format!("{descriptor:?}").contains("sk-"));
}
#[test]
fn error_messages_never_carry_material() {
let reference = SecretRef::first(secret_id(2));
let errors = [
SecretError::NotFound(reference),
SecretError::Unwrap {
reference,
kek: KekRef("AXOND_KEK".to_owned()),
},
SecretError::Ownership {
reference,
owner: owner(),
},
SecretError::Lifecycle {
reference,
state: SecretLifecycle::Revoked,
},
SecretError::Transition {
reference,
source: ForbiddenTransition {
from: SecretLifecycle::Revoked,
to: SecretLifecycle::Active,
},
},
SecretError::Invalid("empty material".to_owned()),
];
for error in errors {
let rendered = error.to_string();
assert!(!rendered.contains("sk-live-do-not-log"));
assert!(!rendered.contains("AXOND_KEK_SECRET_VALUE"));
}
}
#[tokio::test]
async fn stored_material_round_trips_through_a_reference() {
let store = InMemorySecrets::new();
let staged = store
.stage(owner(), SecretMaterial::new("sk-live-1".to_owned()))
.await
.expect("store");
assert_eq!(staged.lifecycle, SecretLifecycle::Staged);
assert!(store.exists(owner(), &staged.reference).await.unwrap());
assert_eq!(
store
.resolve(owner(), &staged.reference)
.await
.unwrap()
.expose(),
"sk-live-1"
);
assert_eq!(
store.describe(owner(), &staged.reference).await.unwrap(),
staged
);
}
#[tokio::test]
async fn rotation_keeps_earlier_versions_resolvable() {
let store = InMemorySecrets::new();
let first = active(&store, owner(), "sk-live-1").await;
let second = store
.rotate(owner(), &first, SecretMaterial::new("sk-live-2".to_owned()))
.await
.unwrap();
assert_eq!(second.reference.secret, first.secret);
assert_eq!(second.reference.version, first.version.next());
assert_eq!(second.lifecycle, SecretLifecycle::Staged);
assert_eq!(
store.describe(owner(), &first).await.unwrap().lifecycle,
SecretLifecycle::Active
);
assert_eq!(
store.resolve(owner(), &first).await.unwrap().expose(),
"sk-live-1"
);
assert_eq!(
store
.resolve(owner(), &second.reference)
.await
.unwrap()
.expose(),
"sk-live-2"
);
let error = store
.rotate(owner(), &first, SecretMaterial::new("sk-live-3".to_owned()))
.await
.expect_err("a version is immutable once it is stored");
assert_eq!(error.category(), FailureCategory::Invalid);
assert_eq!(
store
.resolve(owner(), &second.reference)
.await
.unwrap()
.expose(),
"sk-live-2"
);
}
#[tokio::test]
async fn empty_material_is_rejected_rather_than_stored() {
let store = InMemorySecrets::new();
let error = store
.stage(owner(), 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 = active(&store, owner(), "sk-live-1").await;
store.break_kek();
let error = store
.resolve(owner(), &reference)
.await
.expect_err("KEK is wrong");
assert_eq!(error.category(), FailureCategory::Corrupt);
assert!(!error.retryable());
assert!(store.exists(owner(), &reference).await.unwrap());
assert!(
store
.describe(owner(), &reference)
.await
.unwrap()
.permits_resolution()
);
}
#[tokio::test]
async fn a_missing_reference_is_distinguishable_from_an_outage() {
let store = InMemorySecrets::new();
let unknown = SecretRef::first(secret_id(3));
assert_eq!(
store
.resolve(owner(), &unknown)
.await
.unwrap_err()
.category(),
FailureCategory::NotFound
);
store.set_unavailable(true);
let outage = store.resolve(owner(), &unknown).await.expect_err("outage");
assert_eq!(outage.category(), FailureCategory::Unavailable);
assert!(outage.retryable());
}
#[tokio::test]
async fn material_never_resolves_for_another_owner() {
let store = InMemorySecrets::new();
let intruder = SecretOwner::tenant(tenant_id(9));
let sibling = SecretOwner::project(tenant_id(1), project_id(2));
let reference = active(&store, owner(), "sk-live-1").await;
for other in [intruder, sibling] {
let error = store
.resolve(other, &reference)
.await
.expect_err("material belongs to its owner alone");
assert_eq!(
error,
SecretError::Ownership {
reference,
owner: other
}
);
assert_eq!(error.category(), FailureCategory::NotFound);
assert!(!store.exists(other, &reference).await.unwrap());
assert_eq!(
store.describe(other, &reference).await.unwrap_err(),
SecretError::Ownership {
reference,
owner: other
}
);
assert!(
store
.transition(other, &reference, SecretLifecycle::Revoked)
.await
.is_err()
);
assert!(
store
.rotate(
other,
&reference,
SecretMaterial::new("sk-live-x".to_owned())
)
.await
.is_err()
);
}
assert_eq!(
store.describe(owner(), &reference).await.unwrap().lifecycle,
SecretLifecycle::Active
);
}
#[tokio::test]
async fn only_staged_and_active_material_unwraps() {
let store = InMemorySecrets::new();
let reference = active(&store, owner(), "sk-live-1").await;
store
.transition(owner(), &reference, SecretLifecycle::Disabled)
.await
.unwrap();
let error = store
.resolve(owner(), &reference)
.await
.expect_err("disabled material does not unwrap");
assert_eq!(
error,
SecretError::Lifecycle {
reference,
state: SecretLifecycle::Disabled
}
);
assert_eq!(error.category(), FailureCategory::Denied);
assert!(
!store.exists(owner(), &reference).await.unwrap(),
"disabled material would not resolve"
);
let descriptor = store.describe(owner(), &reference).await.unwrap();
assert_eq!(descriptor.lifecycle, SecretLifecycle::Disabled);
assert!(!descriptor.lifecycle.permits_resolution());
store
.transition(owner(), &reference, SecretLifecycle::Active)
.await
.unwrap();
assert_eq!(
store.resolve(owner(), &reference).await.unwrap().expose(),
"sk-live-1"
);
assert!(store.exists(owner(), &reference).await.unwrap());
store
.transition(owner(), &reference, SecretLifecycle::Revoked)
.await
.unwrap();
assert!(store.resolve(owner(), &reference).await.is_err());
assert!(
!store.exists(owner(), &reference).await.unwrap(),
"revoked material would not resolve"
);
assert_eq!(
store
.transition(owner(), &reference, SecretLifecycle::Active)
.await
.expect_err("revoked material is never put back in service"),
SecretError::Transition {
reference,
source: ForbiddenTransition {
from: SecretLifecycle::Revoked,
to: SecretLifecycle::Active
}
}
);
store
.transition(owner(), &reference, SecretLifecycle::Tombstoned)
.await
.unwrap();
assert!(
!store.holds_material(&reference),
"tombstoning destroys the material, not just its state"
);
assert_eq!(
store.describe(owner(), &reference).await.unwrap().lifecycle,
SecretLifecycle::Tombstoned
);
assert!(
!store.exists(owner(), &reference).await.unwrap(),
"destroyed material would not resolve"
);
assert!(store.resolve(owner(), &reference).await.is_err());
}
#[tokio::test]
async fn a_repeated_lifecycle_request_is_a_no_op_not_a_conflict() {
let store = InMemorySecrets::new();
let staged = store
.stage(owner(), SecretMaterial::new("sk-live-1".to_owned()))
.await
.unwrap();
let reference = staged.reference;
let first = store
.transition(owner(), &reference, SecretLifecycle::Active)
.await
.unwrap();
assert_eq!(
first,
LifecycleTransition::Moved {
from: SecretLifecycle::Staged,
to: SecretLifecycle::Active
}
);
for _ in 0..3 {
assert_eq!(
store
.transition(owner(), &reference, SecretLifecycle::Active)
.await
.unwrap(),
LifecycleTransition::Unchanged(SecretLifecycle::Active)
);
}
assert_eq!(
store.describe(owner(), &reference).await.unwrap().lifecycle,
SecretLifecycle::Active
);
store
.transition(owner(), &reference, SecretLifecycle::Revoked)
.await
.unwrap();
store
.transition(owner(), &reference, SecretLifecycle::Tombstoned)
.await
.unwrap();
assert_eq!(
store
.transition(owner(), &reference, SecretLifecycle::Tombstoned)
.await
.unwrap(),
LifecycleTransition::Unchanged(SecretLifecycle::Tombstoned)
);
}
#[tokio::test]
async fn rotation_of_an_unknown_or_tombstoned_version_is_refused() {
let store = InMemorySecrets::new();
let unknown = SecretRef::first(secret_id(4));
assert_eq!(
store
.rotate(owner(), &unknown, SecretMaterial::new("sk-live".to_owned()))
.await
.unwrap_err(),
SecretError::NotFound(unknown)
);
let reference = active(&store, owner(), "sk-live-1").await;
store
.transition(owner(), &reference, SecretLifecycle::Revoked)
.await
.unwrap();
store
.transition(owner(), &reference, SecretLifecycle::Tombstoned)
.await
.unwrap();
assert_eq!(
store
.rotate(
owner(),
&reference,
SecretMaterial::new("sk-live-2".to_owned())
)
.await
.unwrap_err()
.category(),
FailureCategory::Denied,
"tombstoned material has no next version"
);
}
#[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));
}
}