nodedb_cluster/auth/audit.rs
1// SPDX-License-Identifier: BUSL-1.1
2
3//! Audit log entries for bootstrap join-token lifecycle events.
4//!
5//! Every accept/reject/expire transition that happens inside the
6//! bootstrap listener writes an `AuditEvent` here. In production the
7//! `AuditWriter` trait is implemented by whatever audit-WAL the host
8//! crate provides. The cluster crate supplies a no-op implementation
9//! for tests and a `VecAuditWriter` for unit verification.
10//!
11//! **Persist-before-respond contract:** callers MUST call
12//! [`AuditWriter::append`] and await/unwrap the result before sending
13//! any response to the joiner. This ensures audit records are durable
14//! even when the process crashes immediately after sending the response.
15
16use std::net::SocketAddr;
17use std::sync::{Arc, Mutex};
18use std::time::{SystemTime, UNIX_EPOCH};
19
20/// Outcome of a bootstrap join attempt.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum JoinOutcome {
23 /// Token verified; bundle sent; state transitioned to `InFlight`.
24 Accepted,
25 /// Token MAC invalid or token not registered.
26 Rejected { reason: String },
27 /// Token's expiry timestamp has passed.
28 TokenExpired,
29 /// Token already consumed — replay attempt.
30 Replayed,
31 /// Bundle delivered and joiner ACK received; state → `Consumed`.
32 Consumed,
33 /// In-flight dead-man timer fired; state reverted to `Issued`.
34 InFlightTimeout,
35}
36
37/// One audit record written per bootstrap join event.
38#[derive(Debug, Clone)]
39pub struct AuditEvent {
40 /// Unix milliseconds at event time.
41 pub ts_ms: u64,
42 /// SHA-256 of the token (never the raw token).
43 pub token_hash: [u8; 32],
44 /// Remote address of the connecting joiner (may be `None` for
45 /// internally-generated events like timeouts).
46 pub joiner_addr: Option<SocketAddr>,
47 /// Node id the joiner claimed in the request.
48 pub claimed_node_id: u64,
49 pub outcome: JoinOutcome,
50}
51
52impl AuditEvent {
53 pub fn new(
54 token_hash: [u8; 32],
55 joiner_addr: Option<SocketAddr>,
56 claimed_node_id: u64,
57 outcome: JoinOutcome,
58 ) -> Self {
59 let ts_ms = SystemTime::now()
60 .duration_since(UNIX_EPOCH)
61 .map(|d| d.as_millis() as u64)
62 .unwrap_or(0);
63 Self {
64 ts_ms,
65 token_hash,
66 joiner_addr,
67 claimed_node_id,
68 outcome,
69 }
70 }
71}
72
73/// Abstraction over where audit events are persisted.
74///
75/// Implementations MUST be synchronous and durable before returning so
76/// callers can safely respond to joiners knowing the audit record is on
77/// disk. The contract is: `append` returns only when the event is fsync'd
78/// (or the underlying log has accepted it under its own durability
79/// guarantee).
80pub trait AuditWriter: Send + Sync + 'static {
81 fn append(&self, event: AuditEvent);
82}
83
84/// No-op writer used when no audit log is configured.
85#[derive(Default, Clone)]
86pub struct NoopAuditWriter;
87
88impl AuditWriter for NoopAuditWriter {
89 fn append(&self, _event: AuditEvent) {}
90}
91
92/// In-memory writer for tests — accumulates events in a `Vec` behind
93/// a `Mutex` so tests can inspect what was logged.
94#[derive(Default, Clone)]
95pub struct VecAuditWriter {
96 events: Arc<Mutex<Vec<AuditEvent>>>,
97}
98
99impl VecAuditWriter {
100 pub fn new() -> Self {
101 Self::default()
102 }
103
104 /// Drain all events for inspection.
105 pub fn drain(&self) -> Vec<AuditEvent> {
106 let mut guard = self.events.lock().expect("audit lock poisoned");
107 std::mem::take(&mut *guard)
108 }
109
110 /// Return a snapshot of all events without draining.
111 pub fn snapshot(&self) -> Vec<AuditEvent> {
112 self.events.lock().expect("audit lock poisoned").clone()
113 }
114}
115
116impl AuditWriter for VecAuditWriter {
117 fn append(&self, event: AuditEvent) {
118 self.events.lock().expect("audit lock poisoned").push(event);
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[test]
127 fn vec_writer_accumulates_and_drains() {
128 let w = VecAuditWriter::new();
129 w.append(AuditEvent::new([1u8; 32], None, 1, JoinOutcome::Accepted));
130 w.append(AuditEvent::new(
131 [2u8; 32],
132 None,
133 2,
134 JoinOutcome::Rejected {
135 reason: "bad mac".into(),
136 },
137 ));
138 let events = w.drain();
139 assert_eq!(events.len(), 2);
140 assert_eq!(events[0].outcome, JoinOutcome::Accepted);
141 assert!(matches!(events[1].outcome, JoinOutcome::Rejected { .. }));
142 // Drain empties the buffer.
143 assert!(w.drain().is_empty());
144 }
145
146 #[test]
147 fn noop_writer_does_not_panic() {
148 let w = NoopAuditWriter;
149 w.append(AuditEvent::new([0u8; 32], None, 0, JoinOutcome::Consumed));
150 }
151}