use std::sync::Mutex;
use std::time::SystemTime;
use crate::backend::StoreBackend;
use crate::face::{FaceError, FaceWatchStream, ResourceFormat, ResourceRef};
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum VerbKind {
Apply,
Get,
List,
Delete,
Watch,
Snapshot,
Restore,
}
impl VerbKind {
#[must_use]
pub fn is_mutation(self) -> bool {
matches!(self, VerbKind::Apply | VerbKind::Delete | VerbKind::Restore)
}
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct AuditEvent {
pub timestamp_s: u64,
pub timestamp_ns: u32,
pub verb: VerbKind,
pub reference: Option<ResourceRef>,
pub format: Option<ResourceFormat>,
pub kind_filter: Option<String>,
pub namespace_filter: Option<String>,
pub success: bool,
pub error_message: Option<String>,
pub body_bytes: Option<usize>,
}
impl AuditEvent {
#[must_use]
pub fn now(verb: VerbKind) -> Self {
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
Self {
timestamp_s: now.as_secs(),
timestamp_ns: now.subsec_nanos(),
verb,
reference: None,
format: None,
kind_filter: None,
namespace_filter: None,
success: true,
error_message: None,
body_bytes: None,
}
}
#[must_use]
pub fn with_ref(mut self, r: ResourceRef) -> Self {
self.reference = Some(r);
self
}
#[must_use]
pub fn with_format(mut self, f: ResourceFormat) -> Self {
self.format = Some(f);
self
}
#[must_use]
pub fn with_filter(mut self, kind: &str, namespace: Option<&str>) -> Self {
self.kind_filter = Some(kind.to_string());
self.namespace_filter = namespace.map(str::to_string);
self
}
#[must_use]
pub fn with_body_bytes(mut self, n: usize) -> Self {
self.body_bytes = Some(n);
self
}
#[must_use]
pub fn ok(mut self) -> Self {
self.success = true;
self.error_message = None;
self
}
#[must_use]
pub fn err(mut self, e: &FaceError) -> Self {
self.success = false;
self.error_message = Some(e.to_string());
self
}
}
pub trait AuditLog: Send + Sync + 'static {
fn record(&self, event: AuditEvent);
fn recent(&self, _limit: usize) -> Vec<AuditEvent> {
Vec::new()
}
}
pub struct NoopAuditLog;
impl AuditLog for NoopAuditLog {
fn record(&self, _event: AuditEvent) {}
}
pub struct InMemoryAuditLog {
capacity: usize,
events: Mutex<std::collections::VecDeque<AuditEvent>>,
}
impl InMemoryAuditLog {
#[must_use]
pub fn with_capacity(capacity: usize) -> Self {
Self {
capacity: capacity.max(1),
events: Mutex::new(std::collections::VecDeque::with_capacity(capacity.max(1))),
}
}
#[must_use]
pub fn len(&self) -> usize {
self.events.lock().map(|e| e.len()).unwrap_or(0)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
#[must_use]
pub fn snapshot(&self) -> Vec<AuditEvent> {
self.events
.lock()
.map(|e| e.iter().cloned().collect())
.unwrap_or_default()
}
pub fn clear(&self) {
if let Ok(mut e) = self.events.lock() {
e.clear();
}
}
}
impl AuditLog for InMemoryAuditLog {
fn record(&self, event: AuditEvent) {
let Ok(mut events) = self.events.lock() else { return };
if events.len() >= self.capacity {
events.pop_front();
}
events.push_back(event);
}
fn recent(&self, limit: usize) -> Vec<AuditEvent> {
self.events
.lock()
.map(|e| e.iter().rev().take(limit).cloned().collect::<Vec<_>>())
.unwrap_or_default()
.into_iter()
.rev()
.collect()
}
}
pub struct FileAuditLog {
file: Mutex<std::fs::File>,
}
impl FileAuditLog {
pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self, FaceError> {
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path.as_ref())
.map_err(|e| FaceError::Unsupported(format!("audit log open: {e}")))?;
Ok(Self {
file: Mutex::new(file),
})
}
}
impl AuditLog for FileAuditLog {
fn record(&self, event: AuditEvent) {
let Ok(json) = serde_json::to_string(&event) else { return };
let Ok(mut f) = self.file.lock() else { return };
use std::io::Write;
let _ = writeln!(f, "{json}");
let _ = f.flush();
}
}
pub struct AuditingBackend<B: StoreBackend> {
inner: B,
log: Box<dyn AuditLog>,
}
impl<B: StoreBackend> AuditingBackend<B> {
pub fn new(inner: B, log: impl AuditLog) -> Self {
Self {
inner,
log: Box::new(log),
}
}
#[must_use]
pub fn with_boxed_log(inner: B, log: Box<dyn AuditLog>) -> Self {
Self { inner, log }
}
#[must_use]
pub fn inner(&self) -> &B {
&self.inner
}
#[must_use]
pub fn log(&self) -> &dyn AuditLog {
self.log.as_ref()
}
}
impl<B: StoreBackend> StoreBackend for AuditingBackend<B> {
fn name(&self) -> &str {
self.inner.name()
}
fn apply(&self, format: ResourceFormat, body: &[u8]) -> Result<(), FaceError> {
let result = self.inner.apply(format, body);
let mut event = AuditEvent::now(VerbKind::Apply)
.with_format(format)
.with_body_bytes(body.len());
event = match &result {
Ok(()) => event.ok(),
Err(e) => event.err(e),
};
self.log.record(event);
result
}
fn get(
&self,
reference: &ResourceRef,
format: ResourceFormat,
) -> Result<Vec<u8>, FaceError> {
let result = self.inner.get(reference, format);
let mut event = AuditEvent::now(VerbKind::Get)
.with_ref(reference.clone())
.with_format(format);
event = match &result {
Ok(_) => event.ok(),
Err(e) => event.err(e),
};
self.log.record(event);
result
}
fn list(
&self,
kind: &str,
namespace: Option<&str>,
format: ResourceFormat,
) -> Result<Vec<Vec<u8>>, FaceError> {
let result = self.inner.list(kind, namespace, format);
let mut event = AuditEvent::now(VerbKind::List)
.with_filter(kind, namespace)
.with_format(format);
event = match &result {
Ok(_) => event.ok(),
Err(e) => event.err(e),
};
self.log.record(event);
result
}
fn delete(&self, reference: &ResourceRef) -> Result<(), FaceError> {
let result = self.inner.delete(reference);
let mut event = AuditEvent::now(VerbKind::Delete).with_ref(reference.clone());
event = match &result {
Ok(()) => event.ok(),
Err(e) => event.err(e),
};
self.log.record(event);
result
}
fn watch(
&self,
kind: &str,
namespace: Option<&str>,
format: ResourceFormat,
) -> Result<Box<dyn FaceWatchStream>, FaceError> {
let result = self.inner.watch(kind, namespace, format);
let mut event = AuditEvent::now(VerbKind::Watch)
.with_filter(kind, namespace)
.with_format(format);
event = match &result {
Ok(_) => event.ok(),
Err(e) => event.err(e),
};
self.log.record(event);
result
}
fn resource_count(&self) -> usize {
self.inner.resource_count()
}
fn subscriber_count(&self) -> usize {
self.inner.subscriber_count()
}
fn snapshot(&self) -> Result<Vec<u8>, FaceError> {
let result = self.inner.snapshot();
let mut event = AuditEvent::now(VerbKind::Snapshot);
event = match &result {
Ok(_) => event.ok(),
Err(e) => event.err(e),
};
self.log.record(event);
result
}
fn restore(&self, snapshot_bytes: &[u8]) -> Result<(), FaceError> {
let result = self.inner.restore(snapshot_bytes);
let mut event = AuditEvent::now(VerbKind::Restore).with_body_bytes(snapshot_bytes.len());
event = match &result {
Ok(()) => event.ok(),
Err(e) => event.err(e),
};
self.log.record(event);
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::face::encode_native_envelope;
use crate::face_store::InMemoryStore;
fn pod_ref() -> ResourceRef {
ResourceRef::namespaced("Pod", "nginx", "default")
}
fn envelope() -> Vec<u8> {
encode_native_envelope(&pod_ref(), b"payload").unwrap()
}
fn yaml() -> Vec<u8> {
b"apiVersion: v1\nkind: Pod\nmetadata:\n name: nginx\n namespace: default\nspec: {}\n"
.to_vec()
}
#[test]
fn verb_kind_classifies_mutations() {
assert!(VerbKind::Apply.is_mutation());
assert!(VerbKind::Delete.is_mutation());
assert!(VerbKind::Restore.is_mutation());
assert!(!VerbKind::Get.is_mutation());
assert!(!VerbKind::List.is_mutation());
assert!(!VerbKind::Watch.is_mutation());
assert!(!VerbKind::Snapshot.is_mutation());
}
#[test]
fn audit_event_now_populates_timestamp() {
let ev = AuditEvent::now(VerbKind::Apply);
assert!(ev.timestamp_s > 0);
assert_eq!(ev.verb, VerbKind::Apply);
assert!(ev.success);
assert!(ev.error_message.is_none());
}
#[test]
fn audit_event_builder_chains_cleanly() {
let ev = AuditEvent::now(VerbKind::Apply)
.with_ref(pod_ref())
.with_format(ResourceFormat::Yaml)
.with_body_bytes(123);
assert_eq!(ev.reference.unwrap().name, "nginx");
assert_eq!(ev.format, Some(ResourceFormat::Yaml));
assert_eq!(ev.body_bytes, Some(123));
}
#[test]
fn audit_event_err_attaches_message() {
let err = FaceError::Unsupported("test failure".into());
let ev = AuditEvent::now(VerbKind::Apply).err(&err);
assert!(!ev.success);
assert!(ev.error_message.unwrap().contains("test failure"));
}
#[test]
fn noop_log_drops_every_event_silently() {
let log = NoopAuditLog;
for _ in 0..1000 {
log.record(AuditEvent::now(VerbKind::Apply));
}
assert_eq!(log.recent(10).len(), 0);
}
#[test]
fn in_memory_log_retains_events_up_to_capacity() {
let log = InMemoryAuditLog::with_capacity(3);
for i in 0..5 {
log.record(AuditEvent::now(VerbKind::Apply).with_body_bytes(i));
}
assert_eq!(log.len(), 3);
let snap = log.snapshot();
assert_eq!(snap[0].body_bytes, Some(2));
assert_eq!(snap[1].body_bytes, Some(3));
assert_eq!(snap[2].body_bytes, Some(4));
}
#[test]
fn in_memory_log_recent_returns_most_recent_n_in_order() {
let log = InMemoryAuditLog::with_capacity(10);
for i in 0..5 {
log.record(AuditEvent::now(VerbKind::Apply).with_body_bytes(i));
}
let recent = log.recent(3);
assert_eq!(recent.len(), 3);
assert_eq!(recent[0].body_bytes, Some(2));
assert_eq!(recent[1].body_bytes, Some(3));
assert_eq!(recent[2].body_bytes, Some(4));
}
#[test]
fn in_memory_log_clear_empties_buffer() {
let log = InMemoryAuditLog::with_capacity(10);
log.record(AuditEvent::now(VerbKind::Apply));
assert_eq!(log.len(), 1);
log.clear();
assert!(log.is_empty());
}
#[test]
fn in_memory_log_with_capacity_zero_normalizes_to_one() {
let log = InMemoryAuditLog::with_capacity(0);
log.record(AuditEvent::now(VerbKind::Apply));
log.record(AuditEvent::now(VerbKind::Get));
assert_eq!(log.len(), 1);
}
#[test]
fn file_log_appends_events_as_jsonl() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let log = FileAuditLog::open(&path).unwrap();
log.record(AuditEvent::now(VerbKind::Apply).with_body_bytes(10));
log.record(AuditEvent::now(VerbKind::Get).with_ref(pod_ref()));
drop(log);
let contents = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = contents.lines().collect();
assert_eq!(lines.len(), 2);
let line0: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(line0["verb"], "Apply");
let line1: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
assert_eq!(line1["verb"], "Get");
}
#[test]
fn file_log_survives_close_and_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
{
let log = FileAuditLog::open(&path).unwrap();
log.record(AuditEvent::now(VerbKind::Apply).with_body_bytes(1));
}
let log = FileAuditLog::open(&path).unwrap();
log.record(AuditEvent::now(VerbKind::Delete).with_ref(pod_ref()));
drop(log);
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents.lines().count(), 2);
}
#[test]
fn auditing_backend_records_every_verb() {
let inner = InMemoryStore::new("inner");
let log = InMemoryAuditLog::with_capacity(100);
let backend = AuditingBackend::new(inner, log);
backend.apply(ResourceFormat::Native, &envelope()).unwrap();
let r = pod_ref();
backend.get(&r, ResourceFormat::Native).unwrap();
backend.list("Pod", Some("default"), ResourceFormat::Native).unwrap();
let _ = backend.watch("Pod", None, ResourceFormat::Native).unwrap();
backend.delete(&r).unwrap();
let _ = backend.snapshot().unwrap();
let events = backend
.log()
.recent(100);
let verbs: Vec<VerbKind> = events.iter().map(|e| e.verb).collect();
assert!(verbs.contains(&VerbKind::Apply));
assert!(verbs.contains(&VerbKind::Get));
assert!(verbs.contains(&VerbKind::List));
assert!(verbs.contains(&VerbKind::Watch));
assert!(verbs.contains(&VerbKind::Delete));
assert!(verbs.contains(&VerbKind::Snapshot));
}
#[test]
fn auditing_backend_records_success_flag_correctly() {
let inner = InMemoryStore::new("inner");
let log = InMemoryAuditLog::with_capacity(10);
let backend = AuditingBackend::new(inner, log);
backend.apply(ResourceFormat::Yaml, &yaml()).unwrap();
let missing = ResourceRef::namespaced("Pod", "missing", "default");
let _ = backend.get(&missing, ResourceFormat::Yaml);
let events = backend.log().recent(10);
let apply_ev = events.iter().find(|e| e.verb == VerbKind::Apply).unwrap();
let get_ev = events.iter().find(|e| e.verb == VerbKind::Get).unwrap();
assert!(apply_ev.success, "apply should succeed");
assert!(!get_ev.success, "get on missing should fail");
assert!(get_ev.error_message.is_some());
}
#[test]
fn auditing_backend_records_body_length_not_content() {
let inner = InMemoryStore::new("inner");
let log = InMemoryAuditLog::with_capacity(10);
let backend = AuditingBackend::new(inner, log);
let body = yaml();
let body_len = body.len();
backend.apply(ResourceFormat::Yaml, &body).unwrap();
let events = backend.log().recent(10);
let apply_ev = events.iter().find(|e| e.verb == VerbKind::Apply).unwrap();
assert_eq!(apply_ev.body_bytes, Some(body_len));
}
#[test]
fn auditing_backend_preserves_inner_backend_name() {
let inner = InMemoryStore::new("inner");
let backend = AuditingBackend::new(inner, NoopAuditLog);
assert_eq!(backend.name(), "in-memory");
}
#[test]
fn auditing_backend_inner_borrow_for_telemetry() {
let inner = InMemoryStore::new("inner");
let backend = AuditingBackend::new(inner, NoopAuditLog);
backend.apply(ResourceFormat::Yaml, &yaml()).unwrap();
assert_eq!(backend.inner().len(), 1);
}
#[test]
fn auditing_backend_wraps_filesystem_backend() {
let dir = tempfile::tempdir().unwrap();
let inner = crate::FileSystemBackend::open(dir.path(), "fs").unwrap();
let log = InMemoryAuditLog::with_capacity(10);
let backend = AuditingBackend::new(inner, log);
backend.apply(ResourceFormat::Yaml, &yaml()).unwrap();
assert_eq!(backend.name(), "filesystem");
let events = backend.log().recent(10);
assert_eq!(events.len(), 1);
assert_eq!(events[0].verb, VerbKind::Apply);
}
#[test]
fn auditing_backend_dispatches_through_store_backend_trait_object() {
let inner = InMemoryStore::new("inner");
let backend: Box<dyn StoreBackend> =
Box::new(AuditingBackend::new(inner, NoopAuditLog));
backend.apply(ResourceFormat::Yaml, &yaml()).unwrap();
assert_eq!(backend.resource_count(), 1);
}
#[test]
fn audit_event_serde_round_trips_through_json() {
let ev = AuditEvent::now(VerbKind::Apply)
.with_ref(pod_ref())
.with_format(ResourceFormat::Yaml)
.with_body_bytes(42);
let json = serde_json::to_string(&ev).unwrap();
let back: AuditEvent = serde_json::from_str(&json).unwrap();
assert_eq!(back, ev);
}
#[test]
fn audit_log_is_object_safe() {
fn assert_object_safe<T: ?Sized>() {}
assert_object_safe::<dyn AuditLog>();
let _heterogeneous: Vec<Box<dyn AuditLog>> = vec![
Box::new(NoopAuditLog),
Box::new(InMemoryAuditLog::with_capacity(10)),
];
}
}