1use crate::ids::{AppId, CommandName, NodeId};
14use crate::operational_journal::FileOperationalJournal;
15use crate::redact_text;
16use crate::trace::TraceContext;
17use parking_lot::Mutex;
18use std::collections::VecDeque;
19use std::sync::Arc;
20
21const MAX_AUDIT_RECORDS: usize = 10_000;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum AuditOutcome {
27 Accepted,
29 Rejected,
31 Error,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
37pub struct AuditRecord {
38 pub command_id: String,
40 pub command_name: CommandName,
42 pub app_id: AppId,
44 pub node_id: NodeId,
46 pub timestamp_ms: u64,
48 pub outcome: AuditOutcome,
50 pub message: Option<String>,
52 pub trace: Option<TraceContext>,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum AuditCategory {
60 Command,
62 Query,
64 Event,
66 Scheduler,
68 ControlPlane,
70 PeerRpc,
72 Runtime,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
78pub struct AuditEntry {
79 pub category: AuditCategory,
81 pub operation_id: String,
83 pub operation_name: String,
85 pub app_id: Option<String>,
87 pub node_id: Option<String>,
89 pub started_at_ms: u64,
91 pub completed_at_ms: u64,
93 pub latency_ms: u64,
95 pub outcome: AuditOutcome,
97 pub message: Option<String>,
99 pub trace: Option<TraceContext>,
101}
102
103impl AuditEntry {
104 pub fn new(
106 category: AuditCategory,
107 operation_id: impl Into<String>,
108 operation_name: impl Into<String>,
109 started_at_ms: u64,
110 completed_at_ms: u64,
111 outcome: AuditOutcome,
112 ) -> Self {
113 Self {
114 category,
115 operation_id: operation_id.into(),
116 operation_name: operation_name.into(),
117 app_id: None,
118 node_id: None,
119 started_at_ms,
120 completed_at_ms,
121 latency_ms: completed_at_ms.saturating_sub(started_at_ms),
122 outcome,
123 message: None,
124 trace: None,
125 }
126 }
127
128 pub fn with_runtime_scope(mut self, app_id: &AppId, node_id: &NodeId) -> Self {
130 self.app_id = Some(app_id.as_str().to_string());
131 self.node_id = Some(node_id.as_str().to_string());
132 self
133 }
134
135 pub fn with_message(mut self, message: Option<String>) -> Self {
137 self.message = message.map(|value| redact_text(&value));
138 self
139 }
140
141 pub fn with_trace(mut self, trace: Option<TraceContext>) -> Self {
143 self.trace = trace;
144 self
145 }
146}
147
148#[derive(Debug, Default)]
150pub struct AuditLog {
151 records: Mutex<VecDeque<AuditRecord>>,
152 entries: Mutex<VecDeque<AuditEntry>>,
153 journal: Mutex<Option<Arc<FileOperationalJournal>>>,
154 journal_error: Mutex<Option<String>>,
155}
156
157impl Clone for AuditLog {
158 fn clone(&self) -> Self {
159 let guard = self.records.lock();
160 Self {
161 records: Mutex::new(guard.clone()),
162 entries: Mutex::new(self.entries.lock().clone()),
163 journal: Mutex::new(self.journal.lock().clone()),
164 journal_error: Mutex::new(self.journal_error.lock().clone()),
165 }
166 }
167}
168
169impl AuditLog {
170 pub fn new() -> Self {
172 Self::default()
173 }
174
175 pub fn attach_journal(&self, journal: Arc<FileOperationalJournal>) {
177 let mut entries = journal.audit_entries();
178 if entries.len() > MAX_AUDIT_RECORDS {
179 entries.drain(..entries.len() - MAX_AUDIT_RECORDS);
180 }
181 *self.entries.lock() = entries.into();
182 *self.journal.lock() = Some(journal);
183 *self.journal_error.lock() = None;
184 }
185
186 pub fn durability_error(&self) -> Option<String> {
188 self.journal_error.lock().clone()
189 }
190
191 pub fn push(&self, mut record: AuditRecord) {
193 record.message = record.message.map(|message| redact_text(&message));
194 let completed_at_ms = now_ms();
195 let entry = AuditEntry::new(
196 AuditCategory::Command,
197 record.command_id.clone(),
198 record.command_name.as_str(),
199 record.timestamp_ms,
200 completed_at_ms,
201 record.outcome,
202 )
203 .with_runtime_scope(&record.app_id, &record.node_id)
204 .with_message(record.message.clone())
205 .with_trace(record.trace.clone());
206 let mut guard = self.records.lock();
207 while guard.len() >= MAX_AUDIT_RECORDS {
208 guard.pop_front();
209 }
210 guard.push_back(record);
211 drop(guard);
212 self.push_entry(entry);
213 }
214
215 pub fn push_entry(&self, mut entry: AuditEntry) {
217 entry.message = entry.message.map(|message| redact_text(&message));
218 if let Some(journal) = self.journal.lock().clone() {
219 if let Err(error) = journal.append_audit(entry.clone()) {
220 *self.journal_error.lock() = Some(redact_text(&format!("{error:?}")));
221 }
222 }
223 let mut entries = self.entries.lock();
224 while entries.len() >= MAX_AUDIT_RECORDS {
225 entries.pop_front();
226 }
227 entries.push_back(entry);
228 }
229
230 pub fn len(&self) -> usize {
232 self.records.lock().len()
233 }
234
235 pub fn is_empty(&self) -> bool {
237 self.records.lock().is_empty()
238 }
239
240 pub fn records(&self) -> Vec<AuditRecord> {
242 self.records.lock().iter().cloned().collect()
243 }
244
245 pub fn entries(&self) -> Vec<AuditEntry> {
247 self.entries.lock().iter().cloned().collect()
248 }
249
250 pub fn export_jsonl(&self) -> Result<String, serde_json::Error> {
252 let entries = self.entries.lock();
253 let mut output = String::new();
254 for entry in entries.iter() {
255 output.push_str(&serde_json::to_string(entry)?);
256 output.push('\n');
257 }
258 Ok(output)
259 }
260}
261
262fn now_ms() -> u64 {
263 std::time::SystemTime::now()
264 .duration_since(std::time::UNIX_EPOCH)
265 .map(|duration| duration.as_millis() as u64)
266 .unwrap_or(0)
267}
268
269#[cfg(test)]
270#[path = "audit_tests.rs"]
271mod tests;