use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Mutex;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditEvent {
pub id: String,
pub created_at: u64,
pub action: AuditAction,
pub user_id: Option<String>,
pub actor_id: Option<String>,
pub tenant_id: Option<String>,
pub ip: Option<String>,
pub user_agent: Option<String>,
pub success: bool,
pub reason: Option<String>,
pub metadata: HashMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditAction {
SignIn,
SignOut,
SignInFailed,
SignUp,
PasswordChange,
PasswordReset,
EmailChange,
TotpEnroll,
TotpDisable,
TotpBackupCodesRegenerate,
PasskeyRegister,
PasskeyRevoke,
ApiKeyCreate,
ApiKeyRevoke,
OauthLink,
OauthUnlink,
OrgCreate,
OrgDelete,
OrgInviteSend,
OrgInviteAccept,
OrgMemberRemove,
OrgRoleChange,
AccountDelete,
Custom(String),
}
impl AuditAction {
pub fn as_str(&self) -> &str {
match self {
Self::SignIn => "sign_in",
Self::SignOut => "sign_out",
Self::SignInFailed => "sign_in_failed",
Self::SignUp => "sign_up",
Self::PasswordChange => "password_change",
Self::PasswordReset => "password_reset",
Self::EmailChange => "email_change",
Self::TotpEnroll => "totp_enroll",
Self::TotpDisable => "totp_disable",
Self::TotpBackupCodesRegenerate => "totp_backup_codes_regenerate",
Self::PasskeyRegister => "passkey_register",
Self::PasskeyRevoke => "passkey_revoke",
Self::ApiKeyCreate => "api_key_create",
Self::ApiKeyRevoke => "api_key_revoke",
Self::OauthLink => "oauth_link",
Self::OauthUnlink => "oauth_unlink",
Self::OrgCreate => "org_create",
Self::OrgDelete => "org_delete",
Self::OrgInviteSend => "org_invite_send",
Self::OrgInviteAccept => "org_invite_accept",
Self::OrgMemberRemove => "org_member_remove",
Self::OrgRoleChange => "org_role_change",
Self::AccountDelete => "account_delete",
Self::Custom(s) => s,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct AuditEventBuilder {
pub action: Option<AuditAction>,
pub user_id: Option<String>,
pub actor_id: Option<String>,
pub tenant_id: Option<String>,
pub ip: Option<String>,
pub user_agent: Option<String>,
pub success: bool,
pub reason: Option<String>,
pub metadata: HashMap<String, String>,
}
impl AuditEventBuilder {
pub fn new(action: AuditAction) -> Self {
Self {
action: Some(action),
success: true,
..Default::default()
}
}
pub fn user(mut self, user_id: impl Into<String>) -> Self {
self.user_id = Some(user_id.into());
self
}
pub fn actor(mut self, actor_id: impl Into<String>) -> Self {
self.actor_id = Some(actor_id.into());
self
}
pub fn tenant(mut self, tenant_id: impl Into<String>) -> Self {
self.tenant_id = Some(tenant_id.into());
self
}
pub fn ip(mut self, ip: impl Into<String>) -> Self {
self.ip = Some(ip.into());
self
}
pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
let s = ua.into();
let truncated: String = s.chars().take(256).collect();
self.user_agent = Some(truncated);
self
}
pub fn failed(mut self, reason: impl Into<String>) -> Self {
self.success = false;
self.reason = Some(reason.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 build(self) -> AuditEvent {
let action = self.action.unwrap_or(AuditAction::Custom("unknown".into()));
AuditEvent {
id: format!("evt_{}", random_token(20)),
created_at: now_secs(),
action,
user_id: self.user_id,
actor_id: self.actor_id,
tenant_id: self.tenant_id,
ip: self.ip,
user_agent: self.user_agent,
success: self.success,
reason: self.reason,
metadata: self.metadata,
}
}
}
pub trait AuditBackend: Send + Sync {
fn append(&self, event: &AuditEvent);
fn find_for_tenant(&self, tenant_id: &str, limit: usize) -> Vec<AuditEvent>;
fn find_for_user(&self, user_id: &str, limit: usize) -> Vec<AuditEvent>;
}
pub struct InMemoryAuditBackend {
events: Mutex<Vec<AuditEvent>>,
}
impl Default for InMemoryAuditBackend {
fn default() -> Self {
Self {
events: Mutex::new(Vec::new()),
}
}
}
impl AuditBackend for InMemoryAuditBackend {
fn append(&self, event: &AuditEvent) {
self.events.lock().unwrap().push(event.clone());
}
fn find_for_tenant(&self, tenant_id: &str, limit: usize) -> Vec<AuditEvent> {
let g = self.events.lock().unwrap();
let mut out: Vec<AuditEvent> = g
.iter()
.filter(|e| e.tenant_id.as_deref() == Some(tenant_id))
.cloned()
.collect();
out.sort_by(|a, b| b.created_at.cmp(&a.created_at));
out.truncate(limit);
out
}
fn find_for_user(&self, user_id: &str, limit: usize) -> Vec<AuditEvent> {
let g = self.events.lock().unwrap();
let mut out: Vec<AuditEvent> = g
.iter()
.filter(|e| {
e.user_id.as_deref() == Some(user_id) || e.actor_id.as_deref() == Some(user_id)
})
.cloned()
.collect();
out.sort_by(|a, b| b.created_at.cmp(&a.created_at));
out.truncate(limit);
out
}
}
pub struct AuditStore {
backend: Box<dyn AuditBackend>,
}
impl Default for AuditStore {
fn default() -> Self {
Self::new()
}
}
impl AuditStore {
pub fn new() -> Self {
Self::with_backend(Box::new(InMemoryAuditBackend::default()))
}
pub fn with_backend(backend: Box<dyn AuditBackend>) -> Self {
Self { backend }
}
pub fn log(&self, event: AuditEvent) {
self.backend.append(&event);
}
pub fn find_for_tenant(&self, tenant_id: &str, limit: usize) -> Vec<AuditEvent> {
self.backend.find_for_tenant(tenant_id, limit)
}
pub fn find_for_user(&self, user_id: &str, limit: usize) -> Vec<AuditEvent> {
self.backend.find_for_user(user_id, limit)
}
}
fn random_token(n_bytes: usize) -> String {
use rand::RngCore;
let mut bytes = vec![0u8; n_bytes];
rand::thread_rng().fill_bytes(&mut bytes);
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
URL_SAFE_NO_PAD.encode(bytes)
}
fn now_secs() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_default_success_true() {
let e = AuditEventBuilder::new(AuditAction::SignIn).build();
assert!(e.success);
assert!(e.reason.is_none());
}
#[test]
fn builder_failed_flips_success_and_records_reason() {
let e = AuditEventBuilder::new(AuditAction::SignInFailed)
.failed("WRONG_PASSWORD")
.build();
assert!(!e.success);
assert_eq!(e.reason.as_deref(), Some("WRONG_PASSWORD"));
}
#[test]
fn user_agent_truncated_to_256_chars() {
let huge_ua = "X".repeat(2000);
let e = AuditEventBuilder::new(AuditAction::SignIn)
.user_agent(huge_ua)
.build();
assert_eq!(e.user_agent.as_ref().unwrap().chars().count(), 256);
}
#[test]
fn tenant_query_isolates_cross_tenant() {
let s = AuditStore::new();
s.log(
AuditEventBuilder::new(AuditAction::SignIn)
.tenant("tenant_a")
.user("u1")
.build(),
);
s.log(
AuditEventBuilder::new(AuditAction::SignIn)
.tenant("tenant_b")
.user("u2")
.build(),
);
s.log(
AuditEventBuilder::new(AuditAction::SignIn)
.user("u3")
.build(),
);
let a = s.find_for_tenant("tenant_a", 100);
assert_eq!(a.len(), 1);
assert_eq!(a[0].user_id.as_deref(), Some("u1"));
let b = s.find_for_tenant("tenant_b", 100);
assert_eq!(b.len(), 1);
assert_eq!(b[0].user_id.as_deref(), Some("u2"));
}
#[test]
fn user_query_returns_subject_and_actor_events() {
let s = AuditStore::new();
s.log(
AuditEventBuilder::new(AuditAction::AccountDelete)
.user("alice")
.actor("admin")
.build(),
);
assert_eq!(s.find_for_user("alice", 100).len(), 1);
assert_eq!(s.find_for_user("admin", 100).len(), 1);
assert_eq!(s.find_for_user("bob", 100).len(), 0);
}
#[test]
fn newest_first_ordering() {
let s = AuditStore::new();
s.backend.append(&AuditEvent {
id: "evt_a".into(),
created_at: 100,
action: AuditAction::SignIn,
user_id: Some("u".into()),
actor_id: None,
tenant_id: Some("t".into()),
ip: None,
user_agent: None,
success: true,
reason: None,
metadata: HashMap::new(),
});
s.backend.append(&AuditEvent {
id: "evt_b".into(),
created_at: 200,
action: AuditAction::SignOut,
user_id: Some("u".into()),
actor_id: None,
tenant_id: Some("t".into()),
ip: None,
user_agent: None,
success: true,
reason: None,
metadata: HashMap::new(),
});
let out = s.find_for_tenant("t", 10);
assert_eq!(out[0].id, "evt_b"); assert_eq!(out[1].id, "evt_a");
}
#[test]
fn limit_caps_results() {
let s = AuditStore::new();
for i in 0..50 {
s.log(
AuditEventBuilder::new(AuditAction::SignIn)
.tenant("t")
.user(format!("u_{i}"))
.build(),
);
}
assert_eq!(s.find_for_tenant("t", 10).len(), 10);
}
#[test]
fn metadata_preserves_string_only_values() {
let e = AuditEventBuilder::new(AuditAction::SignIn)
.meta("method", "oauth:google")
.meta("device", "iPhone")
.build();
assert_eq!(
e.metadata.get("method").map(|s| s.as_str()),
Some("oauth:google")
);
assert_eq!(e.metadata.len(), 2);
}
#[test]
fn custom_action_serializes_verbatim() {
let e = AuditEventBuilder::new(AuditAction::Custom(
"pylon.cloud.fly_machine_provision".into(),
))
.build();
assert_eq!(e.action.as_str(), "pylon.cloud.fly_machine_provision");
}
#[test]
fn no_tenant_event_invisible_to_tenant_query() {
let s = AuditStore::new();
s.log(AuditEventBuilder::new(AuditAction::Custom("system.tick".into())).build());
assert_eq!(s.find_for_tenant("tenant_a", 100).len(), 0);
}
}