Skip to main content

microclaw_engine/internal/storage/db/
audit.rs

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
15/// Genesis link for the tamper-evident audit hash chain — the `prev_hash` of the
16/// first sealed entry.
17pub(crate) const AUDIT_GENESIS_HASH: &str = "GENESIS";
18
19/// Lowercase hex of a byte slice (avoids pulling in the `hex` crate).
20pub(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/// Compute the sealing hash for an audit entry: SHA-256 over the previous
29/// entry's hash followed by this entry's content fields, each `\x1f`-terminated
30/// so field boundaries can't be ambiguous. The chain link in `prev_hash` makes
31/// deletion or reordering detectable; covering every field makes in-place edits
32/// detectable.
33#[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
62/// A sealed audit row as read for chain verification: id + content fields +
63/// `prev_hash` + `entry_hash`.
64pub(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/// Result of verifying the audit hash chain.
78#[derive(Debug, Clone)]
79pub struct AuditChainStatus {
80    /// Number of sealed (hash-bearing) entries inspected.
81    pub sealed_entries: usize,
82    /// Whether the chain is fully intact.
83    pub intact: bool,
84    /// The `id` of the first entry where verification failed, if any.
85    pub broken_at: Option<i64>,
86    /// Human-readable reason for the break, if any.
87    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        // Seal the entry into the hash chain. Reading the latest sealed hash and
103        // inserting happen under the same connection lock, so concurrent callers
104        // can't race the chain.
105        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    /// Verify the tamper-evident audit hash chain. Walks all sealed entries in
127    /// `id` order, checking that each links to the previous (`prev_hash`) and
128    /// that its `entry_hash` still matches its content. Returns the first break,
129    /// if any. Unsealed legacy rows (NULL `entry_hash`) are ignored.
130    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        // Collect first so the statement borrow is released before we iterate.
139        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    // --- Metrics history ---
261
262    /// Count audit-chain events of one kind recorded at or after `since`.
263    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}