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};
11use std::io::Write;
12
13/// Immutable audit record for a single IPC request.
14///
15/// Carries at least: UTC timestamp, command enum, initiator identity hash,
16/// optional correlation id, adjudication boolean,
17/// and structured error code on denial.
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub struct AuditRecord {
20 /// UTC timestamp with millisecond precision.
21 pub timestamp: String,
22 /// IPC method name.
23 pub method: String,
24 /// SHA256 hash of the initiator's peer identity (hex-encoded).
25 pub initiator_hash: String,
26 /// Optional correlation identifier for tracing.
27 pub correlation_id: Option<String>,
28 /// Whether the request was allowed.
29 pub allowed: bool,
30 /// Adjudication reason code when denied.
31 pub denial_code: Option<String>,
32 /// The control point that denied the request (C1-C9).
33 pub denial_control_point: Option<String>,
34}
35
36/// Failure strategy when the audit backend is unavailable.
37#[derive(Debug, Clone, Copy, PartialEq)]
38pub enum AuditFailureStrategy {
39 /// Reject write commands when audit cannot be written.
40 FailClosed,
41 /// Defer audit writes with a bounded queue.
42 DeferBounded,
43}
44
45impl AuditFailureStrategy {
46 /// Returns the strategy from a config string.
47 pub fn from_config(value: &str) -> Self {
48 match value {
49 "defer_bounded" => Self::DeferBounded,
50 _ => Self::FailClosed,
51 }
52 }
53}
54
55/// Audit storage backend.
56pub enum AuditBackend {
57 /// In-memory ring buffer — not persisted across restarts.
58 Memory {
59 /// Fixed-size ring buffer of audit records.
60 buffer: Vec<AuditRecord>,
61 /// Write position in the ring.
62 position: usize,
63 },
64 /// Append-only JSON Lines file.
65 File {
66 /// File path for audit records.
67 path: String,
68 /// Opened file handle in append mode.
69 file: std::fs::File,
70 },
71}
72
73impl AuditBackend {
74 /// Returns `true` when the underlying backend is known to be working.
75 /// Memory backends always answer `true`; file backends check whether
76 /// the file descriptor is still usable.
77 pub fn is_healthy(&self) -> bool {
78 match self {
79 Self::Memory { .. } => true,
80 Self::File { file, .. } => {
81 // Quick health check: try to lock metadata.
82 // A permanent I/O error (e.g. disk full, deleted file)
83 // will surface on the next write attempt.
84 file.metadata().is_ok()
85 }
86 }
87 }
88
89 /// Creates a memory-backed audit backend.
90 ///
91 /// # Arguments
92 ///
93 /// - `capacity`: Maximum number of records in the ring buffer.
94 ///
95 /// # Returns
96 ///
97 /// Returns an empty [`AuditBackend::Memory`].
98 pub fn new_memory(capacity: usize) -> Self {
99 Self::Memory {
100 buffer: Vec::with_capacity(capacity),
101 position: 0,
102 }
103 }
104
105 /// Creates a file-backed audit backend with append-only JSON Lines.
106 ///
107 /// Opens or creates the file at `path` in append mode. The file is
108 /// opened once and reused for the lifetime of the backend.
109 ///
110 /// # Arguments
111 ///
112 /// - `path`: Absolute path to the audit file.
113 ///
114 /// # Returns
115 ///
116 /// Returns an [`AuditBackend::File`] or an error if the file cannot
117 /// be opened.
118 pub fn new_file(path: String) -> Result<Self, DashboardError> {
119 let file = std::fs::OpenOptions::new()
120 .create(true)
121 .append(true)
122 .open(&path)
123 .map_err(|error| {
124 DashboardError::audit_write_failed(format!(
125 "failed to open audit file {path}: {error}"
126 ))
127 })?;
128 Ok(Self::File { path, file })
129 }
130
131 /// Creates an audit backend from configuration.
132 ///
133 /// # Arguments
134 ///
135 /// - `config`: Audit configuration.
136 ///
137 /// # Returns
138 ///
139 /// Returns the configured backend, defaulting to memory (4096 capacity)
140 /// when no file path is provided.
141 pub fn from_config(config: &AuditConfig) -> Self {
142 match config.backend.as_str() {
143 "file" => {
144 let path = config.file_path.trim();
145 if path.is_empty() {
146 AuditBackend::new_memory(4096)
147 } else {
148 match AuditBackend::new_file(path.to_owned()) {
149 Ok(backend) => backend,
150 Err(error) => {
151 tracing::error!(
152 target: "rust_supervisor::ipc::security::audit",
153 path = %path,
154 ?error,
155 "failed to open file audit backend, falling back to memory"
156 );
157 AuditBackend::new_memory(4096)
158 }
159 }
160 }
161 }
162 _ => AuditBackend::new_memory(4096),
163 }
164 }
165
166 /// Writes an audit record to the backend.
167 ///
168 /// # Arguments
169 ///
170 /// - `record`: The audit record to persist.
171 ///
172 /// # Returns
173 ///
174 /// Returns `Ok(())` when the write succeeds, or `Err(DashboardError)`
175 /// on failure.
176 pub fn write(&mut self, record: &AuditRecord) -> Result<(), DashboardError> {
177 match self {
178 Self::Memory { buffer, position } => {
179 if buffer.len() < buffer.capacity() {
180 buffer.push(record.clone());
181 } else {
182 buffer[*position] = record.clone();
183 *position = (*position + 1) % buffer.capacity();
184 }
185 Ok(())
186 }
187 Self::File { path: _, file } => {
188 let line = serde_json::to_string(record).map_err(|error| {
189 DashboardError::audit_write_failed(format!(
190 "audit serialization failed: {error}"
191 ))
192 })?;
193 writeln!(file, "{line}").map_err(|error| {
194 DashboardError::audit_write_failed(format!("file audit write failed: {error}"))
195 })
196 }
197 }
198 }
199
200 /// Returns recent audit records (for inspection).
201 ///
202 /// # Arguments
203 ///
204 /// - `count`: Maximum number of recent records to return.
205 ///
206 /// # Returns
207 ///
208 /// Returns a vector of audit records, newest first.
209 pub fn recent(&self, count: usize) -> Vec<AuditRecord> {
210 match self {
211 Self::Memory {
212 buffer,
213 position: _,
214 } => {
215 let start = if buffer.len() > count {
216 buffer.len() - count
217 } else {
218 0
219 };
220 buffer[start..].iter().rev().take(count).cloned().collect()
221 }
222 Self::File { path, file: _ } => {
223 // File backend: read last N lines from file.
224 let _ = path;
225 vec![]
226 }
227 }
228 }
229}
230
231/// Audit alert counter exposed via tracing.
232pub mod alerts {
233 use std::sync::atomic::{AtomicU64, Ordering};
234
235 /// Counter for audit write failures (for SC-004).
236 static AUDIT_WRITE_FAILURES: AtomicU64 = AtomicU64::new(0);
237
238 /// Increments the audit write failure counter.
239 pub fn increment_failure_count() -> u64 {
240 AUDIT_WRITE_FAILURES.fetch_add(1, Ordering::Relaxed)
241 }
242
243 /// Returns the current audit write failure count.
244 pub fn failure_count() -> u64 {
245 AUDIT_WRITE_FAILURES.load(Ordering::Relaxed)
246 }
247}