1#![allow(dead_code)]
4
5use std::collections::VecDeque;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct Actor {
10 pub id: String,
12 pub display_name: String,
14 pub kind: ActorKind,
16}
17
18impl Actor {
19 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum ActorKind {
43 Human,
45 System,
47 ExternalApi,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum AuditEventKind {
54 WorkflowCreated,
56 WorkflowStarted,
58 WorkflowPaused,
60 WorkflowResumed,
62 WorkflowCancelled,
64 WorkflowCompleted,
66 WorkflowFailed,
68 TaskStarted,
70 TaskCompleted,
72 TaskFailed,
74 TaskRetried,
76 ConfigChanged,
78 PermissionChanged,
80 Custom(String),
82}
83
84impl AuditEventKind {
85 #[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#[derive(Debug, Clone)]
109pub struct AuditEvent {
110 pub sequence: u64,
112 pub kind: AuditEventKind,
114 pub actor: Actor,
116 pub workflow_id: String,
118 pub task_id: Option<String>,
120 pub timestamp_epoch_secs: u64,
122 pub detail: Option<String>,
124 pub metadata: Vec<(String, String)>,
126}
127
128impl AuditEvent {
129 fn label(&self) -> String {
130 self.kind.label()
131 }
132}
133
134#[derive(Debug, Default)]
136pub struct AuditLog {
137 events: VecDeque<AuditEvent>,
138 next_sequence: u64,
139 max_capacity: Option<usize>,
140}
141
142impl AuditLog {
143 #[must_use]
145 pub fn new() -> Self {
146 Self::default()
147 }
148
149 #[must_use]
151 pub fn with_capacity(max: usize) -> Self {
152 Self {
153 max_capacity: Some(max),
154 ..Default::default()
155 }
156 }
157
158 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 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 #[must_use]
209 pub fn len(&self) -> usize {
210 self.events.len()
211 }
212
213 #[must_use]
215 pub fn is_empty(&self) -> bool {
216 self.events.is_empty()
217 }
218
219 #[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 #[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 #[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 #[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 #[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 #[must_use]
282 pub fn next_sequence(&self) -> u64 {
283 self.next_sequence
284 }
285}
286
287#[derive(Debug, Clone, PartialEq, Eq)]
289pub enum ComplianceFormat {
290 Csv,
292 NdJson,
294 Text,
296}
297
298#[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 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}