Skip to main content

krabka_audit/
event.rs

1//! Internal audit event model.
2//!
3//! This module is the source of truth for the KSI-MLA-LET catalog.
4
5use serde::Serialize;
6
7/// Outcome of an audited action.
8///
9/// `as_str` is the lowercase status the audit sink records.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, krabka_macros::EnumStr)]
11#[enum_str(case = "lowercase")]
12pub enum AuditOutcome {
13    Success,
14    Failure,
15}
16
17/// The actor responsible for an audited action.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
19pub struct AuditPrincipal {
20    pub name: String,
21    pub auth_method: String,
22}
23
24/// Network source of the action.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
26pub struct AuditEndpoint {
27    pub ip: String,
28    pub port: u16,
29}
30
31/// A resource affected by an admin operation.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
33pub struct AuditResource {
34    pub resource_type: String,
35    pub name: String,
36}
37
38/// Broker lifecycle transitions worth auditing.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
40pub enum LifecycleKind {
41    BrokerStarted,
42    BrokerStopping,
43    ConfigApplied,
44    TlsReloaded,
45}
46
47/// Stage of a privileged action that the audit record captures.
48///
49/// `Attempted` is the durable write-ahead admission record. A freeze then
50/// reaches `Applied` directly. A two-person action walks `Proposed` ->
51/// `Approved` -> `Consumed` -> `Applied`. `Refused` records a gate that fell
52/// closed, and `Bypassed` records a gated action that ran without an approval.
53///
54/// `as_name` is the stable lowercase name for the OCSF body.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, krabka_macros::EnumStr)]
56#[enum_str(case = "lowercase", as_str = as_name, parse = from_name)]
57pub enum PrivilegedPhase {
58    Attempted,
59    Proposed,
60    Approved,
61    Consumed,
62    Applied,
63    Refused,
64    Bypassed,
65}
66
67/// OCSF class group for record headers and routing.
68///
69/// `as_header` is the stable lowercase identifier for the `event_class` record
70/// header value.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, krabka_macros::EnumStr)]
72#[enum_str(case = "snake_case", as_str = as_header, parse = from_header)]
73pub enum AuditEventClass {
74    Authentication,
75    Authorization,
76    ApiActivity,
77    ApplicationLifecycle,
78    /// Internal meta-record for a signed chain checkpoint. It is not an OCSF event.
79    Checkpoint,
80    /// Internal meta-record that declares audit records were lost. It is not an OCSF event.
81    RecordsLost,
82}
83
84impl AuditEventClass {
85    /// Compact tag for the spool frame format.
86    #[must_use]
87    pub fn tag(self) -> u8 {
88        match self {
89            Self::Authentication => 0,
90            Self::Authorization => 1,
91            Self::ApiActivity => 2,
92            Self::ApplicationLifecycle => 3,
93            Self::Checkpoint => 4,
94            Self::RecordsLost => 5,
95        }
96    }
97
98    /// Inverse of [`Self::tag`].
99    #[must_use]
100    pub fn from_tag(tag: u8) -> Option<Self> {
101        match tag {
102            0 => Some(Self::Authentication),
103            1 => Some(Self::Authorization),
104            2 => Some(Self::ApiActivity),
105            3 => Some(Self::ApplicationLifecycle),
106            4 => Some(Self::Checkpoint),
107            5 => Some(Self::RecordsLost),
108            _ => None,
109        }
110    }
111}
112
113/// A single auditable security event.
114///
115/// The caller supplies the times as epoch-millis, so the crate stays pure and
116/// deterministically testable.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum AuditEvent {
119    Authentication {
120        outcome: AuditOutcome,
121        mechanism: String,
122        principal: AuditPrincipal,
123        source: AuditEndpoint,
124        reason: Option<String>,
125        time_ms: i64,
126    },
127    AuthorizationDenied {
128        principal: AuditPrincipal,
129        source: AuditEndpoint,
130        resource_type: String,
131        resource_name: String,
132        operation: String,
133        time_ms: i64,
134    },
135    AdminOperation {
136        outcome: AuditOutcome,
137        principal: AuditPrincipal,
138        source: AuditEndpoint,
139        operation: String,
140        resources: Vec<AuditResource>,
141        time_ms: i64,
142    },
143    /// A privileged action, with the evidence that authorised it.
144    ///
145    /// A topic freeze, a thaw, an unclean leader election, and every other
146    /// action behind the two-person rule write this variant. It is not named
147    /// for break-glass, because a freeze is not a break-glass act and still
148    /// carries the same evidence.
149    ///
150    /// The record keeps the detached `signature` itself, not only
151    /// `signature_verified`. The audit log is hash-chained and its checkpoints
152    /// are Ed25519-signed, so an auditor who trusts the audit chain and holds
153    /// the operator public keys can re-verify who set every freeze from the
154    /// audit topic alone, with no broker and no raft log. That is a second,
155    /// independent copy of the proof.
156    ///
157    /// `proposal_id` is empty when the action needed no proposal, such as a
158    /// freeze. `counterparties` holds the approvers and is empty for the same
159    /// case. `key_id` and `signature` are empty when the action was unsigned.
160    /// `approver_set_fingerprint` is the SHA-256 hex of the sorted configured
161    /// approver list, evaluated at approval time, so a later divergence of
162    /// that list is visible after the fact.
163    PrivilegedAction {
164        outcome: AuditOutcome,
165        phase: PrivilegedPhase,
166        action: String,
167        target: String,
168        proposal_id: String,
169        principal: AuditPrincipal,
170        counterparties: Vec<AuditPrincipal>,
171        approver_set_fingerprint: String,
172        key_id: String,
173        signature: Vec<u8>,
174        signature_verified: bool,
175        /// The timestamp that the operator's detached signature covers.
176        ///
177        /// For a topic freeze this is the record's `set_at_ms`, which sits
178        /// inside the signed preimage. It is carried here because `time_ms` is
179        /// taken when the event is emitted, which is a different instant: an
180        /// auditor rebuilding the preimage from this record needs the stamp the
181        /// operator signed, not the one the broker logged.
182        ///
183        /// `0` where the event carries no signature over a timestamp, which is
184        /// every unsigned action and every break-glass act -- a break-glass
185        /// approval signs the proposal's `created_at_ms` and `expires_at_ms`
186        /// instead, and neither is a single stamp this field could hold.
187        signed_at_ms: i64,
188        source: AuditEndpoint,
189        reason: String,
190        time_ms: i64,
191    },
192    Lifecycle {
193        kind: LifecycleKind,
194        node_id: i64,
195        time_ms: i64,
196    },
197}
198
199impl AuditEvent {
200    /// The OCSF class this event maps to.
201    #[must_use]
202    pub fn class(&self) -> AuditEventClass {
203        match self {
204            Self::Authentication { .. } => AuditEventClass::Authentication,
205            Self::AuthorizationDenied { .. } => AuditEventClass::Authorization,
206            Self::AdminOperation { .. } | Self::PrivilegedAction { .. } => {
207                AuditEventClass::ApiActivity
208            }
209            Self::Lifecycle { .. } => AuditEventClass::ApplicationLifecycle,
210        }
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use {
217        super::*,
218        crate::test_support::{admin, authentication, denied, privileged_freeze},
219        assert2::check,
220    };
221
222    #[yare::parameterized(
223        authentication = { AuditEventClass::Authentication },
224        authorization = { AuditEventClass::Authorization },
225        api_activity = { AuditEventClass::ApiActivity },
226        application_lifecycle = { AuditEventClass::ApplicationLifecycle },
227        checkpoint = { AuditEventClass::Checkpoint },
228        records_lost = { AuditEventClass::RecordsLost },
229    )]
230    fn class_tag_round_trips_and_header_maps(class: AuditEventClass) {
231        check!(AuditEventClass::from_tag(class.tag()) == Some(class));
232        check!(AuditEventClass::from_header(class.as_header()) == Some(class));
233    }
234
235    #[test]
236    fn class_tag_and_header_reject_unknown_values() {
237        check!(AuditEventClass::from_tag(99) == None);
238        check!(AuditEventClass::from_header("nope") == None);
239    }
240
241    #[test]
242    fn event_class_maps_each_variant() {
243        let authn = authentication(51120, Some("authentication failed"), 1_700_000_000_000);
244        let denied = denied(1);
245        let admin = admin(2);
246        let privileged = privileged_freeze(vec![0xde, 0xad], 3, 4);
247        let life = AuditEvent::Lifecycle {
248            kind: LifecycleKind::BrokerStarted,
249            node_id: 1,
250            time_ms: 3,
251        };
252        check!(
253            (
254                authn.class(),
255                denied.class(),
256                admin.class(),
257                privileged.class(),
258                life.class()
259            ) == (
260                AuditEventClass::Authentication,
261                AuditEventClass::Authorization,
262                AuditEventClass::ApiActivity,
263                AuditEventClass::ApiActivity,
264                AuditEventClass::ApplicationLifecycle,
265            )
266        );
267    }
268
269    #[yare::parameterized(
270        attempted = { PrivilegedPhase::Attempted, "attempted" },
271        proposed = { PrivilegedPhase::Proposed, "proposed" },
272        approved = { PrivilegedPhase::Approved, "approved" },
273        consumed = { PrivilegedPhase::Consumed, "consumed" },
274        applied = { PrivilegedPhase::Applied, "applied" },
275        refused = { PrivilegedPhase::Refused, "refused" },
276        bypassed = { PrivilegedPhase::Bypassed, "bypassed" },
277    )]
278    fn privileged_phase_name_round_trips(phase: PrivilegedPhase, name: &str) {
279        check!(phase.as_name() == name);
280        check!(PrivilegedPhase::from_name(phase.as_name()) == Some(phase));
281    }
282
283    #[test]
284    fn privileged_phase_rejects_unknown_name() {
285        check!(PrivilegedPhase::from_name("nope") == None);
286    }
287}