Skip to main content

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}