Skip to main content

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}