use axum::Json;
use axum::extract::{Query, State};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use vti_common::audit::{AuditEnvelope, ChainBreak, ChainVerifier};
use vti_common::error::AppError;
use vti_common::pagination::{Cursor, MAX_LIMIT};
use crate::auth::SuperAdminAuth;
use crate::server::AppState;
use tracing::info;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct AuditQuery {
pub from: Option<DateTime<Utc>>,
pub to: Option<DateTime<Utc>>,
pub action: Option<String>,
pub actor: Option<String>,
pub outcome: Option<String>,
pub context_id: Option<String>,
pub page_size: Option<usize>,
pub cursor: Option<String>,
}
impl AuditQuery {
fn unsupported_filters(&self) -> Vec<&'static str> {
let mut bad = Vec::new();
if self.outcome.is_some() {
bad.push("outcome");
}
if self.context_id.is_some() {
bad.push("contextId");
}
bad
}
fn cursor_binding(&self) -> Vec<u8> {
let mut out = Vec::new();
let mut field = |v: Option<&str>| {
let bytes = v.unwrap_or("").as_bytes();
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
out.extend_from_slice(bytes);
};
field(self.from.map(|t| t.to_rfc3339()).as_deref());
field(self.to.map(|t| t.to_rfc3339()).as_deref());
field(self.action.as_deref());
field(self.actor.as_deref());
out
}
fn matches(&self, env: &AuditEnvelope) -> bool {
if let Some(from) = self.from
&& env.timestamp < from
{
return false;
}
if let Some(to) = self.to
&& env.timestamp >= to
{
return false;
}
if let Some(action) = &self.action
&& action_of(env) != action.as_str()
{
return false;
}
if let Some(actor) = &self.actor
&& env.actor_did_plain.as_deref() != Some(actor.as_str())
{
return false;
}
true
}
}
fn action_of(env: &AuditEnvelope) -> &'static str {
env.event.variant_name()
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct AuditEntry {
pub event_id: String,
pub recorded_at: String,
pub action: String,
pub actor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
pub schema_version: u32,
pub prev_hash: String,
pub entry_hash: String,
pub detail: serde_json::Value,
pub ext: serde_json::Value,
}
impl From<&AuditEnvelope> for AuditEntry {
fn from(env: &AuditEnvelope) -> Self {
let detail = serde_json::to_value(&env.event)
.ok()
.and_then(|mut v| v.get_mut("data").map(serde_json::Value::take))
.unwrap_or(serde_json::Value::Object(Default::default()));
Self {
event_id: env.event_id.to_string(),
recorded_at: env.timestamp.to_rfc3339(),
action: action_of(env).to_owned(),
actor: env.actor_did_plain.clone(),
target: env.target_did_plain.clone(),
schema_version: env.schema_version,
prev_hash: hex::encode(env.prev_hash),
entry_hash: hex::encode(env.entry_hash),
detail,
ext: serde_json::json!({
"vtc": {
"eventVersion": env.event_version,
"auditKeyId": env.audit_key_id.to_string(),
"actorDidHash": hex::encode(env.actor_did_hash),
"targetDidHash": env.target_did_hash.map(hex::encode),
}
}),
}
}
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct AuditListResponse {
pub entries: Vec<AuditEntry>,
pub truncated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
#[utoipa::path(
get, path = "/audit", tag = "audit",
security(("bearer_jwt" = [])),
params(AuditQuery),
responses(
(status = 200, description = "Paginated audit envelopes", body = Object),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Caller is not a super-admin"),
),
)]
pub async fn list_audit(
auth: SuperAdminAuth,
State(state): State<AppState>,
Query(query): Query<AuditQuery>,
) -> Result<Json<AuditListResponse>, AppError> {
let unsupported = query.unsupported_filters();
if !unsupported.is_empty() {
return Err(AppError::Validation(format!(
"this maintainer does not implement the {} filter(s); \
refusing rather than returning unfiltered results",
unsupported.join(", "),
)));
}
let limit = query.page_size.unwrap_or(50).clamp(1, MAX_LIMIT);
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
let audit_key = audit_writer.active_key().await?;
let binding = query.cursor_binding();
let decoded_cursor = match &query.cursor {
Some(s) => Some(Cursor::decode_bound(s, &audit_key.key, &binding)?),
None => None,
};
let mut pairs = state.audit_ks.prefix_iter_raw(Vec::new()).await?;
pairs.sort_by(|(a, _), (b, _)| b.cmp(a));
let start = match &decoded_cursor {
Some(c) => pairs
.iter()
.position(|(k, _)| k.as_slice() < c.last_key.as_slice())
.unwrap_or(pairs.len()),
None => 0,
};
let mut entries: Vec<AuditEntry> = Vec::with_capacity(limit);
let mut last_seen_key: Option<Vec<u8>> = None;
let mut idx = start;
while entries.len() < limit && idx < pairs.len() {
let (key, value) = &pairs[idx];
match serde_json::from_slice::<AuditEnvelope>(value) {
Ok(env) => {
if query.matches(&env) {
entries.push(AuditEntry::from(&env));
last_seen_key = Some(key.clone());
}
}
Err(err) => {
tracing::warn!(
error = %err,
key = %String::from_utf8_lossy(key),
"skipping unparseable audit envelope",
);
}
}
idx += 1;
}
let mut more_matches = false;
while idx < pairs.len() {
if let Ok(env) = serde_json::from_slice::<AuditEnvelope>(&pairs[idx].1)
&& query.matches(&env)
{
more_matches = true;
break;
}
idx += 1;
}
let snapshot_id: u64 = pairs.len() as u64;
let cursor = if more_matches {
last_seen_key.map(|k| Cursor::new(k, snapshot_id).encode_bound(&audit_key.key, &binding))
} else {
None
};
info!(
caller = %auth.0.did,
count = entries.len(),
has_more = cursor.is_some(),
"audit listed",
);
Ok(Json(AuditListResponse {
truncated: cursor.is_some(),
entries,
cursor,
}))
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct VerifyResponse {
pub verified: bool,
pub entries_examined: usize,
pub entries_verified: usize,
pub legacy_skipped: usize,
pub unparseable_skipped: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub head: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chain_break: Option<ChainBreakReport>,
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct ChainBreakReport {
pub kind: String,
pub index: usize,
pub event_id: String,
}
#[utoipa::path(
get, path = "/audit/verify", tag = "audit",
security(("bearer_jwt" = [])),
responses(
(status = 200, description = "Chain verification result", body = VerifyResponse),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Caller is not a super-admin"),
),
)]
pub async fn verify_audit_chain(
auth: SuperAdminAuth,
State(state): State<AppState>,
) -> Result<Json<VerifyResponse>, AppError> {
let mut pairs = state.audit_ks.prefix_iter_raw(Vec::new()).await?;
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
let mut verifier = ChainVerifier::new();
let mut unparseable = 0usize;
let mut chain_break = None;
for (key, value) in &pairs {
let env = match serde_json::from_slice::<AuditEnvelope>(value) {
Ok(env) => env,
Err(err) => {
unparseable += 1;
tracing::warn!(
error = %err,
key = %String::from_utf8_lossy(key),
"skipping unparseable audit envelope during verify",
);
continue;
}
};
if let Err(brk) = verifier.push(&env) {
let (kind, index, event_id) = match brk {
ChainBreak::TamperedEntry { index, event_id } => ("tamperedEntry", index, event_id),
ChainBreak::BrokenLink { index, event_id } => ("brokenLink", index, event_id),
};
chain_break = Some(ChainBreakReport {
kind: kind.to_string(),
index,
event_id: event_id.to_string(),
});
break;
}
}
let verified = chain_break.is_none();
let response = VerifyResponse {
verified,
entries_examined: verifier.index(),
entries_verified: verifier.verified(),
legacy_skipped: verifier.skipped_legacy(),
unparseable_skipped: unparseable,
head: verifier.head().map(hex::encode),
chain_break,
};
if verified {
info!(
caller = %auth.0.did,
examined = response.entries_examined,
verified = response.entries_verified,
legacy_skipped = response.legacy_skipped,
"audit chain verified",
);
} else {
tracing::warn!(
caller = %auth.0.did,
examined = response.entries_examined,
chain_break = ?response.chain_break,
"audit chain verification FAILED",
);
}
Ok(Json(response))
}