use vta_sdk::protocols::audit_management::list::AuditLogEntry;
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
pub mod sink;
pub use sink::{
AUDIT_KEY_CREATED, AuditSink, ChainedKeyspaceAuditSink, FanOutAuditSink, KeyspaceAuditSink,
SYSTEM_ACTOR, SharedAuditSink, shared_chained_sink, shared_keyspace_sink,
};
#[macro_export]
macro_rules! audit {
($action:expr, actor = $actor:expr, resource = $resource:expr, outcome = $outcome:expr) => {
if $outcome.starts_with("success") {
::tracing::event!(
target: "audit",
::tracing::Level::INFO,
action = $action,
actor = %$actor,
resource = %$resource,
outcome = $outcome,
);
} else {
::tracing::event!(
target: "audit",
::tracing::Level::ERROR,
action = $action,
actor = %$actor,
resource = %$resource,
outcome = $outcome,
);
}
};
($action:expr, actor = $actor:expr, outcome = $outcome:expr) => {
if $outcome.starts_with("success") {
::tracing::event!(
target: "audit",
::tracing::Level::INFO,
action = $action,
actor = %$actor,
outcome = $outcome,
);
} else {
::tracing::event!(
target: "audit",
::tracing::Level::ERROR,
action = $action,
actor = %$actor,
outcome = $outcome,
);
}
};
}
pub async fn record(
sink: &SharedAuditSink,
action: &str,
actor: &str,
resource: Option<&str>,
outcome: &str,
channel: Option<&str>,
context_id: Option<&str>,
) -> Result<(), AppError> {
record_with_detail(
sink, action, actor, resource, outcome, channel, context_id, None,
)
.await
}
pub const AUDIT_WRITE_FAILURE_TARGET: &str = "audit.write_failure";
pub const AUDIT_WRITE_FAILURES: &str = "audit_sink_write_failures_total";
pub async fn record_best_effort(
sink: &SharedAuditSink,
action: &str,
actor: &str,
resource: Option<&str>,
outcome: &str,
channel: Option<&str>,
context_id: Option<&str>,
) {
record_with_detail_best_effort(
sink, action, actor, resource, outcome, channel, context_id, None,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn record_with_detail_best_effort(
sink: &SharedAuditSink,
action: &str,
actor: &str,
resource: Option<&str>,
outcome: &str,
channel: Option<&str>,
context_id: Option<&str>,
detail: Option<&str>,
) {
if let Err(e) = record_with_detail(
sink, action, actor, resource, outcome, channel, context_id, detail,
)
.await
{
tracing::error!(
target: AUDIT_WRITE_FAILURE_TARGET,
action,
actor,
resource = ?resource,
outcome,
channel = ?channel,
context_id = ?context_id,
error = %e,
"audit sink rejected an entry; the operation it records still \
succeeded, and this row is lost"
);
metrics::counter!(AUDIT_WRITE_FAILURES, "action" => action.to_string()).increment(1);
}
}
pub const DETAIL_MAX_CHARS: usize = 4096;
fn bound_detail(detail: &str) -> String {
if detail.chars().count() <= DETAIL_MAX_CHARS {
return detail.to_string();
}
const MARK: &str = "… [truncated]";
let kept: String = detail
.chars()
.take(DETAIL_MAX_CHARS - MARK.chars().count())
.collect();
tracing::warn!(
original_chars = detail.chars().count(),
"audit detail exceeded {DETAIL_MAX_CHARS} characters and was truncated"
);
format!("{kept}{MARK}")
}
#[allow(clippy::too_many_arguments)]
pub async fn record_with_detail(
sink: &SharedAuditSink,
action: &str,
actor: &str,
resource: Option<&str>,
outcome: &str,
channel: Option<&str>,
context_id: Option<&str>,
detail: Option<&str>,
) -> Result<(), AppError> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let id = uuid::Uuid::new_v4().to_string();
let entry = AuditLogEntry {
id,
timestamp: now,
action: action.to_string(),
actor: actor.to_string(),
resource: resource.map(String::from),
outcome: outcome.to_string(),
channel: channel.map(String::from),
context_id: context_id.map(String::from),
detail: detail.map(bound_detail),
};
sink.record(&entry).await
}
pub async fn record_consent(
sink: &SharedAuditSink,
action: &str,
actor: &str,
resource: &str,
outcome: &str,
detail: Option<&str>,
) {
if outcome.starts_with("denied") {
tracing::event!(
target: "audit",
tracing::Level::ERROR,
action,
actor,
resource,
outcome,
);
} else {
tracing::event!(
target: "audit",
tracing::Level::INFO,
action,
actor,
resource,
outcome,
);
}
if let Err(e) = record_with_detail(
sink,
action,
actor,
Some(resource),
outcome,
None,
None,
detail,
)
.await
{
tracing::warn!(
action,
actor,
error = %e,
"DTTE consent audit emission failed; ceremony outcome is unaffected"
);
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PruneWatermark {
pub head: String,
pub pruned_entries: usize,
pub pruned_at: String,
}
pub const PRUNE_WATERMARK_KEY: &str = "prune:watermark";
pub async fn prune_watermark(
audit_ks: &KeyspaceHandle,
) -> Result<Option<PruneWatermark>, AppError> {
audit_ks.get(PRUNE_WATERMARK_KEY.to_string()).await
}
pub async fn cleanup_expired_logs(
audit_ks: &KeyspaceHandle,
retention_days: u32,
) -> Result<u64, AppError> {
let cutoff = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.saturating_sub(retention_days as u64 * 86400);
cleanup_logs_before(audit_ks, cutoff).await
}
pub async fn cleanup_logs_before(audit_ks: &KeyspaceHandle, cutoff: u64) -> Result<u64, AppError> {
let cutoff_key = format!("log:{cutoff:020}:");
let keys = audit_ks.prefix_keys("log:").await?;
let mut removed = 0u64;
let mut pruned_chained = 0usize;
let mut last_chained_hash: Option<String> = None;
for key in keys {
let key_str = String::from_utf8_lossy(&key).into_owned();
if key_str.as_str() < cutoff_key.as_str() {
if let Ok(Some(raw)) = audit_ks.get_raw(key.clone()).await
&& let Ok(env) = serde_json::from_slice::<vti_common::audit::AuditEnvelope>(&raw)
{
last_chained_hash = Some(hex::encode(env.entry_hash));
pruned_chained += 1;
}
audit_ks.remove(key).await?;
removed += 1;
} else {
break;
}
}
if let Some(head) = last_chained_hash {
let previous = prune_watermark(audit_ks).await?;
let watermark = PruneWatermark {
head,
pruned_entries: previous.map_or(0, |w| w.pruned_entries) + pruned_chained,
pruned_at: chrono::Utc::now().to_rfc3339(),
};
audit_ks
.insert(PRUNE_WATERMARK_KEY.to_string(), &watermark)
.await?;
}
Ok(removed)
}
#[cfg(test)]
mod detail_bound {
use super::*;
#[test]
fn a_short_detail_is_untouched() {
assert_eq!(
bound_detail("archived on operator request"),
"archived on operator request"
);
}
#[test]
fn an_oversized_detail_is_truncated_and_marked() {
let out = bound_detail(&"x".repeat(DETAIL_MAX_CHARS * 2));
assert!(
out.chars().count() <= DETAIL_MAX_CHARS,
"{}",
out.chars().count()
);
assert!(
out.ends_with("… [truncated]"),
"a silently shortened reason reads as the operator's own words: {out}"
);
}
#[test]
fn truncation_does_not_split_a_codepoint() {
let out = bound_detail(&"𝄞".repeat(DETAIL_MAX_CHARS * 2));
assert!(out.chars().count() <= DETAIL_MAX_CHARS);
assert!(out.starts_with('𝄞'));
}
}
#[cfg(test)]
mod best_effort {
use std::future::Future;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use tracing::field::{Field, Visit};
use tracing_subscriber::Layer;
use tracing_subscriber::layer::{Context, SubscriberExt};
use tracing_subscriber::registry::Registry;
use super::*;
struct Failing;
#[async_trait]
impl AuditSink for Failing {
async fn record(&self, _entry: &AuditLogEntry) -> Result<(), AppError> {
Err(AppError::Internal("sink is down".into()))
}
}
struct Healthy;
#[async_trait]
impl AuditSink for Healthy {
async fn record(&self, _entry: &AuditLogEntry) -> Result<(), AppError> {
Ok(())
}
}
type Events = Arc<Mutex<Vec<(String, tracing::Level, String)>>>;
struct Capture(Events);
impl<S: tracing::Subscriber> Layer<S> for Capture {
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
struct Render<'a>(&'a mut String);
impl Visit for Render<'_> {
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
use std::fmt::Write as _;
let _ = write!(self.0, "{}={value:?} ", field.name());
}
}
let mut rendered = String::new();
event.record(&mut Render(&mut rendered));
self.0.lock().unwrap().push((
event.metadata().target().to_string(),
*event.metadata().level(),
rendered,
));
}
}
fn captured<F: Future<Output = ()>>(f: F) -> Vec<(String, tracing::Level, String)> {
let events: Events = Arc::default();
let subscriber = Registry::default().with(Capture(Arc::clone(&events)));
tracing::subscriber::with_default(subscriber, || {
tokio::runtime::Builder::new_current_thread()
.build()
.expect("a current-thread runtime")
.block_on(f);
});
events.lock().unwrap().clone()
}
#[test]
fn a_refused_write_is_reported_at_error_with_the_row_it_lost() {
let events = captured(async {
record_best_effort(
&(Arc::new(Failing) as SharedAuditSink),
"key.create.internal",
"did:key:zActor",
Some("key-1"),
"success",
Some("rest"),
Some("acme/eng"),
)
.await;
});
let (_, level, fields) = events
.iter()
.find(|(target, ..)| target == AUDIT_WRITE_FAILURE_TARGET)
.expect(
"a refused audit write must be reported — that report is the \
entire difference from `let _ = record(..)`",
);
assert_eq!(
*level,
tracing::Level::ERROR,
"a lost audit row in a hash-chained log is not a warning"
);
for expected in [
"key.create.internal",
"did:key:zActor",
"key-1",
"acme/eng",
"sink is down",
] {
assert!(
fields.contains(expected),
"the report has to carry enough to reconstruct the row by hand, \
because nothing retries it; {expected:?} is missing from \
{fields:?}"
);
}
}
#[test]
fn a_refused_write_still_returns_to_the_caller() {
let events = captured(async {
record_best_effort(
&(Arc::new(Failing) as SharedAuditSink),
"key.revoke",
"did:key:zActor",
None,
"success",
None,
None,
)
.await;
});
assert!(!events.is_empty(), "the failure should have been reported");
}
#[test]
fn a_healthy_write_reports_nothing() {
let events = captured(async {
record_best_effort(
&(Arc::new(Healthy) as SharedAuditSink),
"key.revoke",
"did:key:zActor",
None,
"success",
None,
None,
)
.await;
});
assert!(
!events
.iter()
.any(|(target, ..)| target == AUDIT_WRITE_FAILURE_TARGET),
"nothing failed, so nothing should be reported: {events:?}"
);
}
}