Skip to main content

gonzalo_domain/fleet/
audit.rs

1//! Audit entries: one write-once record per action, in the `fleet-audit`
2//! namespace. Opaque under sync, so a key collision surfaces instead of
3//! merging (ADR 0022).
4
5use super::keys::{self, FleetKeyError};
6use super::{AUDIT_ENTRIES_COLLECTION, FLEET_AUDIT_NAMESPACE, FleetActor};
7use crate::codec::RecordCodec;
8use gonzalo_core::{RecordKey, RecordKind};
9use serde::{Deserialize, Serialize};
10
11/// The outcome an audit entry records.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub enum AuditResult {
14    Succeeded,
15    Denied,
16    Failed(String),
17}
18
19/// Who did what, to what, when, and with what result.
20///
21/// Create it with `Store::put(record, None)` and never update it. A `Conflict`
22/// means the key already exists: build a new key with a different nonce.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct AuditEntry {
25    pub actor: FleetActor,
26    /// What was done, such as `"spawn"` or `"link"`.
27    pub action: String,
28    /// What it was done to.
29    pub target: String,
30    pub at: i64,
31    /// A reference to where it happened, such as a chat message link.
32    pub surface_ref: Option<String>,
33    pub result: AuditResult,
34}
35impl RecordCodec for AuditEntry {}
36impl AuditEntry {
37    pub const KIND: RecordKind = RecordKind::AuditEntry;
38
39    /// `fleet-audit/entries/<at_ms>-<nonce>`, with `at` zero-padded to 13 digits.
40    pub fn key(&self, nonce: &str) -> Result<RecordKey, FleetKeyError> {
41        Ok(RecordKey::new(
42            FLEET_AUDIT_NAMESPACE,
43            AUDIT_ENTRIES_COLLECTION,
44            keys::audit_id(self.at, nonce)?,
45        ))
46    }
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52    use crate::fleet::Authenticator;
53
54    #[test]
55    fn audit_entry_roundtrips_and_keys() {
56        let e = AuditEntry {
57            actor: FleetActor::Unlinked {
58                authenticator: Authenticator::Discord,
59                subject: "1234".into(),
60            },
61            action: "spawn".into(),
62            target: "caliban-ai/gonzalo".into(),
63            at: 1_700_000_000_000,
64            surface_ref: Some("https://discord.com/channels/1/2/3".into()),
65            result: AuditResult::Denied,
66        };
67        assert_eq!(AuditEntry::from_body(&e.to_body().unwrap()).unwrap(), e);
68        assert_eq!(AuditEntry::KIND, RecordKind::AuditEntry);
69        assert_eq!(
70            e.key("n1").unwrap(),
71            RecordKey::new("fleet-audit", "entries", "1700000000000-n1")
72        );
73        assert!(e.key("bad nonce").is_err());
74    }
75}