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};
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}