use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::key_store::RotationReason;
pub const REDACTED_MARKER: &str = "<redacted>";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", content = "data")]
pub enum AuditEvent {
CommunityInstalled(CommunityInstalledData),
EmergencyBootstrapInvoked(EmergencyBootstrapData),
AdminPasskeyRegistered(AdminPasskeyData),
AdminPasskeyRevoked(AdminPasskeyData),
ConfigChanged(ConfigChangedData),
ConfigReloaded(ConfigReloadedData),
RestartRequested(RestartRequestedData),
CommunityProfileUpdated(CommunityProfileUpdatedData),
AuditKeyRotated(AuditKeyRotatedData),
MemberUpdated(MemberUpdatedData),
RoleChanged(RoleChangedData),
AdminPromoted(AdminPromotedData),
JoinRequestSubmitted(JoinRequestData),
JoinRequestApproved(JoinRequestData),
JoinRequestRejected(JoinRequestRejectedData),
MemberAdded(MemberAddedData),
MemberRemoved(MemberRemovedData),
MembershipReciprocated(MembershipReciprocatedData),
PolicyUploaded(PolicyUploadedData),
PolicyActivated(PolicyActivatedData),
VmcIssued(CredentialIssuedData),
VecIssued(CredentialIssuedData),
MembershipRenewed(MembershipRenewedData),
StatusListFlipped(StatusListFlippedData),
DidRotated(DidRotatedData),
RegistryStatusChanged(RegistryStatusChangedData),
RegistrySyncSucceeded(RegistrySyncOutcomeData),
RegistrySyncFailed(RegistrySyncOutcomeData),
RegistryRecordPolicyOverride(RegistryRecordPolicyOverrideData),
CrossCommunitySessionMinted(CrossCommunitySessionMintedData),
VrcPublished(VrcPublishedData),
VrcRevoked(VrcRevokedData),
PersonhoodAsserted(PersonhoodAssertedData),
PersonhoodRevoked(PersonhoodRevokedData),
CustomEndorsementIssued(CustomEndorsementIssuedData),
CustomEndorsementRevoked(CustomEndorsementRevokedData),
EndorsementTypeRegistered(EndorsementTypeRegisteredData),
EndorsementTypeDeleted(EndorsementTypeDeletedData),
WebsiteFileWritten(WebsiteFileWrittenData),
WebsiteFileDeleted(WebsiteFileDeletedData),
WebsiteBundleDeployed(WebsiteBundleDeployedData),
WebsiteGenerationRolledBack(WebsiteGenerationRolledBackData),
AdminUiServed(AdminUiServedData),
AclGranted(AclChangeData),
AclUpdated(AclChangeData),
AclRevoked(AclRevokedData),
InvitationIssued(InvitationIssuedData),
InvitationRevoked(InvitationRevokedData),
SessionRevoked(SessionRevokedData),
SignedOut(SignedOutData),
BackupExported(BackupData),
BackupImported(BackupData),
AdminInviteCreated(AdminInviteData),
AdminInviteRevoked(AdminInviteData),
SchemaRegistered(SchemaChangeData),
SchemaDeleted(SchemaChangeData),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AclChangeData {
pub did: String,
pub role: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub contexts: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AclRevokedData {
pub did: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prior_role: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct InvitationIssuedData {
pub invitation_id: String,
pub subject_did: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
pub valid_until: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status_list_index: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct InvitationRevokedData {
pub invitation_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject_did: Option<String>,
pub newly_revoked: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SessionRevokedData {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
pub revoked_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SignedOutData {
pub session_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct BackupData {
pub keyspace_count: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vtc_did: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminInviteData {
pub jti: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub admin_did: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SchemaChangeData {
pub id: String,
pub kind: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CommunityInstalledData {
pub community_did: String,
pub install_token_jti: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EmergencyBootstrapData {
pub operator_hostname: String,
pub invoked_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminPasskeyData {
pub credential_id_hex: String,
pub label: String,
pub transports: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConfigChangedData {
pub changes: Vec<ConfigChange>,
pub requires_restart: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConfigChange {
pub key: String,
pub old_value: Option<Value>,
pub new_value: Value,
pub source_before: ConfigSource,
}
impl ConfigChange {
pub fn redact_if<F>(&mut self, sensitive: F) -> bool
where
F: Fn(&str) -> bool,
{
if sensitive(&self.key) {
self.old_value = Some(Value::String(REDACTED_MARKER.to_string()));
self.new_value = Value::String(REDACTED_MARKER.to_string());
true
} else {
false
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ConfigSource {
Env,
Db,
Toml,
Default,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConfigReloadedData {
pub keys_reloaded: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RestartRequestedData {
pub drain_timeout_seconds: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub struct FieldChange {
pub field: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub old: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub new: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub struct CommunityProfileUpdatedData {
pub fields_changed: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub changes: Vec<FieldChange>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AuditKeyRotatedData {
pub previous_key_id: String,
pub new_key_id: String,
pub rotation_reason: RotationReason,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MemberUpdatedData {
pub fields_changed: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub changes: Vec<FieldChange>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RoleChangedData {
pub previous_role: String,
pub new_role: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminPromotedData {
pub previous_role: String,
pub authorising_credential_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct JoinRequestData {
pub request_id: String,
pub transport: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct JoinRequestRejectedData {
pub request_id: String,
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy_decision: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MemberAddedData {
pub role: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub via_join_request_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MemberRemovedData {
pub disposition: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prior_role: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PolicyUploadedData {
pub policy_id: String,
pub purpose: String,
pub sha256: String,
pub version: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MembershipReciprocatedData {
pub request_id: String,
pub vmc_id: String,
pub reciprocal_vc_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CredentialIssuedData {
pub credential_id: String,
pub credential_type: String,
pub valid_from: String,
pub valid_until: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status_list_index: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MembershipRenewedData {
pub vmc_id: String,
pub role_vec_id: String,
pub personhood_changed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct StatusListFlippedData {
pub purpose: String,
pub index: u32,
pub revoked: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RegistryRecordPolicyOverrideData {
pub reason: String,
pub attempted_disposition: String,
pub effective_disposition: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RegistrySyncOutcomeData {
pub job_id: String,
pub kind: String,
pub attempts: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CrossCommunitySessionMintedData {
pub outcome: String,
pub foreign_issuer_did: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub foreign_role: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mapped_role: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ttl_seconds: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct VrcPublishedData {
pub vrc_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject_did: Option<String>,
pub edge_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct VrcRevokedData {
pub vrc_id: String,
pub revoked_by: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PersonhoodAssertedData {
pub vmc_id: String,
pub asserted_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PersonhoodRevokedData {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vmc_id: Option<String>,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CustomEndorsementIssuedData {
pub endorsement_id: String,
pub endorsement_type: String,
pub status_list_index: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CustomEndorsementRevokedData {
pub endorsement_id: String,
pub endorsement_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EndorsementTypeRegisteredData {
pub type_uri: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EndorsementTypeDeletedData {
pub type_uri: String,
#[serde(default)]
pub live_endorsements_at_delete: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WebsiteFileWrittenData {
pub path: String,
pub size_bytes: u64,
pub sha256: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WebsiteFileDeletedData {
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WebsiteBundleDeployedData {
pub bundle_sha256: String,
pub bundle_size_bytes: u64,
pub deploy_mode: String,
pub target_generation: u32,
pub pruned_generations: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WebsiteGenerationRolledBackData {
pub from_generation: u32,
pub to_generation: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminUiServedData {
pub index_sha256: String,
pub file_count: u32,
pub mode: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub daemon_version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RegistryStatusChangedData {
pub from: String,
pub to: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DidRotatedData {
pub old_did: String,
pub new_did: String,
pub method: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vmc_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role_vec_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prior_role: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rotation_reason: Option<DidRotationReason>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub enum DidRotationReason {
Routine,
Compromise,
DeviceLoss,
Migration,
Unspecified,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PolicyActivatedData {
pub policy_id: String,
pub purpose: String,
pub sha256: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_policy_id: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn round_trip(event: &AuditEvent) {
let s = serde_json::to_string(event).unwrap();
let back: AuditEvent = serde_json::from_str(&s).unwrap();
assert_eq!(&back, event);
}
fn wire_value(event: &AuditEvent) -> Value {
serde_json::to_value(event).unwrap()
}
#[test]
fn community_installed_tagged_wire_shape() {
let e = AuditEvent::CommunityInstalled(CommunityInstalledData {
community_did: "did:webvh:example.com:abc".into(),
install_token_jti: "jti-1".into(),
});
let v = wire_value(&e);
assert_eq!(v["type"], "CommunityInstalled");
assert_eq!(v["data"]["communityDid"], "did:webvh:example.com:abc");
assert_eq!(v["data"]["installTokenJti"], "jti-1");
round_trip(&e);
}
#[test]
fn emergency_bootstrap_tagged_wire_shape() {
let invoked_at = DateTime::parse_from_rfc3339("2026-05-12T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
let e = AuditEvent::EmergencyBootstrapInvoked(EmergencyBootstrapData {
operator_hostname: "ops-01.example.com".into(),
invoked_at,
});
let v = wire_value(&e);
assert_eq!(v["type"], "EmergencyBootstrapInvoked");
assert_eq!(v["data"]["operatorHostname"], "ops-01.example.com");
round_trip(&e);
}
#[test]
fn admin_passkey_registered_round_trip() {
let e = AuditEvent::AdminPasskeyRegistered(AdminPasskeyData {
credential_id_hex: "deadbeef".into(),
label: "MacBook Air Touch ID".into(),
transports: vec!["internal".into(), "hybrid".into()],
});
let v = wire_value(&e);
assert_eq!(v["type"], "AdminPasskeyRegistered");
assert_eq!(v["data"]["credentialIdHex"], "deadbeef");
assert_eq!(v["data"]["transports"][0], "internal");
round_trip(&e);
}
#[test]
fn admin_passkey_revoked_round_trip() {
let e = AuditEvent::AdminPasskeyRevoked(AdminPasskeyData {
credential_id_hex: "feedface".into(),
label: "iPhone Face ID".into(),
transports: vec!["hybrid".into()],
});
let v = wire_value(&e);
assert_eq!(v["type"], "AdminPasskeyRevoked");
round_trip(&e);
}
#[test]
fn config_changed_round_trip() {
let e = AuditEvent::ConfigChanged(ConfigChangedData {
changes: vec![ConfigChange {
key: "log.level".into(),
old_value: Some(json!("info")),
new_value: json!("debug"),
source_before: ConfigSource::Toml,
}],
requires_restart: false,
});
let v = wire_value(&e);
assert_eq!(v["type"], "ConfigChanged");
assert_eq!(v["data"]["changes"][0]["key"], "log.level");
assert_eq!(v["data"]["changes"][0]["newValue"], "debug");
assert_eq!(v["data"]["changes"][0]["sourceBefore"], "toml");
round_trip(&e);
}
#[test]
fn config_reloaded_round_trip() {
let e = AuditEvent::ConfigReloaded(ConfigReloadedData {
keys_reloaded: vec!["log.level".into(), "audit.retention.config_changed".into()],
});
let v = wire_value(&e);
assert_eq!(v["type"], "ConfigReloaded");
assert_eq!(
v["data"]["keysReloaded"][1],
"audit.retention.config_changed"
);
round_trip(&e);
}
#[test]
fn restart_requested_round_trip() {
let e = AuditEvent::RestartRequested(RestartRequestedData {
drain_timeout_seconds: 30,
});
let v = wire_value(&e);
assert_eq!(v["type"], "RestartRequested");
assert_eq!(v["data"]["drainTimeoutSeconds"], 30);
round_trip(&e);
}
#[test]
fn community_profile_updated_round_trip() {
let e = AuditEvent::CommunityProfileUpdated(CommunityProfileUpdatedData {
fields_changed: vec!["name".into(), "logo_url".into()],
changes: vec![FieldChange {
field: "name".into(),
old: Some(json!("Old Name")),
new: Some(json!("New Name")),
}],
});
let v = wire_value(&e);
assert_eq!(v["type"], "CommunityProfileUpdated");
assert_eq!(v["data"]["fieldsChanged"][0], "name");
assert_eq!(v["data"]["changes"][0]["field"], "name");
assert_eq!(v["data"]["changes"][0]["new"], "New Name");
round_trip(&e);
}
#[test]
fn audit_key_rotated_round_trip() {
let e = AuditEvent::AuditKeyRotated(AuditKeyRotatedData {
previous_key_id: "11111111-1111-1111-1111-111111111111".into(),
new_key_id: "22222222-2222-2222-2222-222222222222".into(),
rotation_reason: RotationReason::Rtbf,
});
let v = wire_value(&e);
assert_eq!(v["type"], "AuditKeyRotated");
assert_eq!(v["data"]["rotationReason"], "Rtbf");
round_trip(&e);
}
#[test]
fn redact_if_masks_sensitive_keys() {
let mut change = ConfigChange {
key: "server.tls.cert_path".into(),
old_value: Some(json!("/etc/old.pem")),
new_value: json!("/etc/new.pem"),
source_before: ConfigSource::Db,
};
let redacted = change.redact_if(|k| k.starts_with("server.tls."));
assert!(redacted);
assert_eq!(change.old_value, Some(json!(REDACTED_MARKER)));
assert_eq!(change.new_value, json!(REDACTED_MARKER));
assert_eq!(change.key, "server.tls.cert_path");
assert_eq!(change.source_before, ConfigSource::Db);
}
#[test]
fn redact_if_leaves_non_sensitive_keys_untouched() {
let mut change = ConfigChange {
key: "log.level".into(),
old_value: Some(json!("info")),
new_value: json!("debug"),
source_before: ConfigSource::Toml,
};
let original = change.clone();
let redacted = change.redact_if(|k| k.starts_with("server.tls."));
assert!(!redacted);
assert_eq!(change, original);
}
#[test]
fn redact_if_handles_unset_old_value() {
let mut change = ConfigChange {
key: "server.tls.key_path".into(),
old_value: None,
new_value: json!("/etc/new.key"),
source_before: ConfigSource::Default,
};
change.redact_if(|k| k.starts_with("server.tls."));
assert_eq!(change.old_value, Some(json!(REDACTED_MARKER)));
}
#[test]
fn policy_uploaded_round_trip() {
let e = AuditEvent::PolicyUploaded(PolicyUploadedData {
policy_id: "11111111-1111-1111-1111-111111111111".into(),
purpose: "join".into(),
sha256: "abc123".into(),
version: 4,
});
let v = wire_value(&e);
assert_eq!(v["type"], "PolicyUploaded");
assert_eq!(
v["data"]["policyId"],
"11111111-1111-1111-1111-111111111111"
);
assert_eq!(v["data"]["purpose"], "join");
assert_eq!(v["data"]["sha256"], "abc123");
assert_eq!(v["data"]["version"], 4);
round_trip(&e);
}
#[test]
fn policy_activated_round_trip_with_predecessor() {
let e = AuditEvent::PolicyActivated(PolicyActivatedData {
policy_id: "22222222-2222-2222-2222-222222222222".into(),
purpose: "removal".into(),
sha256: "deadbeef".into(),
previous_policy_id: Some("11111111-1111-1111-1111-111111111111".into()),
});
let v = wire_value(&e);
assert_eq!(v["type"], "PolicyActivated");
assert_eq!(
v["data"]["previousPolicyId"],
"11111111-1111-1111-1111-111111111111"
);
round_trip(&e);
}
#[test]
fn policy_activated_omits_predecessor_when_none() {
let e = AuditEvent::PolicyActivated(PolicyActivatedData {
policy_id: "22222222-2222-2222-2222-222222222222".into(),
purpose: "personhood".into(),
sha256: "cafe".into(),
previous_policy_id: None,
});
let v = wire_value(&e);
assert!(
v["data"].get("previousPolicyId").is_none(),
"previousPolicyId should be omitted, got {v}"
);
round_trip(&e);
}
#[test]
fn vmc_issued_round_trip() {
let e = AuditEvent::VmcIssued(CredentialIssuedData {
credential_id: "urn:uuid:11111111-1111-1111-1111-111111111111".into(),
credential_type: "VerifiableMembershipCredential".into(),
valid_from: "2026-05-12T00:00:00Z".into(),
valid_until: "2026-06-11T00:00:00Z".into(),
status_list_index: Some(42),
});
let v = wire_value(&e);
assert_eq!(v["type"], "VmcIssued");
assert_eq!(
v["data"]["credentialType"],
"VerifiableMembershipCredential"
);
assert_eq!(v["data"]["statusListIndex"], 42);
round_trip(&e);
}
#[test]
fn vec_issued_round_trip_omits_status_list_index_when_none() {
let e = AuditEvent::VecIssued(CredentialIssuedData {
credential_id: "urn:uuid:22222222-2222-2222-2222-222222222222".into(),
credential_type: "VerifiableEndorsementCredential".into(),
valid_from: "2026-05-12T00:00:00Z".into(),
valid_until: "2026-06-11T00:00:00Z".into(),
status_list_index: None,
});
let v = wire_value(&e);
assert_eq!(v["type"], "VecIssued");
assert!(
v["data"].get("statusListIndex").is_none(),
"statusListIndex should be omitted when None, got {v}"
);
round_trip(&e);
}
#[test]
fn membership_renewed_round_trip() {
let e = AuditEvent::MembershipRenewed(MembershipRenewedData {
vmc_id: "urn:uuid:vmc-1".into(),
role_vec_id: "urn:uuid:vec-1".into(),
personhood_changed: true,
});
let v = wire_value(&e);
assert_eq!(v["type"], "MembershipRenewed");
assert_eq!(v["data"]["personhoodChanged"], true);
round_trip(&e);
}
#[test]
fn status_list_flipped_round_trip() {
let e = AuditEvent::StatusListFlipped(StatusListFlippedData {
purpose: "revocation".into(),
index: 7,
revoked: true,
});
let v = wire_value(&e);
assert_eq!(v["type"], "StatusListFlipped");
assert_eq!(v["data"]["purpose"], "revocation");
assert_eq!(v["data"]["index"], 7);
assert_eq!(v["data"]["revoked"], true);
round_trip(&e);
}
#[test]
fn did_rotated_round_trip_with_credential_ids() {
let e = AuditEvent::DidRotated(DidRotatedData {
old_did: "did:key:zOld".into(),
new_did: "did:key:zNew".into(),
method: "did:key".into(),
vmc_id: Some("urn:uuid:vmc-2".into()),
role_vec_id: Some("urn:uuid:vec-2".into()),
prior_role: Some("member".into()),
rotation_reason: Some(DidRotationReason::Compromise),
});
let v = wire_value(&e);
assert_eq!(v["type"], "DidRotated");
assert_eq!(v["data"]["method"], "did:key");
assert_eq!(v["data"]["oldDid"], "did:key:zOld");
assert_eq!(v["data"]["newDid"], "did:key:zNew");
assert_eq!(v["data"]["vmcId"], "urn:uuid:vmc-2");
round_trip(&e);
}
#[test]
fn did_rotated_omits_credential_ids_when_none() {
let e = AuditEvent::DidRotated(DidRotatedData {
old_did: "did:key:zOld".into(),
new_did: "did:key:zNew".into(),
method: "did:key".into(),
vmc_id: None,
role_vec_id: None,
prior_role: None,
rotation_reason: None,
});
let v = wire_value(&e);
assert!(v["data"].get("vmcId").is_none());
assert!(v["data"].get("roleVecId").is_none());
round_trip(&e);
}
#[test]
fn registry_sync_succeeded_round_trip() {
let e = AuditEvent::RegistrySyncSucceeded(RegistrySyncOutcomeData {
job_id: "11111111-1111-1111-1111-111111111111".into(),
kind: "publishMember".into(),
attempts: 1,
last_error: None,
});
let v = wire_value(&e);
assert_eq!(v["type"], "RegistrySyncSucceeded");
assert_eq!(v["data"]["kind"], "publishMember");
assert_eq!(v["data"]["attempts"], 1);
assert!(
v["data"].get("lastError").is_none(),
"lastError should be omitted on success: {v}"
);
round_trip(&e);
}
#[test]
fn registry_sync_failed_round_trip_carries_last_error() {
let e = AuditEvent::RegistrySyncFailed(RegistrySyncOutcomeData {
job_id: "22222222-2222-2222-2222-222222222222".into(),
kind: "deleteMember".into(),
attempts: 17,
last_error: Some("permanent: bad input".into()),
});
let v = wire_value(&e);
assert_eq!(v["type"], "RegistrySyncFailed");
assert_eq!(v["data"]["attempts"], 17);
assert_eq!(v["data"]["lastError"], "permanent: bad input");
round_trip(&e);
}
#[test]
fn registry_status_changed_round_trip() {
let e = AuditEvent::RegistryStatusChanged(RegistryStatusChangedData {
from: "active".into(),
to: "degraded".into(),
reason: Some("connection refused".into()),
});
let v = wire_value(&e);
assert_eq!(v["type"], "RegistryStatusChanged");
assert_eq!(v["data"]["from"], "active");
assert_eq!(v["data"]["to"], "degraded");
assert_eq!(v["data"]["reason"], "connection refused");
round_trip(&e);
}
#[test]
fn registry_status_changed_omits_reason_when_none() {
let e = AuditEvent::RegistryStatusChanged(RegistryStatusChangedData {
from: "degraded".into(),
to: "active".into(),
reason: None,
});
let v = wire_value(&e);
assert!(
v["data"].get("reason").is_none(),
"reason should be omitted when None, got {v}"
);
round_trip(&e);
}
#[test]
fn registry_record_policy_override_round_trip() {
let e = AuditEvent::RegistryRecordPolicyOverride(RegistryRecordPolicyOverrideData {
reason: "rtbf".into(),
attempted_disposition: "tombstone".into(),
effective_disposition: "purge".into(),
});
let v = wire_value(&e);
assert_eq!(v["type"], "RegistryRecordPolicyOverride");
assert_eq!(v["data"]["reason"], "rtbf");
assert_eq!(v["data"]["attemptedDisposition"], "tombstone");
assert_eq!(v["data"]["effectiveDisposition"], "purge");
round_trip(&e);
}
#[test]
fn cross_community_session_minted_round_trip() {
let e = AuditEvent::CrossCommunitySessionMinted(CrossCommunitySessionMintedData {
outcome: "minted".into(),
foreign_issuer_did: "did:webvh:peer.example.com:abc".into(),
foreign_role: Some("moderator".into()),
mapped_role: Some("monitor".into()),
ttl_seconds: Some(900),
reason: None,
});
let v = wire_value(&e);
assert_eq!(v["type"], "CrossCommunitySessionMinted");
assert_eq!(v["data"]["outcome"], "minted");
assert_eq!(
v["data"]["foreignIssuerDid"],
"did:webvh:peer.example.com:abc"
);
assert_eq!(v["data"]["foreignRole"], "moderator");
assert_eq!(v["data"]["mappedRole"], "monitor");
assert_eq!(v["data"]["ttlSeconds"], 900);
assert!(
v["data"].get("reason").is_none(),
"reason should be omitted on minted: {v}"
);
round_trip(&e);
}
#[test]
fn cross_community_session_minted_denied_carries_reason() {
let e = AuditEvent::CrossCommunitySessionMinted(CrossCommunitySessionMintedData {
outcome: "denied".into(),
foreign_issuer_did: "did:webvh:peer.example.com:abc".into(),
foreign_role: Some("admin".into()),
mapped_role: None,
ttl_seconds: None,
reason: Some("issuer-not-recognised".into()),
});
let v = wire_value(&e);
assert_eq!(v["data"]["outcome"], "denied");
assert_eq!(v["data"]["reason"], "issuer-not-recognised");
assert!(v["data"].get("mappedRole").is_none());
assert!(v["data"].get("ttlSeconds").is_none());
round_trip(&e);
}
#[test]
fn vrc_published_round_trip() {
let e = AuditEvent::VrcPublished(VrcPublishedData {
vrc_id: "11111111-1111-1111-1111-111111111111".into(),
subject_did: Some("did:key:zSubject".into()),
edge_type: "endorses".into(),
});
let v = wire_value(&e);
assert_eq!(v["type"], "VrcPublished");
assert_eq!(v["data"]["vrcId"], "11111111-1111-1111-1111-111111111111");
assert_eq!(v["data"]["subjectDid"], "did:key:zSubject");
assert_eq!(v["data"]["edgeType"], "endorses");
round_trip(&e);
}
#[test]
fn vrc_published_omits_subject_did_when_none() {
let e = AuditEvent::VrcPublished(VrcPublishedData {
vrc_id: "id".into(),
subject_did: None,
edge_type: "reports-to".into(),
});
let v = wire_value(&e);
assert!(v["data"].get("subjectDid").is_none());
round_trip(&e);
}
#[test]
fn vrc_revoked_round_trip() {
let e = AuditEvent::VrcRevoked(VrcRevokedData {
vrc_id: "id".into(),
revoked_by: "issuer".into(),
});
let v = wire_value(&e);
assert_eq!(v["type"], "VrcRevoked");
assert_eq!(v["data"]["revokedBy"], "issuer");
round_trip(&e);
}
#[test]
fn personhood_asserted_round_trip() {
let e = AuditEvent::PersonhoodAsserted(PersonhoodAssertedData {
vmc_id: "vmc-7".into(),
asserted_at: "2026-05-14T10:00:00Z".into(),
});
let v = wire_value(&e);
assert_eq!(v["type"], "PersonhoodAsserted");
assert_eq!(v["data"]["vmcId"], "vmc-7");
assert_eq!(v["data"]["assertedAt"], "2026-05-14T10:00:00Z");
round_trip(&e);
}
#[test]
fn personhood_revoked_round_trip() {
let e = AuditEvent::PersonhoodRevoked(PersonhoodRevokedData {
vmc_id: Some("vmc-8".into()),
reason: "admin".into(),
});
let v = wire_value(&e);
assert_eq!(v["type"], "PersonhoodRevoked");
assert_eq!(v["data"]["vmcId"], "vmc-8");
assert_eq!(v["data"]["reason"], "admin");
round_trip(&e);
}
#[test]
fn personhood_revoked_omits_vmc_id_when_refuse_arm() {
let e = AuditEvent::PersonhoodRevoked(PersonhoodRevokedData {
vmc_id: None,
reason: "renewal-policy".into(),
});
let v = wire_value(&e);
assert!(v["data"].get("vmcId").is_none());
assert_eq!(v["data"]["reason"], "renewal-policy");
round_trip(&e);
}
#[test]
fn custom_endorsement_issued_round_trip() {
let e = AuditEvent::CustomEndorsementIssued(CustomEndorsementIssuedData {
endorsement_id: "end-1".into(),
endorsement_type: "https://example.com/v1/skills/rust".into(),
status_list_index: 42,
});
let v = wire_value(&e);
assert_eq!(v["type"], "CustomEndorsementIssued");
assert_eq!(
v["data"]["endorsementType"],
"https://example.com/v1/skills/rust"
);
assert_eq!(v["data"]["statusListIndex"], 42);
round_trip(&e);
}
#[test]
fn custom_endorsement_revoked_round_trip() {
let e = AuditEvent::CustomEndorsementRevoked(CustomEndorsementRevokedData {
endorsement_id: "end-1".into(),
endorsement_type: "https://example.com/v1/skills/rust".into(),
});
let v = wire_value(&e);
assert_eq!(v["type"], "CustomEndorsementRevoked");
round_trip(&e);
}
#[test]
fn endorsement_type_registered_round_trip() {
let e = AuditEvent::EndorsementTypeRegistered(EndorsementTypeRegisteredData {
type_uri: "https://example.com/v1/skills/rust".into(),
description: Some("Rust proficiency endorsement".into()),
});
let v = wire_value(&e);
assert_eq!(v["type"], "EndorsementTypeRegistered");
assert_eq!(v["data"]["typeUri"], "https://example.com/v1/skills/rust");
assert_eq!(v["data"]["description"], "Rust proficiency endorsement");
round_trip(&e);
}
#[test]
fn endorsement_type_registered_omits_description_when_none() {
let e = AuditEvent::EndorsementTypeRegistered(EndorsementTypeRegisteredData {
type_uri: "https://example.com/v1/x".into(),
description: None,
});
let v = wire_value(&e);
assert!(v["data"].get("description").is_none());
round_trip(&e);
}
#[test]
fn endorsement_type_deleted_round_trip() {
let e = AuditEvent::EndorsementTypeDeleted(EndorsementTypeDeletedData {
type_uri: "https://example.com/v1/skills/rust".into(),
live_endorsements_at_delete: 0,
});
let v = wire_value(&e);
assert_eq!(v["type"], "EndorsementTypeDeleted");
round_trip(&e);
}
#[test]
fn variant_discriminator_strings() {
let cases: Vec<(AuditEvent, &str)> = vec![
(
AuditEvent::CommunityInstalled(CommunityInstalledData {
community_did: "did:webvh:x".into(),
install_token_jti: "j".into(),
}),
"CommunityInstalled",
),
(
AuditEvent::EmergencyBootstrapInvoked(EmergencyBootstrapData {
operator_hostname: "h".into(),
invoked_at: Utc::now(),
}),
"EmergencyBootstrapInvoked",
),
(
AuditEvent::AdminPasskeyRegistered(AdminPasskeyData {
credential_id_hex: "0".into(),
label: "x".into(),
transports: vec![],
}),
"AdminPasskeyRegistered",
),
(
AuditEvent::AdminPasskeyRevoked(AdminPasskeyData {
credential_id_hex: "0".into(),
label: "x".into(),
transports: vec![],
}),
"AdminPasskeyRevoked",
),
(
AuditEvent::ConfigChanged(ConfigChangedData {
changes: vec![],
requires_restart: false,
}),
"ConfigChanged",
),
(
AuditEvent::ConfigReloaded(ConfigReloadedData {
keys_reloaded: vec![],
}),
"ConfigReloaded",
),
(
AuditEvent::RestartRequested(RestartRequestedData {
drain_timeout_seconds: 0,
}),
"RestartRequested",
),
(
AuditEvent::CommunityProfileUpdated(CommunityProfileUpdatedData {
fields_changed: vec![],
changes: vec![],
}),
"CommunityProfileUpdated",
),
(
AuditEvent::AuditKeyRotated(AuditKeyRotatedData {
previous_key_id: "p".into(),
new_key_id: "n".into(),
rotation_reason: RotationReason::Initial,
}),
"AuditKeyRotated",
),
(
AuditEvent::PolicyUploaded(PolicyUploadedData {
policy_id: "p".into(),
purpose: "join".into(),
sha256: "x".into(),
version: 1,
}),
"PolicyUploaded",
),
(
AuditEvent::PolicyActivated(PolicyActivatedData {
policy_id: "p".into(),
purpose: "join".into(),
sha256: "x".into(),
previous_policy_id: None,
}),
"PolicyActivated",
),
(
AuditEvent::VmcIssued(CredentialIssuedData {
credential_id: "id".into(),
credential_type: "VerifiableMembershipCredential".into(),
valid_from: "vf".into(),
valid_until: "vu".into(),
status_list_index: None,
}),
"VmcIssued",
),
(
AuditEvent::VecIssued(CredentialIssuedData {
credential_id: "id".into(),
credential_type: "VerifiableEndorsementCredential".into(),
valid_from: "vf".into(),
valid_until: "vu".into(),
status_list_index: None,
}),
"VecIssued",
),
(
AuditEvent::MembershipRenewed(MembershipRenewedData {
vmc_id: "v".into(),
role_vec_id: "r".into(),
personhood_changed: false,
}),
"MembershipRenewed",
),
(
AuditEvent::StatusListFlipped(StatusListFlippedData {
purpose: "revocation".into(),
index: 0,
revoked: true,
}),
"StatusListFlipped",
),
(
AuditEvent::DidRotated(DidRotatedData {
old_did: "o".into(),
new_did: "n".into(),
method: "did:key".into(),
vmc_id: None,
role_vec_id: None,
prior_role: None,
rotation_reason: None,
}),
"DidRotated",
),
(
AuditEvent::RegistryStatusChanged(RegistryStatusChangedData {
from: "active".into(),
to: "degraded".into(),
reason: None,
}),
"RegistryStatusChanged",
),
(
AuditEvent::RegistrySyncSucceeded(RegistrySyncOutcomeData {
job_id: "j".into(),
kind: "publishMember".into(),
attempts: 1,
last_error: None,
}),
"RegistrySyncSucceeded",
),
(
AuditEvent::RegistrySyncFailed(RegistrySyncOutcomeData {
job_id: "j".into(),
kind: "deleteMember".into(),
attempts: 1,
last_error: Some("x".into()),
}),
"RegistrySyncFailed",
),
(
AuditEvent::RegistryRecordPolicyOverride(RegistryRecordPolicyOverrideData {
reason: "rtbf".into(),
attempted_disposition: "tombstone".into(),
effective_disposition: "purge".into(),
}),
"RegistryRecordPolicyOverride",
),
(
AuditEvent::CrossCommunitySessionMinted(CrossCommunitySessionMintedData {
outcome: "minted".into(),
foreign_issuer_did: "did:webvh:peer".into(),
foreign_role: Some("moderator".into()),
mapped_role: Some("monitor".into()),
ttl_seconds: Some(900),
reason: None,
}),
"CrossCommunitySessionMinted",
),
(
AuditEvent::VrcPublished(VrcPublishedData {
vrc_id: "id".into(),
subject_did: Some("did:key:zX".into()),
edge_type: "endorses".into(),
}),
"VrcPublished",
),
(
AuditEvent::VrcRevoked(VrcRevokedData {
vrc_id: "id".into(),
revoked_by: "admin".into(),
}),
"VrcRevoked",
),
(
AuditEvent::PersonhoodAsserted(PersonhoodAssertedData {
vmc_id: "v".into(),
asserted_at: "2026-05-14T10:00:00Z".into(),
}),
"PersonhoodAsserted",
),
(
AuditEvent::PersonhoodRevoked(PersonhoodRevokedData {
vmc_id: Some("v".into()),
reason: "self".into(),
}),
"PersonhoodRevoked",
),
(
AuditEvent::CustomEndorsementIssued(CustomEndorsementIssuedData {
endorsement_id: "end".into(),
endorsement_type: "https://x/v1/t".into(),
status_list_index: 0,
}),
"CustomEndorsementIssued",
),
(
AuditEvent::CustomEndorsementRevoked(CustomEndorsementRevokedData {
endorsement_id: "end".into(),
endorsement_type: "https://x/v1/t".into(),
}),
"CustomEndorsementRevoked",
),
(
AuditEvent::EndorsementTypeRegistered(EndorsementTypeRegisteredData {
type_uri: "https://x/v1/t".into(),
description: None,
}),
"EndorsementTypeRegistered",
),
(
AuditEvent::EndorsementTypeDeleted(EndorsementTypeDeletedData {
type_uri: "https://x/v1/t".into(),
live_endorsements_at_delete: 0,
}),
"EndorsementTypeDeleted",
),
(
AuditEvent::WebsiteFileWritten(WebsiteFileWrittenData {
path: "index.html".into(),
size_bytes: 42,
sha256: "deadbeef".into(),
}),
"WebsiteFileWritten",
),
(
AuditEvent::WebsiteFileDeleted(WebsiteFileDeletedData {
path: "old.html".into(),
}),
"WebsiteFileDeleted",
),
(
AuditEvent::WebsiteBundleDeployed(WebsiteBundleDeployedData {
bundle_sha256: "deadbeef".into(),
bundle_size_bytes: 1024,
deploy_mode: "managed".into(),
target_generation: 7,
pruned_generations: 2,
}),
"WebsiteBundleDeployed",
),
(
AuditEvent::WebsiteGenerationRolledBack(WebsiteGenerationRolledBackData {
from_generation: 7,
to_generation: 5,
}),
"WebsiteGenerationRolledBack",
),
(
AuditEvent::AdminUiServed(AdminUiServedData {
index_sha256: "deadbeef".into(),
file_count: 3,
mode: "embedded".into(),
daemon_version: None,
}),
"AdminUiServed",
),
];
for (event, expected) in cases {
let v = serde_json::to_value(&event).unwrap();
assert_eq!(v["type"], expected, "discriminator drift for {expected}");
}
}
}