hyperhive/hive-c0re/src/audit_log.rs
atlas a053d33184 refactor(#1865): rename agent_server module to socket_server
Pure rename ahead of the agent+manager server consolidation: the
per-agent socket dispatcher already hosts the shared dispatch and all
lifecycle handlers, and will absorb the manager-only handlers next, so
`agent_server` becomes a misnomer. No logic change — git mv plus a
mechanical `agent_server` -> `socket_server` rename across refs.
2026-06-22 13:58:52 +02:00

341 lines
12 KiB
Rust

//! 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<AuditLog>` 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<AuditLog>` through every entry point.
static GLOBAL: OnceLock<Arc<AuditLog>> = OnceLock::new();
/// Install the process-wide `AuditLog` handle. Idempotent: a second call
/// silently keeps the first handle.
pub fn install(handle: Arc<AuditLog>) {
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<Arc<AuditLog>> {
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<String>,
}
/// Sqlite-backed audit-log store. `Arc<AuditLog>`-friendly: all methods
/// take `&self`, an internal `Mutex<Connection>` serializes access.
pub struct AuditLog {
conn: Mutex<Connection>,
}
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<Self> {
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<AuditEntry> {
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<Vec<AuditEntry>> {
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<i64> {
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<u64> {
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<crate::coordinator::Coordinator>) {
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<AuditEntry> {
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");
}
}