use super::*;
#[derive(Debug, Clone)]
pub struct AuditLogRecord {
pub id: i64,
pub kind: String,
pub actor: String,
pub action: String,
pub target: Option<String>,
pub status: String,
pub detail: Option<String>,
pub created_at: String,
}
pub(crate) const AUDIT_GENESIS_HASH: &str = "GENESIS";
pub(crate) fn to_hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn audit_entry_hash(
prev_hash: &str,
kind: &str,
actor: &str,
action: &str,
target: Option<&str>,
status: &str,
detail: Option<&str>,
created_at: &str,
) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
for field in [
prev_hash,
kind,
actor,
action,
target.unwrap_or(""),
status,
detail.unwrap_or(""),
created_at,
] {
h.update(field.as_bytes());
h.update([0x1f]);
}
to_hex(&h.finalize())
}
pub(crate) type AuditChainRow = (
i64,
String,
String,
String,
Option<String>,
String,
Option<String>,
String,
Option<String>,
String,
);
#[derive(Debug, Clone)]
pub struct AuditChainStatus {
pub sealed_entries: usize,
pub intact: bool,
pub broken_at: Option<i64>,
pub reason: Option<String>,
}
impl Database {
pub fn log_audit_event(
&self,
kind: &str,
actor: &str,
action: &str,
target: Option<&str>,
status: &str,
detail: Option<&str>,
) -> Result<i64, MicroClawError> {
let conn = self.lock_conn();
let now = chrono::Utc::now().to_rfc3339();
let prev_hash: String = conn
.query_row(
"SELECT entry_hash FROM audit_logs
WHERE entry_hash IS NOT NULL
ORDER BY id DESC LIMIT 1",
[],
|row| row.get(0),
)
.optional()?
.unwrap_or_else(|| AUDIT_GENESIS_HASH.to_string());
let entry_hash = audit_entry_hash(
&prev_hash, kind, actor, action, target, status, detail, &now,
);
conn.execute(
"INSERT INTO audit_logs(kind, actor, action, target, status, detail, created_at, prev_hash, entry_hash)
VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![kind, actor, action, target, status, detail, now, prev_hash, entry_hash],
)?;
Ok(conn.last_insert_rowid())
}
pub fn verify_audit_chain(&self) -> Result<AuditChainStatus, MicroClawError> {
let conn = self.lock_conn();
let mut stmt = conn.prepare(
"SELECT id, kind, actor, action, target, status, detail, created_at, prev_hash, entry_hash
FROM audit_logs
WHERE entry_hash IS NOT NULL
ORDER BY id ASC",
)?;
let rows: Vec<AuditChainRow> = stmt
.query_map([], |row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
row.get(6)?,
row.get(7)?,
row.get(8)?,
row.get(9)?,
))
})?
.collect::<Result<_, _>>()?;
let mut expected_prev = AUDIT_GENESIS_HASH.to_string();
let mut sealed = 0usize;
for (id, kind, actor, action, target, status, detail, created_at, prev_hash, entry_hash) in
&rows
{
sealed += 1;
let prev = prev_hash.as_deref().unwrap_or("");
if prev != expected_prev {
return Ok(AuditChainStatus {
sealed_entries: sealed,
intact: false,
broken_at: Some(*id),
reason: Some(
"prev_hash does not link to the previous entry (an entry was deleted, inserted, or reordered)".to_string(),
),
});
}
let recomputed = audit_entry_hash(
prev,
kind,
actor,
action,
target.as_deref(),
status,
detail.as_deref(),
created_at,
);
if &recomputed != entry_hash {
return Ok(AuditChainStatus {
sealed_entries: sealed,
intact: false,
broken_at: Some(*id),
reason: Some(
"entry_hash does not match content (an entry was modified)".to_string(),
),
});
}
expected_prev = entry_hash.clone();
}
Ok(AuditChainStatus {
sealed_entries: sealed,
intact: true,
broken_at: None,
reason: None,
})
}
pub fn list_audit_logs(
&self,
kind: Option<&str>,
limit: usize,
) -> Result<Vec<AuditLogRecord>, MicroClawError> {
let conn = self.lock_conn();
let mut rows = Vec::new();
if let Some(k) = kind {
let mut stmt = conn.prepare(
"SELECT id, kind, actor, action, target, status, detail, created_at
FROM audit_logs
WHERE kind = ?1
ORDER BY id DESC
LIMIT ?2",
)?;
let iter = stmt.query_map(params![k, limit as i64], |row| {
Ok(AuditLogRecord {
id: row.get(0)?,
kind: row.get(1)?,
actor: row.get(2)?,
action: row.get(3)?,
target: row.get(4)?,
status: row.get(5)?,
detail: row.get(6)?,
created_at: row.get(7)?,
})
})?;
for item in iter {
rows.push(item?);
}
} else {
let mut stmt = conn.prepare(
"SELECT id, kind, actor, action, target, status, detail, created_at
FROM audit_logs
ORDER BY id DESC
LIMIT ?1",
)?;
let iter = stmt.query_map(params![limit as i64], |row| {
Ok(AuditLogRecord {
id: row.get(0)?,
kind: row.get(1)?,
actor: row.get(2)?,
action: row.get(3)?,
target: row.get(4)?,
status: row.get(5)?,
detail: row.get(6)?,
created_at: row.get(7)?,
})
})?;
for item in iter {
rows.push(item?);
}
}
Ok(rows)
}
pub fn count_audit_events_since(&self, kind: &str, since: &str) -> Result<i64, MicroClawError> {
let conn = self.lock_conn();
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM audit_logs WHERE kind = ?1 AND created_at >= ?2",
[kind, since],
|row| row.get(0),
)?;
Ok(count)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::internal::storage::db::test_support::*;
#[test]
fn audit_chain_intact_after_appends() {
let (db, dir) = test_db();
db.log_audit_event("auth", "alice", "login", None, "ok", None)
.unwrap();
db.log_audit_event(
"tool",
"bot",
"web_fetch",
Some("https://x"),
"ok",
Some("200"),
)
.unwrap();
db.log_audit_event("auth", "alice", "logout", None, "ok", None)
.unwrap();
let status = db.verify_audit_chain().unwrap();
assert!(status.intact, "reason: {:?}", status.reason);
assert_eq!(status.sealed_entries, 3);
assert_eq!(status.broken_at, None);
cleanup(&dir);
}
#[test]
fn audit_chain_detects_modification() {
let (db, dir) = test_db();
db.log_audit_event("auth", "alice", "login", None, "ok", None)
.unwrap();
let id = db
.log_audit_event("tool", "bot", "web_fetch", Some("https://x"), "ok", None)
.unwrap();
db.log_audit_event("auth", "alice", "logout", None, "ok", None)
.unwrap();
{
let conn = db.lock_conn();
conn.execute(
"UPDATE audit_logs SET status='tampered' WHERE id=?1",
params![id],
)
.unwrap();
}
let status = db.verify_audit_chain().unwrap();
assert!(!status.intact);
assert_eq!(status.broken_at, Some(id));
assert!(status.reason.unwrap().contains("modified"));
cleanup(&dir);
}
#[test]
fn audit_chain_detects_deletion() {
let (db, dir) = test_db();
db.log_audit_event("auth", "alice", "login", None, "ok", None)
.unwrap();
let id = db
.log_audit_event("tool", "bot", "web_fetch", Some("https://x"), "ok", None)
.unwrap();
db.log_audit_event("auth", "alice", "logout", None, "ok", None)
.unwrap();
{
let conn = db.lock_conn();
conn.execute("DELETE FROM audit_logs WHERE id=?1", params![id])
.unwrap();
}
let status = db.verify_audit_chain().unwrap();
assert!(!status.intact);
assert!(status.broken_at.is_some());
assert!(status.reason.unwrap().contains("deleted"));
cleanup(&dir);
}
}