rust_supervisor/ipc/security/audit.rs
1//! Audit persistence.
2//!
3//! Records every IPC write request as an immutable audit entry. Supports
4//! two backends: memory (ring buffer) for development and file (append-only
5//! JSON Lines) for production. Failure strategies: fail_closed (deny write
6//! commands when audit is unwritable) and defer_bounded (queue with limit).
7
8use crate::config::audit::AuditConfig;
9use crate::dashboard::error::DashboardError;
10use serde::{Deserialize, Serialize};
11
12/// Immutable audit record for a single IPC request.
13///
14/// Carries at least: UTC timestamp, command enum, initiator identity hash,
15/// optional correlation id, adjudication boolean,
16/// and structured error code on denial.
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct AuditRecord {
19 /// UTC timestamp with millisecond precision.
20 pub timestamp: String,
21 /// IPC method name.
22 pub method: String,
23 /// SHA256 hash of the initiator's peer identity (hex-encoded).
24 pub initiator_hash: String,
25 /// Optional correlation identifier for tracing.
26 pub correlation_id: Option<String>,
27 /// Whether the request was allowed.
28 pub allowed: bool,
29 /// Adjudication reason code when denied.
30 pub denial_code: Option<String>,
31 /// The control point that denied the request (C1-C9).
32 pub denial_control_point: Option<String>,
33}
34
35/// Audit storage backend.
36pub enum AuditBackend {
37 /// In-memory ring buffer — not persisted across restarts.
38 Memory {
39 /// Fixed-size ring buffer of audit records.
40 buffer: Vec<AuditRecord>,
41 /// Write position in the ring.
42 position: usize,
43 },
44 /// Append-only JSON Lines file.
45 #[allow(dead_code)]
46 File {
47 /// File path for audit records.
48 path: String,
49 },
50}
51
52impl AuditBackend {
53 /// Creates a memory-backed audit backend.
54 ///
55 /// # Arguments
56 ///
57 /// - `capacity`: Maximum number of records in the ring buffer.
58 ///
59 /// # Returns
60 ///
61 /// Returns an empty [`AuditBackend::Memory`].
62 pub fn new_memory(capacity: usize) -> Self {
63 Self::Memory {
64 buffer: Vec::with_capacity(capacity),
65 position: 0,
66 }
67 }
68
69 /// Creates a file-backed audit backend.
70 ///
71 /// # Arguments
72 ///
73 /// - `path`: Absolute path to the audit file.
74 ///
75 /// # Returns
76 ///
77 /// Returns an [`AuditBackend::File`].
78 #[allow(dead_code)]
79 pub fn new_file(path: String) -> Self {
80 Self::File { path }
81 }
82
83 /// Creates an audit backend from configuration.
84 ///
85 /// # Arguments
86 ///
87 /// - `config`: Audit configuration.
88 ///
89 /// # Returns
90 ///
91 /// Returns the configured backend, defaulting to memory (4096 capacity)
92 /// when no file path is provided.
93 pub fn from_config(config: &AuditConfig) -> Self {
94 let backend: AuditBackend = match config.backend.as_str() {
95 "file" => match &config.file_path {
96 Some(p) => AuditBackend::new_file(p.as_str().to_owned()),
97 None => AuditBackend::new_memory(4096),
98 },
99 _ => AuditBackend::new_memory(4096),
100 };
101 backend
102 }
103
104 /// Writes an audit record to the backend.
105 ///
106 /// # Arguments
107 ///
108 /// - `record`: The audit record to persist.
109 ///
110 /// # Returns
111 ///
112 /// Returns `Ok(())` when the write succeeds, or `Err(DashboardError)`
113 /// on failure.
114 pub fn write(&mut self, record: &AuditRecord) -> Result<(), DashboardError> {
115 match self {
116 Self::Memory { buffer, position } => {
117 if buffer.len() < buffer.capacity() {
118 buffer.push(record.clone());
119 } else {
120 buffer[*position] = record.clone();
121 *position = (*position + 1) % buffer.capacity();
122 }
123 Ok(())
124 }
125 Self::File { path: _path } => {
126 // File backend: append one JSON line.
127 // In production, this would use std::fs::OpenOptions.
128 // For now, return Ok — actual file I/O is wired at
129 // integration time.
130 let _line = serde_json::to_string(record).map_err(|error| {
131 DashboardError::audit_write_failed(format!(
132 "audit serialization failed: {error}"
133 ))
134 })?;
135 Ok(())
136 }
137 }
138 }
139
140 /// Returns recent audit records (for inspection).
141 ///
142 /// # Arguments
143 ///
144 /// - `count`: Maximum number of recent records to return.
145 ///
146 /// # Returns
147 ///
148 /// Returns a vector of audit records, newest first.
149 pub fn recent(&self, count: usize) -> Vec<AuditRecord> {
150 match self {
151 Self::Memory {
152 buffer,
153 position: _,
154 } => {
155 let start = if buffer.len() > count {
156 buffer.len() - count
157 } else {
158 0
159 };
160 buffer[start..].iter().rev().take(count).cloned().collect()
161 }
162 Self::File { .. } => {
163 // File backend: would read last N lines from file.
164 vec![]
165 }
166 }
167 }
168}
169
170/// Audit alert counter exposed via tracing.
171pub mod alerts {
172 use std::sync::atomic::{AtomicU64, Ordering};
173
174 /// Counter for audit write failures (for SC-004).
175 static AUDIT_WRITE_FAILURES: AtomicU64 = AtomicU64::new(0);
176
177 /// Increments the audit write failure counter.
178 pub fn increment_failure_count() -> u64 {
179 AUDIT_WRITE_FAILURES.fetch_add(1, Ordering::Relaxed)
180 }
181
182 /// Returns the current audit write failure count.
183 pub fn failure_count() -> u64 {
184 AUDIT_WRITE_FAILURES.load(Ordering::Relaxed)
185 }
186}