Skip to main content

hackamore_control/
audit.rs

1//! The audit sink. Every decision hackamore makes — allow or deny — is recorded. The
2//! trait lets the data plane stay oblivious to where records go; v1 ships an in-memory
3//! sink (used by tests and introspection) and a `tracing` sink for operations.
4
5use hackamore_models::audit::AuditEvent;
6use parking_lot::{Mutex, RwLock};
7use std::io::Write;
8use std::path::{Path, PathBuf};
9
10/// Receives one immutable [`AuditEvent`] per decision. Implementations must be cheap
11/// and non-blocking; the data plane records on the request path.
12pub trait AuditSink: Send + Sync {
13    fn record(&self, event: AuditEvent);
14}
15
16/// Collects events in memory. Used by tests and for local introspection.
17#[derive(Default)]
18pub struct InMemoryAudit {
19    events: RwLock<Vec<AuditEvent>>,
20}
21
22impl InMemoryAudit {
23    pub fn new() -> Self {
24        Self::default()
25    }
26
27    /// A snapshot copy of all recorded events, oldest first.
28    pub fn events(&self) -> Vec<AuditEvent> {
29        self.events.read().clone()
30    }
31}
32
33impl AuditSink for InMemoryAudit {
34    fn record(&self, event: AuditEvent) {
35        self.events.write().push(event);
36    }
37}
38
39/// Emits each event as a structured `tracing` record.
40#[derive(Default)]
41pub struct TracingAudit;
42
43impl AuditSink for TracingAudit {
44    fn record(&self, event: AuditEvent) {
45        tracing::info!(
46            target = %event.action.target,
47            decision = ?event.decision,
48            resource = %event.action.resource.path,
49            verb = ?event.action.verb,
50            detail = %event.detail,
51            "hackamore decision"
52        );
53    }
54}
55
56/// A durable, queryable audit sink: appends each event as one JSON line (JSONL) to a file,
57/// flushed per record so a crash loses at most the in-flight event. The file is a stable
58/// append-only log a SIEM or `jq` can tail and query, unlike the ephemeral `tracing`
59/// stream. A write failure is logged (the request path must not fail because audit I/O
60/// did) — operators should alarm on the `audit write failed` event.
61pub struct FileAudit {
62    path: PathBuf,
63    file: Mutex<std::fs::File>,
64}
65
66impl FileAudit {
67    /// Open (creating, else appending to) the JSONL audit log at `path`.
68    pub fn open(path: impl AsRef<Path>) -> std::io::Result<Self> {
69        let path = path.as_ref().to_path_buf();
70        if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
71            std::fs::create_dir_all(parent)?;
72        }
73        let file = std::fs::OpenOptions::new()
74            .create(true)
75            .append(true)
76            .open(&path)?;
77        Ok(Self {
78            path,
79            file: Mutex::new(file),
80        })
81    }
82
83    /// Read every recorded event back from the JSONL log (introspection / tests). Blank and
84    /// unparseable lines are skipped.
85    pub fn read(path: impl AsRef<Path>) -> std::io::Result<Vec<AuditEvent>> {
86        let text = std::fs::read_to_string(path)?;
87        Ok(text
88            .lines()
89            .filter(|l| !l.trim().is_empty())
90            .filter_map(|l| serde_json::from_str(l).ok())
91            .collect())
92    }
93}
94
95impl AuditSink for FileAudit {
96    fn record(&self, event: AuditEvent) {
97        let line = match serde_json::to_string(&event) {
98            Ok(json) => json,
99            Err(e) => {
100                tracing::error!(error = %e, "audit serialize failed");
101                return;
102            }
103        };
104        let mut file = self.file.lock();
105        if let Err(e) = writeln!(file, "{line}").and_then(|()| file.flush()) {
106            tracing::error!(error = %e, path = %self.path.display(), "audit write failed");
107        }
108    }
109}
110
111#[cfg(test)]
112#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
113mod tests {
114    use super::*;
115    use hackamore_models::action::{Action, CrudKind, Resource, Verb};
116    use hackamore_models::audit::Decision;
117
118    fn event(at: u64, decision: Decision, detail: &str) -> AuditEvent {
119        AuditEvent {
120            at_ms: at,
121            action: Action::of(
122                "github",
123                Verb::crud(CrudKind::Read),
124                Resource::of("repos/o/r", "repo"),
125            ),
126            decision,
127            detail: detail.to_string(),
128        }
129    }
130
131    #[test]
132    fn file_audit_appends_jsonl_and_reads_back() {
133        let path =
134            std::env::temp_dir().join(format!("hackamore-audit-{}.jsonl", std::process::id()));
135        let _ = std::fs::remove_file(&path);
136        {
137            let sink = FileAudit::open(&path).unwrap();
138            sink.record(event(1, Decision::Allow, "allowed"));
139            sink.record(event(2, Decision::Deny, "denied"));
140        }
141        // Reopening appends rather than truncating — the log is durable across restarts.
142        {
143            let sink = FileAudit::open(&path).unwrap();
144            sink.record(event(3, Decision::Allow, "again"));
145        }
146        let events = FileAudit::read(&path).unwrap();
147        assert_eq!(events.len(), 3);
148        assert_eq!(events[0].decision, Decision::Allow);
149        assert_eq!(events[1].decision, Decision::Deny);
150        assert_eq!(events[2].at_ms, 3);
151        let _ = std::fs::remove_file(&path);
152    }
153
154    #[test]
155    fn in_memory_audit_collects_in_order() {
156        let sink = InMemoryAudit::new();
157        for (i, decision) in [Decision::Allow, Decision::Deny].into_iter().enumerate() {
158            sink.record(AuditEvent {
159                at_ms: i as u64,
160                action: Action::of(
161                    "github",
162                    Verb::crud(CrudKind::Read),
163                    Resource::of("repos/o/r", "repo"),
164                ),
165                decision,
166                detail: String::new(),
167            });
168        }
169        let events = sink.events();
170        assert_eq!(events.len(), 2);
171        assert_eq!(events[0].decision, Decision::Allow);
172        assert_eq!(events[1].decision, Decision::Deny);
173    }
174}