Skip to main content

aap_protocol/
audit.rs

1//! AAP AuditChain — tamper-evident append-only log of all agent actions.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::fs::{File, OpenOptions};
6use std::io::{BufRead, BufReader, Write};
7use std::path::Path;
8use uuid::Uuid;
9
10use crate::crypto::{hash_entry, KeyPair, signable};
11use crate::errors::{AAPError, Result};
12
13/// The result of an agent action.
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum AuditResult {
17    Success,
18    Failure,
19    Blocked,
20    Revoked,
21}
22
23/// A single tamper-evident entry in the audit chain.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct AuditEntry {
26    pub aap_version: String,
27    pub entry_id: String,
28    pub prev_hash: String,
29    pub agent_id: String,
30    pub action: String,
31    pub result: AuditResult,
32    pub timestamp: DateTime<Utc>,
33    pub provenance_id: String,
34    pub authorization_level: u8,
35    pub physical: bool,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub result_detail: Option<String>,
38    pub signature: String,
39}
40
41/// Tamper-evident append-only audit log.
42pub struct AuditChain {
43    entries: Vec<AuditEntry>,
44    storage_path: Option<String>,
45}
46
47impl AuditChain {
48    /// Create a new in-memory AuditChain.
49    pub fn new() -> Self {
50        Self { entries: Vec::new(), storage_path: None }
51    }
52
53    /// Create an AuditChain backed by a JSONL file.
54    /// Loads existing entries if the file exists.
55    pub fn with_storage(path: impl AsRef<Path>) -> Result<Self> {
56        let path_str = path.as_ref().to_string_lossy().to_string();
57        let mut entries = Vec::new();
58
59        if path.as_ref().exists() {
60            let f = File::open(&path)
61                .map_err(|e| AAPError::Signature(format!("cannot open audit file: {e}")))?;
62            for line in BufReader::new(f).lines() {
63                let line = line.map_err(|e| AAPError::Signature(e.to_string()))?;
64                if !line.trim().is_empty() {
65                    let entry: AuditEntry = serde_json::from_str(&line)?;
66                    entries.push(entry);
67                }
68            }
69        }
70
71        Ok(Self { entries, storage_path: Some(path_str) })
72    }
73
74    /// Append a new signed entry to the chain.
75    pub fn append(
76        &mut self,
77        agent_id: &str,
78        action: &str,
79        result: AuditResult,
80        provenance_id: &str,
81        agent_kp: &KeyPair,
82        authorization_level: u8,
83        physical: bool,
84    ) -> Result<&AuditEntry> {
85        let prev_hash = self.last_hash();
86
87        let mut entry = AuditEntry {
88            aap_version: "0.1".into(),
89            entry_id: Uuid::new_v4().to_string(),
90            prev_hash,
91            agent_id: agent_id.into(),
92            action: action.into(),
93            result,
94            timestamp: Utc::now(),
95            provenance_id: provenance_id.into(),
96            authorization_level,
97            physical,
98            result_detail: None,
99            signature: String::new(),
100        };
101
102        let v = serde_json::to_value(&entry)?;
103        let data = signable(&v)?;
104        entry.signature = agent_kp.sign(&data);
105
106        if let Some(ref path) = self.storage_path {
107            let mut f = OpenOptions::new()
108                .create(true).append(true).open(path)
109                .map_err(|e| AAPError::Signature(format!("cannot write audit: {e}")))?;
110            let line = serde_json::to_string(&entry)?;
111            writeln!(f, "{}", line)
112                .map_err(|e| AAPError::Signature(e.to_string()))?;
113        }
114
115        self.entries.push(entry);
116        Ok(self.entries.last().unwrap())
117    }
118
119    /// Verify the hash chain integrity.
120    /// Returns `(valid, entries_checked, broken_at_entry_id)`.
121    pub fn verify(&self) -> (bool, usize, Option<String>) {
122        let mut prev_hash = "genesis".to_string();
123
124        for (i, entry) in self.entries.iter().enumerate() {
125            if entry.prev_hash != prev_hash {
126                return (false, i, Some(entry.entry_id.clone()));
127            }
128            let v = serde_json::to_value(entry).unwrap_or_default();
129            prev_hash = hash_entry(&v);
130        }
131        (true, self.entries.len(), None)
132    }
133
134    pub fn len(&self) -> usize { self.entries.len() }
135    pub fn is_empty(&self) -> bool { self.entries.is_empty() }
136    pub fn entries(&self) -> &[AuditEntry] { &self.entries }
137
138    fn last_hash(&self) -> String {
139        match self.entries.last() {
140            None => "genesis".to_string(),
141            Some(e) => {
142                let v = serde_json::to_value(e).unwrap_or_default();
143                hash_entry(&v)
144            }
145        }
146    }
147}
148
149impl Default for AuditChain {
150    fn default() -> Self { Self::new() }
151}