use hackamore_models::audit::AuditEvent;
use parking_lot::{Mutex, RwLock};
use std::io::Write;
use std::path::{Path, PathBuf};
pub trait AuditSink: Send + Sync {
fn record(&self, event: AuditEvent);
}
#[derive(Default)]
pub struct InMemoryAudit {
events: RwLock<Vec<AuditEvent>>,
}
impl InMemoryAudit {
pub fn new() -> Self {
Self::default()
}
pub fn events(&self) -> Vec<AuditEvent> {
self.events.read().clone()
}
}
impl AuditSink for InMemoryAudit {
fn record(&self, event: AuditEvent) {
self.events.write().push(event);
}
}
#[derive(Default)]
pub struct TracingAudit;
impl AuditSink for TracingAudit {
fn record(&self, event: AuditEvent) {
tracing::info!(
target = %event.action.target,
decision = ?event.decision,
resource = %event.action.resource.path,
verb = ?event.action.verb,
detail = %event.detail,
"hackamore decision"
);
}
}
pub struct FileAudit {
path: PathBuf,
file: Mutex<std::fs::File>,
}
impl FileAudit {
pub fn open(path: impl AsRef<Path>) -> std::io::Result<Self> {
let path = path.as_ref().to_path_buf();
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent)?;
}
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
Ok(Self {
path,
file: Mutex::new(file),
})
}
pub fn read(path: impl AsRef<Path>) -> std::io::Result<Vec<AuditEvent>> {
let text = std::fs::read_to_string(path)?;
Ok(text
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect())
}
}
impl AuditSink for FileAudit {
fn record(&self, event: AuditEvent) {
let line = match serde_json::to_string(&event) {
Ok(json) => json,
Err(e) => {
tracing::error!(error = %e, "audit serialize failed");
return;
}
};
let mut file = self.file.lock();
if let Err(e) = writeln!(file, "{line}").and_then(|()| file.flush()) {
tracing::error!(error = %e, path = %self.path.display(), "audit write failed");
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use hackamore_models::action::{Action, CrudKind, Resource, Verb};
use hackamore_models::audit::Decision;
fn event(at: u64, decision: Decision, detail: &str) -> AuditEvent {
AuditEvent {
at_ms: at,
action: Action::of(
"github",
Verb::crud(CrudKind::Read),
Resource::of("repos/o/r", "repo"),
),
decision,
detail: detail.to_string(),
}
}
#[test]
fn file_audit_appends_jsonl_and_reads_back() {
let path =
std::env::temp_dir().join(format!("hackamore-audit-{}.jsonl", std::process::id()));
let _ = std::fs::remove_file(&path);
{
let sink = FileAudit::open(&path).unwrap();
sink.record(event(1, Decision::Allow, "allowed"));
sink.record(event(2, Decision::Deny, "denied"));
}
{
let sink = FileAudit::open(&path).unwrap();
sink.record(event(3, Decision::Allow, "again"));
}
let events = FileAudit::read(&path).unwrap();
assert_eq!(events.len(), 3);
assert_eq!(events[0].decision, Decision::Allow);
assert_eq!(events[1].decision, Decision::Deny);
assert_eq!(events[2].at_ms, 3);
let _ = std::fs::remove_file(&path);
}
#[test]
fn in_memory_audit_collects_in_order() {
let sink = InMemoryAudit::new();
for (i, decision) in [Decision::Allow, Decision::Deny].into_iter().enumerate() {
sink.record(AuditEvent {
at_ms: i as u64,
action: Action::of(
"github",
Verb::crud(CrudKind::Read),
Resource::of("repos/o/r", "repo"),
),
decision,
detail: String::new(),
});
}
let events = sink.events();
assert_eq!(events.len(), 2);
assert_eq!(events[0].decision, Decision::Allow);
assert_eq!(events[1].decision, Decision::Deny);
}
}