diff --git a/app/api-token-vault/src/rotation.rs b/app/api-token-vault/src/rotation.rs new file mode 100644 index 0000000..1115d3b --- /dev/null +++ b/app/api-token-vault/src/rotation.rs @@ -0,0 +1,215 @@ +use crate::vault::Vault; +use crate::token::TokenData; +use chrono::{DateTime, Utc, Duration}; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub struct RotationSchedule { + pub token_name: String, + pub rotation_days: u32, + pub last_rotated: Option>, + pub next_rotation: Option>, +} + +impl RotationSchedule { + pub fn from_token(token_name: &str, token_data: &TokenData) -> Option { + if !token_data.auto_rotate { + return None; + } + + let last_rotated = token_data.last_rotated.unwrap_or(token_data.created_at); + let next_rotation = Some(last_rotated + Duration::days(token_data.rotation_days.unwrap_or(30) as i64)); + + Some(RotationSchedule { + token_name: token_name.to_string(), + rotation_days: token_data.rotation_days.unwrap_or(30), + last_rotated: Some(last_rotated), + next_rotation, + }) + } + + pub fn is_due(&self) -> bool { + if let Some(next) = self.next_rotation { + Utc::now() >= next + } else { + false + } + } + + pub fn days_until_rotation(&self) -> Option { + self.next_rotation.map(|next| { + let diff = next - Utc::now(); + diff.num_days() + }) + } +} + +pub struct RotationEngine; + +impl RotationEngine { + pub fn check_schedules(vault: &Vault) -> Vec { + let mut schedules = Vec::new(); + + for (name, token_data) in &vault.tokens { + if let Some(schedule) = RotationSchedule::from_token(name, token_data) { + schedules.push(schedule); + } + } + + schedules + } + + pub fn get_due_rotations(vault: &Vault) -> Vec { + Self::check_schedules(vault) + .into_iter() + .filter(|s| s.is_due()) + .collect() + } + + pub fn get_upcoming_rotations(vault: &Vault, days: i64) -> Vec { + let now = Utc::now(); + let threshold = now + Duration::days(days); + + Self::check_schedules(vault) + .into_iter() + .filter(|s| { + if let Some(next) = s.next_rotation { + next >= now && next <= threshold + } else { + false + } + }) + .collect() + } + + pub fn rotate_due_tokens(vault: &mut Vault) -> HashMap { + let due_names: Vec = vault.tokens.iter() + .filter(|(_, token)| token.should_rotate()) + .map(|(name, _)| name.clone()) + .collect(); + + let mut results = HashMap::new(); + + for name in due_names { + if let Ok(new_value) = vault.rotate_token(&name, true) { + results.insert(name, new_value); + } + } + + results + } + + pub fn calculate_next_rotation(token_data: &TokenData) -> Option> { + let base_time = token_data.last_rotated.unwrap_or(token_data.created_at); + let days = token_data.rotation_days.unwrap_or(30); + Some(base_time + Duration::days(days as i64)) + } + + pub fn format_schedule_report(schedules: &[RotationSchedule]) -> String { + if schedules.is_empty() { + return "No scheduled rotations".to_string(); + } + + let mut report = String::from("Rotation Schedule:\n"); + report.push_str(&"=".repeat(50)); + report.push('\n'); + + let mut due_count = 0; + let mut upcoming_count = 0; + + for schedule in schedules { + let status = if schedule.is_due() { + due_count += 1; + "DUE NOW" + } else if let Some(days) = schedule.days_until_rotation() { + upcoming_count += 1; + format!("in {} days", days) + } else { + "unknown".to_string() + }; + + report.push_str(&format!( + " {}: every {} days - {}\n", + schedule.token_name, + schedule.rotation_days, + status + )); + } + + report.push_str(&"-".repeat(50)); + report.push('\n'); + report.push_str(&format!("Due now: {}, Upcoming: {}\n", due_count, upcoming_count)); + + report + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vault::Vault; + + #[test] + fn test_rotation_schedule_creation() { + let password = "test"; + let project = "test_schedule"; + + let mut vault = Vault::initialize(password, project).unwrap(); + vault.generate_token("auto_rotate", 32).unwrap(); + vault.set_rotation("auto_rotate", 30).unwrap(); + + let schedules = RotationEngine::check_schedules(&vault); + assert_eq!(schedules.len(), 1); + assert_eq!(schedules[0].token_name, "auto_rotate"); + assert_eq!(schedules[0].rotation_days, 30); + + let vault_path = Vault::get_vault_path(project).unwrap(); + if vault_path.exists() { + std::fs::remove_file(&vault_path).unwrap(); + } + } + + #[test] + fn test_manual_rotation_not_scheduled() { + let password = "test"; + let project = "test_manual"; + + let mut vault = Vault::initialize(password, project).unwrap(); + vault.generate_token("manual", 32).unwrap(); + + let schedules = RotationEngine::check_schedules(&vault); + assert!(schedules.is_empty()); + + let vault_path = Vault::get_vault_path(project).unwrap(); + if vault_path.exists() { + std::fs::remove_file(&vault_path).unwrap(); + } + } + + #[test] + fn test_schedule_report() { + let schedules = vec![ + RotationSchedule { + token_name: "token1".to_string(), + rotation_days: 30, + last_rotated: Some(Utc::now() - Duration::days(35)), + next_rotation: Some(Utc::now() - Duration::days(5)), + }, + RotationSchedule { + token_name: "token2".to_string(), + rotation_days: 60, + last_rotated: Some(Utc::now()), + next_rotation: Some(Utc::now() + Duration::days(60)), + }, + ]; + + let report = RotationEngine::format_schedule_report(&schedules); + assert!(report.contains("token1")); + assert!(report.contains("token2")); + assert!(report.contains("DUE NOW")); + assert!(report.contains("in 60 days")); + + let empty_report = RotationEngine::format_schedule_report(&[]); + assert_eq!(empty_report, "No scheduled rotations"); + } +}