manta_server/server/common/
audit.rs

1//! Audit trail helpers: build and send structured JSON messages to Kafka.
2
3use manta_shared::common::error::MantaError;
4use serde::{Deserialize, Serialize};
5
6use crate::server::common::kafka::Kafka;
7
8#[derive(Serialize, Deserialize, Debug, Clone)]
9/// Wraps a [`Kafka`] instance for sending audit messages.
10pub struct Auditor {
11  /// Kafka producer configured from `[auditor.kafka]` in the binary's
12  /// config file.
13  pub kafka: Kafka,
14}
15
16/// Trait for producing audit messages to a message broker.
17pub trait Audit {
18  /// Publish a single audit message payload. Implementations are
19  /// expected to be fire-and-forget — failures should be logged but
20  /// not propagated to the caller, since audit failures must not
21  /// abort the outer operation.
22  #[allow(async_fn_in_trait)]
23  async fn produce_message(&self, data: &[u8]) -> Result<(), MantaError>;
24}
25
26/// Serialize a JSON audit message and send it to Kafka.
27///
28/// Logs a warning on failure instead of propagating the
29/// error, since audit failures should not abort the
30/// operation.
31async fn send_audit_message(kafka: &Kafka, msg_json: serde_json::Value) {
32  let msg_data = match serde_json::to_string(&msg_json) {
33    Ok(data) => data,
34    Err(e) => {
35      tracing::warn!("Failed serializing audit message: {}", e);
36      return;
37    }
38  };
39
40  if let Err(e) = kafka.produce_message(msg_data.as_bytes()).await {
41    tracing::warn!("Failed producing audit message: {}", e);
42  }
43}
44
45/// Build the JSON payload that [`send_auth_audit`] sends to Kafka.
46///
47/// Split out so unit tests can pin the wire shape (notably: NO
48/// password field, by construction — the function doesn't take one).
49pub(crate) fn build_auth_audit_message(
50  outcome: &str,
51  username: &str,
52  source_ip: &str,
53  site: &str,
54) -> serde_json::Value {
55  serde_json::json!({
56    "event": "auth_attempt",
57    "outcome": outcome,
58    "username": username,
59    "source_ip": source_ip,
60    "site": site,
61  })
62}
63
64/// Send a structured audit event for an `/api/v1/auth/token` attempt.
65///
66/// Used by the server's auth handler — there is no JWT yet (the user is
67/// asking for one), so identity is captured from the submitted username
68/// rather than extracted from a token. The password is never logged.
69///
70/// Always Kafka-only; failures log a warning and do not abort the
71/// outer auth flow.
72pub async fn send_auth_audit(
73  kafka_opt: Option<&Kafka>,
74  outcome: &str,
75  username: &str,
76  source_ip: &str,
77  site: &str,
78) {
79  let Some(kafka) = kafka_opt else { return };
80  send_audit_message(
81    kafka,
82    build_auth_audit_message(outcome, username, source_ip, site),
83  )
84  .await;
85}
86
87#[cfg(test)]
88mod tests {
89  use super::*;
90
91  // ---- build_auth_audit_message ----
92
93  #[test]
94  fn auth_audit_has_expected_wire_shape() {
95    let msg = build_auth_audit_message("success", "alice", "10.0.0.1", "alps");
96    assert_eq!(msg["event"], "auth_attempt");
97    assert_eq!(msg["outcome"], "success");
98    assert_eq!(msg["username"], "alice");
99    assert_eq!(msg["source_ip"], "10.0.0.1");
100    assert_eq!(msg["site"], "alps");
101  }
102
103  #[test]
104  fn auth_audit_payload_has_no_password_field_by_construction() {
105    // The function doesn't take a password — pin via the wire shape
106    // that no `password` / `passwd` / `secret` key sneaks in.
107    let msg = build_auth_audit_message("failure", "alice", "10.0.0.1", "alps");
108    let obj = msg.as_object().expect("payload is an object");
109    for forbidden in ["password", "passwd", "secret", "token"] {
110      assert!(
111        !obj.contains_key(forbidden),
112        "auth audit payload must not contain `{forbidden}`"
113      );
114    }
115  }
116
117  #[test]
118  fn auth_audit_handles_empty_strings_without_panicking() {
119    // Some auth-failure paths pass empty source_ip or site (when not
120    // resolvable). The function should still produce a well-formed
121    // JSON object, not panic or omit keys.
122    let msg = build_auth_audit_message("failure", "", "", "");
123    assert_eq!(msg["username"], "");
124    assert_eq!(msg["source_ip"], "");
125    assert_eq!(msg["site"], "");
126    assert_eq!(msg["event"], "auth_attempt");
127  }
128}