| use anyhow::Result;
|
| use chrono::Utc;
|
| use serde::{Deserialize, Serialize};
|
| use std::fs::OpenOptions;
|
| use std::io::Write;
|
| use std::path::PathBuf;
|
| use std::sync::Arc;
|
| use tokio::sync::Mutex;
|
| use tracing::{debug, info};
|
|
|
| use crate::review_queue::PendingChange;
|
|
|
|
|
| #[derive(Debug, Clone, Serialize, Deserialize)]
|
| pub struct AuditEntry {
|
| pub timestamp: String,
|
| pub event_type: String,
|
| pub change_id: String,
|
| pub agent_name: String,
|
| pub reviewer: Option<String>,
|
| pub decision: String,
|
| pub reason: Option<String>,
|
| pub commit_hash: Option<String>,
|
| pub evidence_url: Option<String>,
|
| }
|
|
|
|
|
| #[derive(Clone)]
|
| pub struct AuditLog {
|
| path: PathBuf,
|
|
|
| write_lock: Arc<Mutex<()>>,
|
| }
|
|
|
| impl AuditLog {
|
|
|
| pub fn new(path: PathBuf) -> Result<Self> {
|
| info!("📋 Audit log initialized at: {:?}", path);
|
|
|
|
|
| if let Some(parent) = path.parent() {
|
| std::fs::create_dir_all(parent)?;
|
| }
|
|
|
|
|
| if !path.exists() {
|
| let mut file = OpenOptions::new()
|
| .create(true)
|
| .write(true)
|
| .open(&path)?;
|
|
|
|
|
| let header = serde_json::json!({
|
| "version": "1.0.0",
|
| "type": "WORM_AUDIT_LOG",
|
| "created_at": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
|
| "entries": []
|
| });
|
|
|
| writeln!(file, "{}", header.to_string())?;
|
| file.sync_all()?;
|
| }
|
|
|
| Ok(AuditLog {
|
| path,
|
| write_lock: Arc::new(Mutex::new(())),
|
| })
|
| }
|
|
|
|
|
| pub async fn log_submitted(&self, change: &PendingChange) -> Result<()> {
|
| let entry = AuditEntry {
|
| timestamp: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
| event_type: "CHANGE_SUBMITTED".to_string(),
|
| change_id: change.id.clone(),
|
| agent_name: change.agent_name.clone(),
|
| reviewer: None,
|
| decision: "AWAITING_REVIEW".to_string(),
|
| reason: None,
|
| commit_hash: None,
|
| evidence_url: Some(change.evidence.clone()),
|
| };
|
|
|
| self.append_entry(&entry).await?;
|
|
|
| info!(
|
| "📝 [AUDIT] Change submitted: {} (agent: {})",
|
| change.id, change.agent_name
|
| );
|
|
|
| Ok(())
|
| }
|
|
|
|
|
| pub async fn log_approval(
|
| &self,
|
| change_id: &str,
|
| reviewer: &str,
|
| description: &str,
|
| ) -> Result<()> {
|
| let entry = AuditEntry {
|
| timestamp: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
| event_type: "CHANGE_APPROVED".to_string(),
|
| change_id: change_id.to_string(),
|
| agent_name: "human-touch".to_string(),
|
| reviewer: Some(reviewer.to_string()),
|
| decision: "APPROVED".to_string(),
|
| reason: Some(format!("Human approval granted: {}", description)),
|
| commit_hash: None,
|
| evidence_url: None,
|
| };
|
|
|
| self.append_entry(&entry).await?;
|
|
|
| info!(
|
| "✅ [AUDIT] Change approved: {} by {}",
|
| change_id, reviewer
|
| );
|
|
|
| Ok(())
|
| }
|
|
|
|
|
| pub async fn log_rejection(
|
| &self,
|
| change_id: &str,
|
| reason: &str,
|
| reviewer: &str,
|
| ) -> Result<()> {
|
| let entry = AuditEntry {
|
| timestamp: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
| event_type: "CHANGE_REJECTED".to_string(),
|
| change_id: change_id.to_string(),
|
| agent_name: "human-touch".to_string(),
|
| reviewer: Some(reviewer.to_string()),
|
| decision: "REJECTED".to_string(),
|
| reason: Some(reason.to_string()),
|
| commit_hash: None,
|
| evidence_url: None,
|
| };
|
|
|
| self.append_entry(&entry).await?;
|
|
|
| info!(
|
| "❌ [AUDIT] Change rejected: {} — {}",
|
| change_id, reason
|
| );
|
|
|
| Ok(())
|
| }
|
|
|
|
|
| pub async fn log_commit(
|
| &self,
|
| change_id: &str,
|
| commit_hash: &str,
|
| reviewer: &str,
|
| ) -> Result<()> {
|
| let entry = AuditEntry {
|
| timestamp: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
| event_type: "CHANGE_COMMITTED".to_string(),
|
| change_id: change_id.to_string(),
|
| agent_name: "human-touch".to_string(),
|
| reviewer: Some(reviewer.to_string()),
|
| decision: "COMMITTED".to_string(),
|
| reason: None,
|
| commit_hash: Some(commit_hash.to_string()),
|
| evidence_url: None,
|
| };
|
|
|
| self.append_entry(&entry).await?;
|
|
|
| info!(
|
| "📝 [AUDIT] Change committed: {} → {}",
|
| change_id, commit_hash
|
| );
|
|
|
| Ok(())
|
| }
|
|
|
|
|
| async fn append_entry(&self, entry: &AuditEntry) -> Result<()> {
|
| let _lock = self.write_lock.lock().await;
|
|
|
| debug!(
|
| "🔒 [WORM] Appending entry: {}",
|
| serde_json::to_string(entry)?
|
| );
|
|
|
|
|
|
|
| let mut file = OpenOptions::new()
|
| .append(true)
|
| .open(&self.path)?;
|
|
|
|
|
| let line = format!("{}\n", serde_json::to_string(entry)?);
|
| file.write_all(line.as_bytes())?;
|
| file.sync_all()?;
|
|
|
| Ok(())
|
| }
|
|
|
|
|
| pub async fn read_entries(&self) -> Result<Vec<AuditEntry>> {
|
| let _lock = self.write_lock.lock().await;
|
|
|
| let content = std::fs::read_to_string(&self.path)?;
|
| let mut entries = Vec::new();
|
|
|
| for line in content.lines() {
|
|
|
| if line.contains("\"version\"") || line.contains("\"type\"") {
|
| continue;
|
| }
|
|
|
|
|
| if line.trim().is_empty() {
|
| continue;
|
| }
|
|
|
| if let Ok(entry) = serde_json::from_str::<AuditEntry>(line) {
|
| entries.push(entry);
|
| }
|
| }
|
|
|
| Ok(entries)
|
| }
|
|
|
|
|
| pub async fn generate_summary(&self) -> Result<AuditSummary> {
|
| let entries = self.read_entries().await?;
|
|
|
| let mut summary = AuditSummary::default();
|
|
|
| for entry in entries {
|
| summary.total_events += 1;
|
|
|
| match entry.event_type.as_str() {
|
| "CHANGE_SUBMITTED" => summary.changes_submitted += 1,
|
| "CHANGE_APPROVED" => summary.changes_approved += 1,
|
| "CHANGE_REJECTED" => summary.changes_rejected += 1,
|
| "CHANGE_COMMITTED" => summary.changes_committed += 1,
|
| _ => {}
|
| }
|
|
|
| if let Some(reviewer) = entry.reviewer {
|
| *summary.reviewers.entry(reviewer).or_insert(0) += 1;
|
| }
|
| }
|
|
|
| Ok(summary)
|
| }
|
| }
|
|
|
|
|
| #[derive(Debug, Default, Serialize, Deserialize)]
|
| pub struct AuditSummary {
|
| pub total_events: usize,
|
| pub changes_submitted: usize,
|
| pub changes_approved: usize,
|
| pub changes_rejected: usize,
|
| pub changes_committed: usize,
|
| pub reviewers: std::collections::HashMap<String, usize>,
|
| }
|
|
|
| #[cfg(test)]
|
| mod tests {
|
| use super::*;
|
| use tempfile::NamedTempFile;
|
|
|
| #[tokio::test]
|
| async fn test_audit_log_creation() {
|
| let tmp = NamedTempFile::new().unwrap();
|
| let log = AuditLog::new(tmp.path().to_path_buf()).unwrap();
|
|
|
| let change = PendingChange {
|
| id: "test-123".to_string(),
|
| description: "Test change".to_string(),
|
| evidence: "https://example.com".to_string(),
|
| agent_name: "test-agent".to_string(),
|
| created_at: Utc::now(),
|
| files: vec![],
|
| diff: "".to_string(),
|
| };
|
|
|
| assert!(log.log_submitted(&change).await.is_ok());
|
| }
|
|
|
| #[tokio::test]
|
| async fn test_audit_entries() {
|
| let tmp = NamedTempFile::new().unwrap();
|
| let log = AuditLog::new(tmp.path().to_path_buf()).unwrap();
|
|
|
| let change = PendingChange {
|
| id: "test-456".to_string(),
|
| description: "Test change".to_string(),
|
| evidence: "https://example.com".to_string(),
|
| agent_name: "test-agent".to_string(),
|
| created_at: Utc::now(),
|
| files: vec![],
|
| diff: "".to_string(),
|
| };
|
|
|
| log.log_submitted(&change).await.unwrap();
|
| log.log_approval("test-456", "reviewer@example.com", "Looks good")
|
| .await
|
| .unwrap();
|
|
|
| let entries = log.read_entries().await.unwrap();
|
| assert!(entries.len() >= 2);
|
|
|
| let submitted = entries.iter().find(|e| e.event_type == "CHANGE_SUBMITTED");
|
| assert!(submitted.is_some());
|
|
|
| let approved = entries.iter().find(|e| e.event_type == "CHANGE_APPROVED");
|
| assert!(approved.is_some());
|
| }
|
| }
|
|
|