use std::sync::Arc;
use serde::{Deserialize, Serialize};
use super::auth::{AdminAction, AdminAuthError, AdminGrant, AdminIdentity};
use super::error::AdminError;
use super::service::{AdminService, log_secret, log_store};
use crate::backends::secrets::{SecretDescriptor, SecretMaterial, SecretStore};
use crate::desired_state::credentials::Credentials;
use crate::desired_state::secrets::{SecretLifecycle, SecretOwner, SecretRef};
use crate::desired_state::{Actor, ResourceScope, SecretId};
fn actor_label(identity: &AdminIdentity) -> String {
match identity.actor() {
Actor::Human { issuer, subject } => format!("{issuer}#{subject}"),
Actor::Breakglass => "breakglass".to_owned(),
Actor::Workload { tenant, principal } => format!("{tenant}/{principal}"),
Actor::System { component } => format!("system:{component}"),
}
}
fn log_secret_operation(
operation: &'static str,
grant: &AdminGrant,
reference: SecretRef,
owner: SecretOwner,
lifecycle: SecretLifecycle,
changed: Option<bool>,
base: Option<SecretRef>,
) {
tracing::info!(
target: "axond.admin.secrets",
operation,
actor = actor_label(grant.identity()),
base = ?base,
reference = %reference,
owner = %owner,
lifecycle = lifecycle.as_str(),
changed = ?changed,
"secret lifecycle operation"
);
}
#[derive(Deserialize)]
#[serde(transparent)]
pub struct PresentedMaterial(String);
impl PresentedMaterial {
fn into_material(self) -> SecretMaterial {
SecretMaterial::new(self.0)
}
}
impl std::fmt::Debug for PresentedMaterial {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("PresentedMaterial(<redacted>)")
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StageSecretRequest {
pub tenant: String,
#[serde(default)]
pub project: Option<String>,
pub material: PresentedMaterial,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RotateSecretRequest {
pub tenant: String,
#[serde(default)]
pub project: Option<String>,
pub reference: String,
pub material: PresentedMaterial,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SecretLifecycleRequest {
pub tenant: String,
#[serde(default)]
pub project: Option<String>,
pub reference: String,
pub lifecycle: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SecretVersionView {
pub reference: String,
pub secret: String,
pub version: u64,
pub owner: String,
pub lifecycle: &'static str,
pub resolvable: bool,
}
impl SecretVersionView {
fn of(descriptor: SecretDescriptor) -> Self {
Self {
reference: descriptor.reference.to_string(),
secret: descriptor.reference.secret.to_string(),
version: descriptor.reference.version.get(),
owner: descriptor.owner.to_string(),
lifecycle: descriptor.lifecycle.as_str(),
resolvable: descriptor.permits_resolution(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SecretVersionsView {
pub secret: String,
pub owner: String,
pub versions: Vec<SecretVersionView>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SecretTransitionView {
pub reference: String,
pub lifecycle: &'static str,
pub changed: bool,
}
impl AdminService {
fn secret_store(&self) -> Result<&Arc<dyn SecretStore>, AdminError> {
self.store()?;
self.secrets
.as_ref()
.ok_or(AdminError::StatefulModeRequired)
}
fn permits_material(
grant: &AdminGrant,
action: AdminAction,
owner: SecretOwner,
) -> Result<(), AdminError> {
if grant.action() != action {
return Err(AdminError::Forbidden(AdminAuthError::ActionNotPermitted {
action,
}));
}
let scope = owner.scope();
let covered = match grant.scope() {
ResourceScope::Deployment => true,
ResourceScope::Tenant(tenant) => scope.tenant() == Some(*tenant),
granted => granted == &scope,
};
if covered {
Ok(())
} else {
Err(AdminError::Forbidden(AdminAuthError::ScopeNotPermitted))
}
}
pub async fn stage_secret(
&self,
grant: &AdminGrant,
owner: SecretOwner,
material: SecretMaterial,
) -> Result<SecretVersionView, AdminError> {
Self::permits_material(grant, AdminAction::WriteSecrets, owner)?;
let store = self.secret_store()?;
let descriptor = store.stage(owner, material).await.map_err(log_secret)?;
if let Some(signal) = &self.change_signal {
signal.force_refresh();
}
log_secret_operation(
"stage",
grant,
descriptor.reference,
descriptor.owner,
descriptor.lifecycle,
None,
None,
);
Ok(SecretVersionView::of(descriptor))
}
pub async fn rotate_secret(
&self,
grant: &AdminGrant,
owner: SecretOwner,
reference: SecretRef,
material: SecretMaterial,
) -> Result<SecretVersionView, AdminError> {
Self::permits_material(grant, AdminAction::WriteSecrets, owner)?;
let store = self.secret_store()?;
let descriptor = store
.rotate(owner, &reference, material)
.await
.map_err(log_secret)?;
if let Some(signal) = &self.change_signal {
signal.force_refresh();
}
log_secret_operation(
"rotate",
grant,
descriptor.reference,
descriptor.owner,
descriptor.lifecycle,
None,
Some(reference),
);
Ok(SecretVersionView::of(descriptor))
}
pub async fn move_secret(
&self,
grant: &AdminGrant,
owner: SecretOwner,
reference: SecretRef,
next: SecretLifecycle,
) -> Result<SecretTransitionView, AdminError> {
Self::permits_material(grant, AdminAction::WriteSecrets, owner)?;
let store = self.secret_store()?;
if next == SecretLifecycle::Tombstoned {
store
.describe(owner, &reference)
.await
.map_err(log_secret)?;
self.refuse_destroying_referenced_material(owner, reference)
.await?;
}
let transition = store
.transition(owner, &reference, next)
.await
.map_err(log_secret)?;
if let Some(signal) = &self.change_signal {
signal.force_refresh();
}
log_secret_operation(
"lifecycle",
grant,
reference,
owner,
transition.state(),
Some(transition.changed()),
None,
);
Ok(SecretTransitionView {
reference: reference.to_string(),
lifecycle: transition.state().as_str(),
changed: transition.changed(),
})
}
pub async fn secret_versions(
&self,
grant: &AdminGrant,
owner: SecretOwner,
secret: SecretId,
) -> Result<SecretVersionsView, AdminError> {
Self::permits_material(grant, AdminAction::ReadSecrets, owner)?;
let store = self.secret_store()?;
let descriptors = store.versions(owner, secret).await.map_err(log_secret)?;
Ok(SecretVersionsView {
secret: secret.to_string(),
owner: owner.to_string(),
versions: descriptors.into_iter().map(SecretVersionView::of).collect(),
})
}
async fn refuse_destroying_referenced_material(
&self,
owner: SecretOwner,
reference: SecretRef,
) -> Result<(), AdminError> {
let store = self.store()?;
let Some(revision) = store.load_desired_revision().await.map_err(log_store)? else {
return Ok(());
};
let credentials =
Credentials::of(revision.state()).map_err(|error| AdminError::RevisionUnreadable {
revision: Some(revision.id()),
detail: error.to_string(),
})?;
let pinned = credentials
.required_secrets()
.any(|(pinned_owner, pinned)| pinned_owner == owner && pinned == reference);
if pinned {
return Err(AdminError::SecretInUse { reference });
}
Ok(())
}
}
pub(super) fn reference_of(schema: &'static str, text: &str) -> Result<SecretRef, AdminError> {
SecretRef::parse(text).map_err(|_| AdminError::RequestInvalid {
schema,
detail: format!(
"`reference` names an exact version, as `{}…@v1`; the text presented is not reported, \
because these requests carry material",
SecretId::PREFIX
),
})
}
pub(super) fn lifecycle_of(
schema: &'static str,
text: &str,
) -> Result<SecretLifecycle, AdminError> {
SecretLifecycle::parse(text).ok_or_else(|| AdminError::RequestInvalid {
schema,
detail: format!(
"`lifecycle` must be one of {}",
SecretLifecycle::ALL
.iter()
.map(|state| state.as_str())
.collect::<Vec<_>>()
.join(", ")
),
})
}
pub(super) fn owner_of(
schema: &'static str,
tenant: &str,
project: Option<&str>,
) -> Result<SecretOwner, AdminError> {
let scope =
super::handlers::scope_of(schema, Some(tenant), project).map_err(|error| match error {
AdminError::RequestInvalid { schema, .. } => AdminError::RequestInvalid {
schema,
detail: "`tenant` and `project` are prefixed ids, as `ten_…` and `prj_…`; the \
text presented is not reported, because these requests carry material"
.to_owned(),
},
other => other,
})?;
SecretOwner::from_scope(&scope).ok_or(AdminError::RequestInvalid {
schema,
detail: "`tenant`: secret material is owned by a tenant, never by the deployment"
.to_owned(),
})
}
pub(super) fn secret_of(schema: &'static str, text: &str) -> Result<SecretId, AdminError> {
SecretId::parse(text).map_err(|_| AdminError::RequestInvalid {
schema,
detail: format!(
"`secret` is a prefixed id, as `{}…`; the text presented is not reported, because \
this surface neighbours material",
SecretId::PREFIX
),
})
}
pub(super) fn material_of(
schema: &'static str,
presented: PresentedMaterial,
) -> Result<SecretMaterial, AdminError> {
let material = presented.into_material();
if material.is_empty() {
return Err(AdminError::SecretMaterialRefused {
detail: format!("`{schema}`: the presented material is empty"),
});
}
Ok(material)
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use super::{
AdminAction, AdminGrant, AdminIdentity, AdminService, lifecycle_of, owner_of, reference_of,
secret_of,
};
use crate::admin::error::AdminError;
use crate::backends::fakes::InMemorySecrets;
use crate::backends::secrets::SecretMaterial;
use crate::desired_state::ResourceScope;
use crate::desired_state::fixtures;
use crate::desired_state::oracle::InMemoryControlPlane;
use tracing_subscriber::layer::SubscriberExt as _;
#[derive(Clone, Default)]
struct CapturedLogs(Arc<Mutex<Vec<u8>>>);
impl CapturedLogs {
fn rendered(&self) -> String {
String::from_utf8(self.0.lock().expect("not poisoned").clone()).expect("utf-8 logs")
}
}
impl std::io::Write for CapturedLogs {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.0
.lock()
.expect("not poisoned")
.extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for CapturedLogs {
type Writer = Self;
fn make_writer(&'writer self) -> Self::Writer {
self.clone()
}
}
fn withholds(error: &AdminError, presented: &str) {
assert!(!error.to_string().contains(presented));
assert!(
!error
.operator_detail()
.is_some_and(|detail| detail.contains(presented))
);
}
#[test]
fn an_invalid_lifecycle_does_not_echo_the_caller_value_to_response_or_log() {
let caller_value = "sk-lifecycle-value-must-not-echo";
let error = lifecycle_of("secret_lifecycle", caller_value).expect_err("invalid state");
withholds(&error, caller_value);
}
#[test]
fn a_field_that_neighbours_material_never_quotes_what_was_presented() {
let pasted = "sk-live-must-not-echo@v1";
withholds(
&reference_of("secret_rotate", pasted).expect_err("not a reference"),
"sk-live-must-not-echo",
);
withholds(
&secret_of("secret_versions", pasted).expect_err("not a secret"),
"sk-live-must-not-echo",
);
withholds(
&owner_of("secret_rotate", pasted, None).expect_err("not a tenant"),
"sk-live-must-not-echo",
);
withholds(
&owner_of(
"secret_rotate",
"ten_00000000-0000-7000-8000-000000000000",
Some(pasted),
)
.expect_err("not a project"),
"sk-live-must-not-echo",
);
}
#[test]
fn a_missing_material_permission_names_the_requested_action() {
let owner = crate::desired_state::secrets::SecretOwner::tenant(fixtures::tenant_id(1));
let grant = AdminGrant::granted(
AdminIdentity::Human {
issuer: "https://idp.example".to_owned(),
subject: "operator@example".to_owned(),
},
AdminAction::Publish,
ResourceScope::Tenant(fixtures::tenant_id(1)),
);
let error = AdminService::permits_material(&grant, AdminAction::WriteSecrets, owner)
.expect_err("publishing authority does not administer secret material");
match error {
AdminError::Forbidden(super::AdminAuthError::ActionNotPermitted { action }) => {
assert_eq!(action, AdminAction::WriteSecrets);
}
other => panic!("unexpected refusal: {other:?}"),
}
}
#[tokio::test]
async fn secret_material_is_absent_from_operational_logs() {
let logs = CapturedLogs::default();
let subscriber = tracing_subscriber::registry().with(
tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_writer(logs.clone()),
);
crate::telemetry::testing::keep_callsites_answerable();
let dispatch = tracing::Dispatch::new(subscriber);
let _default = tracing::dispatcher::set_default(&dispatch);
let store = Arc::new(InMemoryControlPlane::new());
let secrets = Arc::new(InMemorySecrets::new());
let service = AdminService::stateful(store).with_secrets(secrets);
let owner = crate::desired_state::secrets::SecretOwner::tenant(fixtures::tenant_id(1));
let grant = AdminGrant::granted(
AdminIdentity::Human {
issuer: "https://idp.example".to_owned(),
subject: "operator@example".to_owned(),
},
AdminAction::WriteSecrets,
ResourceScope::Tenant(fixtures::tenant_id(1)),
);
let presented = "sk-live-must-not-enter-operational-logs";
service
.stage_secret(&grant, owner, SecretMaterial::new(presented.to_owned()))
.await
.expect("secret is staged");
let rendered = logs.rendered();
assert!(
rendered.contains("secret lifecycle operation"),
"{rendered}"
);
assert!(rendered.contains("operation=\"stage\""), "{rendered}");
assert!(
!rendered.contains(presented),
"material reached a log: {rendered}"
);
}
}