use std::collections::{BTreeMap, HashMap};
use std::sync::{Arc, Mutex};
use crate::backends::secrets::{SecretError, SecretMaterial, SecretResolver};
use crate::desired_state::credentials::Credentials;
use crate::desired_state::secrets::{SecretOwner, SecretRef};
use crate::desired_state::{DesiredState, ResourceRef};
use super::compile::ProjectionError;
#[derive(Debug, Default)]
pub struct MaterialLedger {
held: Mutex<BTreeMap<SecretRef, usize>>,
}
impl MaterialLedger {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
fn retain(
self: &Arc<Self>,
reference: SecretRef,
material: SecretMaterial,
) -> RetainedMaterial {
*self
.held
.lock()
.expect("not poisoned")
.entry(reference)
.or_insert(0) += 1;
RetainedMaterial(Arc::new(Retained {
reference,
material,
ledger: Arc::clone(self),
}))
}
fn release(&self, reference: SecretRef) {
let mut held = self.held.lock().expect("not poisoned");
match held.get_mut(&reference) {
Some(count) if *count > 1 => *count -= 1,
_ => {
held.remove(&reference);
}
}
}
pub fn holds(&self, reference: SecretRef) -> bool {
self.held
.lock()
.expect("not poisoned")
.contains_key(&reference)
}
pub fn retained(&self) -> Vec<SecretRef> {
self.held
.lock()
.expect("not poisoned")
.keys()
.copied()
.collect()
}
pub fn len(&self) -> usize {
self.held.lock().expect("not poisoned").len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[derive(Clone)]
pub struct RetainedMaterial(Arc<Retained>);
struct Retained {
reference: SecretRef,
material: SecretMaterial,
ledger: Arc<MaterialLedger>,
}
impl Drop for Retained {
fn drop(&mut self) {
self.ledger.release(self.reference);
}
}
impl RetainedMaterial {
pub fn reference(&self) -> SecretRef {
self.0.reference
}
pub fn expose(&self) -> &str {
self.0.material.expose()
}
pub fn holders(&self) -> usize {
Arc::strong_count(&self.0)
}
}
impl std::fmt::Debug for RetainedMaterial {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RetainedMaterial")
.field("reference", &self.0.reference)
.field("holders", &self.holders())
.finish_non_exhaustive()
}
}
#[derive(Clone, Debug, Default)]
pub struct ResolvedSecrets {
materials: HashMap<SecretRef, RetainedMaterial>,
}
impl ResolvedSecrets {
pub fn get(&self, reference: SecretRef) -> Option<&RetainedMaterial> {
self.materials.get(&reference)
}
pub fn len(&self) -> usize {
self.materials.len()
}
pub fn is_empty(&self) -> bool {
self.materials.is_empty()
}
pub fn references(&self) -> Vec<SecretRef> {
let mut references: Vec<SecretRef> = self.materials.keys().copied().collect();
references.sort_unstable();
references
}
}
pub struct SecretMaterialization {
resolver: Option<Arc<dyn SecretResolver>>,
ledger: Arc<MaterialLedger>,
}
impl SecretMaterialization {
pub fn new(resolver: Arc<dyn SecretResolver>, ledger: Arc<MaterialLedger>) -> Self {
Self {
resolver: Some(resolver),
ledger,
}
}
pub fn stateless(ledger: Arc<MaterialLedger>) -> Self {
Self {
resolver: None,
ledger,
}
}
pub fn backend(&self) -> Option<&'static str> {
self.resolver.as_ref().map(|resolver| resolver.name())
}
pub fn ledger(&self) -> &Arc<MaterialLedger> {
&self.ledger
}
pub async fn resolve(&self, state: &DesiredState) -> Result<ResolvedSecrets, ProjectionError> {
let credentials = Credentials::of(state).map_err(|error| ProjectionError::Body {
reference: error.reference(),
detail: error.to_string(),
})?;
let mut resolved = ResolvedSecrets::default();
for credential in credentials.all() {
if !credential.body.permits_resolution() {
continue;
}
let reference = credential.body.secret();
if resolved.materials.contains_key(&reference) {
continue;
}
let material = self
.unwrap_one(credential.body.owner(), reference, credential.reference)
.await?;
resolved
.materials
.insert(reference, self.ledger.retain(reference, material));
}
Ok(resolved)
}
async fn unwrap_one(
&self,
owner: SecretOwner,
reference: SecretRef,
holder: ResourceRef,
) -> Result<SecretMaterial, ProjectionError> {
let Some(resolver) = &self.resolver else {
return Err(secret_error(
holder,
reference,
"this process has no secret store configured, so typed provider credentials \
cannot be resolved: a stateful deployment needs a `[secret_store]` section"
.to_owned(),
));
};
resolver
.resolve(owner, &reference)
.await
.map_err(|error: SecretError| secret_error(holder, reference, error.to_string()))
}
}
fn secret_error(holder: ResourceRef, reference: SecretRef, detail: String) -> ProjectionError {
ProjectionError::Secret {
holder,
reference: reference.to_string(),
detail,
}
}
#[cfg(test)]
pub(crate) mod testing {
use super::*;
use async_trait::async_trait;
use crate::backends::{Capabilities, Capability};
pub(crate) struct AnyMaterial;
pub(crate) const MATERIAL: &str = "sk-test-material";
#[async_trait]
impl SecretResolver for AnyMaterial {
fn name(&self) -> &'static str {
"any-material"
}
fn capabilities(&self) -> Capabilities {
Capabilities::new(&[Capability::EnvelopeEncryption])
}
async fn resolve(
&self,
_owner: SecretOwner,
_reference: &SecretRef,
) -> Result<SecretMaterial, SecretError> {
Ok(SecretMaterial::new(MATERIAL.to_owned()))
}
async fn exists(
&self,
_owner: SecretOwner,
_reference: &SecretRef,
) -> Result<bool, SecretError> {
Ok(true)
}
}
pub(crate) fn permissive() -> Arc<SecretMaterialization> {
Arc::new(SecretMaterialization::new(
Arc::new(AnyMaterial),
MaterialLedger::new(),
))
}
pub(crate) fn unavailable() -> Arc<SecretMaterialization> {
let store = crate::backends::fakes::InMemorySecrets::new();
store.set_unavailable(true);
Arc::new(SecretMaterialization::new(
Arc::new(store),
MaterialLedger::new(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backends::fakes::InMemorySecrets;
use crate::desired_state::credentials::ProviderCredentialBody;
use crate::desired_state::secrets::SecretLifecycle;
use crate::desired_state::{Slug, fixtures};
const MATERIAL: &str = "sk-live-fixture";
fn state_with(bodies: Vec<(ProviderCredentialBody, &str)>) -> DesiredState {
let mut state = DesiredState::new();
state.insert(fixtures::tenant(1, "acme")).expect("a tenant");
for (body, slug) in bodies {
state
.insert(body.version(Slug::parse(slug).expect("fixture slug")))
.expect("a credential");
}
state
}
fn body(seed: u64, lifecycle: SecretLifecycle) -> ProviderCredentialBody {
let staged = fixtures::credential_body(&fixtures::tenant_id(1), seed, "primary");
staged
.transitioned(lifecycle)
.expect("a permitted lifecycle for a fixture")
}
fn store(state: &DesiredState) -> Arc<InMemorySecrets> {
let store = Arc::new(InMemorySecrets::new());
for credential in Credentials::of(state)
.expect("readable fixture credentials")
.all()
{
store.seed(
credential.body.owner(),
credential.body.secret(),
MATERIAL,
credential.body.lifecycle(),
);
}
store
}
#[tokio::test]
async fn every_required_version_is_resolved_once_and_registered() {
let state = state_with(vec![
(body(3, SecretLifecycle::Active), "primary"),
(body(4, SecretLifecycle::Staged), "next"),
]);
let ledger = MaterialLedger::new();
let materialization = SecretMaterialization::new(store(&state), Arc::clone(&ledger));
assert_eq!(materialization.backend(), Some("in-memory"));
let resolved = materialization
.resolve(&state)
.await
.expect("the fixture's material is stored");
let mut expected: Vec<SecretRef> = Credentials::of(&state)
.unwrap()
.required_secrets()
.map(|(_, reference)| reference)
.collect();
expected.sort_unstable();
assert_eq!(expected.len(), 2, "staged material resolves too");
assert_eq!(resolved.references(), expected);
assert_eq!(ledger.retained(), expected);
for reference in expected {
assert_eq!(
resolved.get(reference).expect("resolved").expose(),
MATERIAL
);
}
}
#[tokio::test]
async fn material_is_released_when_the_last_holder_drops() {
let state = state_with(vec![(body(3, SecretLifecycle::Active), "primary")]);
let ledger = MaterialLedger::new();
let resolved = SecretMaterialization::new(store(&state), Arc::clone(&ledger))
.resolve(&state)
.await
.expect("resolution");
let reference = resolved.references()[0];
assert!(ledger.holds(reference));
let second = resolved.clone();
assert_eq!(resolved.get(reference).unwrap().holders(), 2);
drop(resolved);
assert!(ledger.holds(reference), "a holder remains");
drop(second);
assert!(ledger.is_empty(), "the last holder releases the material");
assert!(!ledger.holds(reference));
}
#[tokio::test]
async fn an_unavailable_store_refuses_the_candidate_without_disclosure() {
let state = state_with(vec![(body(3, SecretLifecycle::Active), "primary")]);
let store = store(&state);
store.set_unavailable(true);
let ledger = MaterialLedger::new();
let error = SecretMaterialization::new(store, Arc::clone(&ledger))
.resolve(&state)
.await
.expect_err("an unavailable store cannot resolve");
assert!(matches!(error, ProjectionError::Secret { .. }));
let rendered = error.to_string();
assert!(rendered.contains("sct_"), "{rendered}");
assert!(!rendered.contains(MATERIAL), "{rendered}");
assert!(ledger.is_empty());
}
#[tokio::test]
async fn missing_material_refuses_the_candidate() {
let state = state_with(vec![(body(3, SecretLifecycle::Active), "primary")]);
let ledger = MaterialLedger::new();
let error =
SecretMaterialization::new(Arc::new(InMemorySecrets::new()), Arc::clone(&ledger))
.resolve(&state)
.await
.expect_err("nothing is stored");
assert!(error.to_string().contains("is not stored"), "{error}");
assert!(ledger.is_empty());
}
#[tokio::test]
async fn a_disabled_credential_is_published_without_material() {
let disabled = body(3, SecretLifecycle::Disabled);
let state = state_with(vec![
(disabled.clone(), "primary"),
(body(4, SecretLifecycle::Active), "next"),
]);
let ledger = MaterialLedger::new();
let resolved = SecretMaterialization::new(store(&state), Arc::clone(&ledger))
.resolve(&state)
.await
.expect("a disabled credential needs no material");
assert_eq!(resolved.len(), 1);
assert!(resolved.get(disabled.secret()).is_none());
assert!(!ledger.holds(disabled.secret()));
}
#[tokio::test]
async fn a_process_with_no_store_refuses_a_revision_that_needs_material() {
let state = state_with(vec![(body(3, SecretLifecycle::Active), "primary")]);
let ledger = MaterialLedger::new();
let materialization = SecretMaterialization::stateless(Arc::clone(&ledger));
assert_eq!(materialization.backend(), None);
let error = materialization
.resolve(&state)
.await
.expect_err("no store, no material");
assert!(error.to_string().contains("[secret_store]"), "{error}");
assert!(ledger.is_empty());
let empty = SecretMaterialization::stateless(Arc::clone(&ledger))
.resolve(&state_with(Vec::new()))
.await
.expect("nothing to resolve");
assert!(empty.is_empty());
}
}