1use std::collections::BTreeMap;
17
18use axum::http::StatusCode;
19use serde::Serialize;
20
21pub const SCHEMA_VERSION: u32 = 1;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
26#[serde(rename_all = "snake_case")]
27pub enum EventKind {
28 ApiRequest,
30 ConsoleSession,
32 AuthAttempt,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
38#[serde(rename_all = "snake_case")]
39pub enum Verdict {
40 Success,
42 Denied,
44 TimedOut,
46 Rejected,
48 Failed,
50}
51
52impl Verdict {
53 pub fn from_status(status: StatusCode) -> Self {
55 match status.as_u16() {
56 401 | 403 => Self::Denied,
57 408 => Self::TimedOut,
58 400..=499 => Self::Rejected,
59 500.. => Self::Failed,
60 _ => Self::Success,
61 }
62 }
63}
64
65#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
72pub struct Actor {
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub username: Option<String>,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub name: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub subject: Option<String>,
82}
83
84#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
86pub struct Client {
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub ip: Option<String>,
90 #[serde(skip_serializing_if = "Option::is_none")]
93 pub forwarded_for: Option<String>,
94}
95
96#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
98pub struct Request {
99 pub method: String,
101 pub route: String,
103 pub path: String,
105 #[serde(skip_serializing_if = "Option::is_none")]
107 pub query: Option<String>,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
112pub struct Outcome {
113 pub result: Verdict,
115 pub status: u16,
117 pub duration_ms: u64,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
125pub struct Emitter {
126 pub service: &'static str,
128 pub version: &'static str,
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub host: Option<String>,
133}
134
135#[derive(Debug, Clone, PartialEq, Serialize)]
137pub struct AuditEvent {
138 pub schema_version: u32,
140 pub event_id: String,
142 pub timestamp: String,
144 pub kind: EventKind,
146 pub action: String,
150 pub summary: String,
154 pub actor: Actor,
156 pub client: Client,
158 #[serde(skip_serializing_if = "Option::is_none")]
160 pub site: Option<String>,
161 pub request: Request,
163 pub outcome: Outcome,
165 #[serde(skip_serializing_if = "Option::is_none")]
169 pub dry_run: Option<bool>,
170 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
177 pub targets: BTreeMap<String, Vec<String>>,
178 #[serde(skip_serializing_if = "Vec::is_empty")]
186 pub nodes: Vec<String>,
187 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
192 pub produced: BTreeMap<String, String>,
193 #[serde(skip_serializing_if = "serde_json::Map::is_empty")]
196 pub details: serde_json::Map<String, serde_json::Value>,
197 pub emitter: Emitter,
199}
200
201const SUMMARY_MAX_VALUES: usize = 5;
203
204impl AuditEvent {
205 pub fn describe(&self) -> String {
220 use std::fmt::Write as _;
221
222 let who = self
223 .actor
224 .username
225 .as_deref()
226 .or(self.actor.subject.as_deref())
227 .unwrap_or("anonymous");
228 let mut line = match &self.site {
229 Some(site) => format!("{who} @ {site}:"),
230 None => format!("{who}:"),
231 };
232
233 if self.action.contains(' ') {
234 let _ = write!(line, " {} {}", self.request.method, self.request.path);
236 } else {
237 let _ = write!(line, " {}", self.action.replace(['.', '_'], " "));
238 for values in self.targets.values() {
239 for v in values.iter().take(SUMMARY_MAX_VALUES) {
240 let _ = write!(line, " {v}");
241 }
242 if values.len() > SUMMARY_MAX_VALUES {
243 let _ =
244 write!(line, " (+{} more)", values.len() - SUMMARY_MAX_VALUES);
245 }
246 }
247 }
248
249 let mut notes = Vec::new();
250 match self.nodes.len() {
251 0 => {}
252 1 => notes.push("1 node".to_string()),
253 n => notes.push(format!("{n} nodes")),
254 }
255 if self.dry_run == Some(true) {
256 notes.push("dry run".to_string());
257 }
258 if self.details.get("force") == Some(&serde_json::Value::Bool(true)) {
259 notes.push("force".to_string());
260 }
261 if !notes.is_empty() {
262 let _ = write!(line, " ({})", notes.join(", "));
263 }
264
265 let result = serde_json::to_value(self.outcome.result)
266 .ok()
267 .and_then(|v| v.as_str().map(str::to_string))
268 .unwrap_or_default()
269 .replace('_', " ");
270 let _ = write!(line, " → {result}");
271 if self.outcome.result != Verdict::Success {
272 let _ = write!(line, " ({})", self.outcome.status);
273 }
274 for (kind, id) in &self.produced {
275 let _ = write!(line, ", {kind} {id}");
276 }
277 line
282 .chars()
283 .flat_map(|c| {
284 let escaped: Vec<char> = if c.is_control() {
285 c.escape_default().collect()
286 } else {
287 vec![c]
288 };
289 escaped
290 })
291 .collect()
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 fn event(action: &str) -> AuditEvent {
300 AuditEvent {
301 schema_version: SCHEMA_VERSION,
302 event_id: "e".into(),
303 timestamp: "t".into(),
304 kind: EventKind::ApiRequest,
305 action: action.into(),
306 summary: String::new(),
307 actor: Actor {
308 username: Some("rjanalik".into()),
309 ..Actor::default()
310 },
311 client: Client::default(),
312 site: Some("alps".into()),
313 request: Request {
314 method: "POST".into(),
315 route: "/v2/power".into(),
316 path: "/v2/power".into(),
317 query: None,
318 },
319 outcome: Outcome {
320 result: Verdict::Success,
321 status: 200,
322 duration_ms: 1,
323 },
324 dry_run: None,
325 targets: BTreeMap::new(),
326 nodes: Vec::new(),
327 produced: BTreeMap::new(),
328 details: serde_json::Map::new(),
329 emitter: Emitter {
330 service: "manta-server",
331 version: "0",
332 host: None,
333 },
334 }
335 }
336
337 #[test]
338 fn describe_names_who_what_where_and_how_it_ended() {
339 let mut e = event("power.off");
340 e.targets.insert("hosts".into(), vec!["nid001288".into()]);
341 e.nodes = vec!["x1000c0s0b0n0".into()];
342 e.details.insert("force".into(), serde_json::json!(true));
343 e.produced.insert("transition".into(), "abc-123".into());
344 assert_eq!(
345 e.describe(),
346 "rjanalik @ alps: power off nid001288 (1 node, force) → success, \
347 transition abc-123"
348 );
349 }
350
351 #[test]
352 fn describe_marks_dry_runs_and_failures() {
353 let mut e = event("boot_parameters.delete");
354 e.dry_run = Some(true);
355 e.outcome = Outcome {
356 result: Verdict::TimedOut,
357 status: 408,
358 duration_ms: 1,
359 };
360 assert_eq!(
361 e.describe(),
362 "rjanalik @ alps: boot parameters delete (dry run) → timed out (408)"
363 );
364 }
365
366 #[test]
367 fn describe_escapes_control_characters() {
368 let mut e = event("group.delete");
369 e.actor.username = Some("x\nINFO forged".into());
370 e.targets
371 .insert("group".into(), vec!["g\r\u{1b}[31m".into()]);
372 let line = e.describe();
373 assert!(!line.chars().any(char::is_control), "{line:?}");
374 assert_eq!(
375 line,
376 "x\\nINFO forged @ alps: group delete g\\r\\u{1b}[31m → success"
377 );
378 }
379
380 #[test]
381 fn describe_truncates_long_target_lists() {
382 let mut e = event("image.delete");
383 e.targets
384 .insert("image".into(), (1..=7).map(|i| format!("i{i}")).collect());
385 assert_eq!(
386 e.describe(),
387 "rjanalik @ alps: image delete i1 i2 i3 i4 i5 (+2 more) → success"
388 );
389 }
390
391 #[test]
392 fn describe_falls_back_to_method_and_path() {
393 let mut e = event("DELETE /v2/groups/{label}");
394 e.actor = Actor::default();
395 e.site = None;
396 e.request.method = "DELETE".into();
397 e.request.path = "/v2/groups/zinal".into();
398 e.outcome = Outcome {
399 result: Verdict::Denied,
400 status: 401,
401 duration_ms: 1,
402 };
403 assert_eq!(
404 e.describe(),
405 "anonymous: DELETE /v2/groups/zinal → denied (401)"
406 );
407 }
408
409 #[test]
410 fn result_classifies_statuses() {
411 let r = |s: u16| Verdict::from_status(StatusCode::from_u16(s).unwrap());
412 assert_eq!(r(101), Verdict::Success);
413 assert_eq!(r(200), Verdict::Success);
414 assert_eq!(r(204), Verdict::Success);
415 assert_eq!(r(400), Verdict::Rejected);
416 assert_eq!(r(401), Verdict::Denied);
417 assert_eq!(r(403), Verdict::Denied);
418 assert_eq!(r(404), Verdict::Rejected);
419 assert_eq!(r(408), Verdict::TimedOut);
420 assert_eq!(r(409), Verdict::Rejected);
421 assert_eq!(r(500), Verdict::Failed);
422 assert_eq!(r(502), Verdict::Failed);
423 }
424
425 #[test]
426 fn result_serialises_snake_case() {
427 assert_eq!(
428 serde_json::to_value(Verdict::TimedOut).unwrap(),
429 serde_json::json!("timed_out")
430 );
431 assert_eq!(
432 serde_json::to_value(EventKind::ConsoleSession).unwrap(),
433 serde_json::json!("console_session")
434 );
435 }
436}