manta_server/server/common/
audit.rs

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