Skip to main content

spacedb_access/
audit.rs

1//! The access audit log — signed, content-addressed, append-only.
2//!
3//! Every access (allowed *or* denied) becomes an [`AuditEntry`] in the serving
4//! node's [`AuditLog`]: hash-chained (each entry commits to the previous, so the
5//! log is append-only and tamper-evident, like the Iron Bank journal) and signed
6//! by the node (so the owner can attribute the log to their node and detect
7//! forgery). The owner reads this log to see who/what/when, and revokes from it.
8//!
9//! [`AuditLog::verify`] re-walks the chain: sequence, prev-hash links, each
10//! entry's content hash, and each node signature.
11
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15use crate::authorize::{Decision, DenyReason};
16use crate::capability::{Ops, Scope};
17use crate::identity::{verify_sec1, Did, Identity};
18
19pub type AuditResult<T> = Result<T, AuditError>;
20
21#[derive(Debug, Error)]
22pub enum AuditError {
23    #[error("audit canonicalization: {0}")]
24    Canonical(String),
25    #[error("audit chain broken at seq {0}")]
26    BrokenChain(u64),
27    #[error("audit entry hash mismatch at seq {0}")]
28    HashMismatch(u64),
29    #[error("audit node signature invalid at seq {0}")]
30    BadSignature(u64),
31    #[error("audit sequence mismatch at index {0}")]
32    SeqMismatch(u64),
33}
34
35/// What was decided, recorded for the audit trail.
36#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
37pub enum AuditDecision {
38    Allowed,
39    Denied(DenyReason),
40}
41
42impl AuditDecision {
43    /// Map an authorization [`Decision`] into its audit form.
44    pub fn of(decision: &Decision) -> Self {
45        match decision {
46            Decision::Allow => AuditDecision::Allowed,
47            Decision::Deny(reason) => AuditDecision::Denied(reason.clone()),
48        }
49    }
50}
51
52/// One immutable, signed entry in the access log.
53#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
54pub struct AuditEntry {
55    pub seq: u64,
56    /// Hash of the previous entry (all-zero for the first) — the chain link.
57    pub prev_hash: [u8; 32],
58    pub at_unix: u64,
59    pub bearer: Did,
60    pub scope: Scope,
61    pub op: Ops,
62    pub capability_id: Option<[u8; 16]>,
63    pub decision: AuditDecision,
64    /// BLAKE3 of this entry's canonical content (including `prev_hash`).
65    pub entry_hash: [u8; 32],
66    /// The node's signature over the canonical content.
67    pub node_signature: Vec<u8>,
68}
69
70fn content_bytes(
71    seq: u64,
72    prev_hash: &[u8; 32],
73    at_unix: u64,
74    bearer: &Did,
75    scope: &Scope,
76    op: Ops,
77    capability_id: &Option<[u8; 16]>,
78    decision: &AuditDecision,
79) -> AuditResult<Vec<u8>> {
80    postcard::to_allocvec(&(seq, prev_hash, at_unix, bearer, scope, op, capability_id, decision))
81        .map_err(|e| AuditError::Canonical(e.to_string()))
82}
83
84fn hash(bytes: &[u8]) -> [u8; 32] {
85    *blake3::hash(bytes).as_bytes()
86}
87
88/// A node's append-only access log.
89#[derive(Clone, Debug, Default)]
90pub struct AuditLog {
91    entries: Vec<AuditEntry>,
92}
93
94impl AuditLog {
95    pub fn new() -> Self {
96        Self::default()
97    }
98
99    /// Reconstruct a log from persisted entries (then [`verify`](Self::verify)).
100    pub fn from_entries(entries: Vec<AuditEntry>) -> Self {
101        Self { entries }
102    }
103
104    pub fn entries(&self) -> &[AuditEntry] {
105        &self.entries
106    }
107
108    pub fn len(&self) -> usize {
109        self.entries.len()
110    }
111
112    pub fn is_empty(&self) -> bool {
113        self.entries.is_empty()
114    }
115
116    /// Append a signed, chained record of one access decision.
117    #[allow(clippy::too_many_arguments)]
118    pub fn record(
119        &mut self,
120        node: &Identity,
121        at_unix: u64,
122        bearer: &Did,
123        scope: &Scope,
124        op: Ops,
125        capability_id: Option<[u8; 16]>,
126        decision: AuditDecision,
127    ) -> AuditResult<()> {
128        let seq = self.entries.len() as u64;
129        let prev_hash = self
130            .entries
131            .last()
132            .map(|e| e.entry_hash)
133            .unwrap_or([0u8; 32]);
134
135        let canonical = content_bytes(
136            seq,
137            &prev_hash,
138            at_unix,
139            bearer,
140            scope,
141            op,
142            &capability_id,
143            &decision,
144        )?;
145        let entry_hash = hash(&canonical);
146        let node_signature = node.sign(&canonical);
147
148        self.entries.push(AuditEntry {
149            seq,
150            prev_hash,
151            at_unix,
152            bearer: bearer.clone(),
153            scope: scope.clone(),
154            op,
155            capability_id,
156            decision,
157            entry_hash,
158            node_signature,
159        });
160        Ok(())
161    }
162
163    /// Re-walk the chain: sequence, prev-hash links, content hashes, and each
164    /// node signature against `node_public_key`. Any inconsistency is an error.
165    pub fn verify(&self, node_public_key: &[u8]) -> AuditResult<()> {
166        let mut expected_prev = [0u8; 32];
167        for (index, entry) in self.entries.iter().enumerate() {
168            if entry.seq != index as u64 {
169                return Err(AuditError::SeqMismatch(index as u64));
170            }
171            if entry.prev_hash != expected_prev {
172                return Err(AuditError::BrokenChain(entry.seq));
173            }
174            let canonical = content_bytes(
175                entry.seq,
176                &entry.prev_hash,
177                entry.at_unix,
178                &entry.bearer,
179                &entry.scope,
180                entry.op,
181                &entry.capability_id,
182                &entry.decision,
183            )?;
184            if hash(&canonical) != entry.entry_hash {
185                return Err(AuditError::HashMismatch(entry.seq));
186            }
187            if !verify_sec1(node_public_key, &canonical, &entry.node_signature) {
188                return Err(AuditError::BadSignature(entry.seq));
189            }
190            expected_prev = entry.entry_hash;
191        }
192        Ok(())
193    }
194}