use cyberbrain_core::{Error, Result};
use cyberbrain_index::{AuditEntry, AuditStore, NewAuditEntry};
use cyberbrain_policy::audit::GENESIS;
use cyberbrain_policy::{AuditEvent, AuditFilter, AuditSink};
use serde_json::Value;
use std::sync::{Mutex, MutexGuard};
pub const AUDIT_DB_FILE: &str = "audit.db";
pub struct StoreAuditSink {
store: Mutex<AuditStore>,
}
impl StoreAuditSink {
pub fn new(store: AuditStore) -> Self {
Self {
store: Mutex::new(store),
}
}
pub fn count(&self) -> Result<usize> {
self.lock()?.count()
}
pub fn schema_version(&self) -> Result<u32> {
self.lock()?.schema_version()
}
fn lock(&self) -> Result<MutexGuard<'_, AuditStore>> {
self.store.lock().map_err(|_| {
Error::Index("audit store lock poisoned; refusing to touch the record".into())
})
}
}
fn stored_chain_hash(entry: &AuditEntry) -> Option<String> {
let detail: Value = serde_json::from_str(entry.detail.as_deref()?).ok()?;
detail
.get("_chain")?
.get("hash")?
.as_str()
.map(str::to_owned)
}
fn format_ts(ts: jiff::Timestamp) -> String {
let z = ts.to_zoned(jiff::tz::TimeZone::UTC);
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
z.year(),
z.month(),
z.day(),
z.hour(),
z.minute(),
z.second(),
z.subsec_nanosecond() / 1_000_000
)
}
fn to_event(e: AuditEntry) -> Result<AuditEvent> {
let ts = e.ts.parse::<jiff::Timestamp>().map_err(|err| {
Error::Index(format!(
"audit row {}: stored ts {:?} is not a timestamp: {err}",
e.seq, e.ts
))
})?;
let detail = match e.detail {
Some(text) => serde_json::from_str(&text).map_err(|err| {
Error::Index(format!(
"audit row {}: stored detail is not JSON: {err}",
e.seq
))
})?,
None => serde_json::json!({}),
};
Ok(AuditEvent {
ts,
actor: e.actor,
action: e.action,
subject: e.subject.unwrap_or_default(),
detail,
})
}
impl AuditSink for StoreAuditSink {
fn append(&self, event: &AuditEvent) -> Result<()> {
let detail = serde_json::to_string(&event.detail)
.map_err(|e| Error::Index(format!("audit detail does not serialise: {e}")))?;
let expected_prev = event.chain_prev().map(str::to_owned);
let row = NewAuditEntry {
ts: Some(format_ts(event.ts)),
actor: event.actor.clone(),
action: event.action.clone(),
subject: Some(event.subject.clone()),
detail: Some(detail),
};
self.lock()?.append_after(|last| {
if let Some(expected) = &expected_prev {
let actual = last
.and_then(stored_chain_hash)
.unwrap_or_else(|| GENESIS.to_string());
if *expected != actual {
return Err(Error::AuditContended {
seq: last.map(|l| l.seq).unwrap_or(0),
tries: 1,
});
}
}
Ok(row)
})?;
Ok(())
}
fn read(&self, filter: &AuditFilter) -> Result<Vec<AuditEvent>> {
let store_filter = cyberbrain_index::AuditFilter {
since: None,
action: None,
subject: filter.subject.clone(),
contains: filter.contains.clone(),
limit: if filter.since.is_some() || filter.action.is_some() {
None
} else {
filter.limit
},
};
let rows = self.lock()?.read(&store_filter)?;
let mut out = Vec::with_capacity(rows.len());
let narrowed = filter.since.is_some() || filter.action.is_some();
for r in rows {
let e = to_event(r)?;
if let Some(since) = filter.since
&& e.ts < since
{
continue;
}
if let Some(action) = &filter.action
&& !(e.action == *action || e.action.starts_with(&format!("{action}.")))
{
continue;
}
out.push(e);
if narrowed && filter.limit.is_some_and(|l| out.len() >= l) {
break;
}
}
Ok(out)
}
fn last(&self) -> Result<Option<AuditEvent>> {
self.lock()?.last()?.map(to_event).transpose()
}
}
#[cfg(test)]
mod tests {
use super::*;
use cyberbrain_policy::{Actor, AuditAction, AuditLog};
use serde_json::json;
use std::sync::Arc;
fn log() -> (AuditLog, Arc<StoreAuditSink>) {
let sink = Arc::new(StoreAuditSink::new(AuditStore::open_in_memory().unwrap()));
(AuditLog::new(sink.clone()), sink)
}
#[test]
fn detail_round_trip_is_byte_stable_and_the_chain_verifies() {
let (log, sink) = log();
for i in 0..5 {
log.record(
&Actor::Cli,
AuditAction::NoteWrite,
format!("note:{i}"),
json!({ "zeta": i, "alpha": [1, 2, {"y": 1, "x": 2}], "m": {"b": 1, "a": 2} }),
)
.unwrap();
}
assert_eq!(log.verify().unwrap(), 5);
assert_eq!(sink.count().unwrap(), 5);
let rows = log.read(&AuditFilter::default()).unwrap();
assert_eq!(rows.len(), 5);
assert_eq!(rows[0].subject, "note:0");
}
#[test]
fn a_foreign_row_between_two_chained_ones_is_refused_not_forked() {
let (log, sink) = log();
log.record(&Actor::Cli, AuditAction::NoteWrite, "a", json!({}))
.unwrap();
sink.lock()
.unwrap()
.append(&NewAuditEntry {
ts: None,
actor: "other-process".into(),
action: "note.write".into(),
subject: Some("b".into()),
detail: Some("{}".into()),
})
.unwrap();
log.record(&Actor::Cli, AuditAction::NoteWrite, "c", json!({}))
.unwrap();
let err = log.verify().unwrap_err().to_string();
assert!(err.contains("row 2"), "{err}");
assert!(err.contains("no _chain"), "{err}");
}
#[test]
fn the_stored_timestamp_is_exactly_the_one_the_caller_was_handed() {
let (log, _) = log();
let written = log
.record(&Actor::Cli, AuditAction::NoteWrite, "a", json!({}))
.unwrap();
let read_back = log.read(&AuditFilter::default()).unwrap();
assert_eq!(read_back.len(), 1);
assert_eq!(
read_back[0].ts, written.ts,
"the row as stored must carry the timestamp the caller holds"
);
let since_itself = log
.read(&AuditFilter {
since: Some(written.ts),
..Default::default()
})
.unwrap();
assert_eq!(
since_itself.len(),
1,
"a row must be found by a filter built from its own timestamp"
);
}
#[test]
fn read_filters_by_since_and_limit_on_this_side() {
let (log, _) = log();
let first = log
.record(&Actor::Cli, AuditAction::NoteWrite, "a", json!({}))
.unwrap();
log.record(&Actor::Cli, AuditAction::NoteWrite, "b", json!({}))
.unwrap();
let rows = log
.read(&AuditFilter {
since: Some(first.ts),
limit: Some(1),
..Default::default()
})
.unwrap();
assert_eq!(rows.len(), 1);
let rows = log
.read(&AuditFilter {
action: Some("note.write".into()),
..Default::default()
})
.unwrap();
assert_eq!(rows.len(), 2);
}
}