1use {
4 crate::{
5 chain::to_hex,
6 event::{AuditEvent, AuditEventClass},
7 ocsf::{ProductInfo, to_ocsf},
8 },
9 async_trait::async_trait,
10 std::{
11 fmt,
12 sync::{Mutex, PoisonError},
13 },
14 tracing::field::Empty,
15};
16
17pub const HEADER_SEQ: &str = "seq";
19pub const HEADER_PREV_HASH: &str = "prev_hash";
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct AuditRecord {
25 pub class: AuditEventClass,
26 pub value: Vec<u8>,
27 pub headers: Vec<(String, Vec<u8>)>,
28}
29
30impl AuditRecord {
31 #[must_use]
36 pub fn records_lost(count: u64, generation: u64) -> Self {
37 Self {
38 class: AuditEventClass::RecordsLost,
39 value: format!(r#"{{"records_lost":{count},"loss_generation":{generation}}}"#)
40 .into_bytes(),
41 headers: vec![(
42 "event_class".to_owned(),
43 AuditEventClass::RecordsLost.as_header().as_bytes().to_vec(),
44 )],
45 }
46 }
47
48 pub fn push_chain_headers(&mut self, seq: u64, prev_head: &[u8; 32]) {
53 self.headers
54 .push((HEADER_SEQ.to_owned(), seq.to_string().into_bytes()));
55 self.headers
56 .push((HEADER_PREV_HASH.to_owned(), to_hex(prev_head).into_bytes()));
57 }
58
59 #[must_use]
61 #[tracing::instrument(
62 level = "debug",
63 skip_all,
64 fields(class = ?event.class(), bytes = Empty)
65 )]
66 pub fn from_event(event: &AuditEvent, product: &ProductInfo) -> Self {
67 let class = event.class();
68 let value = serde_json::to_vec(&to_ocsf(event, product)).unwrap_or_else(|_| b"{}".to_vec());
69 tracing::Span::current().record("bytes", value.len());
70 let mut headers = vec![(
71 "event_class".to_owned(),
72 class.as_header().as_bytes().to_vec(),
73 )];
74 if let Some(PrincipalStatus { principal, status }) = principal_and_status(event) {
75 headers.push(("principal".to_owned(), principal.into_bytes()));
76 headers.push(("status".to_owned(), status.as_bytes().to_vec()));
77 }
78 Self {
79 class,
80 value,
81 headers,
82 }
83 }
84}
85
86struct PrincipalStatus {
88 principal: String,
89 status: &'static str,
90}
91
92fn principal_and_status(event: &AuditEvent) -> Option<PrincipalStatus> {
93 match event {
94 AuditEvent::Authentication {
95 principal, outcome, ..
96 }
97 | AuditEvent::AdminOperation {
98 principal, outcome, ..
99 }
100 | AuditEvent::PrivilegedAction {
101 principal, outcome, ..
102 } => Some(PrincipalStatus {
103 principal: principal.name.clone(),
104 status: outcome.as_str(),
105 }),
106 AuditEvent::AuthorizationDenied { principal, .. } => Some(PrincipalStatus {
107 principal: principal.name.clone(),
108 status: "denied",
109 }),
110 AuditEvent::Lifecycle { .. } => None,
111 }
112}
113
114#[derive(Debug, thiserror::Error)]
116pub enum AuditError {
117 #[error("audit sink: {0}")]
118 Sink(String),
119 #[error("audit key: {0}")]
120 Key(String),
121 #[error("audit spool I/O: {0}")]
122 Io(String),
123 #[error("audit unavailable: {0}")]
124 Unavailable(String),
125 #[error("audit durability indeterminate: {0}")]
127 Indeterminate(String),
128 #[error("audit spool requires explicit recovery: {0}")]
129 Poisoned(String),
130 #[error("audit spool file {file}: {}", describe_spool_version(*found))]
133 UnsupportedSpoolFormat {
134 file: String,
136 found: Option<i16>,
139 },
140}
141
142fn describe_spool_version(found: Option<i16>) -> String {
143 found.map_or_else(
144 || {
145 "has no version header; it predates krabka 1.0, so the audit spool directory must \
146 be emptied (the node reformatted) before this broker starts"
147 .to_owned()
148 },
149 |version| format!("has version {version}, which this build does not read"),
150 )
151}
152
153#[async_trait]
155pub trait AuditSink: Send + Sync + fmt::Debug {
156 async fn write(&self, record: AuditRecord, durable: bool) -> Result<(), AuditError>;
159}
160
161#[derive(Debug, Default)]
163pub struct MemorySink {
164 records: Mutex<Vec<AuditRecord>>,
165}
166
167impl MemorySink {
168 #[must_use]
169 pub fn records(&self) -> Vec<AuditRecord> {
170 self.records
171 .lock()
172 .unwrap_or_else(PoisonError::into_inner)
173 .clone()
174 }
175}
176
177#[async_trait]
178impl AuditSink for MemorySink {
179 async fn write(&self, record: AuditRecord, _durable: bool) -> Result<(), AuditError> {
180 self.records
181 .lock()
182 .unwrap_or_else(PoisonError::into_inner)
183 .push(record);
184 Ok(())
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use {super::*, crate::test_support::authentication, assert2::check};
191
192 #[test]
193 fn io_error_displays() {
194 let e = AuditError::Io("disk full".to_owned());
195 check!(format!("{e}").contains("disk full"));
196 }
197
198 #[test]
199 fn records_lost_is_a_distinct_meta_record() {
200 check!(
201 AuditRecord::records_lost(3, 7)
202 == AuditRecord {
203 class: AuditEventClass::RecordsLost,
204 value: br#"{"records_lost":3,"loss_generation":7}"#.to_vec(),
205 headers: vec![("event_class".to_owned(), b"records_lost".to_vec())],
206 }
207 );
208 }
209
210 #[test]
211 fn unavailable_error_displays() {
212 check!(
213 AuditError::Unavailable("spool full".into()).to_string()
214 == "audit unavailable: spool full"
215 );
216 }
217
218 fn hdr(r: &AuditRecord, k: &str) -> Option<String> {
219 r.headers
220 .iter()
221 .find(|(hk, _)| hk == k)
222 .map(|(_, v)| String::from_utf8_lossy(v).into_owned())
223 }
224
225 #[test]
226 fn from_event_sets_principal_and_status_headers() {
227 use crate::{
228 event::{AuditEndpoint, AuditEvent, AuditOutcome, AuditPrincipal, LifecycleKind},
229 ocsf::ProductInfo,
230 };
231 let product = ProductInfo {
232 vendor_name: "v".into(),
233 name: "n".into(),
234 version: "0".into(),
235 };
236 let authn = authentication(1, None, 0);
237 let r = AuditRecord::from_event(&authn, &product);
238 check!(
239 (hdr(&r, "principal"), hdr(&r, "status"))
240 == (Some("alice".into()), Some("failure".into()))
241 );
242 let admin = AuditEvent::AdminOperation {
243 outcome: AuditOutcome::Success,
244 principal: AuditPrincipal {
245 name: "bob".into(),
246 auth_method: "MTls".into(),
247 },
248 source: AuditEndpoint {
249 ip: "10.0.0.2".into(),
250 port: 2,
251 },
252 operation: "CreateTopics".into(),
253 resources: vec![],
254 time_ms: 0,
255 };
256 let r = AuditRecord::from_event(&admin, &product);
257 check!(
258 (hdr(&r, "principal"), hdr(&r, "status"))
259 == (Some("bob".into()), Some("success".into()))
260 );
261 let deny = AuditEvent::AuthorizationDenied {
262 principal: AuditPrincipal {
263 name: "carol".into(),
264 auth_method: "MTls".into(),
265 },
266 source: AuditEndpoint {
267 ip: "10.0.0.3".into(),
268 port: 3,
269 },
270 resource_type: "Topic".into(),
271 resource_name: "t".into(),
272 operation: "Write".into(),
273 time_ms: 0,
274 };
275 let r = AuditRecord::from_event(&deny, &product);
276 check!(
277 (hdr(&r, "principal"), hdr(&r, "status"))
278 == (Some("carol".into()), Some("denied".into()))
279 );
280 let life = AuditEvent::Lifecycle {
281 kind: LifecycleKind::BrokerStarted,
282 node_id: 1,
283 time_ms: 0,
284 };
285 let r = AuditRecord::from_event(&life, &product);
286 check!((hdr(&r, "principal"), hdr(&r, "status")) == (None, None));
287 }
288}