Skip to main content

rma_common/suppression/
audit.rs

1//! Enhanced audit trail for suppression operations
2//!
3//! Provides comprehensive audit logging with full context, diffs, and metadata tracking.
4
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8/// Actions that can be audited
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum AuditAction {
12    /// Suppression was created
13    Created,
14    /// Suppression expired automatically
15    Expired,
16    /// Suppression was manually revoked
17    Revoked,
18    /// Suppression expiration was extended
19    Extended,
20    /// Suppression was marked as stale
21    MarkedStale,
22    /// Suppression was reactivated (e.g., after code fix)
23    Reactivated,
24    /// Suppression was imported from JSON
25    Imported,
26    /// Suppression metadata was updated
27    Updated,
28    /// Suppression was submitted for approval
29    SubmittedForApproval,
30    /// Suppression was approved
31    Approved,
32    /// Suppression approval was rejected
33    Rejected,
34    /// Suppression was added to a group
35    AddedToGroup,
36    /// Suppression was removed from a group
37    RemovedFromGroup,
38    /// Tag was added
39    TagAdded,
40    /// Tag was removed
41    TagRemoved,
42    /// Scheduled for auto-revocation
43    ScheduledRevocation,
44    /// Auto-revocation was cancelled
45    RevocationCancelled,
46    /// Bulk operation applied
47    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/// Severity level of an audit event
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
77#[serde(rename_all = "lowercase")]
78pub enum AuditSeverity {
79    /// Informational event
80    #[default]
81    Info,
82    /// Warning event
83    Warning,
84    /// Important event requiring attention
85    Important,
86    /// Critical security event
87    Critical,
88}
89
90/// A field change in an audit event
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct FieldChange {
93    /// Name of the field that changed
94    pub field: String,
95    /// Previous value (if any)
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub old_value: Option<String>,
98    /// New value (if any)
99    #[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/// Context about the environment when the audit event occurred
114#[derive(Debug, Clone, Serialize, Deserialize, Default)]
115pub struct AuditContext {
116    /// Git commit hash (if in a git repo)
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub git_commit: Option<String>,
119    /// Git branch
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub git_branch: Option<String>,
122    /// Hostname where the action occurred
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub hostname: Option<String>,
125    /// Working directory
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub working_dir: Option<String>,
128    /// CI/CD pipeline identifier
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub ci_pipeline: Option<String>,
131    /// CI/CD job identifier
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub ci_job: Option<String>,
134    /// Additional context metadata
135    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
136    pub metadata: HashMap<String, String>,
137}
138
139impl AuditContext {
140    /// Create context from current environment
141    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    /// Add metadata
188    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/// An enhanced audit event with full context
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct AuditEvent {
197    /// Unique event ID
198    pub id: String,
199    /// When this event occurred (ISO 8601)
200    pub timestamp: String,
201    /// ID of the suppression this event relates to
202    pub suppression_id: String,
203    /// What action was taken
204    pub action: AuditAction,
205    /// Severity of this event
206    #[serde(default)]
207    pub severity: AuditSeverity,
208    /// Who performed the action
209    pub actor: String,
210    /// Actor's email (if available)
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub actor_email: Option<String>,
213    /// Human-readable description
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub description: Option<String>,
216    /// Detailed reason for the action
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub reason: Option<String>,
219    /// Fields that changed
220    #[serde(default, skip_serializing_if = "Vec::is_empty")]
221    pub changes: Vec<FieldChange>,
222    /// Environment context
223    #[serde(default, skip_serializing_if = "is_default_context")]
224    pub context: AuditContext,
225    /// Related event IDs (for linked actions)
226    #[serde(default, skip_serializing_if = "Vec::is_empty")]
227    pub related_events: Vec<String>,
228    /// Tags for categorization
229    #[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    /// Create a new audit event with auto-generated ID and timestamp
245    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    /// Create with full environment context
268    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    /// Set severity
279    pub fn severity(mut self, severity: AuditSeverity) -> Self {
280        self.severity = severity;
281        self
282    }
283
284    /// Set description
285    pub fn description(mut self, desc: impl Into<String>) -> Self {
286        self.description = Some(desc.into());
287        self
288    }
289
290    /// Set reason
291    pub fn reason(mut self, reason: impl Into<String>) -> Self {
292        self.reason = Some(reason.into());
293        self
294    }
295
296    /// Add a field change
297    pub fn add_change(mut self, change: FieldChange) -> Self {
298        self.changes.push(change);
299        self
300    }
301
302    /// Add a related event
303    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    /// Add a tag
309    pub fn add_tag(mut self, tag: impl Into<String>) -> Self {
310        self.tags.push(tag.into());
311        self
312    }
313
314    /// Set actor email
315    pub fn actor_email(mut self, email: impl Into<String>) -> Self {
316        self.actor_email = Some(email.into());
317        self
318    }
319
320    /// Parse timestamp to chrono DateTime
321    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    /// Get a human-readable relative time (e.g., "2 hours ago")
328    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    /// Format for display
358    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
367/// Generate a unique event ID
368fn 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(&timestamp, &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/// Audit log query builder
386#[derive(Debug, Clone, Default)]
387pub struct AuditQuery {
388    /// Filter by suppression ID
389    pub suppression_id: Option<String>,
390    /// Filter by actor
391    pub actor: Option<String>,
392    /// Filter by action
393    pub action: Option<AuditAction>,
394    /// Filter by minimum severity
395    pub min_severity: Option<AuditSeverity>,
396    /// Filter by start time
397    pub from: Option<String>,
398    /// Filter by end time
399    pub to: Option<String>,
400    /// Filter by tags
401    pub tags: Vec<String>,
402    /// Maximum results
403    pub limit: Option<usize>,
404    /// Offset for pagination
405    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    /// Check if an event matches this query
448    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        // Context should capture working dir at minimum
512        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}