//! Sqlite-backed audit trail of agent-initiated privileged actions. //! //! Surfaces, durably and operator-visibly, the privileged operations //! hive-c0re performs *on behalf of an agent* — the ones that cross the //! agent/operator trust boundary and so warrant a who/what/when record //! beyond hive-priv's low-level journal trace. First entry: infra //! container restarts via the `infra_admin`-gated `restart` tool (the //! follow-up audit trail for that capability). //! //! Deliberately scoped to *agent-initiated* privileged actions. The bulk //! of `PrivRequest` traffic (token writes, nspawn-flag edits) fires //! constantly during normal lifecycle and is hive-c0re's own bookkeeping, //! not an agent crossing the boundary — logging all of it would drown the //! signal the operator actually wants. //! //! Same process-singleton handle pattern as `build_logs`: installed once //! at `Coordinator::open`, fetched via [`global`] so the recording sites //! (e.g. `socket_server::handle_restart_infra`) don't have to thread an //! `Arc` through every call path. Recording is best-effort: a //! sqlite blip must never fail the underlying privileged action. use std::path::Path; use std::sync::{Arc, Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; use rusqlite::{Connection, params}; use serde::Serialize; /// Process-singleton handle, set once at coordinator startup. Mirrors /// `build_logs::GLOBAL` — lets recording sites write without threading an /// `Arc` through every entry point. static GLOBAL: OnceLock> = OnceLock::new(); /// Install the process-wide `AuditLog` handle. Idempotent: a second call /// silently keeps the first handle. pub fn install(handle: Arc) { let _ = GLOBAL.set(handle); } /// Fetch the process-wide handle, or `None` if `install` hasn't run yet /// (early startup, or unit tests). Callers must gracefully no-op on `None`. #[must_use] pub fn global() -> Option> { GLOBAL.get().cloned() } /// Retain audit rows for 90 days. Longer than build-log retention — this /// is a security/accountability record, not debug noise; the operator may /// want to review "who restarted what" well after the fact. const KEEP_SECS: i64 = 90 * 24 * 3600; const SCHEMA: &str = " CREATE TABLE IF NOT EXISTS audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts_unix INTEGER NOT NULL, agent TEXT NOT NULL, action TEXT NOT NULL, target TEXT NOT NULL, outcome TEXT NOT NULL, detail TEXT ); CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log (ts_unix DESC); "; /// Outcome of a recorded privileged action. Stored as the literal string /// in the `outcome` column. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum AuditOutcome { /// The privileged action succeeded. Ok, /// The privileged action was attempted but failed (e.g. the /// underlying systemctl call errored). Denied-by-capability attempts /// are recorded too — see the recording site. Err, } impl AuditOutcome { fn as_str(self) -> &'static str { match self { Self::Ok => "ok", Self::Err => "err", } } } /// One audit row as returned to the dashboard. #[derive(Debug, Clone, Serialize)] pub struct AuditEntry { pub id: i64, pub ts_unix: i64, /// Agent on whose behalf the action was taken. pub agent: String, /// What was done (e.g. `restart_infra`). pub action: String, /// What it acted on (e.g. `hive-ci`). pub target: String, /// `"ok"` | `"err"`. pub outcome: String, /// Optional free-text detail (e.g. the error message on failure). pub detail: Option, } /// Sqlite-backed audit-log store. `Arc`-friendly: all methods /// take `&self`, an internal `Mutex` serializes access. pub struct AuditLog { conn: Mutex, } impl AuditLog { /// Open (creating if absent) the `audit_log.sqlite` store under /// `db_dir` and apply the schema. `db_dir` is shared with /// `build_logs` (the broker db's parent directory). /// /// # Errors /// Returns an error if the directory can't be created, the sqlite /// file can't be opened, or applying the schema fails. pub fn open(db_dir: &Path) -> Result { std::fs::create_dir_all(db_dir) .with_context(|| format!("create audit_log db parent {}", db_dir.display()))?; let path = db_dir.join("audit_log.sqlite"); let conn = Connection::open(&path) .with_context(|| format!("open audit_log db {}", path.display()))?; conn.execute_batch(SCHEMA) .context("apply audit_log schema")?; Ok(Self { conn: Mutex::new(conn), }) } /// Record one privileged action. Best-effort: a sqlite error is logged /// but never returned, so a transient blip never fails the underlying /// privileged action (the action already happened — losing its audit /// row is strictly less bad than failing the action retroactively). /// /// Returns the inserted [`AuditEntry`] (with its assigned id + /// timestamp) on success, or `None` if the insert failed. The /// returned row is the canonical record — callers that also push a /// live event (e.g. the dashboard stream) emit *this* rather than /// re-deriving the fields, so the stored row and the streamed event /// can't drift. #[must_use] pub fn record( &self, agent: &str, action: &str, target: &str, outcome: AuditOutcome, detail: Option<&str>, ) -> Option { let now = now_secs(); let conn = self.conn.lock().unwrap(); match conn.execute( "INSERT INTO audit_log (ts_unix, agent, action, target, outcome, detail) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![now, agent, action, target, outcome.as_str(), detail], ) { Ok(_) => Some(AuditEntry { id: conn.last_insert_rowid(), ts_unix: now, agent: agent.to_owned(), action: action.to_owned(), target: target.to_owned(), outcome: outcome.as_str().to_owned(), detail: detail.map(str::to_owned), }), Err(e) => { tracing::warn!( %agent, %action, %target, error = ?e, "audit_log: record failed (dropping entry)" ); None } } } /// Return the most recent `limit` rows, newest first. Limit is /// hard-clamped to 500 to bound the worst-case payload. /// /// # Errors /// Returns an error if the query fails to prepare or a row fails to /// deserialize. pub fn list_recent(&self, limit: usize) -> Result> { let limit = limit.min(500); let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT id, ts_unix, agent, action, target, outcome, detail FROM audit_log ORDER BY ts_unix DESC, id DESC LIMIT ?1", )?; let rows = stmt.query_map(params![i64::try_from(limit).unwrap_or(500)], row_to_entry)?; let mut out = Vec::new(); for r in rows { out.push(r?); } Ok(out) } /// Total row count, regardless of the `list_recent` clamp. Lets the /// dashboard show "latest N of TOTAL" instead of silently capping. /// /// # Errors /// Returns an error if the `COUNT(*)` query fails. pub fn count_total(&self) -> Result { let conn = self.conn.lock().unwrap(); let n: i64 = conn.query_row("SELECT COUNT(*) FROM audit_log", [], |r| r.get(0))?; Ok(n) } /// Drop rows older than the retention window. Returns the number of /// rows deleted. Called from the hourly vacuum loop. /// /// # Errors /// Returns an error if the `DELETE` query fails. pub fn vacuum(&self) -> Result { let cutoff = now_secs() - KEEP_SECS; let conn = self.conn.lock().unwrap(); let removed = conn.execute("DELETE FROM audit_log WHERE ts_unix < ?1", params![cutoff])?; Ok(u64::try_from(removed).unwrap_or(0)) } } /// Spawn the hourly retention sweep. Mirrors `build_logs::spawn_vacuum` /// in cadence + shutdown handling. pub fn spawn_vacuum(coord: &Arc) { use std::time::Duration; let audit = coord.audit_log.clone(); let mut shutdown = coord.shutdown_rx(); let interval = Duration::from_hours(1); tokio::spawn(async move { loop { match audit.vacuum() { Ok(0) => {} Ok(n) => tracing::info!(removed = n, "audit_log vacuum"), Err(e) => tracing::warn!(error = ?e, "audit_log vacuum failed"), } tokio::select! { () = tokio::time::sleep(interval) => {} _ = shutdown.changed() => { tracing::info!("audit_log vacuum: shutdown signal received"); break; } } } }); } fn row_to_entry(r: &rusqlite::Row) -> rusqlite::Result { Ok(AuditEntry { id: r.get(0)?, ts_unix: r.get(1)?, agent: r.get(2)?, action: r.get(3)?, target: r.get(4)?, outcome: r.get(5)?, detail: r.get(6)?, }) } fn now_secs() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .ok() .and_then(|d| i64::try_from(d.as_secs()).ok()) .unwrap_or(0) } #[cfg(test)] mod tests { use super::*; fn tmpdb() -> (tempfile::TempDir, AuditLog) { let dir = tempfile::tempdir().expect("tempdir"); let db = AuditLog::open(dir.path()).expect("open"); (dir, db) } #[test] fn record_and_list_newest_first() { let (_d, db) = tmpdb(); // record() returns the canonical inserted row (id + ts assigned). let entry = db .record("atlas", "restart_infra", "hive-ci", AuditOutcome::Ok, None) .expect("record returns the inserted entry"); assert!(entry.id > 0); assert_eq!(entry.target, "hive-ci"); assert_eq!(entry.outcome, "ok"); assert!(entry.detail.is_none()); let _ = db.record( "atlas", "restart_infra", "hive-gateway", AuditOutcome::Err, Some("systemctl failed"), ); let rows = db.list_recent(10).expect("list"); assert_eq!(rows.len(), 2); // Newest first: the gateway/err row was inserted last. assert_eq!(rows[0].target, "hive-gateway"); assert_eq!(rows[0].outcome, "err"); assert_eq!(rows[0].detail.as_deref(), Some("systemctl failed")); assert_eq!(rows[1].target, "hive-ci"); assert_eq!(rows[1].outcome, "ok"); assert!(rows[1].detail.is_none()); assert_eq!(rows[0].agent, "atlas"); assert_eq!(rows[0].action, "restart_infra"); assert_eq!(db.count_total().expect("count"), 2); } #[test] fn list_clamps_to_500() { let (_d, db) = tmpdb(); let _ = db.record("a", "x", "t", AuditOutcome::Ok, None); let rows = db.list_recent(999_999).expect("list"); assert!(rows.len() <= 500); } #[test] fn vacuum_drops_only_old_rows() { let (_d, db) = tmpdb(); let _ = db.record("a", "restart_infra", "hive-ci", AuditOutcome::Ok, None); // Backdate it past the retention window. { let conn = db.conn.lock().unwrap(); conn.execute( "UPDATE audit_log SET ts_unix = ?1", params![now_secs() - KEEP_SECS - 60], ) .unwrap(); } let _ = db.record("a", "restart_infra", "hive-forge", AuditOutcome::Ok, None); let removed = db.vacuum().expect("vacuum"); assert_eq!(removed, 1, "only the backdated row should be reaped"); let rows = db.list_recent(10).expect("list"); assert_eq!(rows.len(), 1); assert_eq!(rows[0].target, "hive-forge"); } }