use acton_reactive::prelude::*;
use super::config::KeyRotationConfig;
use super::manager::KeyManager;
#[cfg(feature = "audit")]
use crate::audit::event::{AuditEvent, AuditEventKind, AuditSeverity};
#[cfg(feature = "audit")]
use crate::audit::logger::AuditLogger;
#[derive(Default)]
pub struct KeyRotationAgentState {
pub key_manager: Option<KeyManager>,
pub config: Option<KeyRotationConfig>,
#[cfg(feature = "audit")]
pub audit_logger: Option<AuditLogger>,
}
impl std::fmt::Debug for KeyRotationAgentState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KeyRotationAgentState")
.field("key_manager", &self.key_manager.is_some())
.field("config", &self.config.is_some())
.finish()
}
}
#[derive(Clone, Debug)]
pub struct CheckRotation;
#[derive(Clone, Debug)]
pub struct ForceRotation;
pub struct KeyRotationAgent;
impl KeyRotationAgent {
pub async fn spawn(
runtime: &mut ActorRuntime,
key_manager: KeyManager,
config: KeyRotationConfig,
#[cfg(feature = "audit")] audit_logger: Option<AuditLogger>,
) -> anyhow::Result<ActorHandle> {
let mut agent = runtime.new_actor::<KeyRotationAgentState>();
agent.model.config = Some(config.clone());
agent.model.key_manager = Some(key_manager.clone());
#[cfg(feature = "audit")]
{
agent.model.audit_logger = audit_logger.clone();
}
agent.mutate_on::<CheckRotation>(|actor, _envelope| {
let km = actor.model.key_manager.clone();
let cfg = actor.model.config.clone();
#[cfg(feature = "audit")]
let audit = actor.model.audit_logger.clone();
tokio::spawn(async move {
if let (Some(km), Some(cfg)) = (km, cfg) {
perform_rotation_check(
&km,
&cfg,
false,
#[cfg(feature = "audit")]
audit.as_ref(),
)
.await;
}
});
Reply::ready()
});
agent.mutate_on::<ForceRotation>(|actor, _envelope| {
let km = actor.model.key_manager.clone();
let cfg = actor.model.config.clone();
#[cfg(feature = "audit")]
let audit = actor.model.audit_logger.clone();
tokio::spawn(async move {
if let (Some(km), Some(cfg)) = (km, cfg) {
perform_rotation_check(
&km,
&cfg,
true,
#[cfg(feature = "audit")]
audit.as_ref(),
)
.await;
}
});
Reply::ready()
});
let start_km = key_manager.clone();
let start_config = config.clone();
#[cfg(feature = "audit")]
let start_audit = audit_logger.clone();
agent.after_start(move |_agent| {
let km = start_km.clone();
let cfg = start_config.clone();
#[cfg(feature = "audit")]
let audit = start_audit.clone();
tokio::spawn(async move {
let period = std::time::Duration::from_secs(cfg.check_interval_secs);
let mut interval = tokio::time::interval(period);
interval.tick().await;
loop {
interval.tick().await;
tracing::debug!("Key rotation agent: periodic check");
perform_rotation_check(
&km,
&cfg,
false,
#[cfg(feature = "audit")]
audit.as_ref(),
)
.await;
}
});
Reply::ready()
});
let handle = agent.start().await;
Ok(handle)
}
}
async fn perform_rotation_check(
key_manager: &KeyManager,
config: &KeyRotationConfig,
force: bool,
#[cfg(feature = "audit")] audit_logger: Option<&AuditLogger>,
) {
match key_manager.retire_expired().await {
Ok(retired_count) => {
if retired_count > 0 {
tracing::info!(
count = retired_count,
service = %key_manager.service_name(),
"retired expired draining keys"
);
#[cfg(feature = "audit")]
if let Some(logger) = audit_logger {
let event = AuditEvent::new(
AuditEventKind::AuthKeyRetired,
AuditSeverity::Informational,
logger.service_name().to_string(),
)
.with_metadata(serde_json::json!({
"retired_count": retired_count,
"service": key_manager.service_name(),
}));
logger.log(event).await;
}
}
}
Err(e) => {
tracing::error!(
error = %e,
service = %key_manager.service_name(),
"failed to retire expired draining keys"
);
}
}
let needs_rotation = if force {
true
} else {
match key_manager.get_signing_key().await {
Ok(Some(active_key)) => {
match key_manager.storage().get_key_by_kid(&active_key.kid).await {
Ok(Some(meta)) => {
if let Some(activated_at) = meta.activated_at {
let age_secs = (chrono::Utc::now() - activated_at).num_seconds();
age_secs >= 0 && (age_secs as u64) >= config.rotation_period_secs
} else {
true
}
}
Ok(None) => {
true
}
Err(e) => {
tracing::error!(
error = %e,
kid = %active_key.kid,
"failed to fetch key metadata for age check"
);
false
}
}
}
Ok(None) => {
tracing::info!(
service = %key_manager.service_name(),
"no active signing key found, bootstrapping"
);
true
}
Err(e) => {
tracing::error!(
error = %e,
service = %key_manager.service_name(),
"failed to get active signing key"
);
#[cfg(feature = "audit")]
if let Some(logger) = audit_logger {
let event = AuditEvent::new(
AuditEventKind::AuthKeyRotationFailed,
AuditSeverity::Error,
logger.service_name().to_string(),
)
.with_metadata(serde_json::json!({
"error": e.to_string(),
"phase": "get_signing_key",
"service": key_manager.service_name(),
}));
logger.log(event).await;
}
false
}
}
};
if needs_rotation {
let old_kid = match key_manager.get_signing_key().await {
Ok(Some(k)) => Some(k.kid.clone()),
_ => None,
};
match key_manager.rotate().await {
Ok(new_key) => {
tracing::info!(
new_kid = %new_key.kid,
old_kid = ?old_kid,
service = %key_manager.service_name(),
forced = force,
"signing key rotated"
);
#[cfg(feature = "audit")]
if let Some(logger) = audit_logger {
let event = AuditEvent::new(
AuditEventKind::AuthKeyRotated,
AuditSeverity::Notice,
logger.service_name().to_string(),
)
.with_metadata(serde_json::json!({
"new_kid": new_key.kid,
"old_kid": old_kid,
"forced": force,
"service": key_manager.service_name(),
}));
logger.log(event).await;
}
}
Err(e) => {
tracing::error!(
error = %e,
service = %key_manager.service_name(),
"key rotation failed"
);
#[cfg(feature = "audit")]
if let Some(logger) = audit_logger {
let event = AuditEvent::new(
AuditEventKind::AuthKeyRotationFailed,
AuditSeverity::Error,
logger.service_name().to_string(),
)
.with_metadata(serde_json::json!({
"error": e.to_string(),
"phase": "rotate",
"service": key_manager.service_name(),
}));
logger.log(event).await;
}
}
}
}
}