use astrid_capabilities::AuditEntryId;
use astrid_core::SessionId;
use astrid_crypto::{ContentHash, KeyPair};
use std::path::Path;
use std::sync::RwLock;
use tracing::{debug, error, warn};
use crate::entry::{AuditAction, AuditEntry, AuditOutcome, AuthorizationProof};
use crate::error::{AuditError, AuditResult};
use crate::storage::{AuditStorage, SurrealKvAuditStorage};
type ChainKey = (SessionId, Option<astrid_core::PrincipalId>);
pub struct AuditLog {
storage: Box<dyn AuditStorage>,
runtime_key: KeyPair,
chain_heads: RwLock<std::collections::HashMap<ChainKey, ContentHash>>,
}
impl AuditLog {
pub fn open(path: impl AsRef<Path>, runtime_key: KeyPair) -> AuditResult<Self> {
let storage = SurrealKvAuditStorage::open(path)?;
Ok(Self {
storage: Box::new(storage),
runtime_key,
chain_heads: RwLock::new(std::collections::HashMap::new()),
})
}
#[must_use]
pub fn in_memory(runtime_key: KeyPair) -> Self {
let storage = SurrealKvAuditStorage::in_memory();
Self {
storage: Box::new(storage),
runtime_key,
chain_heads: RwLock::new(std::collections::HashMap::new()),
}
}
pub fn append(
&self,
session_id: SessionId,
action: AuditAction,
authorization: AuthorizationProof,
outcome: AuditOutcome,
) -> AuditResult<AuditEntryId> {
self.append_inner(session_id, None, action, authorization, outcome)
}
pub fn append_with_principal(
&self,
session_id: SessionId,
principal: astrid_core::PrincipalId,
action: AuditAction,
authorization: AuthorizationProof,
outcome: AuditOutcome,
) -> AuditResult<AuditEntryId> {
self.append_inner(session_id, Some(principal), action, authorization, outcome)
}
fn append_inner(
&self,
session_id: SessionId,
principal: Option<astrid_core::PrincipalId>,
action: AuditAction,
authorization: AuthorizationProof,
outcome: AuditOutcome,
) -> AuditResult<AuditEntryId> {
let chain_key: ChainKey = (session_id.clone(), principal.clone());
let previous_hash = self.get_previous_hash(&chain_key)?;
let entry = if let Some(p) = principal {
AuditEntry::create_with_principal(
session_id,
p,
action,
authorization,
outcome,
previous_hash,
&self.runtime_key,
)
} else {
AuditEntry::create(
session_id,
action,
authorization,
outcome,
previous_hash,
&self.runtime_key,
)
};
let entry_id = entry.id.clone();
let entry_hash = entry.content_hash();
debug!(
entry_id = %entry_id,
action = %entry.action.description(),
"Appending audit entry"
);
self.storage.store(&entry)?;
{
let mut heads = self
.chain_heads
.write()
.map_err(|e| AuditError::StorageError(e.to_string()))?;
heads.insert(chain_key, entry_hash);
}
Ok(entry_id)
}
fn get_previous_hash(&self, chain_key: &ChainKey) -> AuditResult<ContentHash> {
{
let heads = self
.chain_heads
.read()
.map_err(|e| AuditError::StorageError(e.to_string()))?;
if let Some(hash) = heads.get(chain_key) {
return Ok(*hash);
}
}
if let Some(head_id) = self
.storage
.get_chain_head(&chain_key.0, chain_key.1.as_ref())?
&& let Some(entry) = self.storage.get(&head_id)?
{
return Ok(entry.content_hash());
}
Ok(ContentHash::zero())
}
pub fn get(&self, id: &AuditEntryId) -> AuditResult<Option<AuditEntry>> {
self.storage.get(id)
}
pub fn get_session_entries(&self, session_id: &SessionId) -> AuditResult<Vec<AuditEntry>> {
self.storage.get_session_entries(session_id)
}
pub fn verify_chain(&self, session_id: &SessionId) -> AuditResult<ChainVerificationResult> {
let entries = self.storage.get_session_entries(session_id)?;
if entries.is_empty() {
return Ok(ChainVerificationResult {
valid: true,
entries_verified: 0,
issues: Vec::new(),
});
}
let mut chains: std::collections::HashMap<
Option<astrid_core::PrincipalId>,
Vec<&AuditEntry>,
> = std::collections::HashMap::new();
for entry in &entries {
chains
.entry(entry.principal.clone())
.or_default()
.push(entry);
}
let mut issues = Vec::new();
let mut entries_verified: usize = 0;
for chain_entries in chains.values_mut() {
chain_entries.sort_by(|a, b| a.timestamp.0.cmp(&b.timestamp.0));
if !chain_entries[0].previous_hash.is_zero() {
issues.push(ChainIssue::InvalidGenesis {
entry_id: chain_entries[0].id.clone(),
});
}
for entry in chain_entries.iter() {
if let Err(e) = entry.verify_signature() {
error!(entry_id = %entry.id, error = %e, "Invalid signature");
issues.push(ChainIssue::InvalidSignature {
entry_id: entry.id.clone(),
});
}
entries_verified = entries_verified.saturating_add(1);
}
for i in 1..chain_entries.len() {
#[expect(clippy::arithmetic_side_effects)]
let prev = chain_entries[i - 1];
let curr = chain_entries[i];
if !curr.follows(prev) {
warn!(
current = %curr.id,
previous = %prev.id,
"Chain link broken"
);
issues.push(ChainIssue::BrokenLink {
entry_id: curr.id.clone(),
expected_previous: prev.content_hash(),
actual_previous: curr.previous_hash,
});
}
}
}
Ok(ChainVerificationResult {
valid: issues.is_empty(),
entries_verified,
issues,
})
}
pub fn verify_principal_chain(
&self,
session_id: &SessionId,
principal: Option<&astrid_core::PrincipalId>,
) -> AuditResult<ChainVerificationResult> {
let entries = self.get_principal_entries(session_id, principal)?;
if entries.is_empty() {
return Ok(ChainVerificationResult {
valid: true,
entries_verified: 0,
issues: Vec::new(),
});
}
let mut issues = Vec::new();
let mut entries_verified: usize = 0;
let mut sorted = entries;
sorted.sort_by(|a, b| a.timestamp.0.cmp(&b.timestamp.0));
if !sorted[0].previous_hash.is_zero() {
issues.push(ChainIssue::InvalidGenesis {
entry_id: sorted[0].id.clone(),
});
}
for entry in &sorted {
if let Err(e) = entry.verify_signature() {
error!(entry_id = %entry.id, error = %e, "Invalid signature");
issues.push(ChainIssue::InvalidSignature {
entry_id: entry.id.clone(),
});
}
entries_verified = entries_verified.saturating_add(1);
}
for i in 1..sorted.len() {
#[expect(clippy::arithmetic_side_effects)]
let prev = &sorted[i - 1];
let curr = &sorted[i];
if !curr.follows(prev) {
warn!(current = %curr.id, previous = %prev.id, "Chain link broken");
issues.push(ChainIssue::BrokenLink {
entry_id: curr.id.clone(),
expected_previous: prev.content_hash(),
actual_previous: curr.previous_hash,
});
}
}
Ok(ChainVerificationResult {
valid: issues.is_empty(),
entries_verified,
issues,
})
}
pub fn get_principal_entries(
&self,
session_id: &SessionId,
principal: Option<&astrid_core::PrincipalId>,
) -> AuditResult<Vec<AuditEntry>> {
let all = self.storage.get_session_entries(session_id)?;
Ok(all
.into_iter()
.filter(|e| e.principal.as_ref() == principal)
.collect())
}
pub fn verify_all(&self) -> AuditResult<Vec<(SessionId, ChainVerificationResult)>> {
let sessions = self.storage.list_sessions()?;
let mut results = Vec::new();
for session_id in sessions {
let result = self.verify_chain(&session_id)?;
results.push((session_id, result));
}
Ok(results)
}
pub fn count(&self) -> AuditResult<usize> {
self.storage.count()
}
pub fn count_session(&self, session_id: &SessionId) -> AuditResult<usize> {
self.storage.count_session(session_id)
}
pub fn list_sessions(&self) -> AuditResult<Vec<SessionId>> {
self.storage.list_sessions()
}
pub fn flush(&self) -> AuditResult<()> {
self.storage.flush()
}
#[must_use]
pub fn runtime_public_key(&self) -> astrid_crypto::PublicKey {
self.runtime_key.export_public_key()
}
}
impl std::fmt::Debug for AuditLog {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuditLog")
.field("runtime_key_id", &self.runtime_key.key_id_hex())
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone)]
pub struct ChainVerificationResult {
pub valid: bool,
pub entries_verified: usize,
pub issues: Vec<ChainIssue>,
}
#[derive(Debug, Clone)]
pub enum ChainIssue {
InvalidGenesis {
entry_id: AuditEntryId,
},
InvalidSignature {
entry_id: AuditEntryId,
},
BrokenLink {
entry_id: AuditEntryId,
expected_previous: ContentHash,
actual_previous: ContentHash,
},
}
impl std::fmt::Display for ChainIssue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidGenesis { entry_id } => {
write!(f, "Invalid genesis at {entry_id}")
},
Self::InvalidSignature { entry_id } => {
write!(f, "Invalid signature at {entry_id}")
},
Self::BrokenLink { entry_id, .. } => {
write!(f, "Broken chain link at {entry_id}")
},
}
}
}
#[cfg(test)]
pub(crate) struct AuditBuilder<'a> {
log: &'a AuditLog,
session_id: SessionId,
action: Option<AuditAction>,
authorization: Option<AuthorizationProof>,
}
#[cfg(test)]
impl<'a> AuditBuilder<'a> {
pub(crate) fn new(log: &'a AuditLog, session_id: SessionId) -> Self {
Self {
log,
session_id,
action: None,
authorization: None,
}
}
#[must_use]
pub(crate) fn action(mut self, action: AuditAction) -> Self {
self.action = Some(action);
self
}
#[must_use]
pub(crate) fn authorization(mut self, auth: AuthorizationProof) -> Self {
self.authorization = Some(auth);
self
}
pub(crate) fn success(self) -> AuditResult<AuditEntryId> {
self.log.append(
self.session_id,
self.action.expect("action required"),
self.authorization
.unwrap_or(AuthorizationProof::NotRequired {
reason: "unspecified".to_string(),
}),
AuditOutcome::success(),
)
}
pub(crate) fn success_with(self, details: impl Into<String>) -> AuditResult<AuditEntryId> {
self.log.append(
self.session_id,
self.action.expect("action required"),
self.authorization
.unwrap_or(AuthorizationProof::NotRequired {
reason: "unspecified".to_string(),
}),
AuditOutcome::success_with(details),
)
}
pub(crate) fn failure(self, error: impl Into<String>) -> AuditResult<AuditEntryId> {
self.log.append(
self.session_id,
self.action.expect("action required"),
self.authorization
.unwrap_or(AuthorizationProof::NotRequired {
reason: "unspecified".to_string(),
}),
AuditOutcome::failure(error),
)
}
}
#[cfg(test)]
#[path = "log_tests.rs"]
mod tests;