1use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum AuditAction {
12 Created,
14 Expired,
16 Revoked,
18 Extended,
20 MarkedStale,
22 Reactivated,
24 Imported,
26 Updated,
28 SubmittedForApproval,
30 Approved,
32 Rejected,
34 AddedToGroup,
36 RemovedFromGroup,
38 TagAdded,
40 TagRemoved,
42 ScheduledRevocation,
44 RevocationCancelled,
46 BulkOperation,
48}
49
50impl std::fmt::Display for AuditAction {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 match self {
53 AuditAction::Created => write!(f, "created"),
54 AuditAction::Expired => write!(f, "expired"),
55 AuditAction::Revoked => write!(f, "revoked"),
56 AuditAction::Extended => write!(f, "extended"),
57 AuditAction::MarkedStale => write!(f, "marked-stale"),
58 AuditAction::Reactivated => write!(f, "reactivated"),
59 AuditAction::Imported => write!(f, "imported"),
60 AuditAction::Updated => write!(f, "updated"),
61 AuditAction::SubmittedForApproval => write!(f, "submitted-for-approval"),
62 AuditAction::Approved => write!(f, "approved"),
63 AuditAction::Rejected => write!(f, "rejected"),
64 AuditAction::AddedToGroup => write!(f, "added-to-group"),
65 AuditAction::RemovedFromGroup => write!(f, "removed-from-group"),
66 AuditAction::TagAdded => write!(f, "tag-added"),
67 AuditAction::TagRemoved => write!(f, "tag-removed"),
68 AuditAction::ScheduledRevocation => write!(f, "scheduled-revocation"),
69 AuditAction::RevocationCancelled => write!(f, "revocation-cancelled"),
70 AuditAction::BulkOperation => write!(f, "bulk-operation"),
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
77#[serde(rename_all = "lowercase")]
78pub enum AuditSeverity {
79 #[default]
81 Info,
82 Warning,
84 Important,
86 Critical,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct FieldChange {
93 pub field: String,
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub old_value: Option<String>,
98 #[serde(skip_serializing_if = "Option::is_none")]
100 pub new_value: Option<String>,
101}
102
103impl FieldChange {
104 pub fn new(field: impl Into<String>, old: Option<String>, new: Option<String>) -> Self {
105 Self {
106 field: field.into(),
107 old_value: old,
108 new_value: new,
109 }
110 }
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, Default)]
115pub struct AuditContext {
116 #[serde(skip_serializing_if = "Option::is_none")]
118 pub git_commit: Option<String>,
119 #[serde(skip_serializing_if = "Option::is_none")]
121 pub git_branch: Option<String>,
122 #[serde(skip_serializing_if = "Option::is_none")]
124 pub hostname: Option<String>,
125 #[serde(skip_serializing_if = "Option::is_none")]
127 pub working_dir: Option<String>,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 pub ci_pipeline: Option<String>,
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub ci_job: Option<String>,
134 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
136 pub metadata: HashMap<String, String>,
137}
138
139impl AuditContext {
140 pub fn from_environment() -> Self {
142 let git_commit = std::process::Command::new("git")
143 .args(["rev-parse", "HEAD"])
144 .output()
145 .ok()
146 .and_then(|o| String::from_utf8(o.stdout).ok())
147 .map(|s| s.trim().to_string())
148 .filter(|s| !s.is_empty());
149
150 let git_branch = std::process::Command::new("git")
151 .args(["rev-parse", "--abbrev-ref", "HEAD"])
152 .output()
153 .ok()
154 .and_then(|o| String::from_utf8(o.stdout).ok())
155 .map(|s| s.trim().to_string())
156 .filter(|s| !s.is_empty());
157
158 let hostname = std::env::var("HOSTNAME")
159 .or_else(|_| std::env::var("COMPUTERNAME"))
160 .ok();
161
162 let working_dir = std::env::current_dir()
163 .ok()
164 .map(|p| p.to_string_lossy().to_string());
165
166 let ci_pipeline = std::env::var("CI_PIPELINE_ID")
167 .or_else(|_| std::env::var("GITHUB_RUN_ID"))
168 .or_else(|_| std::env::var("BUILD_ID"))
169 .ok();
170
171 let ci_job = std::env::var("CI_JOB_ID")
172 .or_else(|_| std::env::var("GITHUB_JOB"))
173 .or_else(|_| std::env::var("JOB_NAME"))
174 .ok();
175
176 Self {
177 git_commit,
178 git_branch,
179 hostname,
180 working_dir,
181 ci_pipeline,
182 ci_job,
183 metadata: HashMap::new(),
184 }
185 }
186
187 pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
189 self.metadata.insert(key.into(), value.into());
190 self
191 }
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct AuditEvent {
197 pub id: String,
199 pub timestamp: String,
201 pub suppression_id: String,
203 pub action: AuditAction,
205 #[serde(default)]
207 pub severity: AuditSeverity,
208 pub actor: String,
210 #[serde(skip_serializing_if = "Option::is_none")]
212 pub actor_email: Option<String>,
213 #[serde(skip_serializing_if = "Option::is_none")]
215 pub description: Option<String>,
216 #[serde(skip_serializing_if = "Option::is_none")]
218 pub reason: Option<String>,
219 #[serde(default, skip_serializing_if = "Vec::is_empty")]
221 pub changes: Vec<FieldChange>,
222 #[serde(default, skip_serializing_if = "is_default_context")]
224 pub context: AuditContext,
225 #[serde(default, skip_serializing_if = "Vec::is_empty")]
227 pub related_events: Vec<String>,
228 #[serde(default, skip_serializing_if = "Vec::is_empty")]
230 pub tags: Vec<String>,
231}
232
233fn is_default_context(ctx: &AuditContext) -> bool {
234 ctx.git_commit.is_none()
235 && ctx.git_branch.is_none()
236 && ctx.hostname.is_none()
237 && ctx.working_dir.is_none()
238 && ctx.ci_pipeline.is_none()
239 && ctx.ci_job.is_none()
240 && ctx.metadata.is_empty()
241}
242
243impl AuditEvent {
244 pub fn new(
246 suppression_id: impl Into<String>,
247 action: AuditAction,
248 actor: impl Into<String>,
249 ) -> Self {
250 Self {
251 id: generate_event_id(),
252 timestamp: chrono::Utc::now().to_rfc3339(),
253 suppression_id: suppression_id.into(),
254 action,
255 severity: AuditSeverity::Info,
256 actor: actor.into(),
257 actor_email: None,
258 description: None,
259 reason: None,
260 changes: Vec::new(),
261 context: AuditContext::default(),
262 related_events: Vec::new(),
263 tags: Vec::new(),
264 }
265 }
266
267 pub fn with_context(
269 suppression_id: impl Into<String>,
270 action: AuditAction,
271 actor: impl Into<String>,
272 ) -> Self {
273 let mut event = Self::new(suppression_id, action, actor);
274 event.context = AuditContext::from_environment();
275 event
276 }
277
278 pub fn severity(mut self, severity: AuditSeverity) -> Self {
280 self.severity = severity;
281 self
282 }
283
284 pub fn description(mut self, desc: impl Into<String>) -> Self {
286 self.description = Some(desc.into());
287 self
288 }
289
290 pub fn reason(mut self, reason: impl Into<String>) -> Self {
292 self.reason = Some(reason.into());
293 self
294 }
295
296 pub fn add_change(mut self, change: FieldChange) -> Self {
298 self.changes.push(change);
299 self
300 }
301
302 pub fn add_related(mut self, event_id: impl Into<String>) -> Self {
304 self.related_events.push(event_id.into());
305 self
306 }
307
308 pub fn add_tag(mut self, tag: impl Into<String>) -> Self {
310 self.tags.push(tag.into());
311 self
312 }
313
314 pub fn actor_email(mut self, email: impl Into<String>) -> Self {
316 self.actor_email = Some(email.into());
317 self
318 }
319
320 pub fn parsed_timestamp(&self) -> Option<chrono::DateTime<chrono::Utc>> {
322 chrono::DateTime::parse_from_rfc3339(&self.timestamp)
323 .ok()
324 .map(|dt| dt.with_timezone(&chrono::Utc))
325 }
326
327 pub fn relative_time(&self) -> String {
329 if let Some(timestamp) = self.parsed_timestamp() {
330 let now = chrono::Utc::now();
331 let duration = now.signed_duration_since(timestamp);
332
333 let days = duration.num_days();
334 if days > 0 {
335 return format!("{} day{} ago", days, if days == 1 { "" } else { "s" });
336 }
337
338 let hours = duration.num_hours();
339 if hours > 0 {
340 return format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" });
341 }
342
343 let minutes = duration.num_minutes();
344 if minutes > 0 {
345 return format!(
346 "{} minute{} ago",
347 minutes,
348 if minutes == 1 { "" } else { "s" }
349 );
350 }
351
352 return "just now".to_string();
353 }
354 self.timestamp.clone()
355 }
356
357 pub fn format_summary(&self) -> String {
359 let mut summary = format!("{} by {}", self.action, self.actor);
360 if let Some(ref desc) = self.description {
361 summary.push_str(&format!(": {}", desc));
362 }
363 summary
364 }
365}
366
367fn generate_event_id() -> String {
369 use std::time::{SystemTime, UNIX_EPOCH};
370
371 let timestamp = SystemTime::now()
372 .duration_since(UNIX_EPOCH)
373 .unwrap_or_default()
374 .as_nanos();
375
376 let mut hasher = std::collections::hash_map::DefaultHasher::new();
377 std::hash::Hash::hash(×tamp, &mut hasher);
378 std::hash::Hash::hash(&std::process::id(), &mut hasher);
379 std::hash::Hash::hash(&std::thread::current().id(), &mut hasher);
380 let random = std::hash::Hasher::finish(&hasher);
381
382 format!("evt_{:016x}{:08x}", timestamp as u64, random as u32)
383}
384
385#[derive(Debug, Clone, Default)]
387pub struct AuditQuery {
388 pub suppression_id: Option<String>,
390 pub actor: Option<String>,
392 pub action: Option<AuditAction>,
394 pub min_severity: Option<AuditSeverity>,
396 pub from: Option<String>,
398 pub to: Option<String>,
400 pub tags: Vec<String>,
402 pub limit: Option<usize>,
404 pub offset: usize,
406}
407
408impl AuditQuery {
409 pub fn new() -> Self {
410 Self::default()
411 }
412
413 pub fn for_suppression(id: impl Into<String>) -> Self {
414 Self {
415 suppression_id: Some(id.into()),
416 ..Default::default()
417 }
418 }
419
420 pub fn by_actor(actor: impl Into<String>) -> Self {
421 Self {
422 actor: Some(actor.into()),
423 ..Default::default()
424 }
425 }
426
427 pub fn with_action(mut self, action: AuditAction) -> Self {
428 self.action = Some(action);
429 self
430 }
431
432 pub fn with_limit(mut self, limit: usize) -> Self {
433 self.limit = Some(limit);
434 self
435 }
436
437 pub fn with_offset(mut self, offset: usize) -> Self {
438 self.offset = offset;
439 self
440 }
441
442 pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
443 self.tags.push(tag.into());
444 self
445 }
446
447 pub fn matches(&self, event: &AuditEvent) -> bool {
449 if let Some(ref id) = self.suppression_id
450 && event.suppression_id != *id
451 {
452 return false;
453 }
454
455 if let Some(ref actor) = self.actor
456 && event.actor != *actor
457 {
458 return false;
459 }
460
461 if let Some(action) = self.action
462 && event.action != action
463 {
464 return false;
465 }
466
467 if !self.tags.is_empty() && !self.tags.iter().any(|t| event.tags.contains(t)) {
468 return false;
469 }
470
471 true
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 #[test]
480 fn test_audit_event() {
481 let event = AuditEvent::new("suppression-123", AuditAction::Created, "admin");
482
483 assert_eq!(event.suppression_id, "suppression-123");
484 assert_eq!(event.action, AuditAction::Created);
485 assert_eq!(event.actor, "admin");
486 assert!(event.description.is_none());
487 assert!(!event.id.is_empty());
488 }
489
490 #[test]
491 fn test_audit_event_with_details() {
492 let event = AuditEvent::new("suppression-123", AuditAction::Revoked, "admin")
493 .severity(AuditSeverity::Important)
494 .reason("False positive confirmed")
495 .add_change(FieldChange::new(
496 "status",
497 Some("active".to_string()),
498 Some("revoked".to_string()),
499 ));
500
501 assert_eq!(event.action, AuditAction::Revoked);
502 assert_eq!(event.severity, AuditSeverity::Important);
503 assert_eq!(event.reason, Some("False positive confirmed".to_string()));
504 assert_eq!(event.changes.len(), 1);
505 }
506
507 #[test]
508 fn test_audit_context() {
509 let ctx = AuditContext::from_environment().with_metadata("custom_key", "custom_value");
510
511 assert!(ctx.working_dir.is_some());
513 assert_eq!(
514 ctx.metadata.get("custom_key"),
515 Some(&"custom_value".to_string())
516 );
517 }
518
519 #[test]
520 fn test_audit_query() {
521 let event = AuditEvent::new("supp-1", AuditAction::Created, "user1").add_tag("security");
522
523 let query1 = AuditQuery::for_suppression("supp-1");
524 assert!(query1.matches(&event));
525
526 let query2 = AuditQuery::for_suppression("supp-2");
527 assert!(!query2.matches(&event));
528
529 let query3 = AuditQuery::new().with_tag("security");
530 assert!(query3.matches(&event));
531
532 let query4 = AuditQuery::new().with_tag("other");
533 assert!(!query4.matches(&event));
534 }
535
536 #[test]
537 fn test_relative_time() {
538 let event = AuditEvent::new("id", AuditAction::Created, "user");
539 let relative = event.relative_time();
540 assert!(
541 relative.contains("just now")
542 || relative.contains("second")
543 || relative.contains("minute")
544 );
545 }
546}