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