use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::IpAddr;
use std::time::{SystemTime, UNIX_EPOCH};
pub type AuditEventId = String;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditAction {
Create,
Read,
Update,
Delete,
Login,
Logout,
LoginFailed,
PermissionGranted,
PermissionRevoked,
DataExport,
DataDeletionRequest,
ConfigChange,
ApiKeyCreated,
ApiKeyRevoked,
PasswordChange,
MfaChange,
Custom(String),
}
impl AuditAction {
pub fn is_gdpr_relevant(&self) -> bool {
matches!(
self,
AuditAction::Create
| AuditAction::Update
| AuditAction::Delete
| AuditAction::DataExport
| AuditAction::DataDeletionRequest
| AuditAction::Login
| AuditAction::PermissionGranted
| AuditAction::PermissionRevoked
)
}
pub fn is_security_relevant(&self) -> bool {
matches!(
self,
AuditAction::Login
| AuditAction::LoginFailed
| AuditAction::Logout
| AuditAction::PermissionGranted
| AuditAction::PermissionRevoked
| AuditAction::ApiKeyCreated
| AuditAction::ApiKeyRevoked
| AuditAction::PasswordChange
| AuditAction::MfaChange
| AuditAction::ConfigChange
)
}
}
impl std::fmt::Display for AuditAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AuditAction::Create => write!(f, "create"),
AuditAction::Read => write!(f, "read"),
AuditAction::Update => write!(f, "update"),
AuditAction::Delete => write!(f, "delete"),
AuditAction::Login => write!(f, "login"),
AuditAction::Logout => write!(f, "logout"),
AuditAction::LoginFailed => write!(f, "login_failed"),
AuditAction::PermissionGranted => write!(f, "permission_granted"),
AuditAction::PermissionRevoked => write!(f, "permission_revoked"),
AuditAction::DataExport => write!(f, "data_export"),
AuditAction::DataDeletionRequest => write!(f, "data_deletion_request"),
AuditAction::ConfigChange => write!(f, "config_change"),
AuditAction::ApiKeyCreated => write!(f, "api_key_created"),
AuditAction::ApiKeyRevoked => write!(f, "api_key_revoked"),
AuditAction::PasswordChange => write!(f, "password_change"),
AuditAction::MfaChange => write!(f, "mfa_change"),
AuditAction::Custom(s) => write!(f, "custom:{}", s),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum AuditSeverity {
#[default]
Info,
Warning,
Critical,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ComplianceInfo {
#[serde(default)]
pub involves_personal_data: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub data_subject_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub legal_basis: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retention_category: Option<String>,
#[serde(default)]
pub special_category_data: bool,
#[serde(default)]
pub cross_border_transfer: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub soc2_control: Option<String>,
}
impl ComplianceInfo {
pub fn new() -> Self {
Self::default()
}
pub fn personal_data(mut self, involves: bool) -> Self {
self.involves_personal_data = involves;
self
}
pub fn data_subject(mut self, id: impl Into<String>) -> Self {
self.data_subject_id = Some(id.into());
self
}
pub fn legal_basis(mut self, basis: impl Into<String>) -> Self {
self.legal_basis = Some(basis.into());
self
}
pub fn retention(mut self, category: impl Into<String>) -> Self {
self.retention_category = Some(category.into());
self
}
pub fn special_category(mut self, is_special: bool) -> Self {
self.special_category_data = is_special;
self
}
pub fn cross_border(mut self, transfer: bool) -> Self {
self.cross_border_transfer = transfer;
self
}
pub fn soc2_control(mut self, control: impl Into<String>) -> Self {
self.soc2_control = Some(control.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEvent {
pub id: AuditEventId,
pub timestamp: u64,
pub action: AuditAction,
pub success: bool,
pub severity: AuditSeverity,
#[serde(skip_serializing_if = "Option::is_none")]
pub actor_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub actor_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ip_address: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub metadata: HashMap<String, String>,
#[serde(default)]
pub compliance: ComplianceInfo,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub changes: Option<AuditChanges>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditChanges {
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub before: HashMap<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub after: HashMap<String, serde_json::Value>,
}
impl AuditChanges {
pub fn new() -> Self {
Self {
before: HashMap::new(),
after: HashMap::new(),
}
}
pub fn field(
mut self,
name: impl Into<String>,
before: impl Into<serde_json::Value>,
after: impl Into<serde_json::Value>,
) -> Self {
let name = name.into();
self.before.insert(name.clone(), before.into());
self.after.insert(name, after.into());
self
}
}
impl Default for AuditChanges {
fn default() -> Self {
Self::new()
}
}
impl AuditEvent {
pub fn new(action: AuditAction) -> Self {
let id = generate_event_id();
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
Self {
id,
timestamp,
action,
success: true,
severity: AuditSeverity::Info,
actor_id: None,
actor_type: None,
ip_address: None,
user_agent: None,
resource_type: None,
resource_id: None,
request_id: None,
session_id: None,
metadata: HashMap::new(),
compliance: ComplianceInfo::default(),
error_message: None,
changes: None,
}
}
pub fn success(mut self, success: bool) -> Self {
self.success = success;
if !success {
self.severity = AuditSeverity::Warning;
}
self
}
pub fn severity(mut self, severity: AuditSeverity) -> Self {
self.severity = severity;
self
}
pub fn actor(mut self, actor_id: impl Into<String>) -> Self {
self.actor_id = Some(actor_id.into());
self
}
pub fn actor_type(mut self, actor_type: impl Into<String>) -> Self {
self.actor_type = Some(actor_type.into());
self
}
pub fn ip_address(mut self, ip: IpAddr) -> Self {
self.ip_address = Some(ip.to_string());
self
}
pub fn ip_address_str(mut self, ip: impl Into<String>) -> Self {
self.ip_address = Some(ip.into());
self
}
pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
self.user_agent = Some(ua.into());
self
}
pub fn resource(
mut self,
resource_type: impl Into<String>,
resource_id: impl Into<String>,
) -> Self {
self.resource_type = Some(resource_type.into());
self.resource_id = Some(resource_id.into());
self
}
pub fn resource_type(mut self, resource_type: impl Into<String>) -> Self {
self.resource_type = Some(resource_type.into());
self
}
pub fn resource_id(mut self, resource_id: impl Into<String>) -> Self {
self.resource_id = Some(resource_id.into());
self
}
pub fn request_id(mut self, request_id: impl Into<String>) -> Self {
self.request_id = Some(request_id.into());
self
}
pub fn session_id(mut self, session_id: impl Into<String>) -> Self {
self.session_id = Some(session_id.into());
self
}
pub fn meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
pub fn compliance(mut self, compliance: ComplianceInfo) -> Self {
self.compliance = compliance;
self
}
pub fn error(mut self, message: impl Into<String>) -> Self {
self.error_message = Some(message.into());
self.success = false;
if self.severity == AuditSeverity::Info {
self.severity = AuditSeverity::Warning;
}
self
}
pub fn changes(mut self, changes: AuditChanges) -> Self {
self.changes = Some(changes);
self
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
}
fn generate_event_id() -> String {
use rand::{rngs::OsRng, RngCore};
let mut bytes = [0u8; 16];
OsRng.fill_bytes(&mut bytes);
format!(
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
bytes[0], bytes[1], bytes[2], bytes[3],
bytes[4], bytes[5],
bytes[6], bytes[7],
bytes[8], bytes[9],
bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_audit_event_creation() {
let event = AuditEvent::new(AuditAction::Create)
.resource("users", "user-123")
.actor("admin@example.com")
.success(true);
assert_eq!(event.action, AuditAction::Create);
assert_eq!(event.resource_type, Some("users".to_string()));
assert_eq!(event.resource_id, Some("user-123".to_string()));
assert_eq!(event.actor_id, Some("admin@example.com".to_string()));
assert!(event.success);
assert!(!event.id.is_empty());
assert!(event.timestamp > 0);
}
#[test]
fn test_audit_event_with_compliance() {
let compliance = ComplianceInfo::new()
.personal_data(true)
.data_subject("user-456")
.legal_basis("consent")
.soc2_control("CC6.1");
let event = AuditEvent::new(AuditAction::Update).compliance(compliance);
assert!(event.compliance.involves_personal_data);
assert_eq!(
event.compliance.data_subject_id,
Some("user-456".to_string())
);
assert_eq!(event.compliance.legal_basis, Some("consent".to_string()));
assert_eq!(event.compliance.soc2_control, Some("CC6.1".to_string()));
}
#[test]
fn test_audit_event_with_changes() {
let changes = AuditChanges::new()
.field("email", "old@example.com", "new@example.com")
.field("name", "Old Name", "New Name");
let event = AuditEvent::new(AuditAction::Update).changes(changes);
let c = event.changes.unwrap();
assert_eq!(c.before.get("email").unwrap(), "old@example.com");
assert_eq!(c.after.get("email").unwrap(), "new@example.com");
}
#[test]
fn test_audit_action_relevance() {
assert!(AuditAction::DataExport.is_gdpr_relevant());
assert!(AuditAction::Login.is_security_relevant());
assert!(!AuditAction::Read.is_security_relevant());
}
#[test]
fn test_audit_event_serialization() {
let event = AuditEvent::new(AuditAction::Login)
.actor("user@example.com")
.ip_address("192.168.1.1".parse().unwrap())
.meta("browser", "Chrome");
let json = event.to_json().unwrap();
assert!(json.contains("login"));
assert!(json.contains("user@example.com"));
assert!(json.contains("192.168.1.1"));
}
}
#[cfg(test)]
mod property_tests {
use super::*;
use proptest::prelude::*;
fn audit_action_strategy() -> impl Strategy<Value = AuditAction> {
prop_oneof![
Just(AuditAction::Create),
Just(AuditAction::Read),
Just(AuditAction::Update),
Just(AuditAction::Delete),
Just(AuditAction::Login),
Just(AuditAction::Logout),
Just(AuditAction::LoginFailed),
Just(AuditAction::PermissionGranted),
Just(AuditAction::PermissionRevoked),
Just(AuditAction::DataExport),
Just(AuditAction::DataDeletionRequest),
Just(AuditAction::ConfigChange),
Just(AuditAction::ApiKeyCreated),
Just(AuditAction::ApiKeyRevoked),
Just(AuditAction::PasswordChange),
Just(AuditAction::MfaChange),
"[a-z_]{3,20}".prop_map(AuditAction::Custom),
]
}
fn actor_id_strategy() -> impl Strategy<Value = String> {
"[a-z0-9_.-]{3,50}@[a-z]{3,10}\\.[a-z]{2,4}"
}
fn resource_type_strategy() -> impl Strategy<Value = String> {
prop_oneof![
Just("users".to_string()),
Just("orders".to_string()),
Just("products".to_string()),
Just("invoices".to_string()),
Just("sessions".to_string()),
]
}
fn resource_id_strategy() -> impl Strategy<Value = String> {
"[a-zA-Z0-9-]{10,36}"
}
fn ip_address_strategy() -> impl Strategy<Value = IpAddr> {
prop_oneof![
(0u8..255, 0u8..255, 0u8..255, 0u8..255).prop_map(|(a, b, c, d)| format!(
"{}.{}.{}.{}",
a, b, c, d
)
.parse::<IpAddr>()
.unwrap()),
]
}
fn compliance_strategy() -> impl Strategy<Value = ComplianceInfo> {
(
proptest::bool::ANY, proptest::option::of("[a-z0-9-]{10,20}"), proptest::option::of(prop_oneof![
Just("consent".to_string()),
Just("contract".to_string()),
Just("legitimate_interest".to_string()),
]),
proptest::option::of(prop_oneof![
Just("short_term".to_string()),
Just("long_term".to_string()),
Just("permanent".to_string()),
]),
proptest::bool::ANY, proptest::bool::ANY, proptest::option::of("[A-Z]{2,4}[0-9.]{1,5}"), )
.prop_map(
|(
personal_data,
subject_id,
legal_basis,
retention,
special,
cross_border,
soc2,
)| {
let mut info = ComplianceInfo::new().personal_data(personal_data);
if let Some(id) = subject_id {
info = info.data_subject(id);
}
if let Some(basis) = legal_basis {
info = info.legal_basis(basis);
}
if let Some(ret) = retention {
info = info.retention(ret);
}
info = info.special_category(special).cross_border(cross_border);
if let Some(ctrl) = soc2 {
info = info.soc2_control(ctrl);
}
info
},
)
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_event_has_required_fields(action in audit_action_strategy()) {
let event = AuditEvent::new(action.clone());
prop_assert!(!event.id.is_empty());
prop_assert!(event.id.contains('-')); prop_assert_eq!(event.id.split('-').count(), 5);
prop_assert!(event.timestamp > 0);
prop_assert!(event.timestamp < u64::MAX / 2);
prop_assert_eq!(event.action, action);
}
#[test]
fn prop_event_ids_unique(action in audit_action_strategy()) {
let event1 = AuditEvent::new(action.clone());
let event2 = AuditEvent::new(action);
prop_assert_ne!(event1.id, event2.id);
}
#[test]
fn prop_serialization_roundtrip(
action in audit_action_strategy(),
actor in actor_id_strategy(),
resource_type in resource_type_strategy(),
resource_id in resource_id_strategy(),
success in proptest::bool::ANY,
) {
let event = AuditEvent::new(action)
.actor(actor.clone())
.resource(resource_type.clone(), resource_id.clone())
.success(success);
let json = event.to_json().unwrap();
let deserialized: AuditEvent = serde_json::from_str(&json).unwrap();
prop_assert_eq!(deserialized.id, event.id);
prop_assert_eq!(deserialized.timestamp, event.timestamp);
prop_assert_eq!(deserialized.action, event.action);
prop_assert_eq!(deserialized.success, event.success);
prop_assert_eq!(deserialized.actor_id, event.actor_id);
prop_assert_eq!(deserialized.resource_type, event.resource_type);
prop_assert_eq!(deserialized.resource_id, event.resource_id);
}
#[test]
fn prop_compliance_serialization(
action in audit_action_strategy(),
compliance in compliance_strategy(),
) {
let event = AuditEvent::new(action).compliance(compliance.clone());
let json = event.to_json().unwrap();
let deserialized: AuditEvent = serde_json::from_str(&json).unwrap();
prop_assert_eq!(
deserialized.compliance.involves_personal_data,
compliance.involves_personal_data
);
prop_assert_eq!(
deserialized.compliance.data_subject_id,
compliance.data_subject_id
);
prop_assert_eq!(
deserialized.compliance.legal_basis,
compliance.legal_basis
);
prop_assert_eq!(
deserialized.compliance.retention_category,
compliance.retention_category
);
prop_assert_eq!(
deserialized.compliance.special_category_data,
compliance.special_category_data
);
prop_assert_eq!(
deserialized.compliance.cross_border_transfer,
compliance.cross_border_transfer
);
prop_assert_eq!(
deserialized.compliance.soc2_control,
compliance.soc2_control
);
}
#[test]
fn prop_ip_address_field(
action in audit_action_strategy(),
ip in ip_address_strategy(),
) {
let event = AuditEvent::new(action).ip_address(ip);
prop_assert!(event.ip_address.is_some());
let ip_str = event.ip_address.as_ref().unwrap();
prop_assert!(ip_str.parse::<IpAddr>().is_ok());
}
#[test]
fn prop_metadata_preservation(
action in audit_action_strategy(),
key in "[a-z_]{3,20}",
value in "[a-zA-Z0-9 ]{1,50}",
) {
let event = AuditEvent::new(action).meta(key.clone(), value.clone());
prop_assert!(event.metadata.contains_key(&key));
prop_assert_eq!(event.metadata.get(&key), Some(&value));
let json = event.to_json().unwrap();
let deserialized: AuditEvent = serde_json::from_str(&json).unwrap();
prop_assert_eq!(deserialized.metadata.get(&key), Some(&value));
}
#[test]
fn prop_failed_action_flags(
action in audit_action_strategy(),
error_msg in "[a-zA-Z0-9 ]{10,100}",
) {
let event = AuditEvent::new(action).error(error_msg.clone());
prop_assert!(!event.success);
prop_assert_eq!(event.error_message, Some(error_msg));
prop_assert!(event.severity >= AuditSeverity::Warning);
}
#[test]
fn prop_changes_preservation(
action in audit_action_strategy(),
field_name in "[a-z_]{3,15}",
old_value in "[a-zA-Z0-9]{5,20}",
new_value in "[a-zA-Z0-9]{5,20}",
) {
let changes = AuditChanges::new()
.field(field_name.clone(), old_value.clone(), new_value.clone());
let event = AuditEvent::new(action).changes(changes);
prop_assert!(event.changes.is_some());
let c = event.changes.as_ref().unwrap();
prop_assert_eq!(c.before.get(&field_name).unwrap(), &serde_json::json!(old_value));
prop_assert_eq!(c.after.get(&field_name).unwrap(), &serde_json::json!(new_value));
let json = event.to_json().unwrap();
let deserialized: AuditEvent = serde_json::from_str(&json).unwrap();
let dc = deserialized.changes.unwrap();
prop_assert_eq!(dc.before.get(&field_name).unwrap(), &serde_json::json!(old_value));
prop_assert_eq!(dc.after.get(&field_name).unwrap(), &serde_json::json!(new_value));
}
#[test]
fn prop_gdpr_relevance(action in audit_action_strategy()) {
let is_gdpr = action.is_gdpr_relevant();
match action {
AuditAction::Create
| AuditAction::Update
| AuditAction::Delete
| AuditAction::DataExport
| AuditAction::DataDeletionRequest
| AuditAction::Login
| AuditAction::PermissionGranted
| AuditAction::PermissionRevoked => {
prop_assert!(is_gdpr);
}
_ => {
}
}
}
#[test]
fn prop_soc2_relevance(action in audit_action_strategy()) {
let is_soc2 = action.is_security_relevant();
match action {
AuditAction::Login
| AuditAction::LoginFailed
| AuditAction::Logout
| AuditAction::PermissionGranted
| AuditAction::PermissionRevoked
| AuditAction::ApiKeyCreated
| AuditAction::ApiKeyRevoked
| AuditAction::PasswordChange
| AuditAction::MfaChange
| AuditAction::ConfigChange => {
prop_assert!(is_soc2);
}
_ => {
prop_assert!(!is_soc2);
}
}
}
#[test]
fn prop_timestamps_reasonable(_seed in 0u32..100) {
use std::time::{Duration, SystemTime, UNIX_EPOCH};
let event1 = AuditEvent::new(AuditAction::Create);
std::thread::sleep(Duration::from_millis(1));
let event2 = AuditEvent::new(AuditAction::Update);
prop_assert!(event2.timestamp >= event1.timestamp);
let now_millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
prop_assert!(event1.timestamp <= now_millis);
prop_assert!(event2.timestamp <= now_millis);
let one_hour_ago = now_millis - (60 * 60 * 1000);
prop_assert!(event1.timestamp >= one_hour_ago);
prop_assert!(event2.timestamp >= one_hour_ago);
}
}
}