use std::sync::Arc;
use std::time::Duration;
use uuid::Uuid;
use crate::security::api_keys::{
ApiKeyManager, ApiKeyRecord, GeneratedKey, KeyRotationInfo, KeyStatus, RotationReason,
};
use crate::security::audit::{AuditActor, AuditEvent, AuditEventType, AuditLogger, AuditOutcome};
use chrono::{DateTime, Utc};
use secrecy::SecretString;
#[derive(Debug, Clone, serde::Deserialize)]
pub struct RotationConfig {
pub overlap_period: Duration,
pub notification_period: Duration,
pub grace_period: Duration,
pub max_key_age: Duration,
pub auto_rotate: bool,
}
impl Default for RotationConfig {
fn default() -> Self {
Self {
overlap_period: Duration::from_secs(3600), notification_period: Duration::from_secs(604800), grace_period: Duration::from_secs(86400), max_key_age: Duration::from_secs(7776000), auto_rotate: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RotationState {
Stable,
Initiated { new_key_id: Uuid },
Overlapping {
old_key_id: Uuid,
new_key_id: Uuid,
overlap_ends: DateTime<Utc>,
},
Transitioning {
old_key_id: Uuid,
new_key_id: Uuid,
grace_ends: DateTime<Utc>,
},
Completed { new_key_id: Uuid },
}
#[derive(Debug, thiserror::Error)]
pub enum RotationError {
#[error("Key is already being rotated")]
AlreadyRotating,
#[error("Key is not in rotation state")]
NotRotating,
#[error("Overlap period still active")]
OverlapPeriodActive,
#[error("Key not found: {0}")]
KeyNotFound(Uuid),
#[error("Key error: {0}")]
KeyError(String),
#[error("Audit error: {0}")]
AuditError(String),
}
#[derive(Debug, Clone)]
pub enum RotationNotificationEvent {
RotationInitiated,
OverlapEnding,
RotationCompleted,
EmergencyRotation,
}
#[derive(Debug, Clone)]
pub struct RotationNotification {
pub event: RotationNotificationEvent,
pub old_key_prefix: String,
pub new_key_prefix: String,
pub overlap_ends: DateTime<Utc>,
pub action_required: bool,
}
#[async_trait::async_trait]
pub trait RotationNotifier: Send + Sync {
async fn send_rotation_notification(
&self,
tenant_id: Uuid,
notification: RotationNotification,
) -> Result<(), String>;
async fn send_urgent_notification(
&self,
tenant_id: Uuid,
title: &str,
message: &str,
) -> Result<(), String>;
}
#[derive(Debug)]
pub struct RotationHandle {
pub old_key_id: Uuid,
pub new_key_id: Uuid,
pub new_api_key: SecretString,
pub state: RotationState,
}
#[derive(Debug, Clone)]
pub struct RotationCandidate {
pub key_id: Uuid,
pub tenant_id: Uuid,
pub reason: RotationReason,
pub priority: RotationPriority,
pub age_days: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RotationPriority {
Low,
Medium,
High,
Critical,
}
pub struct KeyRotationManager {
key_manager: Arc<ApiKeyManager>,
config: RotationConfig,
audit: Arc<AuditLogger>,
notifier: Option<Arc<dyn RotationNotifier>>,
}
impl KeyRotationManager {
pub fn new(
key_manager: Arc<ApiKeyManager>,
config: RotationConfig,
audit: Arc<AuditLogger>,
) -> Self {
Self {
key_manager,
config,
audit,
notifier: None,
}
}
pub fn with_notifier(mut self, notifier: Arc<dyn RotationNotifier>) -> Self {
self.notifier = Some(notifier);
self
}
pub async fn initiate_rotation(
&self,
tenant_id: Uuid,
old_key_id: Uuid,
reason: RotationReason,
) -> Result<RotationHandle, RotationError> {
let old_key = self
.key_manager
.get_key(old_key_id)
.await
.map_err(|e| RotationError::KeyError(e.to_string()))?;
if old_key.status == KeyStatus::Rotating {
return Err(RotationError::AlreadyRotating);
}
let expires_in = old_key.expires_at.map(|e| {
(e - old_key.created_at)
.to_std()
.unwrap_or(Duration::from_secs(86400 * 365))
});
let new_key = self
.key_manager
.generate_key(tenant_id, old_key.scopes.clone(), expires_in)
.await
.map_err(|e| RotationError::KeyError(e.to_string()))?;
self.key_manager
.update_status(old_key_id, KeyStatus::Rotating)
.await
.map_err(|e| RotationError::KeyError(e.to_string()))?;
self.key_manager
.set_rotation_info(
old_key_id,
KeyRotationInfo {
previous_key_id: None,
rotation_started: Utc::now(),
rotation_completed: None,
reason: reason.clone(),
},
)
.await
.map_err(|e| RotationError::KeyError(e.to_string()))?;
let overlap_ends = Utc::now()
+ chrono::Duration::from_std(self.config.overlap_period)
.unwrap_or(chrono::Duration::hours(1));
self.audit
.log(
AuditEvent::new(AuditEventType::KeyRotationStarted, AuditActor::System)
.with_tenant(tenant_id)
.with_key(old_key_id)
.with_details(serde_json::json!({
"old_key_id": old_key_id,
"new_key_id": new_key.key_id,
"reason": format!("{:?}", reason),
"overlap_ends": overlap_ends,
}))
.with_outcome(AuditOutcome::Success),
)
.await
.map_err(|e| RotationError::AuditError(e.to_string()))?;
if let Some(ref notifier) = self.notifier {
let _ = notifier
.send_rotation_notification(
tenant_id,
RotationNotification {
event: RotationNotificationEvent::RotationInitiated,
old_key_prefix: old_key.key_prefix.clone(),
new_key_prefix: new_key.key_id.to_string()[..8].to_string(),
overlap_ends,
action_required: true,
},
)
.await;
}
Ok(RotationHandle {
old_key_id,
new_key_id: new_key.key_id,
new_api_key: new_key.api_key,
state: RotationState::Overlapping {
old_key_id,
new_key_id: new_key.key_id,
overlap_ends,
},
})
}
pub async fn complete_rotation(
&self,
old_key_id: Uuid,
new_key_id: Uuid,
) -> Result<(), RotationError> {
let old_key = self
.key_manager
.get_key(old_key_id)
.await
.map_err(|e| RotationError::KeyError(e.to_string()))?;
if old_key.status != KeyStatus::Rotating {
return Err(RotationError::NotRotating);
}
if let Some(rotation) = &old_key.rotation {
let overlap_end = rotation.rotation_started
+ chrono::Duration::from_std(self.config.overlap_period)
.unwrap_or(chrono::Duration::hours(1));
if Utc::now() < overlap_end {
return Err(RotationError::OverlapPeriodActive);
}
}
self.key_manager
.update_status(old_key_id, KeyStatus::Revoked)
.await
.map_err(|e| RotationError::KeyError(e.to_string()))?;
self.key_manager
.update_status(new_key_id, KeyStatus::Active)
.await
.map_err(|e| RotationError::KeyError(e.to_string()))?;
let rotation_duration = old_key
.rotation
.as_ref()
.map(|r| (Utc::now() - r.rotation_started).num_seconds())
.unwrap_or(0);
self.audit
.log(
AuditEvent::new(AuditEventType::KeyRotationCompleted, AuditActor::System)
.with_tenant(old_key.tenant_id)
.with_key(new_key_id)
.with_details(serde_json::json!({
"old_key_id": old_key_id,
"new_key_id": new_key_id,
"rotation_duration_secs": rotation_duration,
}))
.with_outcome(AuditOutcome::Success),
)
.await
.map_err(|e| RotationError::AuditError(e.to_string()))?;
Ok(())
}
pub async fn check_rotation_required(&self) -> Vec<RotationCandidate> {
let mut candidates = Vec::new();
let keys = match self.key_manager.list_active_keys().await {
Ok(keys) => keys,
Err(_) => return candidates,
};
for key in keys {
let age = Utc::now() - key.created_at;
let max_age_duration =
chrono::Duration::from_std(self.config.max_key_age).unwrap_or(chrono::Duration::days(90));
if age > max_age_duration {
candidates.push(RotationCandidate {
key_id: key.key_id,
tenant_id: key.tenant_id,
reason: RotationReason::Scheduled,
priority: RotationPriority::High,
age_days: age.num_days() as u32,
});
continue;
}
let notification_threshold = self.config.max_key_age - self.config.notification_period;
let notification_duration =
chrono::Duration::from_std(notification_threshold).unwrap_or(chrono::Duration::days(83));
if age > notification_duration {
candidates.push(RotationCandidate {
key_id: key.key_id,
tenant_id: key.tenant_id,
reason: RotationReason::Scheduled,
priority: RotationPriority::Medium,
age_days: age.num_days() as u32,
});
}
}
candidates
}
pub async fn emergency_rotation(
&self,
tenant_id: Uuid,
key_id: Uuid,
reason: &str,
) -> Result<RotationHandle, RotationError> {
self.key_manager
.update_status(key_id, KeyStatus::Revoked)
.await
.map_err(|e| RotationError::KeyError(e.to_string()))?;
let old_key = self
.key_manager
.get_key(key_id)
.await
.map_err(|e| RotationError::KeyError(e.to_string()))?;
let expires_in = old_key.expires_at.and_then(|e| {
let remaining = e - Utc::now();
if remaining > chrono::Duration::zero() {
remaining.to_std().ok()
} else {
Some(Duration::from_secs(86400)) }
});
let new_key = self
.key_manager
.generate_key(tenant_id, old_key.scopes.clone(), expires_in)
.await
.map_err(|e| RotationError::KeyError(e.to_string()))?;
self.audit
.log(
AuditEvent::new(AuditEventType::SecurityIncident, AuditActor::System)
.with_tenant(tenant_id)
.with_key(key_id)
.with_details(serde_json::json!({
"incident_type": "emergency_key_rotation",
"reason": reason,
"old_key_id": key_id,
"new_key_id": new_key.key_id,
}))
.with_outcome(AuditOutcome::Success),
)
.await
.map_err(|e| RotationError::AuditError(e.to_string()))?;
if let Some(ref notifier) = self.notifier {
let _ = notifier
.send_urgent_notification(
tenant_id,
"Emergency Key Rotation",
&format!(
"Your API key has been rotated due to: {}. Please update immediately.",
reason
),
)
.await;
}
Ok(RotationHandle {
old_key_id: key_id,
new_key_id: new_key.key_id,
new_api_key: new_key.api_key,
state: RotationState::Completed {
new_key_id: new_key.key_id,
},
})
}
pub async fn get_rotation_status(&self, key_id: Uuid) -> Result<RotationState, RotationError> {
let key = self
.key_manager
.get_key(key_id)
.await
.map_err(|e| RotationError::KeyError(e.to_string()))?;
match key.status {
KeyStatus::Rotating => {
if let Some(rotation) = &key.rotation {
let overlap_end = rotation.rotation_started
+ chrono::Duration::from_std(self.config.overlap_period)
.unwrap_or(chrono::Duration::hours(1));
if Utc::now() < overlap_end {
Ok(RotationState::Overlapping {
old_key_id: key_id,
new_key_id: Uuid::nil(), overlap_ends: overlap_end,
})
} else {
let grace_end = overlap_end
+ chrono::Duration::from_std(self.config.grace_period)
.unwrap_or(chrono::Duration::days(1));
Ok(RotationState::Transitioning {
old_key_id: key_id,
new_key_id: Uuid::nil(),
grace_ends: grace_end,
})
}
} else {
Ok(RotationState::Stable)
}
}
KeyStatus::Revoked => {
if key.rotation.is_some() {
Ok(RotationState::Completed {
new_key_id: Uuid::nil(),
})
} else {
Ok(RotationState::Stable)
}
}
_ => Ok(RotationState::Stable),
}
}
}
pub struct NoOpRotationNotifier;
#[async_trait::async_trait]
impl RotationNotifier for NoOpRotationNotifier {
async fn send_rotation_notification(
&self,
_tenant_id: Uuid,
_notification: RotationNotification,
) -> Result<(), String> {
Ok(())
}
async fn send_urgent_notification(
&self,
_tenant_id: Uuid,
_title: &str,
_message: &str,
) -> Result<(), String> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_rotation_config() {
let config = RotationConfig::default();
assert_eq!(config.overlap_period, Duration::from_secs(3600));
assert_eq!(config.notification_period, Duration::from_secs(604800));
assert_eq!(config.grace_period, Duration::from_secs(86400));
assert_eq!(config.max_key_age, Duration::from_secs(7776000));
assert!(config.auto_rotate);
}
#[test]
fn test_rotation_priority_ordering() {
assert!(RotationPriority::Critical != RotationPriority::High);
assert!(RotationPriority::High != RotationPriority::Medium);
assert!(RotationPriority::Medium != RotationPriority::Low);
}
}