use serde::{Deserialize, Serialize};
use std::time::SystemTime;
use tracing::{error, info, warn};
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct AuditLogger {
service_name: String,
include_ip: bool,
hash_identifiers: bool,
}
impl AuditLogger {
pub fn new(service_name: impl Into<String>) -> Self {
Self {
service_name: service_name.into(),
include_ip: true,
hash_identifiers: false,
}
}
pub fn privacy_focused(service_name: impl Into<String>) -> Self {
Self {
service_name: service_name.into(),
include_ip: false,
hash_identifiers: true,
}
}
pub fn with_ip_logging(mut self, include: bool) -> Self {
self.include_ip = include;
self
}
pub fn with_identifier_hashing(mut self, hash: bool) -> Self {
self.hash_identifiers = hash;
self
}
pub fn log(&self, event: AuthEvent) {
let record = AuditRecord {
id: Uuid::now_v7(),
timestamp: SystemTime::now(),
service: self.service_name.clone(),
event: self.maybe_redact(event),
};
match &record.event {
AuthEvent::LoginSuccess {
user_id, provider, ..
} => {
info!(
target: "audit::auth",
audit_id = %record.id,
event_type = "login_success",
user_id = %self.maybe_hash(user_id),
provider = %provider,
service = %self.service_name,
"Authentication successful"
);
}
AuthEvent::LoginFailure {
attempted_user,
provider,
reason,
ip_address,
..
} => {
warn!(
target: "audit::auth",
audit_id = %record.id,
event_type = "login_failure",
attempted_user = ?attempted_user.as_ref().map(|u| self.maybe_hash(u)),
provider = %provider,
reason = %reason,
ip_address = ?self.maybe_include_ip(ip_address.as_deref()),
service = %self.service_name,
"Authentication failed"
);
}
AuthEvent::LoginAttempt {
user_identifier,
provider,
..
} => {
info!(
target: "audit::auth",
audit_id = %record.id,
event_type = "login_attempt",
user_identifier = %self.maybe_hash(user_identifier),
provider = %provider,
service = %self.service_name,
"Login attempt initiated"
);
}
AuthEvent::TokenIssued {
user_id,
token_type,
expires_in,
..
} => {
info!(
target: "audit::auth",
audit_id = %record.id,
event_type = "token_issued",
user_id = %self.maybe_hash(user_id),
token_type = %token_type,
expires_in_secs = ?expires_in,
service = %self.service_name,
"Token issued"
);
}
AuthEvent::TokenRefreshed {
user_id, token_id, ..
} => {
info!(
target: "audit::auth",
audit_id = %record.id,
event_type = "token_refreshed",
user_id = %self.maybe_hash(user_id),
token_id = %self.maybe_hash(token_id),
service = %self.service_name,
"Token refreshed"
);
}
AuthEvent::TokenRevoked {
user_id,
token_id,
reason,
..
} => {
info!(
target: "audit::auth",
audit_id = %record.id,
event_type = "token_revoked",
user_id = %self.maybe_hash(user_id),
token_id = %self.maybe_hash(token_id),
reason = %reason,
service = %self.service_name,
"Token revoked"
);
}
AuthEvent::TokenExpired { user_id, token_id } => {
info!(
target: "audit::auth",
audit_id = %record.id,
event_type = "token_expired",
user_id = %self.maybe_hash(user_id),
token_id = %self.maybe_hash(token_id),
service = %self.service_name,
"Token expired"
);
}
AuthEvent::PermissionDenied {
user_id,
resource,
action,
required_permission,
} => {
warn!(
target: "audit::auth",
audit_id = %record.id,
event_type = "permission_denied",
user_id = %self.maybe_hash(user_id),
resource = %resource,
action = %action,
required_permission = %required_permission,
service = %self.service_name,
"Permission denied"
);
}
AuthEvent::SessionCreated {
user_id,
session_id,
..
} => {
info!(
target: "audit::auth",
audit_id = %record.id,
event_type = "session_created",
user_id = %self.maybe_hash(user_id),
session_id = %self.maybe_hash(session_id),
service = %self.service_name,
"Session created"
);
}
AuthEvent::SessionTerminated {
user_id,
session_id,
reason,
} => {
info!(
target: "audit::auth",
audit_id = %record.id,
event_type = "session_terminated",
user_id = %self.maybe_hash(user_id),
session_id = %self.maybe_hash(session_id),
reason = %reason,
service = %self.service_name,
"Session terminated"
);
}
AuthEvent::RateLimited {
identifier,
endpoint,
limit,
window_secs,
} => {
warn!(
target: "audit::auth",
audit_id = %record.id,
event_type = "rate_limited",
identifier = %self.maybe_hash(identifier),
endpoint = %endpoint,
limit = %limit,
window_secs = %window_secs,
service = %self.service_name,
"Rate limit exceeded"
);
}
AuthEvent::SuspiciousActivity {
user_id,
activity_type,
details,
severity,
} => {
error!(
target: "audit::auth",
audit_id = %record.id,
event_type = "suspicious_activity",
user_id = ?user_id.as_ref().map(|u| self.maybe_hash(u)),
activity_type = %activity_type,
details = %details,
severity = %severity,
service = %self.service_name,
"Suspicious activity detected"
);
}
}
}
pub fn log_with_correlation(&self, event: AuthEvent, correlation_id: &str) {
let record = AuditRecord {
id: Uuid::now_v7(),
timestamp: SystemTime::now(),
service: self.service_name.clone(),
event: self.maybe_redact(event),
};
let span = tracing::info_span!(
"audit",
correlation_id = %correlation_id,
audit_id = %record.id
);
let _guard = span.enter();
self.log(record.event);
}
fn maybe_hash(&self, value: &str) -> String {
if self.hash_identifiers {
let hash = blake3::hash(value.as_bytes());
format!("sha3:{}", &hash.to_hex()[..16])
} else {
value.to_string()
}
}
fn maybe_include_ip(&self, ip: Option<&str>) -> Option<String> {
if self.include_ip {
ip.map(String::from)
} else {
ip.map(|_| "[REDACTED]".to_string())
}
}
fn maybe_redact(&self, event: AuthEvent) -> AuthEvent {
if !self.include_ip {
match event {
AuthEvent::LoginSuccess {
user_id,
provider,
user_agent,
..
} => AuthEvent::LoginSuccess {
user_id,
provider,
ip_address: Some("[REDACTED]".to_string()),
user_agent,
},
AuthEvent::LoginFailure {
attempted_user,
provider,
reason,
user_agent,
..
} => AuthEvent::LoginFailure {
attempted_user,
provider,
reason,
ip_address: Some("[REDACTED]".to_string()),
user_agent,
},
AuthEvent::SessionCreated {
user_id,
session_id,
user_agent,
..
} => AuthEvent::SessionCreated {
user_id,
session_id,
ip_address: Some("[REDACTED]".to_string()),
user_agent,
},
other => other,
}
} else {
event
}
}
}
impl Default for AuditLogger {
fn default() -> Self {
Self::new("turbomcp")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AuthEvent {
LoginAttempt {
user_identifier: String,
provider: String,
ip_address: Option<String>,
user_agent: Option<String>,
},
LoginSuccess {
user_id: String,
provider: String,
ip_address: Option<String>,
user_agent: Option<String>,
},
LoginFailure {
attempted_user: Option<String>,
provider: String,
reason: String,
ip_address: Option<String>,
user_agent: Option<String>,
},
TokenIssued {
user_id: String,
token_type: String,
expires_in: Option<u64>,
scopes: Vec<String>,
},
TokenRefreshed {
user_id: String,
token_id: String,
new_expires_in: Option<u64>,
},
TokenRevoked {
user_id: String,
token_id: String,
reason: String,
revoked_by: Option<String>,
},
TokenExpired {
user_id: String,
token_id: String,
},
PermissionDenied {
user_id: String,
resource: String,
action: String,
required_permission: String,
},
SessionCreated {
user_id: String,
session_id: String,
ip_address: Option<String>,
user_agent: Option<String>,
},
SessionTerminated {
user_id: String,
session_id: String,
reason: String,
},
RateLimited {
identifier: String,
endpoint: String,
limit: u32,
window_secs: u32,
},
SuspiciousActivity {
user_id: Option<String>,
activity_type: String,
details: String,
severity: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditRecord {
pub id: Uuid,
#[serde(with = "system_time_serde")]
pub timestamp: SystemTime,
pub service: String,
pub event: AuthEvent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EventOutcome {
Success,
Failure,
Denied,
RateLimited,
}
mod system_time_serde {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
pub fn serialize<S>(time: &SystemTime, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let duration = time.duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO);
duration.as_secs().serialize(serializer)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<SystemTime, D::Error>
where
D: Deserializer<'de>,
{
let secs = u64::deserialize(deserializer)?;
Ok(UNIX_EPOCH + Duration::from_secs(secs))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_audit_logger_creation() {
let logger = AuditLogger::new("test-service");
assert_eq!(logger.service_name, "test-service");
assert!(logger.include_ip);
assert!(!logger.hash_identifiers);
}
#[test]
fn test_privacy_focused_logger() {
let logger = AuditLogger::privacy_focused("secure-service");
assert!(!logger.include_ip);
assert!(logger.hash_identifiers);
}
#[test]
fn test_identifier_hashing() {
let logger = AuditLogger::new("test").with_identifier_hashing(true);
let hashed = logger.maybe_hash("user123");
assert!(hashed.starts_with("sha3:"));
assert_eq!(hashed.len(), 21); }
#[test]
fn test_ip_redaction() {
let logger = AuditLogger::new("test").with_ip_logging(false);
let ip = logger.maybe_include_ip(Some("192.168.1.1"));
assert_eq!(ip, Some("[REDACTED]".to_string()));
}
#[test]
fn test_event_serialization() {
let event = AuthEvent::LoginSuccess {
user_id: "user123".to_string(),
provider: "oauth2".to_string(),
ip_address: Some("10.0.0.1".to_string()),
user_agent: Some("Mozilla/5.0".to_string()),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"login_success\""));
assert!(json.contains("\"user_id\":\"user123\""));
}
#[test]
fn test_audit_record_serialization() {
let record = AuditRecord {
id: Uuid::nil(),
timestamp: std::time::UNIX_EPOCH,
service: "test".to_string(),
event: AuthEvent::TokenExpired {
user_id: "user".to_string(),
token_id: "token".to_string(),
},
};
let json = serde_json::to_string(&record).unwrap();
assert!(json.contains("\"service\":\"test\""));
assert!(json.contains("\"timestamp\":0"));
}
}