Skip to main content

chronix_security/audit/
model.rs

1//! Audit event model and types.
2
3use serde::{Deserialize, Serialize};
4use sha2::Sha256;
5use std::collections::HashMap;
6use std::fmt;
7
8/// The result of an authorization decision or operation.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum AuditDecision {
12    /// The operation was allowed.
13    Allow,
14    /// The operation was denied.
15    Deny,
16}
17
18impl fmt::Display for AuditDecision {
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match self {
21            Self::Allow => write!(f, "allow"),
22            Self::Deny => write!(f, "deny"),
23        }
24    }
25}
26
27/// Category of auditable actions.
28///
29/// Every variant has a producer. That was not true: **nineteen of twenty-five
30/// were constructed by nothing** — `NamespaceDelete`, `DataExport`,
31/// `PolicyLoad`, `ApiKeyCreate` and `SchemaChange` among them — while the
32/// hardening checklist told an operator to "review audit event coverage
33/// (26 built-in action types)". A category nothing emits is not coverage; it
34/// is a gap wearing the shape of one. `audit_action_coverage` walks this enum
35/// and requires a construction site outside this crate for each.
36#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum AuditAction {
39    // ── Data ────────────────────────────────────────────────────────
40    /// Data write.
41    Write,
42    /// Data read / query.
43    Read,
44    /// Data deletion.
45    Delete,
46    /// Schema change — a column declared, a measurement dropped or restored.
47    SchemaChange,
48    /// Data exported out of the database.
49    DataExport,
50
51    // ── Authentication ──────────────────────────────────────────────
52    /// Failed authentication attempt.
53    ///
54    /// There is deliberately no `LoginSuccess`: a database authenticates
55    /// every request, so recording the successes would make the trail a
56    /// second request log and bury the events it exists for.
57    LoginFailure,
58
59    // ── Administration ──────────────────────────────────────────────
60    /// Administrative operation — backup, restore, a runtime setting.
61    Admin,
62    /// API key created.
63    ApiKeyCreate,
64    /// API key revoked.
65    ApiKeyRevoke,
66    /// Cedar policy set loaded.
67    PolicyLoad,
68    /// Namespace created.
69    NamespaceCreate,
70    /// Namespace deleted.
71    NamespaceDelete,
72    /// Namespace quota changed.
73    QuotaChange,
74
75    // ── Rollups, triggers and signals ───────────────────────────────
76    /// Rollup rule created.
77    CreateRollup,
78    /// Rollup rule dropped.
79    DropRollup,
80    /// Trigger created.
81    TriggerCreate,
82    /// Trigger dropped.
83    TriggerDrop,
84    /// Signal fired.
85    SignalFired,
86
87    /// Anything an embedded consumer wants to record that is not above.
88    Custom(String),
89}
90
91impl AuditAction {
92    /// Every variant except [`Custom`](Self::Custom), for the coverage check.
93    pub const ALL: &'static [Self] = &[
94        Self::Write,
95        Self::Read,
96        Self::Delete,
97        Self::SchemaChange,
98        Self::DataExport,
99        Self::LoginFailure,
100        Self::Admin,
101        Self::ApiKeyCreate,
102        Self::ApiKeyRevoke,
103        Self::PolicyLoad,
104        Self::NamespaceCreate,
105        Self::NamespaceDelete,
106        Self::QuotaChange,
107        Self::CreateRollup,
108        Self::DropRollup,
109        Self::TriggerCreate,
110        Self::TriggerDrop,
111        Self::SignalFired,
112    ];
113}
114
115impl fmt::Display for AuditAction {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        match self {
118            Self::Write => write!(f, "write"),
119            Self::Read => write!(f, "read"),
120            Self::Delete => write!(f, "delete"),
121            Self::SchemaChange => write!(f, "schema_change"),
122            Self::DataExport => write!(f, "data_export"),
123            Self::LoginFailure => write!(f, "login_failure"),
124            Self::Admin => write!(f, "admin"),
125            Self::ApiKeyCreate => write!(f, "api_key_create"),
126            Self::ApiKeyRevoke => write!(f, "api_key_revoke"),
127            Self::PolicyLoad => write!(f, "policy_load"),
128            Self::NamespaceCreate => write!(f, "namespace_create"),
129            Self::NamespaceDelete => write!(f, "namespace_delete"),
130            Self::QuotaChange => write!(f, "quota_change"),
131            Self::CreateRollup => write!(f, "create_rollup"),
132            Self::DropRollup => write!(f, "drop_rollup"),
133            Self::TriggerCreate => write!(f, "trigger_create"),
134            Self::TriggerDrop => write!(f, "trigger_drop"),
135            Self::SignalFired => write!(f, "signal_fired"),
136            Self::Custom(s) => write!(f, "{s}"),
137        }
138    }
139}
140
141/// A structured audit event capturing a security-relevant operation.
142///
143/// Events carry a SHA-256 hash chain — each event's `event_hash`
144/// covers `prev_hash || canonical_payload`, making log tampering detectable
145/// by verifying the chain with [`verify_hash_chain`].
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct AuditEvent {
148    /// Monotonically increasing event ID.
149    pub id: u64,
150    /// Nanosecond timestamp.
151    pub timestamp: i64,
152    /// The principal (user / service) that performed the action.
153    pub principal: String,
154    /// The action performed.
155    pub action: AuditAction,
156    /// The resource targeted (measurement, series, etc.).
157    pub resource: String,
158    /// Authorization decision.
159    pub decision: AuditDecision,
160    /// Source IP address (if available).
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub source_ip: Option<String>,
163    /// Request ID for correlation.
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub request_id: Option<String>,
166    /// Additional context (e.g., deny reasons, error details).
167    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
168    pub metadata: HashMap<String, String>,
169    /// Hex SHA-256 hash of the previous event (or `None` for the
170    /// first event in the chain).
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub prev_hash: Option<String>,
173    /// Hex SHA-256 hash of `prev_hash || canonical_payload`.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub event_hash: Option<String>,
176}
177
178impl AuditEvent {
179    /// Create a new audit event builder.
180    #[must_use]
181    pub fn new(
182        principal: impl Into<String>,
183        action: AuditAction,
184        resource: impl Into<String>,
185        decision: AuditDecision,
186    ) -> Self {
187        Self {
188            id: 0, // set by logger
189            timestamp: now_nanos(),
190            principal: principal.into(),
191            action,
192            resource: resource.into(),
193            decision,
194            source_ip: None,
195            request_id: None,
196            metadata: HashMap::new(),
197            prev_hash: None,
198            event_hash: None,
199        }
200    }
201
202    /// Set the source IP.
203    #[must_use]
204    pub fn with_source_ip(mut self, ip: impl Into<String>) -> Self {
205        self.source_ip = Some(ip.into());
206        self
207    }
208
209    /// Set the request ID.
210    #[must_use]
211    pub fn with_request_id(mut self, id: impl Into<String>) -> Self {
212        self.request_id = Some(id.into());
213        self
214    }
215
216    /// Add a metadata key-value pair.
217    #[must_use]
218    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
219        self.metadata.insert(key.into(), value.into());
220        self
221    }
222
223    /// Set the timestamp explicitly (for tests).
224    #[must_use]
225    pub fn with_timestamp(mut self, ts: i64) -> Self {
226        self.timestamp = ts;
227        self
228    }
229}
230
231impl fmt::Display for AuditEvent {
232    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233        write!(
234            f,
235            "[{}] principal={} action={} resource={} decision={}",
236            self.timestamp, self.principal, self.action, self.resource, self.decision,
237        )
238    }
239}
240
241/// Returns a **strictly increasing** timestamp in nanoseconds since epoch.
242///
243/// Uses a CAS loop to guarantee that every call returns a
244/// **unique** timestamp, even when multiple threads call simultaneously
245/// within the same nanosecond. If wall-clock time has not advanced past
246/// the last emitted value, we increment by 1 — producing a synthetic
247/// sub-nanosecond tiebreaker. This eliminates the duplicate-timestamp
248/// problem that made ordering ambiguous.
249///
250/// Combined with the per-event sequence `id` assigned in `log()`, audit
251/// events now have two independent total-order keys.
252fn now_nanos() -> i64 {
253    use std::sync::atomic::{AtomicI64, Ordering};
254    static LAST: AtomicI64 = AtomicI64::new(0);
255
256    let wall = i64::try_from(
257        std::time::SystemTime::now()
258            .duration_since(std::time::UNIX_EPOCH)
259            .unwrap_or_default()
260            .as_nanos(),
261    )
262    .unwrap_or(i64::MAX);
263
264    // CAS loop: guarantee strictly increasing output.
265    loop {
266        let prev = LAST.load(Ordering::SeqCst);
267        let next = wall.max(prev + 1);
268        if LAST
269            .compare_exchange_weak(prev, next, Ordering::SeqCst, Ordering::Relaxed)
270            .is_ok()
271        {
272            return next;
273        }
274    }
275}
276
277// ── Hash-chain helpers ───────────────────────────────────────
278
279impl AuditEvent {
280    /// Compute the canonical payload bytes used as input to the chain hash.
281    ///
282    /// The canonical form is `id|timestamp|principal|action|resource|decision`
283    /// followed by sorted metadata key-value pairs, ensuring deterministic
284    /// output regardless of `HashMap` iteration order.
285    fn canonical_payload(&self) -> Vec<u8> {
286        let mut buf = format!(
287            "{}|{}|{}|{}|{}|{}",
288            self.id, self.timestamp, self.principal, self.action, self.resource, self.decision,
289        )
290        .into_bytes();
291
292        // Sort metadata by key for deterministic hashing
293        let mut keys: Vec<&String> = self.metadata.keys().collect();
294        keys.sort();
295        for key in keys {
296            buf.push(b'|');
297            buf.extend_from_slice(key.as_bytes());
298            buf.push(b'=');
299            buf.extend_from_slice(self.metadata[key].as_bytes());
300        }
301
302        buf
303    }
304
305    /// Compute HMAC-SHA-256 of `prev_hash || canonical_payload` and set
306    /// `self.prev_hash` and `self.event_hash`.
307    ///
308    /// Uses HMAC with a secret key instead of plain SHA-256, so
309    /// an attacker with database write access cannot recompute the chain.
310    /// Falls back to plain SHA-256 if no key is provided (backward compat).
311    pub fn seal(&mut self, prev_hash: Option<&str>) {
312        self.seal_with_key(prev_hash, None);
313    }
314
315    /// Seal the event hash with an optional HMAC key.
316    ///
317    /// When `hmac_key` is `Some`, uses HMAC-SHA-256 for tamper resistance.
318    /// When `None`, falls back to plain SHA-256 (backward compatible).
319    pub fn seal_with_key(&mut self, prev_hash: Option<&str>, hmac_key: Option<&[u8]>) {
320        self.prev_hash = prev_hash.map(String::from);
321
322        let hash = if let Some(key) = hmac_key {
323            use hmac::{Hmac, KeyInit, Mac};
324            type HmacSha256 = Hmac<Sha256>;
325            let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
326            if let Some(ph) = &self.prev_hash {
327                mac.update(ph.as_bytes());
328            }
329            mac.update(&self.canonical_payload());
330            hex_encode(&mac.finalize().into_bytes())
331        } else {
332            use sha2::Digest;
333            let mut hasher = Sha256::new();
334            if let Some(ph) = &self.prev_hash {
335                hasher.update(ph.as_bytes());
336            }
337            hasher.update(self.canonical_payload());
338            hex_encode(&hasher.finalize())
339        };
340
341        self.event_hash = Some(hash);
342    }
343}
344
345/// Verify the integrity of a hash chain produced by `AuditLogger`.
346///
347/// Returns `Ok(())` if every event's `event_hash` matches the expected
348/// hash, or an error describing the first broken link.
349///
350/// When `hmac_key` is `Some`, uses HMAC-SHA-256 for verification.
351/// When `None`, falls back to plain SHA-256 for backward compatibility.
352pub fn verify_hash_chain(events: &[AuditEvent]) -> std::result::Result<(), String> {
353    verify_hash_chain_with_key(events, None)
354}
355
356/// Verify the hash chain with an optional HMAC key.
357pub fn verify_hash_chain_with_key(
358    events: &[AuditEvent],
359    hmac_key: Option<&[u8]>,
360) -> std::result::Result<(), String> {
361    verify_chain_from(events, None, hmac_key)
362}
363
364/// Verify a chain that continues from an earlier one.
365///
366/// `expected_first_prev` is the `event_hash` of the last event before this
367/// slice, or `None` when the slice starts the chain. A log that survives
368/// restarts is one chain, so verifying a tail of it needs the link it
369/// hangs from — without this a reader could only ever check a whole file
370/// from its first byte.
371pub fn verify_chain_from(
372    events: &[AuditEvent],
373    expected_first_prev: Option<&str>,
374    hmac_key: Option<&[u8]>,
375) -> std::result::Result<(), String> {
376    let mut expected_prev: Option<&str> = expected_first_prev;
377
378    for (idx, event) in events.iter().enumerate() {
379        // Check prev_hash linkage
380        if event.prev_hash.as_deref() != expected_prev {
381            return Err(format!(
382                "event id={} (index {idx}): prev_hash mismatch — expected {:?}, got {:?}",
383                event.id, expected_prev, event.prev_hash,
384            ));
385        }
386
387        // Recompute the hash
388        let expected_hash = if let Some(key) = hmac_key {
389            use hmac::{Hmac, KeyInit, Mac};
390            type HmacSha256 = Hmac<Sha256>;
391            let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
392            if let Some(ph) = &event.prev_hash {
393                mac.update(ph.as_bytes());
394            }
395            mac.update(&event.canonical_payload());
396            hex_encode(&mac.finalize().into_bytes())
397        } else {
398            use sha2::Digest;
399            let mut hasher = Sha256::new();
400            if let Some(ph) = &event.prev_hash {
401                hasher.update(ph.as_bytes());
402            }
403            hasher.update(event.canonical_payload());
404            hex_encode(&hasher.finalize())
405        };
406
407        let actual = event.event_hash.as_deref().unwrap_or("");
408        if actual != expected_hash {
409            return Err(format!(
410                "event id={} (index {idx}): event_hash mismatch — expected {expected_hash}, got {actual}",
411                event.id,
412            ));
413        }
414
415        expected_prev = event.event_hash.as_deref();
416    }
417
418    Ok(())
419}
420
421/// Hex-encode a byte slice (lowercase).
422fn hex_encode(bytes: &[u8]) -> String {
423    let mut s = String::with_capacity(bytes.len() * 2);
424    for &b in bytes {
425        use std::fmt::Write;
426        let _ = write!(s, "{b:02x}");
427    }
428    s
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn audit_event_creation() {
437        let event = AuditEvent::new("alice", AuditAction::Write, "cpu", AuditDecision::Allow)
438            .with_source_ip("10.0.0.1")
439            .with_request_id("req-123");
440        assert_eq!(event.principal, "alice");
441        assert_eq!(event.resource, "cpu");
442        assert_eq!(event.decision, AuditDecision::Allow);
443        assert_eq!(event.source_ip.as_deref(), Some("10.0.0.1"));
444        assert_eq!(event.request_id.as_deref(), Some("req-123"));
445    }
446
447    #[test]
448    fn audit_event_with_metadata() {
449        let event = AuditEvent::new("bob", AuditAction::Delete, "logs", AuditDecision::Deny)
450            .with_metadata("reason", "insufficient privileges");
451        assert_eq!(
452            event.metadata.get("reason").unwrap(),
453            "insufficient privileges"
454        );
455    }
456
457    #[test]
458    fn audit_event_serialization() {
459        let event = AuditEvent::new("alice", AuditAction::Read, "cpu", AuditDecision::Allow)
460            .with_timestamp(1_700_000_000_000);
461        let json = serde_json::to_string(&event).unwrap();
462        assert!(json.contains("\"principal\":\"alice\""));
463        assert!(json.contains("\"action\":\"read\""));
464        assert!(json.contains("\"decision\":\"allow\""));
465
466        let deserialized: AuditEvent = serde_json::from_str(&json).unwrap();
467        assert_eq!(deserialized.principal, "alice");
468        assert_eq!(deserialized.timestamp, 1_700_000_000_000);
469    }
470
471    #[test]
472    fn audit_event_display() {
473        let event = AuditEvent::new("alice", AuditAction::Write, "cpu", AuditDecision::Allow)
474            .with_timestamp(100);
475        let s = event.to_string();
476        assert!(s.contains("principal=alice"));
477        assert!(s.contains("action=write"));
478        assert!(s.contains("decision=allow"));
479    }
480
481    #[test]
482    fn audit_action_display() {
483        assert_eq!(AuditAction::LoginFailure.to_string(), "login_failure");
484        assert_eq!(AuditAction::ApiKeyCreate.to_string(), "api_key_create");
485        assert_eq!(AuditAction::Custom("foo".into()).to_string(), "foo");
486    }
487
488    #[test]
489    fn decision_display() {
490        assert_eq!(AuditDecision::Allow.to_string(), "allow");
491        assert_eq!(AuditDecision::Deny.to_string(), "deny");
492    }
493
494    #[test]
495    fn auth_actions_roundtrip() {
496        let actions = vec![
497            AuditAction::Write,
498            AuditAction::LoginFailure,
499            AuditAction::TriggerCreate,
500            AuditAction::SignalFired,
501            AuditAction::Custom("custom_op".into()),
502        ];
503        for action in actions {
504            let event = AuditEvent::new("user", action, "res", AuditDecision::Allow);
505            let json = serde_json::to_string(&event).unwrap();
506            let de: AuditEvent = serde_json::from_str(&json).unwrap();
507            assert_eq!(de.action, event.action);
508        }
509    }
510
511    #[test]
512    fn hash_chain_seal_and_verify() {
513        let mut events: Vec<AuditEvent> = Vec::new();
514        let mut prev_hash: Option<String> = None;
515
516        for i in 0..5 {
517            let mut event =
518                AuditEvent::new("alice", AuditAction::Read, "cpu", AuditDecision::Allow)
519                    .with_timestamp(1000 + i);
520            event.id = i as u64;
521            event.seal(prev_hash.as_deref());
522            prev_hash = event.event_hash.clone();
523            events.push(event);
524        }
525
526        // All events should have hashes
527        for event in &events {
528            assert!(event.event_hash.is_some());
529        }
530        // First event has no prev_hash
531        assert!(events[0].prev_hash.is_none());
532        // Subsequent events chain
533        for i in 1..events.len() {
534            assert_eq!(events[i].prev_hash, events[i - 1].event_hash);
535        }
536
537        // Chain should verify
538        assert!(verify_hash_chain(&events).is_ok());
539    }
540
541    #[test]
542    fn hash_chain_detects_tampering() {
543        let mut events: Vec<AuditEvent> = Vec::new();
544        let mut prev_hash: Option<String> = None;
545
546        for i in 0..3 {
547            let mut event =
548                AuditEvent::new("alice", AuditAction::Write, "cpu", AuditDecision::Allow)
549                    .with_timestamp(1000 + i);
550            event.id = i as u64;
551            event.seal(prev_hash.as_deref());
552            prev_hash = event.event_hash.clone();
553            events.push(event);
554        }
555
556        // Tamper with the second event
557        events[1].principal = "mallory".to_string();
558        assert!(verify_hash_chain(&events).is_err());
559    }
560
561    #[test]
562    fn hash_chain_empty_is_valid() {
563        assert!(verify_hash_chain(&[]).is_ok());
564    }
565
566    #[test]
567    fn sealed_event_roundtrips_json() {
568        let mut event = AuditEvent::new("alice", AuditAction::Read, "cpu", AuditDecision::Allow)
569            .with_timestamp(42);
570        event.id = 1;
571        event.seal(None);
572
573        let json = serde_json::to_string(&event).unwrap();
574        let de: AuditEvent = serde_json::from_str(&json).unwrap();
575        assert_eq!(de.event_hash, event.event_hash);
576        assert_eq!(de.prev_hash, event.prev_hash);
577    }
578
579    #[test]
580    fn now_nanos_produces_strictly_increasing_timestamps() {
581        // Verify that rapid successive calls produce unique,
582        // strictly increasing timestamps.
583        let mut prev = 0_i64;
584        for _ in 0..1000 {
585            let ts = now_nanos();
586            assert!(ts > prev, "timestamp {ts} must be > previous {prev}");
587            prev = ts;
588        }
589    }
590
591    #[test]
592    fn now_nanos_concurrent_uniqueness() {
593        use std::collections::HashSet;
594        use std::sync::Arc;
595
596        let timestamps = Arc::new(parking_lot::Mutex::new(Vec::new()));
597        let handles: Vec<_> = (0..4)
598            .map(|_| {
599                let ts = Arc::clone(&timestamps);
600                std::thread::spawn(move || {
601                    let mut local = Vec::with_capacity(250);
602                    for _ in 0..250 {
603                        local.push(now_nanos());
604                    }
605                    ts.lock().extend(local);
606                })
607            })
608            .collect();
609        for h in handles {
610            h.join().unwrap();
611        }
612        let all = timestamps.lock();
613        let unique: HashSet<_> = all.iter().copied().collect();
614        assert_eq!(
615            unique.len(),
616            all.len(),
617            "all {} timestamps must be unique, got {} unique",
618            all.len(),
619            unique.len()
620        );
621    }
622}