use std::sync::Arc;
use async_trait::async_trait;
use vta_sdk::protocols::audit_management::list::AuditLogEntry;
use vti_common::audit::event::{AuditEvent, VtaOperationData};
use vti_common::audit::key_store::AuditKeyStore;
use vti_common::audit::writer::AuditWriter;
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 const SYSTEM_ACTOR: &str = "urn:vti:vta:system";
pub const AUDIT_KEY_CREATED: &str = "audit.key.created";
#[derive(Clone)]
pub struct ChainedKeyspaceAuditSink {
keyspace: KeyspaceHandle,
key_store: AuditKeyStore,
writer: AuditWriter,
opened: Arc<tokio::sync::OnceCell<()>>,
}
impl ChainedKeyspaceAuditSink {
pub fn new(keyspace: KeyspaceHandle, key_keyspace: KeyspaceHandle) -> Self {
let key_store = AuditKeyStore::new(key_keyspace);
let writer = AuditWriter::new(keyspace.clone(), key_store.clone())
.with_storage_key(|env| {
format!(
"log:{:020}:{:09}:{}",
env.timestamp.timestamp().max(0),
env.timestamp.timestamp_subsec_nanos(),
env.event_id
)
.into_bytes()
});
Self {
keyspace,
key_store,
writer,
opened: Arc::new(tokio::sync::OnceCell::new()),
}
}
pub fn keyspace(&self) -> &KeyspaceHandle {
&self.keyspace
}
async fn ensure_open(&self) -> Result<(), AppError> {
let already_established = self.key_store.try_active().await?.is_some();
if already_established {
return Ok(());
}
let key = self.key_store.ensure_initial_random().await?;
self.writer
.write(
SYSTEM_ACTOR,
None,
AuditEvent::VtaOperation(VtaOperationData {
action: AUDIT_KEY_CREATED.to_string(),
resource: Some(key.key_id.as_uuid().to_string()),
outcome: "success".to_string(),
channel: None,
context_id: None,
detail: None,
}),
)
.await?;
Ok(())
}
}
#[async_trait]
impl AuditSink for ChainedKeyspaceAuditSink {
async fn record(&self, entry: &AuditLogEntry) -> Result<(), AppError> {
self.opened
.get_or_try_init(|| self.ensure_open())
.await
.map(|_| ())?;
let (target, resource) = match entry.resource.as_deref() {
Some(r) if r.starts_with("did:") => (Some(r), None),
other => (None, other),
};
self.writer
.write(
&entry.actor,
target,
AuditEvent::VtaOperation(VtaOperationData {
action: entry.action.clone(),
resource: resource.map(str::to_string),
outcome: entry.outcome.clone(),
channel: entry.channel.clone(),
context_id: entry.context_id.clone(),
detail: entry.detail.clone(),
}),
)
.await
.map(|_| ())
}
}
#[must_use]
pub fn shared_keyspace_sink(keyspace: KeyspaceHandle) -> SharedAuditSink {
Arc::new(KeyspaceAuditSink::new(keyspace))
}
#[must_use]
pub fn shared_chained_sink(
keyspace: KeyspaceHandle,
key_keyspace: KeyspaceHandle,
) -> SharedAuditSink {
Arc::new(ChainedKeyspaceAuditSink::new(keyspace, key_keyspace))
}
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);
}
}
#[cfg(test)]
mod chained_sink_tests {
use super::*;
use vti_common::audit::verify_chain;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
fn keyspaces() -> (KeyspaceHandle, KeyspaceHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.expect("store");
(
store.keyspace("audit").expect("audit"),
store.keyspace("audit_key").expect("audit_key"),
dir,
)
}
fn entry(action: &str, actor: &str, resource: Option<&str>) -> AuditLogEntry {
AuditLogEntry {
id: uuid::Uuid::new_v4().to_string(),
timestamp: 1_700_000_000,
action: action.to_string(),
actor: actor.to_string(),
resource: resource.map(str::to_string),
outcome: "success".to_string(),
channel: Some("rest".to_string()),
context_id: Some("acme/eng".to_string()),
detail: None,
}
}
async fn envelopes(ks: &KeyspaceHandle) -> Vec<vti_common::audit::AuditEnvelope> {
let pairs = ks.prefix_iter_raw("log:").await.expect("scan");
pairs
.iter()
.filter_map(|(_, v)| serde_json::from_slice(v).ok())
.collect()
}
#[tokio::test]
async fn the_chain_opens_with_the_creation_of_its_own_key() {
let (audit_ks, key_ks, _dir) = keyspaces();
let sink = ChainedKeyspaceAuditSink::new(audit_ks.clone(), key_ks);
sink.record(&entry("auth.challenge", "did:key:z6MkA", None))
.await
.expect("record");
let found = envelopes(&audit_ks).await;
assert_eq!(
found.len(),
2,
"the key's creation, then the caller's event"
);
let opening = &found[0];
match &opening.event {
vti_common::audit::event::AuditEvent::VtaOperation(op) => {
assert_eq!(op.action, AUDIT_KEY_CREATED);
assert_eq!(
op.resource.as_deref(),
Some(opening.audit_key_id.as_uuid().to_string().as_str()),
"the opening entry names the key it is hashed under"
);
}
other => panic!("unexpected opening event: {other:?}"),
}
assert_eq!(opening.actor_did_plain.as_deref(), Some(SYSTEM_ACTOR));
}
#[tokio::test]
async fn writes_chain_to_one_another() {
let (audit_ks, key_ks, _dir) = keyspaces();
let sink = ChainedKeyspaceAuditSink::new(audit_ks.clone(), key_ks);
for action in ["auth.challenge", "acl.create", "keys.sign"] {
sink.record(&entry(action, "did:key:z6MkA", None))
.await
.expect("record");
}
let found = envelopes(&audit_ks).await;
assert_eq!(found.len(), 4, "three events plus the opening entry");
verify_chain(&found).expect("the log verifies as a chain");
}
#[tokio::test]
async fn a_second_sink_over_the_same_keyspace_continues_the_chain() {
let (audit_ks, key_ks, _dir) = keyspaces();
ChainedKeyspaceAuditSink::new(audit_ks.clone(), key_ks.clone())
.record(&entry("auth.challenge", "did:key:z6MkA", None))
.await
.expect("first sink");
ChainedKeyspaceAuditSink::new(audit_ks.clone(), key_ks)
.record(&entry("acl.create", "did:key:z6MkB", None))
.await
.expect("second sink");
let found = envelopes(&audit_ks).await;
assert_eq!(found.len(), 3, "one opening entry, not two");
verify_chain(&found).expect("the chain survives the restart");
}
#[tokio::test]
async fn a_did_resource_becomes_a_hashed_target() {
let (audit_ks, key_ks, _dir) = keyspaces();
let sink = ChainedKeyspaceAuditSink::new(audit_ks.clone(), key_ks);
sink.record(&entry(
"acl.create",
"did:key:zAdmin",
Some("did:key:zSubject"),
))
.await
.expect("did resource");
sink.record(&entry("keys.sign", "did:key:zAdmin", Some("key-3f2a")))
.await
.expect("opaque resource");
let found = envelopes(&audit_ks).await;
let did_row = &found[1];
assert_eq!(
did_row.target_did_plain.as_deref(),
Some("did:key:zSubject")
);
assert!(did_row.target_did_hash.is_some());
let key_row = &found[2];
assert!(
key_row.target_did_plain.is_none(),
"a key id is not an identifier to commit to"
);
match &key_row.event {
vti_common::audit::event::AuditEvent::VtaOperation(op) => {
assert_eq!(op.resource.as_deref(), Some("key-3f2a"));
}
other => panic!("unexpected event: {other:?}"),
}
}
}
#[cfg(test)]
mod verify_support_tests {
use super::*;
use vti_common::audit::{AuditEnvelope, verify_chain};
use vti_common::config::StoreConfig;
use vti_common::store::Store;
fn keyspaces() -> (KeyspaceHandle, KeyspaceHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.expect("store");
(
store.keyspace("audit").expect("audit"),
store.keyspace("audit_key").expect("audit_key"),
dir,
)
}
fn entry(action: &str) -> AuditLogEntry {
AuditLogEntry {
id: uuid::Uuid::new_v4().to_string(),
timestamp: 1_700_000_000,
action: action.to_string(),
actor: "did:key:z6MkA".to_string(),
resource: None,
outcome: "success".to_string(),
channel: None,
context_id: None,
detail: None,
}
}
#[tokio::test]
async fn writes_within_one_second_verify_in_key_order() {
let (audit_ks, key_ks, _dir) = keyspaces();
let sink = ChainedKeyspaceAuditSink::new(audit_ks.clone(), key_ks);
for i in 0..25 {
sink.record(&entry(&format!("op.{i}")))
.await
.expect("record");
}
let mut pairs = audit_ks.prefix_iter_raw("log:").await.expect("scan");
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
let envelopes: Vec<AuditEnvelope> = pairs
.iter()
.filter_map(|(_, v)| serde_json::from_slice(v).ok())
.collect();
assert_eq!(envelopes.len(), 26, "25 events plus the opening entry");
verify_chain(&envelopes)
.expect("key order is write order, so the chain verifies as written");
}
#[tokio::test]
async fn an_altered_entry_breaks_verification() {
let (audit_ks, key_ks, _dir) = keyspaces();
let sink = ChainedKeyspaceAuditSink::new(audit_ks.clone(), key_ks);
for i in 0..3 {
sink.record(&entry(&format!("op.{i}")))
.await
.expect("record");
}
let mut pairs = audit_ks.prefix_iter_raw("log:").await.expect("scan");
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
let mut envelopes: Vec<AuditEnvelope> = pairs
.iter()
.filter_map(|(_, v)| serde_json::from_slice(v).ok())
.collect();
verify_chain(&envelopes).expect("intact before tampering");
if let vti_common::audit::event::AuditEvent::VtaOperation(op) = &mut envelopes[2].event {
op.outcome = "failure".to_string();
}
assert!(
verify_chain(&envelopes).is_err(),
"an altered entry must not verify"
);
}
}