confium_coordinator/coordinator/
audit.rs1use crate::coordinator::session::{SessionId, SignerId};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct AuditEntry {
10 pub timestamp: DateTime<Utc>,
12 pub event: AuditEvent,
14 pub session_id: SessionId,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(tag = "type", rename_all = "snake_case")]
21pub enum AuditEvent {
22 SessionCreated {
24 requested_by: SignerId,
26 quorum_id: String,
28 },
29 CommitmentReceived {
31 signer: SignerId,
33 },
34 ShareReceived {
36 signer: SignerId,
38 },
39 Aggregated,
41 Expired,
43 Aborted {
45 reason: String,
47 },
48}
49
50#[derive(Debug, Default)]
52pub struct AuditLog {
53 entries: Vec<AuditEntry>,
54}
55
56impl AuditLog {
57 pub fn new() -> Self {
59 Self::default()
60 }
61
62 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 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 pub fn all(&self) -> &[AuditEntry] {
81 &self.entries
82 }
83
84 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 pub fn export_json(&self) -> Result<String, serde_json::Error> {
96 serde_json::to_string_pretty(&self.entries)
97 }
98
99 pub fn count(&self) -> usize {
101 self.entries.len()
102 }
103
104 pub fn query(&self, query: &AuditQuery) -> Vec<&AuditEntry> {
107 self.entries.iter().filter(|e| query.matches(e)).collect()
108 }
109
110 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 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#[derive(Debug, Default, Clone)]
135pub struct AuditQuery {
136 pub session_id: Option<String>,
138 pub signer_id: Option<String>,
140 pub event_type: Option<String>,
142 pub time_start: Option<DateTime<Utc>>,
144 pub time_end: Option<DateTime<Utc>>,
146}
147
148impl AuditQuery {
149 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 pub fn new() -> Self {
181 Self::default()
182 }
183}
184
185impl AuditEntry {
186 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 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}