Skip to main content

kipuka/audit/
mod.rs

1//! Structured audit trail (NIAP CA PP FAU family).
2//!
3//! All EST and administrative operations that must be logged for Common
4//! Criteria evaluation call [`record`].  The function inserts one row into
5//! `audit_events`, enforces the overflow policy (FAU_STG.4), and maintains
6//! the rolling security-violation counter for the alarm response (FAU_ARP.1).
7//!
8//! # NIAP CA PP requirements implemented
9//!
10//! | SFR | Requirement | Implementation |
11//! |-----|-------------|----------------|
12//! | FAU_GEN.1 | Audit record generation | [`AuditEventType`] taxonomy covers all required events |
13//! | FAU_STG.1(1) | Audit trail protection | Append-only at application level |
14//! | FAU_STG.4 | Audit storage exhaustion | [`OverflowAction::Halt`] rejects EST operations |
15//! | FAU_ARP.1 | Security alarm | Alarm after N consecutive violations |
16
17use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
18
19/// Every auditable operation the server can perform.
20///
21/// NIAP CA PP FAU_GEN.1: the following categories of events MUST be
22/// auditable:
23///
24/// - Certificate lifecycle (enrollment, re-enrollment, rejection, revocation)
25/// - Key management (generation, destruction, HSM operations)
26/// - OTP lifecycle (creation, usage, expiration, revocation)
27/// - Authentication events (success, failure)
28/// - Administrative operations (login, logout, config changes)
29/// - CA health status changes
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum AuditEventType {
32    // ── CA lifecycle ─────────────────────────────────────────────────────
33    /// CA started or restarted.
34    CaStart,
35    /// CA stopped (graceful shutdown).
36    CaStop,
37    /// CA health status changed (degraded, recovered).
38    CaHealthChange,
39
40    // ── Certificate lifecycle ────────────────────────────────────────────
41    /// Certificate enrollment request received.
42    EnrollRequest,
43    /// Certificate issued successfully.
44    CertIssue,
45    /// Certificate re-enrollment completed.
46    CertReenroll,
47    /// Enrollment request rejected.
48    EnrollReject,
49    /// Certificate revoked.
50    CertRevoke,
51    /// CRL generated.
52    CrlGenerate,
53
54    // ── Key management ───────────────────────────────────────────────────
55    /// Signing key generated (software or HSM).
56    KeyGenerate,
57    /// Signing key loaded from file or HSM.
58    KeyLoad,
59    /// Key destroyed or deactivated.
60    KeyDestroy,
61
62    // ── OTP lifecycle (RHELBU-3536 R7) ───────────────────────────────────
63    /// OTP created by administrator.
64    OtpCreate,
65    /// OTP used for enrollment authentication.
66    OtpUse,
67    /// OTP expired (TTL reached).
68    OtpExpire,
69    /// OTP revoked by administrator.
70    OtpRevoke,
71
72    // ── Authentication ───────────────────────────────────────────────────
73    /// Client authentication succeeded (mTLS, OTP, Basic, etc.).
74    AuthSuccess,
75    /// Client authentication failed.
76    AuthFailure,
77
78    // ── Admin operations ─────────────────────────────────────────────────
79    /// Admin operator logged in.
80    AdminLogin,
81    /// Admin operator logged out.
82    AdminLogout,
83    /// Admin performed a privileged operation.
84    AdminAction,
85
86    // ── Security anomalies ───────────────────────────────────────────────
87    /// Security violation detected (repeated auth failures, etc.).
88    SecurityViolation,
89}
90
91impl AuditEventType {
92    /// Return the canonical dot-separated string for this event type.
93    pub fn as_str(self) -> &'static str {
94        match self {
95            AuditEventType::CaStart => "ca.start",
96            AuditEventType::CaStop => "ca.stop",
97            AuditEventType::CaHealthChange => "ca.health-change",
98            AuditEventType::EnrollRequest => "enroll.request",
99            AuditEventType::CertIssue => "cert.issue",
100            AuditEventType::CertReenroll => "cert.reenroll",
101            AuditEventType::EnrollReject => "enroll.reject",
102            AuditEventType::CertRevoke => "cert.revoke",
103            AuditEventType::CrlGenerate => "crl.generate",
104            AuditEventType::KeyGenerate => "key.generate",
105            AuditEventType::KeyLoad => "key.load",
106            AuditEventType::KeyDestroy => "key.destroy",
107            AuditEventType::OtpCreate => "otp.create",
108            AuditEventType::OtpUse => "otp.use",
109            AuditEventType::OtpExpire => "otp.expire",
110            AuditEventType::OtpRevoke => "otp.revoke",
111            AuditEventType::AuthSuccess => "auth.success",
112            AuditEventType::AuthFailure => "auth.failure",
113            AuditEventType::AdminLogin => "admin.login",
114            AuditEventType::AdminLogout => "admin.logout",
115            AuditEventType::AdminAction => "admin.action",
116            AuditEventType::SecurityViolation => "security.violation",
117        }
118    }
119}
120
121/// A single audit event ready for recording.
122pub struct AuditEvent {
123    /// The type of auditable event.
124    pub event_type: AuditEventType,
125    /// CA identifier (when the event is CA-specific).
126    pub ca_id: Option<String>,
127    /// Subject of the event (e.g., certificate subject DN, operator name).
128    pub subject: Option<String>,
129    /// Human-readable detail string.
130    pub detail: Option<String>,
131    /// Client IP address (when applicable).
132    pub client_addr: Option<String>,
133    /// Operator identity (for admin actions).
134    pub operator: Option<String>,
135}
136
137impl AuditEvent {
138    /// Create a new audit event with the given type.
139    pub fn new(event_type: AuditEventType) -> Self {
140        Self {
141            event_type,
142            ca_id: None,
143            subject: None,
144            detail: None,
145            client_addr: None,
146            operator: None,
147        }
148    }
149
150    /// Builder: set the CA ID.
151    pub fn with_ca_id(mut self, ca_id: impl Into<String>) -> Self {
152        self.ca_id = Some(ca_id.into());
153        self
154    }
155
156    /// Builder: set the subject.
157    pub fn with_subject(mut self, subject: impl Into<String>) -> Self {
158        self.subject = Some(subject.into());
159        self
160    }
161
162    /// Builder: set the detail.
163    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
164        self.detail = Some(detail.into());
165        self
166    }
167
168    /// Builder: set the client address.
169    pub fn with_client_addr(mut self, addr: impl Into<String>) -> Self {
170        self.client_addr = Some(addr.into());
171        self
172    }
173
174    /// Builder: set the operator.
175    pub fn with_operator(mut self, operator: impl Into<String>) -> Self {
176        self.operator = Some(operator.into());
177        self
178    }
179}
180
181/// Shared audit state (overflow flag, alarm counter).
182///
183/// Lives in `AppState` and survives the lifetime of the server process.
184/// Callers pass the database pool explicitly so the same state can be
185/// used from any async context.
186pub struct AuditState {
187    /// When `true`, EST operations MUST be rejected (FAU_STG.4 halt).
188    pub halted: AtomicBool,
189
190    /// Rolling count of consecutive security violations.
191    /// Reset to 0 after a successful authentication.
192    pub violation_count: AtomicU32,
193}
194
195impl AuditState {
196    /// Create a new audit state with no violations and not halted.
197    pub fn new() -> Self {
198        Self {
199            halted: AtomicBool::new(false),
200            violation_count: AtomicU32::new(0),
201        }
202    }
203
204    /// Check whether EST operations should be rejected due to audit
205    /// storage exhaustion (FAU_STG.4).
206    pub fn is_halted(&self) -> bool {
207        self.halted.load(Ordering::Relaxed)
208    }
209
210    /// Set the halted flag (called when audit storage is full and
211    /// overflow policy is `halt`).
212    pub fn set_halted(&self, halted: bool) {
213        self.halted.store(halted, Ordering::Relaxed);
214    }
215
216    /// Increment the security violation counter and return the new count.
217    pub fn record_violation(&self) -> u32 {
218        self.violation_count.fetch_add(1, Ordering::Relaxed) + 1
219    }
220
221    /// Reset the violation counter (called after successful authentication).
222    pub fn reset_violations(&self) {
223        self.violation_count.store(0, Ordering::Relaxed);
224    }
225}
226
227impl Default for AuditState {
228    fn default() -> Self {
229        Self::new()
230    }
231}
232
233/// Record an audit event to the database.
234///
235/// When the database insert fails, the error is logged but not propagated
236/// to avoid failing EST operations due to audit backend issues (unless
237/// the overflow policy requires halting).
238pub async fn record(pool: &sqlx::AnyPool, state: &AuditState, event: AuditEvent) {
239    // FAU_STG.4: check overflow before recording
240    if state.is_halted() {
241        tracing::warn!(
242            event_type = event.event_type.as_str(),
243            "audit halted — dropping event"
244        );
245        return;
246    }
247
248    // Pack detail into detail_json with proper JSON escaping (no
249    // format!() interpolation that could allow injection via quotes or
250    // backslashes in detail/ca_id values).
251    let detail_json = match (&event.detail, &event.ca_id) {
252        (Some(d), Some(ca)) => Some(serde_json::json!({"detail": d, "ca_id": ca}).to_string()),
253        (Some(d), None) => Some(serde_json::json!({"detail": d}).to_string()),
254        (None, Some(ca)) => Some(serde_json::json!({"ca_id": ca}).to_string()),
255        (None, None) => None,
256    };
257
258    let sql = crate::db::pg_sql(
259        "INSERT INTO audit_events (event_type, actor, target, detail_json, source_ip, session_id) \
260         VALUES (?, ?, ?, ?, ?, ?)",
261    );
262    let result = sqlx::query(sql)
263        .bind(event.event_type.as_str())
264        .bind(&event.operator)
265        .bind(&event.subject)
266        .bind(&detail_json)
267        .bind(&event.client_addr)
268        .bind(None::<String>)
269        .execute(pool)
270        .await;
271
272    if let Err(e) = result {
273        tracing::error!(
274            event_type = event.event_type.as_str(),
275            error = %e,
276            "failed to record audit event"
277        );
278    }
279
280    // Track security violations for FAU_ARP.1
281    if event.event_type == AuditEventType::SecurityViolation {
282        let count = state.record_violation();
283        tracing::warn!(
284            consecutive_violations = count,
285            "security violation recorded"
286        );
287    } else if event.event_type == AuditEventType::AuthSuccess {
288        state.reset_violations();
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn event_type_strings() {
298        assert_eq!(AuditEventType::CaStart.as_str(), "ca.start");
299        assert_eq!(AuditEventType::CertIssue.as_str(), "cert.issue");
300        assert_eq!(AuditEventType::OtpCreate.as_str(), "otp.create");
301        assert_eq!(AuditEventType::AuthFailure.as_str(), "auth.failure");
302        assert_eq!(AuditEventType::AdminLogin.as_str(), "admin.login");
303        assert_eq!(
304            AuditEventType::SecurityViolation.as_str(),
305            "security.violation"
306        );
307    }
308
309    #[test]
310    fn audit_state_violation_tracking() {
311        let state = AuditState::new();
312        assert_eq!(state.record_violation(), 1);
313        assert_eq!(state.record_violation(), 2);
314        state.reset_violations();
315        assert_eq!(state.record_violation(), 1);
316    }
317
318    #[test]
319    fn audit_state_halt_flag() {
320        let state = AuditState::new();
321        assert!(!state.is_halted());
322        state.set_halted(true);
323        assert!(state.is_halted());
324        state.set_halted(false);
325        assert!(!state.is_halted());
326    }
327
328    #[test]
329    fn audit_event_builder() {
330        let event = AuditEvent::new(AuditEventType::CertIssue)
331            .with_ca_id("production")
332            .with_subject("CN=device.example.com")
333            .with_detail("serial=ABC123")
334            .with_client_addr("10.0.0.1");
335
336        assert_eq!(event.event_type, AuditEventType::CertIssue);
337        assert_eq!(event.ca_id.as_deref(), Some("production"));
338        assert_eq!(event.subject.as_deref(), Some("CN=device.example.com"));
339        assert_eq!(event.detail.as_deref(), Some("serial=ABC123"));
340        assert_eq!(event.client_addr.as_deref(), Some("10.0.0.1"));
341        assert!(event.operator.is_none());
342    }
343}