use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::Path;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::Serialize;
use crate::Error;
#[derive(Debug, Clone, Copy)]
pub(crate) enum Outcome {
Ok,
Denied,
Error,
}
impl Outcome {
fn as_str(self) -> &'static str {
match self {
Outcome::Ok => "ok",
Outcome::Denied => "denied",
Outcome::Error => "error",
}
}
pub(crate) fn of<T>(result: &Result<T, Error>) -> Self {
match result {
Ok(_) => Outcome::Ok,
Err(_) => Outcome::Error,
}
}
}
#[derive(Serialize)]
struct Record<'a> {
ts_ms: u64,
actor: &'a str,
action: &'a str,
resource: &'a str,
outcome: &'a str,
}
pub(crate) struct AuditLog {
sink: Option<Mutex<File>>,
}
impl AuditLog {
pub(crate) fn open(path: Option<&Path>) -> Result<Self, Error> {
let sink = match path {
Some(path) => {
let file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(Error::Io)?;
Some(Mutex::new(file))
}
None => None,
};
Ok(Self { sink })
}
pub(crate) fn record(&self, actor: &str, action: &str, resource: &str, outcome: Outcome) {
let outcome = outcome.as_str();
tracing::info!(target: "quiver::audit", actor, action, resource, outcome, "audit");
let Some(sink) = &self.sink else { return };
let ts_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as u64);
let record = Record {
ts_ms,
actor,
action,
resource,
outcome,
};
let Ok(mut line) = serde_json::to_string(&record) else {
tracing::error!(target: "quiver::audit", "failed to serialize an audit record");
return;
};
line.push('\n');
match sink.lock() {
Ok(mut file) => {
if let Err(e) = file.write_all(line.as_bytes()).and_then(|()| file.flush()) {
tracing::error!(target: "quiver::audit", error = %e, "failed to append an audit record");
}
}
Err(_) => tracing::error!(target: "quiver::audit", "audit sink lock poisoned"),
}
}
pub(crate) fn deny(&self, actor: &str, action: &str, resource: &str) {
self.record(actor, action, resource, Outcome::Denied);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
fn read_lines(path: &Path) -> Vec<serde_json::Value> {
let mut contents = String::new();
File::open(path)
.expect("open audit log")
.read_to_string(&mut contents)
.expect("read audit log");
contents
.lines()
.map(|l| serde_json::from_str(l).expect("each line is valid JSON"))
.collect()
}
#[test]
fn open_none_records_without_a_file() {
let log = AuditLog::open(None).expect("open tracing-only audit log");
log.record("actor", "upsert", "c", Outcome::Ok);
log.deny("actor", "upsert", "c");
}
#[test]
fn records_are_appended_as_json_lines() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("audit.log");
let log = AuditLog::open(Some(&path)).expect("open file audit log");
log.record("ci-admin", "create_collection", "acme.docs", Outcome::Ok);
log.deny("key:abcd", "upsert", "acme.docs");
log.record("ci-admin", "upsert", "acme.docs", Outcome::Error);
let lines = read_lines(&path);
assert_eq!(lines.len(), 3);
assert_eq!(lines[0]["actor"], "ci-admin");
assert_eq!(lines[0]["action"], "create_collection");
assert_eq!(lines[0]["resource"], "acme.docs");
assert_eq!(lines[0]["outcome"], "ok");
assert!(lines[0]["ts_ms"].as_u64().is_some());
assert_eq!(lines[1]["outcome"], "denied");
assert_eq!(lines[2]["outcome"], "error");
}
#[test]
fn open_appends_rather_than_truncates() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("audit.log");
AuditLog::open(Some(&path))
.expect("open")
.record("a", "upsert", "c", Outcome::Ok);
AuditLog::open(Some(&path))
.expect("reopen")
.record("a", "delete_points", "c", Outcome::Ok);
let lines = read_lines(&path);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0]["action"], "upsert");
assert_eq!(lines[1]["action"], "delete_points");
}
}