1use serde::{Deserialize, Serialize};
23use std::collections::HashMap;
24use std::sync::Mutex;
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct AuditEvent {
30 pub id: String,
32 pub created_at: u64,
34 pub action: AuditAction,
38 pub user_id: Option<String>,
42 pub actor_id: Option<String>,
46 pub tenant_id: Option<String>,
49 pub ip: Option<String>,
52 pub user_agent: Option<String>,
54 pub success: bool,
57 pub reason: Option<String>,
60 pub metadata: HashMap<String, String>,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum AuditAction {
68 SignIn,
72 SignOut,
73 SignInFailed,
75 SignUp,
77 PasswordChange,
79 PasswordReset,
81 EmailChange,
82 TotpEnroll,
84 TotpDisable,
86 TotpBackupCodesRegenerate,
88 PasskeyRegister,
89 PasskeyRevoke,
90 ApiKeyCreate,
91 ApiKeyRevoke,
92 OauthLink,
93 OauthUnlink,
94 OrgCreate,
95 OrgDelete,
96 OrgInviteSend,
97 OrgInviteAccept,
98 OrgMemberRemove,
99 OrgRoleChange,
100 AccountDelete,
101 Custom(String),
104}
105
106impl AuditAction {
107 pub fn as_str(&self) -> &str {
108 match self {
109 Self::SignIn => "sign_in",
110 Self::SignOut => "sign_out",
111 Self::SignInFailed => "sign_in_failed",
112 Self::SignUp => "sign_up",
113 Self::PasswordChange => "password_change",
114 Self::PasswordReset => "password_reset",
115 Self::EmailChange => "email_change",
116 Self::TotpEnroll => "totp_enroll",
117 Self::TotpDisable => "totp_disable",
118 Self::TotpBackupCodesRegenerate => "totp_backup_codes_regenerate",
119 Self::PasskeyRegister => "passkey_register",
120 Self::PasskeyRevoke => "passkey_revoke",
121 Self::ApiKeyCreate => "api_key_create",
122 Self::ApiKeyRevoke => "api_key_revoke",
123 Self::OauthLink => "oauth_link",
124 Self::OauthUnlink => "oauth_unlink",
125 Self::OrgCreate => "org_create",
126 Self::OrgDelete => "org_delete",
127 Self::OrgInviteSend => "org_invite_send",
128 Self::OrgInviteAccept => "org_invite_accept",
129 Self::OrgMemberRemove => "org_member_remove",
130 Self::OrgRoleChange => "org_role_change",
131 Self::AccountDelete => "account_delete",
132 Self::Custom(s) => s,
133 }
134 }
135}
136
137#[derive(Debug, Clone, Default)]
140pub struct AuditEventBuilder {
141 pub action: Option<AuditAction>,
142 pub user_id: Option<String>,
143 pub actor_id: Option<String>,
144 pub tenant_id: Option<String>,
145 pub ip: Option<String>,
146 pub user_agent: Option<String>,
147 pub success: bool,
148 pub reason: Option<String>,
149 pub metadata: HashMap<String, String>,
150}
151
152impl AuditEventBuilder {
153 pub fn new(action: AuditAction) -> Self {
154 Self {
155 action: Some(action),
156 success: true,
157 ..Default::default()
158 }
159 }
160 pub fn user(mut self, user_id: impl Into<String>) -> Self {
161 self.user_id = Some(user_id.into());
162 self
163 }
164 pub fn actor(mut self, actor_id: impl Into<String>) -> Self {
165 self.actor_id = Some(actor_id.into());
166 self
167 }
168 pub fn tenant(mut self, tenant_id: impl Into<String>) -> Self {
169 self.tenant_id = Some(tenant_id.into());
170 self
171 }
172 pub fn ip(mut self, ip: impl Into<String>) -> Self {
173 self.ip = Some(ip.into());
174 self
175 }
176 pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
177 let s = ua.into();
178 let truncated: String = s.chars().take(256).collect();
182 self.user_agent = Some(truncated);
183 self
184 }
185 pub fn failed(mut self, reason: impl Into<String>) -> Self {
186 self.success = false;
187 self.reason = Some(reason.into());
188 self
189 }
190 pub fn meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
191 self.metadata.insert(key.into(), value.into());
192 self
193 }
194 pub fn build(self) -> AuditEvent {
195 let action = self.action.unwrap_or(AuditAction::Custom("unknown".into()));
196 AuditEvent {
197 id: format!("evt_{}", random_token(20)),
198 created_at: now_secs(),
199 action,
200 user_id: self.user_id,
201 actor_id: self.actor_id,
202 tenant_id: self.tenant_id,
203 ip: self.ip,
204 user_agent: self.user_agent,
205 success: self.success,
206 reason: self.reason,
207 metadata: self.metadata,
208 }
209 }
210}
211
212pub trait AuditBackend: Send + Sync {
213 fn append(&self, event: &AuditEvent);
214 fn find_for_tenant(&self, tenant_id: &str, limit: usize) -> Vec<AuditEvent>;
217 fn find_for_user(&self, user_id: &str, limit: usize) -> Vec<AuditEvent>;
220}
221
222pub struct InMemoryAuditBackend {
223 events: Mutex<Vec<AuditEvent>>,
224}
225
226impl Default for InMemoryAuditBackend {
227 fn default() -> Self {
228 Self {
229 events: Mutex::new(Vec::new()),
230 }
231 }
232}
233
234impl AuditBackend for InMemoryAuditBackend {
235 fn append(&self, event: &AuditEvent) {
236 self.events.lock().unwrap().push(event.clone());
237 }
238 fn find_for_tenant(&self, tenant_id: &str, limit: usize) -> Vec<AuditEvent> {
239 let g = self.events.lock().unwrap();
240 let mut out: Vec<AuditEvent> = g
241 .iter()
242 .filter(|e| e.tenant_id.as_deref() == Some(tenant_id))
243 .cloned()
244 .collect();
245 out.sort_by(|a, b| b.created_at.cmp(&a.created_at));
246 out.truncate(limit);
247 out
248 }
249 fn find_for_user(&self, user_id: &str, limit: usize) -> Vec<AuditEvent> {
250 let g = self.events.lock().unwrap();
251 let mut out: Vec<AuditEvent> = g
252 .iter()
253 .filter(|e| {
254 e.user_id.as_deref() == Some(user_id) || e.actor_id.as_deref() == Some(user_id)
255 })
256 .cloned()
257 .collect();
258 out.sort_by(|a, b| b.created_at.cmp(&a.created_at));
259 out.truncate(limit);
260 out
261 }
262}
263
264pub struct AuditStore {
265 backend: Box<dyn AuditBackend>,
266}
267
268impl Default for AuditStore {
269 fn default() -> Self {
270 Self::new()
271 }
272}
273
274impl AuditStore {
275 pub fn new() -> Self {
276 Self::with_backend(Box::new(InMemoryAuditBackend::default()))
277 }
278 pub fn with_backend(backend: Box<dyn AuditBackend>) -> Self {
279 Self { backend }
280 }
281
282 pub fn log(&self, event: AuditEvent) {
285 self.backend.append(&event);
286 }
287
288 pub fn find_for_tenant(&self, tenant_id: &str, limit: usize) -> Vec<AuditEvent> {
289 self.backend.find_for_tenant(tenant_id, limit)
290 }
291 pub fn find_for_user(&self, user_id: &str, limit: usize) -> Vec<AuditEvent> {
292 self.backend.find_for_user(user_id, limit)
293 }
294}
295
296fn random_token(n_bytes: usize) -> String {
297 use rand::RngCore;
298 let mut bytes = vec![0u8; n_bytes];
299 rand::thread_rng().fill_bytes(&mut bytes);
300 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
301 URL_SAFE_NO_PAD.encode(bytes)
302}
303
304fn now_secs() -> u64 {
305 use std::time::{SystemTime, UNIX_EPOCH};
306 SystemTime::now()
307 .duration_since(UNIX_EPOCH)
308 .unwrap_or_default()
309 .as_secs()
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 #[test]
317 fn builder_default_success_true() {
318 let e = AuditEventBuilder::new(AuditAction::SignIn).build();
319 assert!(e.success);
320 assert!(e.reason.is_none());
321 }
322
323 #[test]
324 fn builder_failed_flips_success_and_records_reason() {
325 let e = AuditEventBuilder::new(AuditAction::SignInFailed)
326 .failed("WRONG_PASSWORD")
327 .build();
328 assert!(!e.success);
329 assert_eq!(e.reason.as_deref(), Some("WRONG_PASSWORD"));
330 }
331
332 #[test]
333 fn user_agent_truncated_to_256_chars() {
334 let huge_ua = "X".repeat(2000);
335 let e = AuditEventBuilder::new(AuditAction::SignIn)
336 .user_agent(huge_ua)
337 .build();
338 assert_eq!(e.user_agent.as_ref().unwrap().chars().count(), 256);
339 }
340
341 #[test]
342 fn tenant_query_isolates_cross_tenant() {
343 let s = AuditStore::new();
346 s.log(
347 AuditEventBuilder::new(AuditAction::SignIn)
348 .tenant("tenant_a")
349 .user("u1")
350 .build(),
351 );
352 s.log(
353 AuditEventBuilder::new(AuditAction::SignIn)
354 .tenant("tenant_b")
355 .user("u2")
356 .build(),
357 );
358 s.log(
359 AuditEventBuilder::new(AuditAction::SignIn)
360 .user("u3")
362 .build(),
363 );
364 let a = s.find_for_tenant("tenant_a", 100);
365 assert_eq!(a.len(), 1);
366 assert_eq!(a[0].user_id.as_deref(), Some("u1"));
367 let b = s.find_for_tenant("tenant_b", 100);
368 assert_eq!(b.len(), 1);
369 assert_eq!(b[0].user_id.as_deref(), Some("u2"));
370 }
371
372 #[test]
373 fn user_query_returns_subject_and_actor_events() {
374 let s = AuditStore::new();
378 s.log(
379 AuditEventBuilder::new(AuditAction::AccountDelete)
380 .user("alice")
381 .actor("admin")
382 .build(),
383 );
384 assert_eq!(s.find_for_user("alice", 100).len(), 1);
385 assert_eq!(s.find_for_user("admin", 100).len(), 1);
386 assert_eq!(s.find_for_user("bob", 100).len(), 0);
387 }
388
389 #[test]
390 fn newest_first_ordering() {
391 let s = AuditStore::new();
392 s.backend.append(&AuditEvent {
394 id: "evt_a".into(),
395 created_at: 100,
396 action: AuditAction::SignIn,
397 user_id: Some("u".into()),
398 actor_id: None,
399 tenant_id: Some("t".into()),
400 ip: None,
401 user_agent: None,
402 success: true,
403 reason: None,
404 metadata: HashMap::new(),
405 });
406 s.backend.append(&AuditEvent {
407 id: "evt_b".into(),
408 created_at: 200,
409 action: AuditAction::SignOut,
410 user_id: Some("u".into()),
411 actor_id: None,
412 tenant_id: Some("t".into()),
413 ip: None,
414 user_agent: None,
415 success: true,
416 reason: None,
417 metadata: HashMap::new(),
418 });
419 let out = s.find_for_tenant("t", 10);
420 assert_eq!(out[0].id, "evt_b"); assert_eq!(out[1].id, "evt_a");
422 }
423
424 #[test]
425 fn limit_caps_results() {
426 let s = AuditStore::new();
427 for i in 0..50 {
428 s.log(
429 AuditEventBuilder::new(AuditAction::SignIn)
430 .tenant("t")
431 .user(format!("u_{i}"))
432 .build(),
433 );
434 }
435 assert_eq!(s.find_for_tenant("t", 10).len(), 10);
436 }
437
438 #[test]
439 fn metadata_preserves_string_only_values() {
440 let e = AuditEventBuilder::new(AuditAction::SignIn)
443 .meta("method", "oauth:google")
444 .meta("device", "iPhone")
445 .build();
446 assert_eq!(
447 e.metadata.get("method").map(|s| s.as_str()),
448 Some("oauth:google")
449 );
450 assert_eq!(e.metadata.len(), 2);
451 }
452
453 #[test]
454 fn custom_action_serializes_verbatim() {
455 let e = AuditEventBuilder::new(AuditAction::Custom(
456 "pylon.cloud.fly_machine_provision".into(),
457 ))
458 .build();
459 assert_eq!(e.action.as_str(), "pylon.cloud.fly_machine_provision");
460 }
461
462 #[test]
463 fn no_tenant_event_invisible_to_tenant_query() {
464 let s = AuditStore::new();
467 s.log(AuditEventBuilder::new(AuditAction::Custom("system.tick".into())).build());
468 assert_eq!(s.find_for_tenant("tenant_a", 100).len(), 0);
469 }
470}