use std::sync::Arc;
use async_trait::async_trait;
use vta_sdk::protocols::audit_management::list::AuditLogEntry;
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
#[async_trait]
pub trait AuditSink: Send + Sync {
async fn record(&self, entry: &AuditLogEntry) -> Result<(), AppError>;
}
pub type SharedAuditSink = Arc<dyn AuditSink>;
#[derive(Clone)]
pub struct KeyspaceAuditSink {
keyspace: KeyspaceHandle,
}
impl KeyspaceAuditSink {
pub fn new(keyspace: KeyspaceHandle) -> Self {
Self { keyspace }
}
pub fn keyspace(&self) -> &KeyspaceHandle {
&self.keyspace
}
pub fn storage_key(entry: &AuditLogEntry) -> String {
format!("log:{:020}:{}", entry.timestamp, entry.id)
}
}
#[async_trait]
impl AuditSink for KeyspaceAuditSink {
async fn record(&self, entry: &AuditLogEntry) -> Result<(), AppError> {
self.keyspace.insert(Self::storage_key(entry), entry).await
}
}
pub struct FanOutAuditSink {
sinks: Vec<SharedAuditSink>,
}
impl FanOutAuditSink {
pub fn new(sinks: Vec<SharedAuditSink>) -> Self {
Self { sinks }
}
}
#[async_trait]
impl AuditSink for FanOutAuditSink {
async fn record(&self, entry: &AuditLogEntry) -> Result<(), AppError> {
let mut first_err = None;
for sink in &self.sinks {
if let Err(e) = sink.record(entry).await {
tracing::warn!(
action = %entry.action,
actor = %entry.actor,
error = %e,
"an audit sink rejected an entry; continuing with the rest"
);
if first_err.is_none() {
first_err = Some(e);
}
}
}
match first_err {
Some(e) => Err(e),
None => Ok(()),
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
fn entry(action: &str) -> AuditLogEntry {
AuditLogEntry {
id: "11111111-1111-4111-8111-111111111111".into(),
timestamp: 1_700_000_000,
action: action.into(),
actor: "did:key:zTest".into(),
resource: None,
outcome: "success".into(),
channel: None,
context_id: None,
detail: None,
}
}
#[derive(Default)]
struct Recording {
seen: Mutex<Vec<String>>,
fail: bool,
}
#[async_trait]
impl AuditSink for Recording {
async fn record(&self, entry: &AuditLogEntry) -> Result<(), AppError> {
self.seen.lock().unwrap().push(entry.action.clone());
if self.fail {
return Err(AppError::Internal("sink refused".into()));
}
Ok(())
}
}
#[test]
fn the_storage_key_sorts_lexicographically_by_time() {
let mut early = entry("a");
early.timestamp = 9;
let mut late = entry("b");
late.timestamp = 100;
assert!(
KeyspaceAuditSink::storage_key(&early) < KeyspaceAuditSink::storage_key(&late),
"an earlier entry must sort first as a string, or the sweep's early \
`break` skips live rows"
);
}
#[tokio::test]
async fn fan_out_reaches_every_sink_even_when_one_fails() {
let failing = Arc::new(Recording {
fail: true,
..Default::default()
});
let healthy = Arc::new(Recording::default());
let fan = FanOutAuditSink::new(vec![
Arc::clone(&failing) as SharedAuditSink,
Arc::clone(&healthy) as SharedAuditSink,
]);
let result = fan.record(&entry("keys.create")).await;
assert!(result.is_err(), "the failure must still be reported");
assert_eq!(
healthy.seen.lock().unwrap().as_slice(),
["keys.create"],
"the sink after the failing one must still have received the entry"
);
}
#[tokio::test]
async fn fan_out_is_ok_when_every_sink_accepts() {
let a = Arc::new(Recording::default());
let b = Arc::new(Recording::default());
let fan = FanOutAuditSink::new(vec![
Arc::clone(&a) as SharedAuditSink,
Arc::clone(&b) as SharedAuditSink,
]);
assert!(fan.record(&entry("acl.grant")).await.is_ok());
assert_eq!(a.seen.lock().unwrap().len(), 1);
assert_eq!(b.seen.lock().unwrap().len(), 1);
}
}