1use astrid_capabilities::AuditEntryId;
6use astrid_core::SessionId;
7use astrid_crypto::{ContentHash, KeyPair};
8use std::path::Path;
9use std::sync::RwLock;
10use tracing::{debug, error, warn};
11
12use crate::entry::{AuditAction, AuditEntry, AuditOutcome, AuthorizationProof};
13use crate::error::{AuditError, AuditResult};
14use crate::storage::{AuditStorage, SurrealKvAuditStorage};
15
16type ChainKey = (SessionId, Option<astrid_core::PrincipalId>);
21
22pub struct AuditLog {
24 storage: Box<dyn AuditStorage>,
26 runtime_key: KeyPair,
28 chain_heads: RwLock<std::collections::HashMap<ChainKey, ContentHash>>,
33}
34
35impl AuditLog {
36 pub fn open(path: impl AsRef<Path>, runtime_key: KeyPair) -> AuditResult<Self> {
42 let storage = SurrealKvAuditStorage::open(path)?;
43 Ok(Self {
44 storage: Box::new(storage),
45 runtime_key,
46 chain_heads: RwLock::new(std::collections::HashMap::new()),
47 })
48 }
49
50 #[must_use]
52 pub fn in_memory(runtime_key: KeyPair) -> Self {
53 let storage = SurrealKvAuditStorage::in_memory();
54 Self {
55 storage: Box::new(storage),
56 runtime_key,
57 chain_heads: RwLock::new(std::collections::HashMap::new()),
58 }
59 }
60
61 pub fn append(
67 &self,
68 session_id: SessionId,
69 action: AuditAction,
70 authorization: AuthorizationProof,
71 outcome: AuditOutcome,
72 ) -> AuditResult<AuditEntryId> {
73 self.append_inner(session_id, None, action, authorization, outcome)
74 }
75
76 pub fn append_with_principal(
86 &self,
87 session_id: SessionId,
88 principal: astrid_core::PrincipalId,
89 action: AuditAction,
90 authorization: AuthorizationProof,
91 outcome: AuditOutcome,
92 ) -> AuditResult<AuditEntryId> {
93 self.append_inner(session_id, Some(principal), action, authorization, outcome)
94 }
95
96 fn append_inner(
98 &self,
99 session_id: SessionId,
100 principal: Option<astrid_core::PrincipalId>,
101 action: AuditAction,
102 authorization: AuthorizationProof,
103 outcome: AuditOutcome,
104 ) -> AuditResult<AuditEntryId> {
105 let chain_key: ChainKey = (session_id.clone(), principal.clone());
107 let previous_hash = self.get_previous_hash(&chain_key)?;
108
109 let entry = if let Some(p) = principal {
112 AuditEntry::create_with_principal(
113 session_id,
114 p,
115 action,
116 authorization,
117 outcome,
118 previous_hash,
119 &self.runtime_key,
120 )
121 } else {
122 AuditEntry::create(
123 session_id,
124 action,
125 authorization,
126 outcome,
127 previous_hash,
128 &self.runtime_key,
129 )
130 };
131
132 let entry_id = entry.id.clone();
133 let entry_hash = entry.content_hash();
134
135 debug!(
136 entry_id = %entry_id,
137 action = %entry.action.description(),
138 "Appending audit entry"
139 );
140
141 self.storage.store(&entry)?;
143
144 {
146 let mut heads = self
147 .chain_heads
148 .write()
149 .map_err(|e| AuditError::StorageError(e.to_string()))?;
150 heads.insert(chain_key, entry_hash);
151 }
152
153 Ok(entry_id)
154 }
155
156 fn get_previous_hash(&self, chain_key: &ChainKey) -> AuditResult<ContentHash> {
158 {
160 let heads = self
161 .chain_heads
162 .read()
163 .map_err(|e| AuditError::StorageError(e.to_string()))?;
164 if let Some(hash) = heads.get(chain_key) {
165 return Ok(*hash);
166 }
167 }
168
169 if let Some(head_id) = self
171 .storage
172 .get_chain_head(&chain_key.0, chain_key.1.as_ref())?
173 && let Some(entry) = self.storage.get(&head_id)?
174 {
175 return Ok(entry.content_hash());
176 }
177
178 Ok(ContentHash::zero())
180 }
181
182 pub fn get(&self, id: &AuditEntryId) -> AuditResult<Option<AuditEntry>> {
188 self.storage.get(id)
189 }
190
191 pub fn get_session_entries(&self, session_id: &SessionId) -> AuditResult<Vec<AuditEntry>> {
197 self.storage.get_session_entries(session_id)
198 }
199
200 pub fn verify_chain(&self, session_id: &SessionId) -> AuditResult<ChainVerificationResult> {
210 let entries = self.storage.get_session_entries(session_id)?;
211
212 if entries.is_empty() {
213 return Ok(ChainVerificationResult {
214 valid: true,
215 entries_verified: 0,
216 issues: Vec::new(),
217 });
218 }
219
220 let mut chains: std::collections::HashMap<
222 Option<astrid_core::PrincipalId>,
223 Vec<&AuditEntry>,
224 > = std::collections::HashMap::new();
225 for entry in &entries {
226 chains
227 .entry(entry.principal.clone())
228 .or_default()
229 .push(entry);
230 }
231
232 let mut issues = Vec::new();
233 let mut entries_verified: usize = 0;
234
235 for chain_entries in chains.values_mut() {
237 chain_entries.sort_by(|a, b| a.timestamp.0.cmp(&b.timestamp.0));
239
240 if !chain_entries[0].previous_hash.is_zero() {
242 issues.push(ChainIssue::InvalidGenesis {
243 entry_id: chain_entries[0].id.clone(),
244 });
245 }
246
247 for entry in chain_entries.iter() {
249 if let Err(e) = entry.verify_signature() {
250 error!(entry_id = %entry.id, error = %e, "Invalid signature");
251 issues.push(ChainIssue::InvalidSignature {
252 entry_id: entry.id.clone(),
253 });
254 }
255 entries_verified = entries_verified.saturating_add(1);
256 }
257
258 for i in 1..chain_entries.len() {
260 #[expect(clippy::arithmetic_side_effects)]
261 let prev = chain_entries[i - 1];
262 let curr = chain_entries[i];
263
264 if !curr.follows(prev) {
265 warn!(
266 current = %curr.id,
267 previous = %prev.id,
268 "Chain link broken"
269 );
270 issues.push(ChainIssue::BrokenLink {
271 entry_id: curr.id.clone(),
272 expected_previous: prev.content_hash(),
273 actual_previous: curr.previous_hash,
274 });
275 }
276 }
277 }
278
279 Ok(ChainVerificationResult {
280 valid: issues.is_empty(),
281 entries_verified,
282 issues,
283 })
284 }
285
286 pub fn verify_principal_chain(
294 &self,
295 session_id: &SessionId,
296 principal: Option<&astrid_core::PrincipalId>,
297 ) -> AuditResult<ChainVerificationResult> {
298 let entries = self.get_principal_entries(session_id, principal)?;
299
300 if entries.is_empty() {
301 return Ok(ChainVerificationResult {
302 valid: true,
303 entries_verified: 0,
304 issues: Vec::new(),
305 });
306 }
307
308 let mut issues = Vec::new();
309 let mut entries_verified: usize = 0;
310
311 let mut sorted = entries;
312 sorted.sort_by(|a, b| a.timestamp.0.cmp(&b.timestamp.0));
313
314 if !sorted[0].previous_hash.is_zero() {
315 issues.push(ChainIssue::InvalidGenesis {
316 entry_id: sorted[0].id.clone(),
317 });
318 }
319
320 for entry in &sorted {
321 if let Err(e) = entry.verify_signature() {
322 error!(entry_id = %entry.id, error = %e, "Invalid signature");
323 issues.push(ChainIssue::InvalidSignature {
324 entry_id: entry.id.clone(),
325 });
326 }
327 entries_verified = entries_verified.saturating_add(1);
328 }
329
330 for i in 1..sorted.len() {
331 #[expect(clippy::arithmetic_side_effects)]
332 let prev = &sorted[i - 1];
333 let curr = &sorted[i];
334 if !curr.follows(prev) {
335 warn!(current = %curr.id, previous = %prev.id, "Chain link broken");
336 issues.push(ChainIssue::BrokenLink {
337 entry_id: curr.id.clone(),
338 expected_previous: prev.content_hash(),
339 actual_previous: curr.previous_hash,
340 });
341 }
342 }
343
344 Ok(ChainVerificationResult {
345 valid: issues.is_empty(),
346 entries_verified,
347 issues,
348 })
349 }
350
351 pub fn get_principal_entries(
359 &self,
360 session_id: &SessionId,
361 principal: Option<&astrid_core::PrincipalId>,
362 ) -> AuditResult<Vec<AuditEntry>> {
363 let all = self.storage.get_session_entries(session_id)?;
364 Ok(all
365 .into_iter()
366 .filter(|e| e.principal.as_ref() == principal)
367 .collect())
368 }
369
370 pub fn verify_all(&self) -> AuditResult<Vec<(SessionId, ChainVerificationResult)>> {
376 let sessions = self.storage.list_sessions()?;
377 let mut results = Vec::new();
378
379 for session_id in sessions {
380 let result = self.verify_chain(&session_id)?;
381 results.push((session_id, result));
382 }
383
384 Ok(results)
385 }
386
387 pub fn count(&self) -> AuditResult<usize> {
393 self.storage.count()
394 }
395
396 pub fn count_session(&self, session_id: &SessionId) -> AuditResult<usize> {
402 self.storage.count_session(session_id)
403 }
404
405 pub fn list_sessions(&self) -> AuditResult<Vec<SessionId>> {
411 self.storage.list_sessions()
412 }
413
414 pub fn flush(&self) -> AuditResult<()> {
420 self.storage.flush()
421 }
422
423 #[must_use]
425 pub fn runtime_public_key(&self) -> astrid_crypto::PublicKey {
426 self.runtime_key.export_public_key()
427 }
428}
429
430impl std::fmt::Debug for AuditLog {
431 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
432 f.debug_struct("AuditLog")
433 .field("runtime_key_id", &self.runtime_key.key_id_hex())
434 .finish_non_exhaustive()
435 }
436}
437
438#[derive(Debug, Clone)]
440pub struct ChainVerificationResult {
441 pub valid: bool,
443 pub entries_verified: usize,
445 pub issues: Vec<ChainIssue>,
447}
448
449#[derive(Debug, Clone)]
451pub enum ChainIssue {
452 InvalidGenesis {
454 entry_id: AuditEntryId,
456 },
457 InvalidSignature {
459 entry_id: AuditEntryId,
461 },
462 BrokenLink {
464 entry_id: AuditEntryId,
466 expected_previous: ContentHash,
468 actual_previous: ContentHash,
470 },
471}
472
473impl std::fmt::Display for ChainIssue {
474 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
475 match self {
476 Self::InvalidGenesis { entry_id } => {
477 write!(f, "Invalid genesis at {entry_id}")
478 },
479 Self::InvalidSignature { entry_id } => {
480 write!(f, "Invalid signature at {entry_id}")
481 },
482 Self::BrokenLink { entry_id, .. } => {
483 write!(f, "Broken chain link at {entry_id}")
484 },
485 }
486 }
487}
488
489#[cfg(test)]
491pub(crate) struct AuditBuilder<'a> {
492 log: &'a AuditLog,
493 session_id: SessionId,
494 action: Option<AuditAction>,
495 authorization: Option<AuthorizationProof>,
496}
497
498#[cfg(test)]
499impl<'a> AuditBuilder<'a> {
500 pub(crate) fn new(log: &'a AuditLog, session_id: SessionId) -> Self {
502 Self {
503 log,
504 session_id,
505 action: None,
506 authorization: None,
507 }
508 }
509
510 #[must_use]
512 pub(crate) fn action(mut self, action: AuditAction) -> Self {
513 self.action = Some(action);
514 self
515 }
516
517 #[must_use]
519 pub(crate) fn authorization(mut self, auth: AuthorizationProof) -> Self {
520 self.authorization = Some(auth);
521 self
522 }
523
524 pub(crate) fn success(self) -> AuditResult<AuditEntryId> {
534 self.log.append(
535 self.session_id,
536 self.action.expect("action required"),
537 self.authorization
538 .unwrap_or(AuthorizationProof::NotRequired {
539 reason: "unspecified".to_string(),
540 }),
541 AuditOutcome::success(),
542 )
543 }
544
545 pub(crate) fn success_with(self, details: impl Into<String>) -> AuditResult<AuditEntryId> {
555 self.log.append(
556 self.session_id,
557 self.action.expect("action required"),
558 self.authorization
559 .unwrap_or(AuthorizationProof::NotRequired {
560 reason: "unspecified".to_string(),
561 }),
562 AuditOutcome::success_with(details),
563 )
564 }
565
566 pub(crate) fn failure(self, error: impl Into<String>) -> AuditResult<AuditEntryId> {
576 self.log.append(
577 self.session_id,
578 self.action.expect("action required"),
579 self.authorization
580 .unwrap_or(AuthorizationProof::NotRequired {
581 reason: "unspecified".to_string(),
582 }),
583 AuditOutcome::failure(error),
584 )
585 }
586}
587
588#[cfg(test)]
589#[path = "log_tests.rs"]
590mod tests;