hackamore_control/
audit.rs1use hackamore_models::audit::AuditEvent;
6use parking_lot::{Mutex, RwLock};
7use std::io::Write;
8use std::path::{Path, PathBuf};
9
10pub trait AuditSink: Send + Sync {
13 fn record(&self, event: AuditEvent);
14}
15
16#[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 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#[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
56pub struct FileAudit {
62 path: PathBuf,
63 file: Mutex<std::fs::File>,
64}
65
66impl FileAudit {
67 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 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 {
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}