Skip to main content

astrid_audit/
log.rs

1//! Audit log - main interface for audit logging.
2//!
3//! Provides a high-level API for recording and verifying audit entries.
4
5use 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
16/// Key for the per-chain head cache: (session, optional principal).
17///
18/// System entries (no principal) use `(session_id, None)`.
19/// Principal entries use `(session_id, Some(principal))`.
20type ChainKey = (SessionId, Option<astrid_core::PrincipalId>);
21
22/// Audit log for recording and verifying security events.
23pub struct AuditLog {
24    /// Storage backend.
25    storage: Box<dyn AuditStorage>,
26    /// Runtime signing key.
27    runtime_key: KeyPair,
28    /// Current chain heads per (session, principal) pair.
29    ///
30    /// Each principal maintains its own independent chain within a session.
31    /// System entries (no principal) use `(session_id, None)`.
32    chain_heads: RwLock<std::collections::HashMap<ChainKey, ContentHash>>,
33}
34
35impl AuditLog {
36    /// Create a new audit log with `SurrealKV` persistence.
37    ///
38    /// # Errors
39    ///
40    /// Returns an error if the storage backend fails to open at the given path.
41    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    /// Create an in-memory audit log (for testing).
51    #[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    /// Append a new audit entry.
62    ///
63    /// # Errors
64    ///
65    /// Returns an error if the entry cannot be stored or the chain head cannot be updated.
66    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    /// Append a new audit entry tagged with the acting principal.
77    ///
78    /// Use this when the action was performed on behalf of a specific
79    /// user (e.g., cross-principal KV write, tool execution). The
80    /// principal is included in the cryptographic signing data.
81    ///
82    /// # Errors
83    ///
84    /// Returns an error if the entry cannot be stored or the chain head cannot be updated.
85    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    /// Shared implementation for `append` and `append_with_principal`.
97    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        // Get the previous hash for this entry's chain (system or principal).
106        let chain_key: ChainKey = (session_id.clone(), principal.clone());
107        let previous_hash = self.get_previous_hash(&chain_key)?;
108
109        // Create and sign the entry. session_id is moved into create,
110        // chain_key retains the clone for the cache update below.
111        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        // Store the entry
142        self.storage.store(&entry)?;
143
144        // Update cached chain head for this entry's chain.
145        {
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    /// Get the previous hash for a chain (session + optional principal).
157    fn get_previous_hash(&self, chain_key: &ChainKey) -> AuditResult<ContentHash> {
158        // Check cache first
159        {
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        // Check storage
170        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        // Genesis - no previous entry for this chain
179        Ok(ContentHash::zero())
180    }
181
182    /// Get an entry by ID.
183    ///
184    /// # Errors
185    ///
186    /// Returns an error if the storage backend fails to retrieve the entry.
187    pub fn get(&self, id: &AuditEntryId) -> AuditResult<Option<AuditEntry>> {
188        self.storage.get(id)
189    }
190
191    /// Get all entries for a session.
192    ///
193    /// # Errors
194    ///
195    /// Returns an error if the storage backend fails to retrieve entries.
196    pub fn get_session_entries(&self, session_id: &SessionId) -> AuditResult<Vec<AuditEntry>> {
197        self.storage.get_session_entries(session_id)
198    }
199
200    /// Verify the integrity of all audit chains in a session.
201    ///
202    /// Each principal (and the system chain) is verified independently.
203    /// A session with entries from principals "alice" and "bob" plus system
204    /// entries will verify three independent chains.
205    ///
206    /// # Errors
207    ///
208    /// Returns an error if entries cannot be retrieved from storage.
209    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        // Group entries by principal (None = system chain).
221        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        // Verify each chain independently.
236        for chain_entries in chains.values_mut() {
237            // Sort by timestamp within each chain.
238            chain_entries.sort_by(|a, b| a.timestamp.0.cmp(&b.timestamp.0));
239
240            // Verify genesis (first entry has zero previous hash).
241            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            // Verify signatures.
248            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            // Verify chain linking within this principal's chain.
259            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    /// Verify the integrity of a single principal's chain within a session.
287    ///
288    /// Pass `None` to verify the system chain (entries without a principal).
289    ///
290    /// # Errors
291    ///
292    /// Returns an error if entries cannot be retrieved from storage.
293    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    /// Get entries for a specific principal within a session.
352    ///
353    /// Pass `None` to get system entries (no principal).
354    ///
355    /// # Errors
356    ///
357    /// Returns an error if entries cannot be retrieved from storage.
358    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    /// Verify the entire audit log (all sessions).
371    ///
372    /// # Errors
373    ///
374    /// Returns an error if sessions cannot be listed or verified.
375    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    /// Count total entries.
388    ///
389    /// # Errors
390    ///
391    /// Returns an error if the storage backend fails.
392    pub fn count(&self) -> AuditResult<usize> {
393        self.storage.count()
394    }
395
396    /// Count entries for a session.
397    ///
398    /// # Errors
399    ///
400    /// Returns an error if the storage backend fails.
401    pub fn count_session(&self, session_id: &SessionId) -> AuditResult<usize> {
402        self.storage.count_session(session_id)
403    }
404
405    /// List all sessions.
406    ///
407    /// # Errors
408    ///
409    /// Returns an error if the storage backend fails.
410    pub fn list_sessions(&self) -> AuditResult<Vec<SessionId>> {
411        self.storage.list_sessions()
412    }
413
414    /// Flush pending writes.
415    ///
416    /// # Errors
417    ///
418    /// Returns an error if the storage backend fails to flush.
419    pub fn flush(&self) -> AuditResult<()> {
420        self.storage.flush()
421    }
422
423    /// Get the runtime public key.
424    #[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/// Result of chain verification.
439#[derive(Debug, Clone)]
440pub struct ChainVerificationResult {
441    /// Whether the chain is valid.
442    pub valid: bool,
443    /// Number of entries verified.
444    pub entries_verified: usize,
445    /// Issues found (empty if valid).
446    pub issues: Vec<ChainIssue>,
447}
448
449/// An issue found during chain verification.
450#[derive(Debug, Clone)]
451pub enum ChainIssue {
452    /// First entry doesn't have zero previous hash.
453    InvalidGenesis {
454        /// The entry with invalid genesis.
455        entry_id: AuditEntryId,
456    },
457    /// Entry has invalid signature.
458    InvalidSignature {
459        /// The entry with invalid signature.
460        entry_id: AuditEntryId,
461    },
462    /// Chain link is broken.
463    BrokenLink {
464        /// The entry with broken link.
465        entry_id: AuditEntryId,
466        /// Expected previous hash.
467        expected_previous: ContentHash,
468        /// Actual previous hash in entry.
469        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/// Builder for audit entries with fluent API.
490#[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    /// Create a new audit builder.
501    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    /// Set the action.
511    #[must_use]
512    pub(crate) fn action(mut self, action: AuditAction) -> Self {
513        self.action = Some(action);
514        self
515    }
516
517    /// Set the authorization.
518    #[must_use]
519    pub(crate) fn authorization(mut self, auth: AuthorizationProof) -> Self {
520        self.authorization = Some(auth);
521        self
522    }
523
524    /// Record success.
525    ///
526    /// # Panics
527    ///
528    /// Panics if `action` was not set on the builder.
529    ///
530    /// # Errors
531    ///
532    /// Returns an error if the audit entry cannot be appended.
533    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    /// Record success with details.
546    ///
547    /// # Panics
548    ///
549    /// Panics if `action` was not set on the builder.
550    ///
551    /// # Errors
552    ///
553    /// Returns an error if the audit entry cannot be appended.
554    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    /// Record failure.
567    ///
568    /// # Panics
569    ///
570    /// Panics if `action` was not set on the builder.
571    ///
572    /// # Errors
573    ///
574    /// Returns an error if the audit entry cannot be appended.
575    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;