Skip to main content

confium_coordinator/coordinator/
audit.rs

1//! Audit log for coordinator sessions.
2
3use crate::coordinator::session::{SessionId, SignerId};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7/// A single audit log entry.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct AuditEntry {
10    /// When the event occurred.
11    pub timestamp: DateTime<Utc>,
12    /// Type of event.
13    pub event: AuditEvent,
14    /// Session involved.
15    pub session_id: SessionId,
16}
17
18/// Type of audit event.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(tag = "type", rename_all = "snake_case")]
21pub enum AuditEvent {
22    /// Session created.
23    SessionCreated {
24        /// Requesting actor.
25        requested_by: SignerId,
26        /// Quorum.
27        quorum_id: String,
28    },
29    /// Commitment received from signer.
30    CommitmentReceived {
31        /// Signer.
32        signer: SignerId,
33    },
34    /// Share received from signer.
35    ShareReceived {
36        /// Signer.
37        signer: SignerId,
38    },
39    /// Aggregation completed.
40    Aggregated,
41    /// Session expired.
42    Expired,
43    /// Session aborted.
44    Aborted {
45        /// Reason.
46        reason: String,
47    },
48}
49
50/// Append-only audit log.
51#[derive(Debug, Default)]
52pub struct AuditLog {
53    entries: Vec<AuditEntry>,
54}
55
56impl AuditLog {
57    /// Construct a new empty log.
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    /// Append an entry.
63    pub fn append(&mut self, session_id: impl Into<SessionId>, event: AuditEvent) {
64        self.entries.push(AuditEntry {
65            timestamp: Utc::now(),
66            event,
67            session_id: session_id.into(),
68        });
69    }
70
71    /// Get all entries for a session.
72    pub fn entries_for(&self, session_id: &str) -> Vec<&AuditEntry> {
73        self.entries
74            .iter()
75            .filter(|e| e.session_id == session_id)
76            .collect()
77    }
78
79    /// All entries.
80    pub fn all(&self) -> &[AuditEntry] {
81        &self.entries
82    }
83
84    /// Serialize to JSONL.
85    pub fn to_jsonl(&self) -> Result<String, serde_json::Error> {
86        let mut out = String::new();
87        for entry in &self.entries {
88            out.push_str(&serde_json::to_string(entry)?);
89            out.push('\n');
90        }
91        Ok(out)
92    }
93
94    /// Export as a JSON array.
95    pub fn export_json(&self) -> Result<String, serde_json::Error> {
96        serde_json::to_string_pretty(&self.entries)
97    }
98
99    /// Total entry count.
100    pub fn count(&self) -> usize {
101        self.entries.len()
102    }
103
104    /// Run a structured query against the audit log. Only non-None
105    /// filters are applied — an empty query returns all entries.
106    pub fn query(&self, query: &AuditQuery) -> Vec<&AuditEntry> {
107        self.entries.iter().filter(|e| query.matches(e)).collect()
108    }
109
110    /// All events involving a specific signer.
111    pub fn query_by_signer(&self, signer_id: &str) -> Vec<&AuditEntry> {
112        self.query(&AuditQuery {
113            signer_id: Some(signer_id.into()),
114            ..Default::default()
115        })
116    }
117
118    /// All events within a time range (inclusive).
119    pub fn query_by_time_range(
120        &self,
121        start: DateTime<Utc>,
122        end: DateTime<Utc>,
123    ) -> Vec<&AuditEntry> {
124        self.query(&AuditQuery {
125            time_start: Some(start),
126            time_end: Some(end),
127            ..Default::default()
128        })
129    }
130}
131
132/// Structured query filter for the audit log. All fields are optional;
133/// `None` means "no filter on this dimension".
134#[derive(Debug, Default, Clone)]
135pub struct AuditQuery {
136    /// Filter by session ID.
137    pub session_id: Option<String>,
138    /// Filter by signer ID (matches any event involving this signer).
139    pub signer_id: Option<String>,
140    /// Filter by event type name (e.g., "session_created", "aggregated").
141    pub event_type: Option<String>,
142    /// Filter: events at or after this time.
143    pub time_start: Option<DateTime<Utc>>,
144    /// Filter: events at or before this time.
145    pub time_end: Option<DateTime<Utc>>,
146}
147
148impl AuditQuery {
149    /// Check if an entry matches this query.
150    fn matches(&self, entry: &AuditEntry) -> bool {
151        if let Some(ref sid) = self.session_id {
152            if &entry.session_id != sid {
153                return false;
154            }
155        }
156        if let Some(ref signer) = self.signer_id {
157            if !entry.involves_signer(signer) {
158                return false;
159            }
160        }
161        if let Some(ref etype) = self.event_type {
162            if entry.event_type_name() != etype {
163                return false;
164            }
165        }
166        if let Some(start) = self.time_start {
167            if entry.timestamp < start {
168                return false;
169            }
170        }
171        if let Some(end) = self.time_end {
172            if entry.timestamp > end {
173                return false;
174            }
175        }
176        true
177    }
178
179    /// Create a new empty query (matches all entries).
180    pub fn new() -> Self {
181        Self::default()
182    }
183}
184
185impl AuditEntry {
186    /// Does this entry involve the given signer?
187    pub fn involves_signer(&self, signer_id: &str) -> bool {
188        match &self.event {
189            AuditEvent::SessionCreated { requested_by, .. } => requested_by == signer_id,
190            AuditEvent::CommitmentReceived { signer } => signer == signer_id,
191            AuditEvent::ShareReceived { signer } => signer == signer_id,
192            _ => false,
193        }
194    }
195
196    /// Event type as a snake_case string.
197    pub fn event_type_name(&self) -> &'static str {
198        match &self.event {
199            AuditEvent::SessionCreated { .. } => "session_created",
200            AuditEvent::CommitmentReceived { .. } => "commitment_received",
201            AuditEvent::ShareReceived { .. } => "share_received",
202            AuditEvent::Aggregated => "aggregated",
203            AuditEvent::Expired => "expired",
204            AuditEvent::Aborted { .. } => "aborted",
205        }
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn append_and_query() {
215        let mut log = AuditLog::new();
216        log.append(
217            "session-1",
218            AuditEvent::SessionCreated {
219                requested_by: "alice".into(),
220                quorum_id: "biml-root".into(),
221            },
222        );
223        log.append(
224            "session-1",
225            AuditEvent::CommitmentReceived {
226                signer: "alice".into(),
227            },
228        );
229        log.append(
230            "session-2",
231            AuditEvent::SessionCreated {
232                requested_by: "bob".into(),
233                quorum_id: "biml-root".into(),
234            },
235        );
236
237        let s1_entries = log.entries_for("session-1");
238        assert_eq!(s1_entries.len(), 2);
239        let s2_entries = log.entries_for("session-2");
240        assert_eq!(s2_entries.len(), 1);
241    }
242
243    #[test]
244    fn jsonl_round_trips() {
245        let mut log = AuditLog::new();
246        log.append("session-1", AuditEvent::Aggregated);
247        let jsonl = log.to_jsonl().unwrap();
248        assert!(jsonl.contains("session-1"));
249        assert!(jsonl.contains("aggregated"));
250    }
251
252    #[test]
253    fn query_by_signer() {
254        let mut log = AuditLog::new();
255        log.append(
256            "s1",
257            AuditEvent::SessionCreated {
258                requested_by: "alice".into(),
259                quorum_id: "q".into(),
260            },
261        );
262        log.append(
263            "s1",
264            AuditEvent::CommitmentReceived {
265                signer: "alice".into(),
266            },
267        );
268        log.append(
269            "s1",
270            AuditEvent::ShareReceived {
271                signer: "bob".into(),
272            },
273        );
274
275        let alice_events = log.query_by_signer("alice");
276        assert_eq!(alice_events.len(), 2);
277        let bob_events = log.query_by_signer("bob");
278        assert_eq!(bob_events.len(), 1);
279        let nobody = log.query_by_signer("nobody");
280        assert_eq!(nobody.len(), 0);
281    }
282
283    #[test]
284    fn query_by_time_range() {
285        let mut log = AuditLog::new();
286        log.append("s1", AuditEvent::Aggregated);
287        std::thread::sleep(std::time::Duration::from_millis(10));
288        let mid = Utc::now();
289        std::thread::sleep(std::time::Duration::from_millis(10));
290        log.append("s2", AuditEvent::Aggregated);
291
292        let before = log.query_by_time_range(Utc::now() - chrono::Duration::minutes(1), mid);
293        assert_eq!(before.len(), 1);
294        let after = log.query_by_time_range(mid, Utc::now() + chrono::Duration::minutes(1));
295        assert_eq!(after.len(), 1);
296    }
297
298    #[test]
299    fn query_empty_returns_all() {
300        let mut log = AuditLog::new();
301        log.append("s1", AuditEvent::Aggregated);
302        log.append("s2", AuditEvent::Expired);
303        let all = log.query(&AuditQuery::new());
304        assert_eq!(all.len(), 2);
305    }
306
307    #[test]
308    fn query_by_event_type() {
309        let mut log = AuditLog::new();
310        log.append("s1", AuditEvent::Aggregated);
311        log.append("s2", AuditEvent::Expired);
312        log.append("s3", AuditEvent::Aggregated);
313
314        let agg = log.query(&AuditQuery {
315            event_type: Some("aggregated".into()),
316            ..Default::default()
317        });
318        assert_eq!(agg.len(), 2);
319    }
320
321    #[test]
322    fn count_works() {
323        let mut log = AuditLog::new();
324        assert_eq!(log.count(), 0);
325        log.append("s1", AuditEvent::Aggregated);
326        log.append("s2", AuditEvent::Expired);
327        assert_eq!(log.count(), 2);
328    }
329
330    #[test]
331    fn export_json_returns_array() {
332        let mut log = AuditLog::new();
333        log.append("s1", AuditEvent::Aggregated);
334        let json = log.export_json().unwrap();
335        assert!(json.starts_with('['));
336        assert!(json.ends_with(']'));
337        assert!(json.contains("aggregated"));
338    }
339
340    #[test]
341    fn involves_signer_checks_all_event_types() {
342        let entry = AuditEntry {
343            timestamp: Utc::now(),
344            event: AuditEvent::SessionCreated {
345                requested_by: "alice".into(),
346                quorum_id: "q".into(),
347            },
348            session_id: "s1".into(),
349        };
350        assert!(entry.involves_signer("alice"));
351        assert!(!entry.involves_signer("bob"));
352    }
353}