Skip to main content

oximedia_workflow/
audit_log.rs

1//! Workflow audit logging: immutable event log, actor tracking, and compliance export.
2
3#![allow(dead_code)]
4
5use std::collections::VecDeque;
6
7/// The actor that performed an action.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct Actor {
10    /// Unique identifier (user ID, service account, etc.).
11    pub id: String,
12    /// Human-readable display name.
13    pub display_name: String,
14    /// Actor type.
15    pub kind: ActorKind,
16}
17
18impl Actor {
19    /// Create a human actor.
20    #[must_use]
21    pub fn human(id: &str, name: &str) -> Self {
22        Self {
23            id: id.to_string(),
24            display_name: name.to_string(),
25            kind: ActorKind::Human,
26        }
27    }
28
29    /// Create a system/service actor.
30    #[must_use]
31    pub fn system(id: &str) -> Self {
32        Self {
33            id: id.to_string(),
34            display_name: id.to_string(),
35            kind: ActorKind::System,
36        }
37    }
38}
39
40/// Kind of actor.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum ActorKind {
43    /// Human user.
44    Human,
45    /// Automated system or service.
46    System,
47    /// External API caller.
48    ExternalApi,
49}
50
51/// Category of an audit event.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum AuditEventKind {
54    /// Workflow was created.
55    WorkflowCreated,
56    /// Workflow was started.
57    WorkflowStarted,
58    /// Workflow was paused.
59    WorkflowPaused,
60    /// Workflow was resumed.
61    WorkflowResumed,
62    /// Workflow was cancelled.
63    WorkflowCancelled,
64    /// Workflow completed successfully.
65    WorkflowCompleted,
66    /// Workflow failed.
67    WorkflowFailed,
68    /// A task started.
69    TaskStarted,
70    /// A task completed.
71    TaskCompleted,
72    /// A task failed.
73    TaskFailed,
74    /// A task was retried.
75    TaskRetried,
76    /// Configuration was changed.
77    ConfigChanged,
78    /// Permission was changed.
79    PermissionChanged,
80    /// A custom application event.
81    Custom(String),
82}
83
84impl AuditEventKind {
85    /// Return a string label for the event kind.
86    #[must_use]
87    pub fn label(&self) -> String {
88        match self {
89            Self::WorkflowCreated => "WORKFLOW_CREATED".to_string(),
90            Self::WorkflowStarted => "WORKFLOW_STARTED".to_string(),
91            Self::WorkflowPaused => "WORKFLOW_PAUSED".to_string(),
92            Self::WorkflowResumed => "WORKFLOW_RESUMED".to_string(),
93            Self::WorkflowCancelled => "WORKFLOW_CANCELLED".to_string(),
94            Self::WorkflowCompleted => "WORKFLOW_COMPLETED".to_string(),
95            Self::WorkflowFailed => "WORKFLOW_FAILED".to_string(),
96            Self::TaskStarted => "TASK_STARTED".to_string(),
97            Self::TaskCompleted => "TASK_COMPLETED".to_string(),
98            Self::TaskFailed => "TASK_FAILED".to_string(),
99            Self::TaskRetried => "TASK_RETRIED".to_string(),
100            Self::ConfigChanged => "CONFIG_CHANGED".to_string(),
101            Self::PermissionChanged => "PERMISSION_CHANGED".to_string(),
102            Self::Custom(s) => format!("CUSTOM:{s}"),
103        }
104    }
105}
106
107/// A single immutable audit event.
108#[derive(Debug, Clone)]
109pub struct AuditEvent {
110    /// Monotonically increasing sequence number.
111    pub sequence: u64,
112    /// Event kind.
113    pub kind: AuditEventKind,
114    /// Actor who triggered the event.
115    pub actor: Actor,
116    /// Workflow ID this event relates to.
117    pub workflow_id: String,
118    /// Optional task ID this event relates to.
119    pub task_id: Option<String>,
120    /// Wall-clock timestamp as a Unix epoch second (for deterministic tests we use u64).
121    pub timestamp_epoch_secs: u64,
122    /// Optional free-form detail message.
123    pub detail: Option<String>,
124    /// Key-value metadata.
125    pub metadata: Vec<(String, String)>,
126}
127
128impl AuditEvent {
129    fn label(&self) -> String {
130        self.kind.label()
131    }
132}
133
134/// The append-only audit log.
135#[derive(Debug, Default)]
136pub struct AuditLog {
137    events: VecDeque<AuditEvent>,
138    next_sequence: u64,
139    max_capacity: Option<usize>,
140}
141
142impl AuditLog {
143    /// Create a new unbounded audit log.
144    #[must_use]
145    pub fn new() -> Self {
146        Self::default()
147    }
148
149    /// Create an audit log with a maximum number of retained events (oldest are dropped).
150    #[must_use]
151    pub fn with_capacity(max: usize) -> Self {
152        Self {
153            max_capacity: Some(max),
154            ..Default::default()
155        }
156    }
157
158    /// Append a new event to the log.
159    pub fn append(
160        &mut self,
161        kind: AuditEventKind,
162        actor: Actor,
163        workflow_id: &str,
164        task_id: Option<&str>,
165        timestamp: u64,
166        detail: Option<String>,
167    ) -> u64 {
168        let seq = self.next_sequence;
169        self.next_sequence += 1;
170        let event = AuditEvent {
171            sequence: seq,
172            kind,
173            actor,
174            workflow_id: workflow_id.to_string(),
175            task_id: task_id.map(std::string::ToString::to_string),
176            timestamp_epoch_secs: timestamp,
177            detail,
178            metadata: Vec::new(),
179        };
180        if let Some(max) = self.max_capacity {
181            if self.events.len() >= max {
182                self.events.pop_front();
183            }
184        }
185        self.events.push_back(event);
186        seq
187    }
188
189    /// Append an event with additional metadata.
190    pub fn append_with_metadata(
191        &mut self,
192        kind: AuditEventKind,
193        actor: Actor,
194        workflow_id: &str,
195        task_id: Option<&str>,
196        timestamp: u64,
197        detail: Option<String>,
198        metadata: Vec<(String, String)>,
199    ) -> u64 {
200        let seq = self.append(kind, actor, workflow_id, task_id, timestamp, detail);
201        if let Some(event) = self.events.back_mut() {
202            event.metadata = metadata;
203        }
204        seq
205    }
206
207    /// Return the number of stored events.
208    #[must_use]
209    pub fn len(&self) -> usize {
210        self.events.len()
211    }
212
213    /// Return true if no events are stored.
214    #[must_use]
215    pub fn is_empty(&self) -> bool {
216        self.events.is_empty()
217    }
218
219    /// Filter events by workflow ID.
220    #[must_use]
221    pub fn events_for_workflow(&self, workflow_id: &str) -> Vec<&AuditEvent> {
222        self.events
223            .iter()
224            .filter(|e| e.workflow_id == workflow_id)
225            .collect()
226    }
227
228    /// Filter events by actor ID.
229    #[must_use]
230    pub fn events_for_actor(&self, actor_id: &str) -> Vec<&AuditEvent> {
231        self.events
232            .iter()
233            .filter(|e| e.actor.id == actor_id)
234            .collect()
235    }
236
237    /// Filter events by kind label (string match).
238    #[must_use]
239    pub fn events_by_kind(&self, kind: &AuditEventKind) -> Vec<&AuditEvent> {
240        let label = kind.label();
241        self.events
242            .iter()
243            .filter(|e| e.kind.label() == label)
244            .collect()
245    }
246
247    /// Return events in a timestamp range (inclusive).
248    #[must_use]
249    pub fn events_in_range(&self, from_epoch: u64, to_epoch: u64) -> Vec<&AuditEvent> {
250        self.events
251            .iter()
252            .filter(|e| e.timestamp_epoch_secs >= from_epoch && e.timestamp_epoch_secs <= to_epoch)
253            .collect()
254    }
255
256    /// Export all events as CSV lines for compliance reporting.
257    #[must_use]
258    pub fn export_csv(&self) -> Vec<String> {
259        let mut lines = vec![
260            "sequence,timestamp,kind,actor_id,actor_name,workflow_id,task_id,detail".to_string(),
261        ];
262        for e in &self.events {
263            let task_id = e.task_id.as_deref().unwrap_or("");
264            let detail = e.detail.as_deref().unwrap_or("").replace(',', ";");
265            lines.push(format!(
266                "{},{},{},{},{},{},{},{}",
267                e.sequence,
268                e.timestamp_epoch_secs,
269                e.label(),
270                e.actor.id,
271                e.actor.display_name,
272                e.workflow_id,
273                task_id,
274                detail,
275            ));
276        }
277        lines
278    }
279
280    /// Return next sequence number (useful for assertions in tests).
281    #[must_use]
282    pub fn next_sequence(&self) -> u64 {
283        self.next_sequence
284    }
285}
286
287/// Compliance export format options.
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub enum ComplianceFormat {
290    /// Comma-separated values.
291    Csv,
292    /// Newline-delimited JSON.
293    NdJson,
294    /// Plain text summary.
295    Text,
296}
297
298/// Generate a compliance export in the requested format.
299#[must_use]
300pub fn export_compliance(log: &AuditLog, format: ComplianceFormat) -> String {
301    match format {
302        ComplianceFormat::Csv => log.export_csv().join("\n"),
303        ComplianceFormat::NdJson => log
304            .events
305            .iter()
306            .map(|e| {
307                format!(
308                    r#"{{"seq":{},"ts":{},"kind":"{}","actor":"{}","wf":"{}"}}"#,
309                    e.sequence,
310                    e.timestamp_epoch_secs,
311                    e.label(),
312                    e.actor.id,
313                    e.workflow_id,
314                )
315            })
316            .collect::<Vec<_>>()
317            .join("\n"),
318        ComplianceFormat::Text => {
319            let mut out = String::new();
320            for e in &log.events {
321                out.push_str(&format!(
322                    "[{}] {} actor={} wf={}\n",
323                    e.sequence,
324                    e.label(),
325                    e.actor.id,
326                    e.workflow_id,
327                ));
328            }
329            out
330        }
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    fn human() -> Actor {
339        Actor::human("user-1", "Alice")
340    }
341
342    fn system() -> Actor {
343        Actor::system("scheduler")
344    }
345
346    #[test]
347    fn test_append_increments_sequence() {
348        let mut log = AuditLog::new();
349        let s1 = log.append(
350            AuditEventKind::WorkflowCreated,
351            human(),
352            "wf-1",
353            None,
354            1000,
355            None,
356        );
357        let s2 = log.append(
358            AuditEventKind::WorkflowStarted,
359            human(),
360            "wf-1",
361            None,
362            1001,
363            None,
364        );
365        assert_eq!(s1, 0);
366        assert_eq!(s2, 1);
367        assert_eq!(log.len(), 2);
368    }
369
370    #[test]
371    fn test_log_is_empty_initially() {
372        let log = AuditLog::new();
373        assert!(log.is_empty());
374        assert_eq!(log.len(), 0);
375    }
376
377    #[test]
378    fn test_events_for_workflow() {
379        let mut log = AuditLog::new();
380        log.append(
381            AuditEventKind::WorkflowCreated,
382            human(),
383            "wf-1",
384            None,
385            1000,
386            None,
387        );
388        log.append(
389            AuditEventKind::WorkflowCreated,
390            human(),
391            "wf-2",
392            None,
393            1001,
394            None,
395        );
396        let wf1_events = log.events_for_workflow("wf-1");
397        assert_eq!(wf1_events.len(), 1);
398        assert_eq!(wf1_events[0].workflow_id, "wf-1");
399    }
400
401    #[test]
402    fn test_events_for_actor() {
403        let mut log = AuditLog::new();
404        log.append(
405            AuditEventKind::WorkflowStarted,
406            human(),
407            "wf-1",
408            None,
409            1000,
410            None,
411        );
412        log.append(
413            AuditEventKind::WorkflowStarted,
414            system(),
415            "wf-2",
416            None,
417            1001,
418            None,
419        );
420        assert_eq!(log.events_for_actor("user-1").len(), 1);
421        assert_eq!(log.events_for_actor("scheduler").len(), 1);
422    }
423
424    #[test]
425    fn test_events_by_kind() {
426        let mut log = AuditLog::new();
427        log.append(
428            AuditEventKind::WorkflowCreated,
429            human(),
430            "wf-1",
431            None,
432            1000,
433            None,
434        );
435        log.append(
436            AuditEventKind::WorkflowStarted,
437            human(),
438            "wf-1",
439            None,
440            1001,
441            None,
442        );
443        log.append(
444            AuditEventKind::WorkflowCreated,
445            system(),
446            "wf-2",
447            None,
448            1002,
449            None,
450        );
451        let created = log.events_by_kind(&AuditEventKind::WorkflowCreated);
452        assert_eq!(created.len(), 2);
453    }
454
455    #[test]
456    fn test_events_in_range() {
457        let mut log = AuditLog::new();
458        log.append(
459            AuditEventKind::TaskStarted,
460            human(),
461            "wf-1",
462            Some("t1"),
463            100,
464            None,
465        );
466        log.append(
467            AuditEventKind::TaskCompleted,
468            human(),
469            "wf-1",
470            Some("t1"),
471            200,
472            None,
473        );
474        log.append(
475            AuditEventKind::TaskStarted,
476            human(),
477            "wf-1",
478            Some("t2"),
479            300,
480            None,
481        );
482        let range = log.events_in_range(150, 250);
483        assert_eq!(range.len(), 1);
484        assert_eq!(range[0].timestamp_epoch_secs, 200);
485    }
486
487    #[test]
488    fn test_capacity_evicts_oldest() {
489        let mut log = AuditLog::with_capacity(3);
490        for i in 0..5u64 {
491            log.append(AuditEventKind::TaskStarted, system(), "wf-1", None, i, None);
492        }
493        assert_eq!(log.len(), 3);
494        // Oldest (seq=0,1) should be gone; youngest (seq=2,3,4) remain.
495        let events = log.events_for_workflow("wf-1");
496        assert_eq!(events[0].sequence, 2);
497    }
498
499    #[test]
500    fn test_export_csv_header() {
501        let log = AuditLog::new();
502        let csv = log.export_csv();
503        assert_eq!(csv.len(), 1);
504        assert!(csv[0].starts_with("sequence,"));
505    }
506
507    #[test]
508    fn test_export_csv_rows() {
509        let mut log = AuditLog::new();
510        log.append(
511            AuditEventKind::WorkflowCompleted,
512            human(),
513            "wf-1",
514            None,
515            9999,
516            Some("done".to_string()),
517        );
518        let csv = log.export_csv();
519        assert_eq!(csv.len(), 2);
520        assert!(csv[1].contains("WORKFLOW_COMPLETED"));
521        assert!(csv[1].contains("user-1"));
522    }
523
524    #[test]
525    fn test_export_compliance_ndjson() {
526        let mut log = AuditLog::new();
527        log.append(
528            AuditEventKind::WorkflowFailed,
529            system(),
530            "wf-9",
531            None,
532            5000,
533            None,
534        );
535        let output = export_compliance(&log, ComplianceFormat::NdJson);
536        assert!(output.contains("WORKFLOW_FAILED"));
537        assert!(output.contains("wf-9"));
538    }
539
540    #[test]
541    fn test_export_compliance_text() {
542        let mut log = AuditLog::new();
543        log.append(
544            AuditEventKind::WorkflowPaused,
545            human(),
546            "wf-3",
547            None,
548            2000,
549            None,
550        );
551        let output = export_compliance(&log, ComplianceFormat::Text);
552        assert!(output.contains("WORKFLOW_PAUSED"));
553        assert!(output.contains("wf-3"));
554    }
555
556    #[test]
557    fn test_append_with_metadata() {
558        let mut log = AuditLog::new();
559        let meta = vec![("key".to_string(), "value".to_string())];
560        log.append_with_metadata(
561            AuditEventKind::ConfigChanged,
562            human(),
563            "wf-1",
564            None,
565            3000,
566            None,
567            meta,
568        );
569        let events = log.events_for_workflow("wf-1");
570        assert_eq!(events[0].metadata.len(), 1);
571        assert_eq!(events[0].metadata[0].0, "key");
572    }
573
574    #[test]
575    fn test_audit_event_kind_labels() {
576        assert_eq!(AuditEventKind::WorkflowCreated.label(), "WORKFLOW_CREATED");
577        assert_eq!(AuditEventKind::TaskRetried.label(), "TASK_RETRIED");
578        assert_eq!(
579            AuditEventKind::Custom("MY_EVENT".to_string()).label(),
580            "CUSTOM:MY_EVENT"
581        );
582    }
583
584    #[test]
585    fn test_actor_kinds() {
586        let h = Actor::human("u1", "Bob");
587        assert_eq!(h.kind, ActorKind::Human);
588        let s = Actor::system("svc");
589        assert_eq!(s.kind, ActorKind::System);
590    }
591
592    #[test]
593    fn test_next_sequence_counter() {
594        let mut log = AuditLog::new();
595        assert_eq!(log.next_sequence(), 0);
596        log.append(
597            AuditEventKind::WorkflowCreated,
598            human(),
599            "wf-1",
600            None,
601            0,
602            None,
603        );
604        assert_eq!(log.next_sequence(), 1);
605    }
606
607    #[test]
608    fn test_task_id_captured() {
609        let mut log = AuditLog::new();
610        log.append(
611            AuditEventKind::TaskStarted,
612            system(),
613            "wf-1",
614            Some("task-42"),
615            100,
616            None,
617        );
618        let events = log.events_for_workflow("wf-1");
619        assert_eq!(events[0].task_id.as_deref(), Some("task-42"));
620    }
621}