use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use smallvec::SmallVec;
use smol_str::SmolStr;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AuditRecord {
pub action: SmolStr,
pub actor: SmolStr,
pub org_id: SmolStr,
pub input_digest: [u8; 32],
pub output_ids: SmallVec<[SmolStr; 8]>,
pub fingerprint: [u8; 32],
pub lease_epoch: Option<u64>,
pub recorded_at: DateTime<Utc>,
pub lsn: u64,
}
pub fn digest_input(input: &serde_json::Value) -> [u8; 32] {
let canonical = serde_json::to_string(input).unwrap_or_default();
*blake3::hash(canonical.as_bytes()).as_bytes()
}
pub async fn append_audit(
ctx: &crate::OpContext,
record: &AuditRecord,
) -> Result<u64, crate::OpError> {
LEDGER
.lock()
.unwrap()
.get_or_insert_with(HashMap::new)
.entry(record.org_id.clone())
.or_default()
.push(record.clone());
let digest_hex = hex64(&record.input_digest);
let fp_hex = hex64(&record.fingerprint);
let q = exocortex_storage::CypherQuery {
template_id: "audit_append",
params: serde_json::json!({
"action": record.action.to_string(),
"actor": record.actor.to_string(),
"org_id": record.org_id.to_string(),
"input_digest": digest_hex,
"output_ids": record
.output_ids
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>(),
"fingerprint": fp_hex,
"lease_epoch": record.lease_epoch.map(|e| e.to_string()).unwrap_or_default(),
"recorded_at": record.recorded_at.to_rfc3339(),
"lsn": record.lsn,
}),
read_only: false,
deadline: chrono::Utc::now() + chrono::Duration::seconds(5),
};
match ctx.storage.query_cypher(&q).await {
Ok(_) => {
metrics::counter!("exocortex_audit_appended_total", "sink" => "storage").increment(1)
}
Err(e) => {
metrics::counter!("exocortex_audit_appended_total", "sink" => "memory").increment(1);
tracing::warn!(%e, "audit storage write failed; in-process ledger holds the record");
}
}
Ok(record.lsn)
}
pub async fn audit_range(
ctx: &crate::OpContext,
org: &str,
since_lsn: u64,
) -> Result<Vec<serde_json::Value>, crate::OpError> {
let q = exocortex_storage::CypherQuery {
template_id: "audit_range",
params: serde_json::json!({
"org_id": org,
"since_lsn": since_lsn,
"limit": 1000u32,
}),
read_only: true,
deadline: chrono::Utc::now() + chrono::Duration::seconds(5),
};
if let Ok(rs) = ctx.storage.query_cypher(&q).await {
if !rs.rows.is_empty() {
return Ok(rs.rows);
}
}
let ledger = LEDGER.lock().unwrap();
Ok(ledger
.as_ref()
.and_then(|m| m.get(org))
.map(|rows| {
rows.iter()
.filter(|r| r.lsn > since_lsn)
.map(|r| serde_json::to_value(r).unwrap_or_default())
.collect()
})
.unwrap_or_default())
}
use std::collections::HashMap;
use std::sync::Mutex;
static LEDGER: Mutex<Option<HashMap<SmolStr, Vec<AuditRecord>>>> = Mutex::new(None);
fn hex64(bytes: &[u8; 32]) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(64);
for b in bytes {
let _ = write!(out, "{b:02x}");
}
out
}