diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index 0e037388..68d00ccd 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -522,7 +522,7 @@ async fn handle_restart_child(coord: &Arc, agent: &str, name: &str) // tool. These names are never agent children, so this branch is // disjoint from the child-restart path below. if hive_sh4re::priv_proto::RESTARTABLE_INFRA_CONTAINERS.contains(&name) { - return handle_restart_infra(agent, name).await; + return handle_restart_infra(coord, agent, name).await; } if let Some(err) = require_child(agent, name, "restart") { return err; @@ -544,13 +544,24 @@ async fn handle_restart_child(coord: &Arc, agent: &str, name: &str) /// known to be in `RESTARTABLE_INFRA_CONTAINERS`; this gates on the /// capability and routes the systemctl restart through hive-priv (which /// re-validates the name root-side). Direct, not approval-gated. -async fn handle_restart_infra(agent: &str, container: &str) -> AgentResponse { - // Record the attempt in the operator-visible privileged-action audit trail. - // Best-effort: no-op when the global handle isn't installed (early - // startup / tests). `action` is stable so the dashboard can group. +async fn handle_restart_infra( + coord: &Arc, + agent: &str, + container: &str, +) -> AgentResponse { + // Record the attempt in the operator-visible privileged-action audit + // trail, then emit a live `AuditEntryAdded` so the dashboard audit view + // appends it off `/dashboard/stream`. Best-effort: `record` returns the + // canonical row (or `None` on a sqlite blip), and we stream exactly that + // row so the stored + streamed views can't drift. `action` is stable so + // the dashboard can group/filter. let audit = |outcome: crate::audit_log::AuditOutcome, detail: Option<&str>| { - if let Some(log) = crate::audit_log::global() { - log.record(agent, "restart_infra", container, outcome, detail); + if let Some(entry) = + coord + .audit_log + .record(agent, "restart_infra", container, outcome, detail) + { + coord.emit_audit_entry(entry); } }; if !crate::capabilities::has_cap(agent, hive_sh4re::Capability::InfraAdmin) { diff --git a/hive-c0re/src/audit_log.rs b/hive-c0re/src/audit_log.rs index 5f7760dd..e17f8e93 100644 --- a/hive-c0re/src/audit_log.rs +++ b/hive-c0re/src/audit_log.rs @@ -133,6 +133,13 @@ impl AuditLog { /// 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. pub fn record( &self, agent: &str, @@ -140,19 +147,31 @@ impl AuditLog { target: &str, outcome: AuditOutcome, detail: Option<&str>, - ) { + ) -> Option { let now = now_secs(); let conn = self.conn.lock().unwrap(); - if let Err(e) = conn.execute( + 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], ) { - tracing::warn!( - %agent, %action, %target, - error = ?e, - "audit_log: record failed (dropping entry)" - ); + 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 + } } } @@ -261,8 +280,15 @@ mod tests { #[test] fn record_and_list_newest_first() { let (_d, db) = tmpdb(); - db.record("atlas", "restart_infra", "hive-ci", AuditOutcome::Ok, None); - db.record( + // 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", @@ -286,7 +312,7 @@ mod tests { #[test] fn list_clamps_to_500() { let (_d, db) = tmpdb(); - db.record("a", "x", "t", AuditOutcome::Ok, None); + let _ = db.record("a", "x", "t", AuditOutcome::Ok, None); let rows = db.list_recent(999_999).expect("list"); assert!(rows.len() <= 500); } @@ -294,7 +320,7 @@ mod tests { #[test] fn vacuum_drops_only_old_rows() { let (_d, db) = tmpdb(); - db.record("a", "restart_infra", "hive-ci", AuditOutcome::Ok, None); + let _ = db.record("a", "restart_infra", "hive-ci", AuditOutcome::Ok, None); // Backdate it past the retention window. { let conn = db.conn.lock().unwrap(); @@ -304,7 +330,7 @@ mod tests { ) .unwrap(); } - db.record("a", "restart_infra", "hive-forge", AuditOutcome::Ok, None); + 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"); diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 8a893f79..8e632edf 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -692,6 +692,18 @@ impl Coordinator { self.meta_updates_active.load(Ordering::SeqCst) > 0 } + /// Emit `AuditEntryAdded` immediately after a privileged-action row + /// is recorded, so the dashboard audit view live-appends it off + /// `/dashboard/stream`. Pass the [`AuditEntry`](crate::audit_log::AuditEntry) + /// returned by `audit_log::record` so the streamed event is the same + /// canonical row that was stored. + pub fn emit_audit_entry(&self, entry: crate::audit_log::AuditEntry) { + self.emit_dashboard_event(DashboardEvent::AuditEntryAdded { + seq: self.next_seq(), + entry, + }); + } + /// Emit `ApprovalAdded` immediately after the row is inserted in /// sqlite. Caller passes the diff text it already computed (or /// `None` for spawn approvals which carry no diff). diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index aaaefbbc..d13f5a77 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -13,6 +13,17 @@ use crate::rebuild_queue::QueueEntry; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "snake_case", tag = "kind")] pub enum DashboardEvent { + /// A new agent-initiated privileged action was recorded in the audit + /// log. The audit view (`/audit.html`) prepends `entry` live off + /// `/dashboard/stream` instead of polling. The `AuditEntry` fields + /// are flattened alongside the `kind` tag + `seq`, so the wire shape + /// matches one row of the `/api/audit-log` `entries` array exactly + /// (`{kind, seq, id, ts_unix, agent, action, target, outcome, detail}`). + AuditEntryAdded { + seq: u64, + #[serde(flatten)] + entry: crate::audit_log::AuditEntry, + }, /// Broker `Sent` event mirrored onto the dashboard channel. /// `file_refs` carries every path-shaped token in `body` that /// hive-c0re verified is a regular file under the allow-listed @@ -270,6 +281,7 @@ impl DashboardEvent { DashboardEvent::RemindersChanged { .. } => "reminders_changed", DashboardEvent::CapabilitiesChanged { .. } => "capabilities_changed", DashboardEvent::ToolGroupsChanged { .. } => "tool_groups_changed", + DashboardEvent::AuditEntryAdded { .. } => "audit_entry_added", } } } @@ -408,6 +420,18 @@ mod tests { descriptions: std::collections::BTreeMap::new(), assignments: std::collections::BTreeMap::new(), }, + DashboardEvent::AuditEntryAdded { + seq: 1, + entry: crate::audit_log::AuditEntry { + id: 1, + ts_unix: 0, + agent: "atlas".into(), + action: "restart_infra".into(), + target: "hive-ci".into(), + outcome: "ok".into(), + detail: None, + }, + }, ]; for ev in samples { let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise"); @@ -418,4 +442,34 @@ mod tests { assert_eq!(ev.kind_tag(), serde_kind, "kind_tag() drift on {ev:?}"); } } + + /// The flattened `AuditEntry` fields must sit alongside `kind`/`seq` + /// at the top level (not nested under `entry`) so the wire shape + /// matches one `/api/audit-log` row — the audit view prepends it + /// directly. + #[test] + fn audit_entry_added_flattens_to_top_level() { + let ev = DashboardEvent::AuditEntryAdded { + seq: 7, + entry: crate::audit_log::AuditEntry { + id: 42, + ts_unix: 1_700_000_000, + agent: "atlas".into(), + action: "restart_infra".into(), + target: "hive-gateway".into(), + outcome: "err".into(), + detail: Some("denied: missing infra_admin capability".into()), + }, + }; + let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise"); + assert_eq!(v["kind"], "audit_entry_added"); + assert_eq!(v["seq"], 7); + assert_eq!(v["id"], 42); + assert_eq!(v["agent"], "atlas"); + assert_eq!(v["target"], "hive-gateway"); + assert_eq!(v["outcome"], "err"); + assert_eq!(v["detail"], "denied: missing infra_admin capability"); + // Not nested — there must be no `entry` sub-object. + assert!(v.get("entry").is_none()); + } }