1use super::*;
2
3#[derive(Debug, Clone)]
4pub struct AuditLogRecord {
5 pub id: i64,
6 pub kind: String,
7 pub actor: String,
8 pub action: String,
9 pub target: Option<String>,
10 pub status: String,
11 pub detail: Option<String>,
12 pub created_at: String,
13}
14
15pub(crate) const AUDIT_GENESIS_HASH: &str = "GENESIS";
18
19pub(crate) fn to_hex(bytes: &[u8]) -> String {
21 let mut s = String::with_capacity(bytes.len() * 2);
22 for b in bytes {
23 s.push_str(&format!("{b:02x}"));
24 }
25 s
26}
27
28#[allow(clippy::too_many_arguments)]
34pub(crate) fn audit_entry_hash(
35 prev_hash: &str,
36 kind: &str,
37 actor: &str,
38 action: &str,
39 target: Option<&str>,
40 status: &str,
41 detail: Option<&str>,
42 created_at: &str,
43) -> String {
44 use sha2::{Digest, Sha256};
45 let mut h = Sha256::new();
46 for field in [
47 prev_hash,
48 kind,
49 actor,
50 action,
51 target.unwrap_or(""),
52 status,
53 detail.unwrap_or(""),
54 created_at,
55 ] {
56 h.update(field.as_bytes());
57 h.update([0x1f]);
58 }
59 to_hex(&h.finalize())
60}
61
62pub(crate) type AuditChainRow = (
65 i64,
66 String,
67 String,
68 String,
69 Option<String>,
70 String,
71 Option<String>,
72 String,
73 Option<String>,
74 String,
75);
76
77#[derive(Debug, Clone)]
79pub struct AuditChainStatus {
80 pub sealed_entries: usize,
82 pub intact: bool,
84 pub broken_at: Option<i64>,
86 pub reason: Option<String>,
88}
89
90impl Database {
91 pub fn log_audit_event(
92 &self,
93 kind: &str,
94 actor: &str,
95 action: &str,
96 target: Option<&str>,
97 status: &str,
98 detail: Option<&str>,
99 ) -> Result<i64, MicroClawError> {
100 let conn = self.lock_conn();
101 let now = chrono::Utc::now().to_rfc3339();
102 let prev_hash: String = conn
106 .query_row(
107 "SELECT entry_hash FROM audit_logs
108 WHERE entry_hash IS NOT NULL
109 ORDER BY id DESC LIMIT 1",
110 [],
111 |row| row.get(0),
112 )
113 .optional()?
114 .unwrap_or_else(|| AUDIT_GENESIS_HASH.to_string());
115 let entry_hash = audit_entry_hash(
116 &prev_hash, kind, actor, action, target, status, detail, &now,
117 );
118 conn.execute(
119 "INSERT INTO audit_logs(kind, actor, action, target, status, detail, created_at, prev_hash, entry_hash)
120 VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
121 params![kind, actor, action, target, status, detail, now, prev_hash, entry_hash],
122 )?;
123 Ok(conn.last_insert_rowid())
124 }
125
126 pub fn verify_audit_chain(&self) -> Result<AuditChainStatus, MicroClawError> {
131 let conn = self.lock_conn();
132 let mut stmt = conn.prepare(
133 "SELECT id, kind, actor, action, target, status, detail, created_at, prev_hash, entry_hash
134 FROM audit_logs
135 WHERE entry_hash IS NOT NULL
136 ORDER BY id ASC",
137 )?;
138 let rows: Vec<AuditChainRow> = stmt
140 .query_map([], |row| {
141 Ok((
142 row.get(0)?,
143 row.get(1)?,
144 row.get(2)?,
145 row.get(3)?,
146 row.get(4)?,
147 row.get(5)?,
148 row.get(6)?,
149 row.get(7)?,
150 row.get(8)?,
151 row.get(9)?,
152 ))
153 })?
154 .collect::<Result<_, _>>()?;
155
156 let mut expected_prev = AUDIT_GENESIS_HASH.to_string();
157 let mut sealed = 0usize;
158 for (id, kind, actor, action, target, status, detail, created_at, prev_hash, entry_hash) in
159 &rows
160 {
161 sealed += 1;
162 let prev = prev_hash.as_deref().unwrap_or("");
163 if prev != expected_prev {
164 return Ok(AuditChainStatus {
165 sealed_entries: sealed,
166 intact: false,
167 broken_at: Some(*id),
168 reason: Some(
169 "prev_hash does not link to the previous entry (an entry was deleted, inserted, or reordered)".to_string(),
170 ),
171 });
172 }
173 let recomputed = audit_entry_hash(
174 prev,
175 kind,
176 actor,
177 action,
178 target.as_deref(),
179 status,
180 detail.as_deref(),
181 created_at,
182 );
183 if &recomputed != entry_hash {
184 return Ok(AuditChainStatus {
185 sealed_entries: sealed,
186 intact: false,
187 broken_at: Some(*id),
188 reason: Some(
189 "entry_hash does not match content (an entry was modified)".to_string(),
190 ),
191 });
192 }
193 expected_prev = entry_hash.clone();
194 }
195
196 Ok(AuditChainStatus {
197 sealed_entries: sealed,
198 intact: true,
199 broken_at: None,
200 reason: None,
201 })
202 }
203
204 pub fn list_audit_logs(
205 &self,
206 kind: Option<&str>,
207 limit: usize,
208 ) -> Result<Vec<AuditLogRecord>, MicroClawError> {
209 let conn = self.lock_conn();
210 let mut rows = Vec::new();
211 if let Some(k) = kind {
212 let mut stmt = conn.prepare(
213 "SELECT id, kind, actor, action, target, status, detail, created_at
214 FROM audit_logs
215 WHERE kind = ?1
216 ORDER BY id DESC
217 LIMIT ?2",
218 )?;
219 let iter = stmt.query_map(params![k, limit as i64], |row| {
220 Ok(AuditLogRecord {
221 id: row.get(0)?,
222 kind: row.get(1)?,
223 actor: row.get(2)?,
224 action: row.get(3)?,
225 target: row.get(4)?,
226 status: row.get(5)?,
227 detail: row.get(6)?,
228 created_at: row.get(7)?,
229 })
230 })?;
231 for item in iter {
232 rows.push(item?);
233 }
234 } else {
235 let mut stmt = conn.prepare(
236 "SELECT id, kind, actor, action, target, status, detail, created_at
237 FROM audit_logs
238 ORDER BY id DESC
239 LIMIT ?1",
240 )?;
241 let iter = stmt.query_map(params![limit as i64], |row| {
242 Ok(AuditLogRecord {
243 id: row.get(0)?,
244 kind: row.get(1)?,
245 actor: row.get(2)?,
246 action: row.get(3)?,
247 target: row.get(4)?,
248 status: row.get(5)?,
249 detail: row.get(6)?,
250 created_at: row.get(7)?,
251 })
252 })?;
253 for item in iter {
254 rows.push(item?);
255 }
256 }
257 Ok(rows)
258 }
259
260 pub fn count_audit_events_since(&self, kind: &str, since: &str) -> Result<i64, MicroClawError> {
264 let conn = self.lock_conn();
265 let count: i64 = conn.query_row(
266 "SELECT COUNT(*) FROM audit_logs WHERE kind = ?1 AND created_at >= ?2",
267 [kind, since],
268 |row| row.get(0),
269 )?;
270 Ok(count)
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::internal::storage::db::test_support::*;
278
279 #[test]
280 fn audit_chain_intact_after_appends() {
281 let (db, dir) = test_db();
282 db.log_audit_event("auth", "alice", "login", None, "ok", None)
283 .unwrap();
284 db.log_audit_event(
285 "tool",
286 "bot",
287 "web_fetch",
288 Some("https://x"),
289 "ok",
290 Some("200"),
291 )
292 .unwrap();
293 db.log_audit_event("auth", "alice", "logout", None, "ok", None)
294 .unwrap();
295 let status = db.verify_audit_chain().unwrap();
296 assert!(status.intact, "reason: {:?}", status.reason);
297 assert_eq!(status.sealed_entries, 3);
298 assert_eq!(status.broken_at, None);
299 cleanup(&dir);
300 }
301
302 #[test]
303 fn audit_chain_detects_modification() {
304 let (db, dir) = test_db();
305 db.log_audit_event("auth", "alice", "login", None, "ok", None)
306 .unwrap();
307 let id = db
308 .log_audit_event("tool", "bot", "web_fetch", Some("https://x"), "ok", None)
309 .unwrap();
310 db.log_audit_event("auth", "alice", "logout", None, "ok", None)
311 .unwrap();
312 {
313 let conn = db.lock_conn();
314 conn.execute(
315 "UPDATE audit_logs SET status='tampered' WHERE id=?1",
316 params![id],
317 )
318 .unwrap();
319 }
320 let status = db.verify_audit_chain().unwrap();
321 assert!(!status.intact);
322 assert_eq!(status.broken_at, Some(id));
323 assert!(status.reason.unwrap().contains("modified"));
324 cleanup(&dir);
325 }
326
327 #[test]
328 fn audit_chain_detects_deletion() {
329 let (db, dir) = test_db();
330 db.log_audit_event("auth", "alice", "login", None, "ok", None)
331 .unwrap();
332 let id = db
333 .log_audit_event("tool", "bot", "web_fetch", Some("https://x"), "ok", None)
334 .unwrap();
335 db.log_audit_event("auth", "alice", "logout", None, "ok", None)
336 .unwrap();
337 {
338 let conn = db.lock_conn();
339 conn.execute("DELETE FROM audit_logs WHERE id=?1", params![id])
340 .unwrap();
341 }
342 let status = db.verify_audit_chain().unwrap();
343 assert!(!status.intact);
344 assert!(status.broken_at.is_some());
345 assert!(status.reason.unwrap().contains("deleted"));
346 cleanup(&dir);
347 }
348}