use std::collections::VecDeque;
use std::io::Write;
use std::sync::atomic::{AtomicU64, Ordering};
use metrics::{counter, gauge};
use parking_lot::RwLock;
use tracing::{debug, error, warn};
use crate::audit::error::{AuditError, Result};
use crate::audit::model::{AuditDecision, AuditEvent};
pub trait AuditSink: Send + Sync {
fn emit(&self, event: &AuditEvent) -> Result<()>;
fn flush(&self) -> Result<()> {
Ok(())
}
fn is_durable(&self) -> bool {
false
}
}
pub struct MemorySink {
events: RwLock<VecDeque<AuditEvent>>,
max_capacity: usize,
dropped_count: AtomicU64,
}
impl MemorySink {
#[must_use]
pub fn new(max_capacity: usize) -> Self {
Self {
events: RwLock::new(VecDeque::with_capacity(max_capacity)),
max_capacity,
dropped_count: AtomicU64::new(0),
}
}
#[must_use]
pub fn dropped_count(&self) -> u64 {
self.dropped_count.load(Ordering::Relaxed)
}
#[must_use]
pub fn events(&self) -> Vec<AuditEvent> {
self.events.read().iter().cloned().collect()
}
#[must_use]
pub fn len(&self) -> usize {
self.events.read().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.events.read().is_empty()
}
#[must_use]
pub fn query_by_principal(&self, principal: &str) -> Vec<AuditEvent> {
self.events
.read()
.iter()
.filter(|e| e.principal == principal)
.cloned()
.collect()
}
#[must_use]
pub fn query_by_decision(&self, decision: AuditDecision) -> Vec<AuditEvent> {
self.events
.read()
.iter()
.filter(|e| e.decision == decision)
.cloned()
.collect()
}
#[must_use]
pub fn query_by_resource(&self, resource: &str) -> Vec<AuditEvent> {
self.events
.read()
.iter()
.filter(|e| e.resource == resource)
.cloned()
.collect()
}
#[must_use]
pub fn query_by_time_range(&self, min_ts: i64, max_ts: i64) -> Vec<AuditEvent> {
self.events
.read()
.iter()
.filter(|e| e.timestamp >= min_ts && e.timestamp <= max_ts)
.cloned()
.collect()
}
pub fn drain(&self) -> Vec<AuditEvent> {
let mut q = self.events.write();
std::mem::take(&mut *q).into()
}
#[must_use]
pub fn fill_ratio(&self) -> f64 {
if self.max_capacity == 0 {
return 1.0;
}
self.events.read().len() as f64 / self.max_capacity as f64
}
}
impl Default for MemorySink {
fn default() -> Self {
Self::new(100_000)
}
}
impl AuditSink for MemorySink {
fn emit(&self, event: &AuditEvent) -> Result<()> {
let mut q = self.events.write();
if q.len() >= self.max_capacity {
q.pop_front();
let dropped = self.dropped_count.fetch_add(1, Ordering::Relaxed) + 1;
counter!("chronix_audit_memory_sink_dropped_total").increment(1);
if dropped == 1 || dropped == 10 || dropped == 100 || dropped.is_multiple_of(1000) {
warn!(
dropped,
capacity = self.max_capacity,
"MemorySink overflow: oldest audit events being dropped"
);
}
}
q.push_back(event.clone());
let fill = q.len() as f64 / self.max_capacity.max(1) as f64;
gauge!("chronix_audit_memory_sink_fill_ratio").set(fill);
Ok(())
}
}
pub struct WriterSink<W: Write + Send + Sync> {
writer: parking_lot::Mutex<W>,
}
impl<W: Write + Send + Sync> WriterSink<W> {
pub fn new(writer: W) -> Self {
Self {
writer: parking_lot::Mutex::new(writer),
}
}
}
impl<W: Write + Send + Sync> AuditSink for WriterSink<W> {
fn emit(&self, event: &AuditEvent) -> Result<()> {
let json =
serde_json::to_string(event).map_err(|e| AuditError::Serialization(e.to_string()))?;
let mut w = self.writer.lock();
writeln!(w, "{json}")?;
Ok(())
}
fn flush(&self) -> Result<()> {
self.writer.lock().flush()?;
Ok(())
}
fn is_durable(&self) -> bool {
true
}
}
pub struct FileSink {
file: parking_lot::Mutex<std::fs::File>,
sync_each: bool,
good_len: std::sync::atomic::AtomicU64,
}
impl FileSink {
pub fn open(path: impl AsRef<std::path::Path>, sync_each: bool) -> Result<Self> {
let path = path.as_ref();
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
let good_len = file.metadata()?.len();
Ok(Self {
file: parking_lot::Mutex::new(file),
sync_each,
good_len: std::sync::atomic::AtomicU64::new(good_len),
})
}
}
impl AuditSink for FileSink {
fn emit(&self, event: &AuditEvent) -> Result<()> {
use std::sync::atomic::Ordering;
let mut line =
serde_json::to_vec(event).map_err(|e| AuditError::Serialization(e.to_string()))?;
line.push(b'\n');
let mut f = self.file.lock();
match f.write_all(&line) {
Ok(()) => {}
Err(e) => {
let good = self.good_len.load(Ordering::SeqCst);
if let Err(t) = f.set_len(good) {
tracing::error!(error = %t, offset = good, "audit log rewind failed");
}
return Err(e.into());
}
}
self.good_len.fetch_add(line.len() as u64, Ordering::SeqCst);
if self.sync_each {
f.sync_data()?;
}
Ok(())
}
fn flush(&self) -> Result<()> {
let mut f = self.file.lock();
f.flush()?;
f.sync_data()?;
Ok(())
}
fn is_durable(&self) -> bool {
true
}
}
pub fn last_event(path: impl AsRef<std::path::Path>) -> Result<Option<AuditEvent>> {
let path = path.as_ref();
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(path)?;
Ok(content
.lines()
.rev()
.find_map(|line| serde_json::from_str::<AuditEvent>(line).ok()))
}
pub fn read_events(path: impl AsRef<std::path::Path>) -> Result<Vec<AuditEvent>> {
let path = path.as_ref();
if !path.exists() {
return Ok(Vec::new());
}
let content = std::fs::read_to_string(path)?;
Ok(content
.lines()
.filter_map(|line| serde_json::from_str::<AuditEvent>(line).ok())
.collect())
}
pub struct TracingSink;
impl AuditSink for TracingSink {
fn emit(&self, event: &AuditEvent) -> Result<()> {
match event.decision {
AuditDecision::Allow => {
tracing::info!(
target: "chronix::audit",
id = event.id,
principal = %event.principal,
action = %event.action,
resource = %event.resource,
decision = "allow",
source_ip = event.source_ip.as_deref().unwrap_or("-"),
request_id = event.request_id.as_deref().unwrap_or("-"),
"Audit event"
);
}
AuditDecision::Deny => {
tracing::warn!(
target: "chronix::audit",
id = event.id,
principal = %event.principal,
action = %event.action,
resource = %event.resource,
decision = "deny",
source_ip = event.source_ip.as_deref().unwrap_or("-"),
request_id = event.request_id.as_deref().unwrap_or("-"),
"Audit event (DENIED)"
);
}
}
Ok(())
}
}
pub struct AuditLogger {
sinks: RwLock<Vec<Box<dyn AuditSink>>>,
sequence: AtomicU64,
prev_hash: parking_lot::Mutex<Option<String>>,
hmac_key: Option<Vec<u8>>,
}
impl AuditLogger {
#[must_use]
pub fn new() -> Self {
Self {
sinks: RwLock::new(Vec::new()),
sequence: AtomicU64::new(1),
prev_hash: parking_lot::Mutex::new(None),
hmac_key: None,
}
}
#[must_use]
pub fn with_hmac_key(mut self, key: Vec<u8>) -> Self {
self.hmac_key = Some(key);
self
}
#[must_use]
pub fn resume_from(self, last_id: u64, last_hash: Option<String>) -> Self {
self.sequence.store(last_id + 1, Ordering::Relaxed);
*self.prev_hash.lock() = last_hash;
self
}
#[must_use]
pub fn is_keyed(&self) -> bool {
self.hmac_key.is_some()
}
pub fn add_sink(&self, sink: Box<dyn AuditSink>) {
self.sinks.write().push(sink);
}
pub fn validate_has_durable_sink(&self) -> Result<()> {
let sinks = self.sinks.read();
let has_durable = sinks.iter().any(|s| s.is_durable());
if !has_durable {
warn!(
"No durable audit sink configured! Audit events will be \
lost on process restart. Add a WriterSink (file) or equivalent \
persistent sink for compliance."
);
return Err(AuditError::NoDurableSink);
}
Ok(())
}
#[must_use]
pub fn has_durable_sink(&self) -> bool {
self.sinks.read().iter().any(|s| s.is_durable())
}
pub fn log(&self, mut event: AuditEvent) {
event.id = self.sequence.fetch_add(1, Ordering::Relaxed);
let mut prev = self.prev_hash.lock();
event.seal_with_key(prev.as_deref(), self.hmac_key.as_deref());
counter!(
"chronix_audit_events_total",
"action" => event.action.to_string(),
"decision" => event.decision.to_string()
)
.increment(1);
let sinks = self.sinks.read();
let mut durable_failed = false;
let mut durable_wrote = false;
for sink in sinks.iter() {
if let Err(e) = sink.emit(&event) {
warn!(
sink = std::any::type_name_of_val(sink),
error = %e,
"Failed to emit audit event"
);
counter!("chronix_audit_emit_errors_total").increment(1);
counter!(
"chronix_audit_events_dropped_total",
"sink" => std::any::type_name_of_val(sink).to_string(),
)
.increment(1);
if sink.is_durable() {
durable_failed = true;
}
} else if sink.is_durable() {
durable_wrote = true;
}
}
if durable_failed && !durable_wrote {
error!(
id = event.id,
action = %event.action,
"audit event reached no durable sink; it is dropped and the \
chain continues from the last one that landed"
);
counter!("chronix_audit_chain_gaps_total").increment(1);
} else {
*prev = event.event_hash.clone();
}
drop(prev);
debug!(id = event.id, action = %event.action, "Audit event logged");
}
#[must_use]
pub fn next_sequence(&self) -> u64 {
self.sequence.load(Ordering::Relaxed)
}
#[must_use]
pub fn sink_count(&self) -> usize {
self.sinks.read().len()
}
pub fn flush(&self) {
let sinks = self.sinks.read();
for sink in sinks.iter() {
if let Err(e) = sink.flush() {
warn!(error = %e, "Failed to flush audit sink");
}
}
}
pub fn log_strict(&self, mut event: AuditEvent) -> Result<()> {
event.id = self.sequence.fetch_add(1, Ordering::Relaxed);
{
let mut prev = self.prev_hash.lock();
event.seal_with_key(prev.as_deref(), self.hmac_key.as_deref());
*prev = event.event_hash.clone();
}
counter!(
"chronix_audit_events_total",
"action" => event.action.to_string(),
"decision" => event.decision.to_string()
)
.increment(1);
let sinks = self.sinks.read();
let total = sinks.len();
let mut failures: Vec<(String, String)> = Vec::new();
for sink in sinks.iter() {
if let Err(e) = sink.emit(&event) {
counter!("chronix_audit_emit_errors_total").increment(1);
counter!(
"chronix_audit_events_dropped_total",
"sink" => std::any::type_name_of_val(sink).to_string(),
)
.increment(1);
let sink_name = std::any::type_name_of_val(sink).to_string();
warn!(
sink = %sink_name,
error = %e,
"Strict audit emit failed"
);
failures.push((sink_name, e.to_string()));
}
}
if failures.is_empty() {
debug!(id = event.id, action = %event.action, "Audit event logged (strict)");
Ok(())
} else {
let succeeded = total - failures.len();
Err(AuditError::PartialDelivery {
succeeded,
total,
failures,
})
}
}
pub fn flush_strict(&self) -> Result<()> {
let sinks = self.sinks.read();
let total = sinks.len();
let mut failures: Vec<(String, String)> = Vec::new();
for sink in sinks.iter() {
if let Err(e) = sink.flush() {
let sink_name = std::any::type_name_of_val(sink).to_string();
warn!(sink = %sink_name, error = %e, "Strict audit flush failed");
failures.push((sink_name, e.to_string()));
}
}
if failures.is_empty() {
Ok(())
} else {
let succeeded = total - failures.len();
Err(AuditError::PartialDelivery {
succeeded,
total,
failures,
})
}
}
}
impl Default for AuditLogger {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audit::model::{AuditAction, AuditDecision, AuditEvent};
use std::sync::Arc;
fn make_event(action: AuditAction, decision: AuditDecision) -> AuditEvent {
AuditEvent::new("alice", action, "cpu", decision).with_timestamp(1_700_000_000_000)
}
#[test]
fn concurrent_events_leave_a_verifiable_chain() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let logger = Arc::new(AuditLogger::new());
logger.add_sink(Box::new(FileSink::open(&path, true).unwrap()));
const THREADS: usize = 8;
const PER_THREAD: usize = 25;
std::thread::scope(|scope| {
for t in 0..THREADS {
let logger = Arc::clone(&logger);
scope.spawn(move || {
for i in 0..PER_THREAD {
logger.log(
AuditEvent::new(
format!("worker-{t}"),
AuditAction::Read,
format!("m{i}"),
AuditDecision::Allow,
)
.with_timestamp(1_700_000_000_000),
);
}
});
}
});
logger.flush();
let events = read_events(&path).unwrap();
assert_eq!(
events.len(),
THREADS * PER_THREAD,
"every event must reach the file"
);
crate::audit::model::verify_hash_chain(&events).expect(
"a log written by concurrent callers must verify; a chain that does \
not is indistinguishable from a tampered one",
);
}
struct MemoryHandle(Arc<MemorySink>);
impl AuditSink for MemoryHandle {
fn emit(&self, event: &AuditEvent) -> Result<()> {
self.0.emit(event)
}
fn flush(&self) -> Result<()> {
self.0.flush()
}
}
#[derive(Debug)]
struct BudgetedSink {
fail_after: std::sync::atomic::AtomicUsize,
durable: bool,
}
impl AuditSink for BudgetedSink {
fn emit(&self, _event: &AuditEvent) -> Result<()> {
use std::sync::atomic::Ordering;
if self.fail_after.load(Ordering::SeqCst) == 0 {
return Err(AuditError::Serialization("disk full".into()));
}
self.fail_after.fetch_sub(1, Ordering::SeqCst);
Ok(())
}
fn flush(&self) -> Result<()> {
Ok(())
}
fn is_durable(&self) -> bool {
self.durable
}
}
#[test]
fn a_dropped_event_does_not_break_the_chain_for_the_rest() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let logger = AuditLogger::new();
logger.add_sink(Box::new(FileSink::open(&path, true).unwrap()));
let failing = Box::new(BudgetedSink {
fail_after: std::sync::atomic::AtomicUsize::new(2),
durable: false,
});
logger.add_sink(failing);
for i in 0..5 {
logger.log(
AuditEvent::new(
"alice",
AuditAction::Read,
format!("m{i}"),
AuditDecision::Allow,
)
.with_timestamp(1_700_000_000_000),
);
}
logger.flush();
let events = read_events(&path).unwrap();
assert_eq!(events.len(), 5, "the file sink accepted every event");
crate::audit::model::verify_hash_chain(&events)
.expect("a non-durable sink failing must not touch the chain");
}
#[test]
fn the_chain_skips_an_event_no_durable_sink_took() {
let logger = AuditLogger::new();
let memory = Arc::new(MemorySink::new(100));
logger.add_sink(Box::new(MemoryHandle(Arc::clone(&memory))));
logger.add_sink(Box::new(BudgetedSink {
fail_after: std::sync::atomic::AtomicUsize::new(2),
durable: true,
}));
for i in 0..5 {
logger.log(
AuditEvent::new(
"alice",
AuditAction::Read,
format!("m{i}"),
AuditDecision::Allow,
)
.with_timestamp(1_700_000_000_000),
);
}
let seen = memory.events();
assert_eq!(seen.len(), 5);
assert_eq!(
seen[2].prev_hash, seen[3].prev_hash,
"two events that reached no durable sink must hang from the same link"
);
assert_eq!(
seen[1].event_hash.as_deref(),
seen[2].prev_hash.as_deref(),
"the first dropped event still links to the last one that landed"
);
}
#[test]
fn memory_sink_stores_events() {
let sink = MemorySink::new(100);
assert!(sink.is_empty());
let event = make_event(AuditAction::Write, AuditDecision::Allow);
sink.emit(&event).unwrap();
assert_eq!(sink.len(), 1);
}
#[test]
fn memory_sink_evicts_on_capacity() {
let sink = MemorySink::new(2);
for i in 0..3 {
let mut event = make_event(AuditAction::Read, AuditDecision::Allow);
event.timestamp = i;
sink.emit(&event).unwrap();
}
assert_eq!(sink.len(), 2);
let events = sink.events();
assert_eq!(events[0].timestamp, 1);
assert_eq!(events[1].timestamp, 2);
}
#[test]
fn memory_sink_query_by_principal() {
let sink = MemorySink::new(100);
sink.emit(&AuditEvent::new(
"alice",
AuditAction::Write,
"cpu",
AuditDecision::Allow,
))
.unwrap();
sink.emit(&AuditEvent::new(
"bob",
AuditAction::Read,
"cpu",
AuditDecision::Deny,
))
.unwrap();
sink.emit(&AuditEvent::new(
"alice",
AuditAction::Delete,
"logs",
AuditDecision::Allow,
))
.unwrap();
let alice = sink.query_by_principal("alice");
assert_eq!(alice.len(), 2);
let bob = sink.query_by_principal("bob");
assert_eq!(bob.len(), 1);
}
#[test]
fn memory_sink_query_by_decision() {
let sink = MemorySink::new(100);
sink.emit(&make_event(AuditAction::Write, AuditDecision::Allow))
.unwrap();
sink.emit(&make_event(AuditAction::Read, AuditDecision::Deny))
.unwrap();
sink.emit(&make_event(AuditAction::Delete, AuditDecision::Deny))
.unwrap();
let denied = sink.query_by_decision(AuditDecision::Deny);
assert_eq!(denied.len(), 2);
}
#[test]
fn memory_sink_query_by_resource() {
let sink = MemorySink::new(100);
sink.emit(&AuditEvent::new(
"a",
AuditAction::Write,
"cpu",
AuditDecision::Allow,
))
.unwrap();
sink.emit(&AuditEvent::new(
"b",
AuditAction::Write,
"memory",
AuditDecision::Allow,
))
.unwrap();
sink.emit(&AuditEvent::new(
"c",
AuditAction::Write,
"cpu",
AuditDecision::Allow,
))
.unwrap();
let cpu = sink.query_by_resource("cpu");
assert_eq!(cpu.len(), 2);
}
#[test]
fn memory_sink_query_by_time_range() {
let sink = MemorySink::new(100);
for ts in [100, 200, 300] {
let mut event = make_event(AuditAction::Read, AuditDecision::Allow);
event.timestamp = ts;
sink.emit(&event).unwrap();
}
let range = sink.query_by_time_range(150, 250);
assert_eq!(range.len(), 1);
assert_eq!(range[0].timestamp, 200);
}
#[test]
fn memory_sink_drain() {
let sink = MemorySink::new(100);
sink.emit(&make_event(AuditAction::Write, AuditDecision::Allow))
.unwrap();
sink.emit(&make_event(AuditAction::Read, AuditDecision::Allow))
.unwrap();
let drained = sink.drain();
assert_eq!(drained.len(), 2);
assert!(sink.is_empty());
}
#[test]
fn writer_sink_json_lines() {
let buf: Vec<u8> = Vec::new();
let sink = WriterSink::new(buf);
let event1 = make_event(AuditAction::Write, AuditDecision::Allow);
let event2 = make_event(AuditAction::Read, AuditDecision::Deny);
sink.emit(&event1).unwrap();
sink.emit(&event2).unwrap();
sink.flush().unwrap();
let output = sink.writer.lock();
let text = String::from_utf8(output.clone()).unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2);
let parsed: AuditEvent = serde_json::from_str(lines[0]).unwrap();
assert_eq!(parsed.principal, "alice");
assert_eq!(parsed.decision, AuditDecision::Allow);
let parsed2: AuditEvent = serde_json::from_str(lines[1]).unwrap();
assert_eq!(parsed2.decision, AuditDecision::Deny);
}
#[test]
fn tracing_sink_emits_ok() {
let sink = TracingSink;
let event = make_event(AuditAction::Write, AuditDecision::Allow);
assert!(sink.emit(&event).is_ok());
let deny = make_event(AuditAction::Delete, AuditDecision::Deny);
assert!(sink.emit(&deny).is_ok());
}
#[test]
fn logger_assigns_sequence_numbers() {
let mem = Arc::new(MemorySink::new(100));
let logger = AuditLogger::new();
logger.add_sink(Box::new(SinkWrapper(Arc::clone(&mem))));
logger.log(make_event(AuditAction::Write, AuditDecision::Allow));
logger.log(make_event(AuditAction::Read, AuditDecision::Allow));
logger.log(make_event(AuditAction::Delete, AuditDecision::Deny));
let events = mem.events();
assert_eq!(events.len(), 3);
assert_eq!(events[0].id, 1);
assert_eq!(events[1].id, 2);
assert_eq!(events[2].id, 3);
assert_eq!(logger.next_sequence(), 4);
}
#[test]
fn logger_dispatches_to_multiple_sinks() {
let mem1 = Arc::new(MemorySink::new(100));
let mem2 = Arc::new(MemorySink::new(100));
let logger = AuditLogger::new();
logger.add_sink(Box::new(SinkWrapper(Arc::clone(&mem1))));
logger.add_sink(Box::new(SinkWrapper(Arc::clone(&mem2))));
assert_eq!(logger.sink_count(), 2);
logger.log(make_event(AuditAction::Write, AuditDecision::Allow));
assert_eq!(mem1.len(), 1);
assert_eq!(mem2.len(), 1);
}
#[test]
fn logger_all_actions_logged() {
let mem = Arc::new(MemorySink::new(100));
let logger = AuditLogger::new();
logger.add_sink(Box::new(SinkWrapper(Arc::clone(&mem))));
logger.log(make_event(AuditAction::Write, AuditDecision::Allow));
logger.log(make_event(AuditAction::Write, AuditDecision::Deny));
logger.log(make_event(AuditAction::LoginFailure, AuditDecision::Allow));
logger.log(make_event(AuditAction::LoginFailure, AuditDecision::Deny));
logger.log(make_event(AuditAction::ApiKeyCreate, AuditDecision::Allow));
assert_eq!(mem.len(), 5);
let denied = mem.query_by_decision(AuditDecision::Deny);
assert_eq!(denied.len(), 2);
}
#[test]
fn logger_with_file_sink() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.log");
let file = std::fs::File::create(&path).unwrap();
let logger = AuditLogger::new();
logger.add_sink(Box::new(WriterSink::new(file)));
logger.log(
AuditEvent::new("alice", AuditAction::Write, "cpu", AuditDecision::Allow)
.with_source_ip("10.0.0.1")
.with_request_id("req-001")
.with_timestamp(1_700_000_000_000),
);
logger.log(
AuditEvent::new("bob", AuditAction::Read, "memory", AuditDecision::Deny)
.with_timestamp(1_700_000_000_001),
);
logger.flush();
let content = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 2);
let e1: AuditEvent = serde_json::from_str(lines[0]).unwrap();
assert_eq!(e1.principal, "alice");
assert_eq!(e1.source_ip.as_deref(), Some("10.0.0.1"));
assert_eq!(e1.request_id.as_deref(), Some("req-001"));
assert_eq!(e1.id, 1);
let e2: AuditEvent = serde_json::from_str(lines[1]).unwrap();
assert_eq!(e2.principal, "bob");
assert_eq!(e2.decision, AuditDecision::Deny);
assert_eq!(e2.id, 2);
}
struct SinkWrapper(Arc<MemorySink>);
impl AuditSink for SinkWrapper {
fn emit(&self, event: &AuditEvent) -> Result<()> {
self.0.emit(event)
}
fn flush(&self) -> Result<()> {
self.0.flush()
}
}
struct FailingSink;
impl AuditSink for FailingSink {
fn emit(&self, _event: &AuditEvent) -> Result<()> {
Err(AuditError::Internal("sink unavailable".into()))
}
fn flush(&self) -> Result<()> {
Err(AuditError::Internal("flush failed".into()))
}
}
#[test]
fn log_strict_succeeds_with_healthy_sinks() {
let mem = Arc::new(MemorySink::new(100));
let logger = AuditLogger::new();
logger.add_sink(Box::new(SinkWrapper(Arc::clone(&mem))));
logger
.log_strict(make_event(AuditAction::Write, AuditDecision::Allow))
.unwrap();
assert_eq!(mem.len(), 1);
}
#[test]
fn log_strict_returns_error_on_sink_failure() {
let logger = AuditLogger::new();
logger.add_sink(Box::new(FailingSink));
let result = logger.log_strict(make_event(AuditAction::Write, AuditDecision::Allow));
assert!(result.is_err());
}
#[test]
fn log_strict_fails_if_any_sink_fails() {
let mem = Arc::new(MemorySink::new(100));
let logger = AuditLogger::new();
logger.add_sink(Box::new(SinkWrapper(Arc::clone(&mem))));
logger.add_sink(Box::new(FailingSink));
let result = logger.log_strict(make_event(AuditAction::Delete, AuditDecision::Deny));
assert_eq!(mem.len(), 1);
match result {
Err(AuditError::PartialDelivery {
succeeded,
total,
failures,
}) => {
assert_eq!(succeeded, 1);
assert_eq!(total, 2);
assert_eq!(failures.len(), 1);
}
other => panic!("Expected PartialDelivery, got: {other:?}"),
}
}
#[test]
fn flush_strict_propagates_failure() {
let logger = AuditLogger::new();
logger.add_sink(Box::new(FailingSink));
let result = logger.flush_strict();
assert!(result.is_err());
}
#[test]
fn flush_strict_succeeds_with_healthy_sinks() {
let mem = Arc::new(MemorySink::new(100));
let logger = AuditLogger::new();
logger.add_sink(Box::new(SinkWrapper(Arc::clone(&mem))));
logger.flush_strict().unwrap();
}
#[test]
fn log_best_effort_continues_on_sink_failure() {
let mem = Arc::new(MemorySink::new(100));
let logger = AuditLogger::new();
logger.add_sink(Box::new(FailingSink)); logger.add_sink(Box::new(SinkWrapper(Arc::clone(&mem))));
logger.log(make_event(AuditAction::Write, AuditDecision::Allow));
assert_eq!(mem.len(), 1);
}
#[test]
fn memory_sink_is_not_durable() {
let sink = MemorySink::new(100);
assert!(!sink.is_durable());
}
#[test]
fn writer_sink_is_durable() {
let sink = WriterSink::new(Vec::<u8>::new());
assert!(sink.is_durable());
}
#[test]
fn validate_no_durable_sink_fails() {
let logger = AuditLogger::new();
logger.add_sink(Box::new(MemorySink::new(100)));
let result = logger.validate_has_durable_sink();
assert!(matches!(result, Err(AuditError::NoDurableSink)));
assert!(!logger.has_durable_sink());
}
#[test]
fn validate_with_durable_sink_succeeds() {
let logger = AuditLogger::new();
logger.add_sink(Box::new(MemorySink::new(100)));
logger.add_sink(Box::new(WriterSink::new(Vec::<u8>::new())));
logger.validate_has_durable_sink().unwrap();
assert!(logger.has_durable_sink());
}
#[test]
fn validate_empty_logger_fails() {
let logger = AuditLogger::new();
let result = logger.validate_has_durable_sink();
assert!(matches!(result, Err(AuditError::NoDurableSink)));
}
fn make_named_event(principal: &str) -> AuditEvent {
AuditEvent::new(principal, AuditAction::Write, "cpu", AuditDecision::Allow)
.with_timestamp(1_700_000_000_000)
}
#[test]
fn overflow_increments_dropped_count() {
let sink = MemorySink::new(3);
for i in 0..5 {
sink.emit(&make_named_event(&format!("user{i}"))).unwrap();
}
assert_eq!(sink.dropped_count(), 2);
assert_eq!(sink.len(), 3);
}
#[test]
fn fill_ratio_reports_correctly() {
let sink = MemorySink::new(10);
assert!((sink.fill_ratio() - 0.0).abs() < f64::EPSILON);
for i in 0..5 {
sink.emit(&make_named_event(&format!("u{i}"))).unwrap();
}
assert!((sink.fill_ratio() - 0.5).abs() < f64::EPSILON);
for i in 5..10 {
sink.emit(&make_named_event(&format!("u{i}"))).unwrap();
}
assert!((sink.fill_ratio() - 1.0).abs() < f64::EPSILON);
}
#[test]
fn fill_ratio_stays_at_one_when_overflowing() {
let sink = MemorySink::new(2);
for i in 0..5 {
sink.emit(&make_named_event(&format!("u{i}"))).unwrap();
}
assert!((sink.fill_ratio() - 1.0).abs() < f64::EPSILON);
assert_eq!(sink.dropped_count(), 3);
}
#[test]
fn overflow_preserves_newest_events() {
let sink = MemorySink::new(3);
for i in 0..6 {
sink.emit(&make_named_event(&format!("user{i}"))).unwrap();
}
let events = sink.events();
let principals: Vec<&str> = events.iter().map(|e| e.principal.as_str()).collect();
assert_eq!(principals, vec!["user3", "user4", "user5"]);
}
}
#[cfg(test)]
mod durability_tests {
use super::*;
use crate::audit::model::verify_chain_from;
use crate::audit::{AuditAction, AuditDecision};
fn event(principal: &str) -> AuditEvent {
AuditEvent::new(principal, AuditAction::Delete, "cpu", AuditDecision::Allow)
}
#[test]
fn a_restart_continues_one_chain() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
{
let logger = AuditLogger::new();
logger.add_sink(Box::new(FileSink::open(&path, true).unwrap()));
logger.log(event("alice"));
logger.log(event("bob"));
}
let last = last_event(&path).unwrap().expect("a previous event");
assert_eq!(last.id, 2);
{
let logger = AuditLogger::new().resume_from(last.id, last.event_hash.clone());
logger.add_sink(Box::new(FileSink::open(&path, true).unwrap()));
logger.log(event("carol"));
}
let events = read_events(&path).unwrap();
assert_eq!(events.len(), 3, "every event survives the restart");
assert_eq!(
events.iter().map(|e| e.id).collect::<Vec<_>>(),
vec![1, 2, 3],
"sequence numbers continue rather than restarting at 1"
);
verify_chain_from(&events, None, None)
.expect("the chain must verify straight through the restart");
}
#[test]
fn a_keyed_chain_verifies_only_with_its_key() {
let key = b"seal".to_vec();
let logger = AuditLogger::new().with_hmac_key(key.clone());
let handle = std::sync::Arc::new(MemorySink::new(16));
logger.add_sink(Box::new(SharedMemorySink(handle.clone())));
logger.log(event("alice"));
logger.log_strict(event("bob")).unwrap();
let events = handle.events();
assert_eq!(events.len(), 2);
verify_chain_from(&events, None, Some(&key))
.expect("both `log` and `log_strict` must seal the same way");
assert!(
verify_chain_from(&events, None, None).is_err(),
"a keyed chain must not verify as an unkeyed one"
);
}
struct SharedMemorySink(std::sync::Arc<MemorySink>);
impl AuditSink for SharedMemorySink {
fn emit(&self, event: &AuditEvent) -> Result<()> {
self.0.emit(event)
}
fn flush(&self) -> Result<()> {
self.0.flush()
}
}
#[test]
fn a_partial_trailing_line_is_skipped() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
{
let logger = AuditLogger::new();
logger.add_sink(Box::new(FileSink::open(&path, true).unwrap()));
logger.log(event("alice"));
}
{
use std::io::Write as _;
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
write!(f, "{{\"id\":2,\"principal\":\"tr").unwrap();
}
let last = last_event(&path).unwrap().expect("the complete event");
assert_eq!(last.id, 1);
assert_eq!(read_events(&path).unwrap().len(), 1);
}
}