Skip to main content

audit_trail/
record.rs

1//! The audit [`Record`] and its component types.
2//!
3//! A record captures the canonical "who / what / when / where / result" tuple
4//! plus the chain linkage (`prev_hash`, `hash`). Records are borrowed: their
5//! string fields hold `&str` references rather than owning allocations, so the
6//! hot append path costs nothing on the heap.
7
8use crate::clock::Timestamp;
9use crate::hash::Digest;
10
11/// Monotonically-increasing identifier for an audit record.
12///
13/// Ids start at `0` for the genesis record and increment by one for every
14/// successful append.
15///
16/// # Example
17///
18/// ```
19/// use audit_trail::RecordId;
20///
21/// let id = RecordId::from_u64(7);
22/// assert_eq!(id.as_u64(), 7);
23/// ```
24#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
25pub struct RecordId(u64);
26
27impl RecordId {
28    /// The first identifier produced by a fresh chain.
29    pub const GENESIS: Self = Self(0);
30
31    /// Wrap a raw `u64` as a [`RecordId`].
32    #[inline]
33    pub const fn from_u64(value: u64) -> Self {
34        Self(value)
35    }
36
37    /// Return the underlying `u64`.
38    #[inline]
39    pub const fn as_u64(self) -> u64 {
40        self.0
41    }
42}
43
44/// The subject performing an audited action (the "who").
45///
46/// Typically a user id, service principal, or session identifier.
47///
48/// # Example
49///
50/// ```
51/// use audit_trail::Actor;
52///
53/// let actor = Actor::new("user-42");
54/// assert_eq!(actor.as_str(), "user-42");
55/// ```
56#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
57pub struct Actor<'a>(&'a str);
58
59impl<'a> Actor<'a> {
60    /// Wrap a string slice as an [`Actor`].
61    #[inline]
62    pub const fn new(value: &'a str) -> Self {
63        Self(value)
64    }
65
66    /// Borrow the underlying string slice.
67    #[inline]
68    pub const fn as_str(&self) -> &'a str {
69        self.0
70    }
71}
72
73/// The verb of an audited event (the "what").
74///
75/// Conventionally a dotted action name such as `"user.login"` or
76/// `"record.delete"`.
77///
78/// # Example
79///
80/// ```
81/// use audit_trail::Action;
82///
83/// let action = Action::new("user.login");
84/// assert_eq!(action.as_str(), "user.login");
85/// ```
86#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
87pub struct Action<'a>(&'a str);
88
89impl<'a> Action<'a> {
90    /// Wrap a string slice as an [`Action`].
91    #[inline]
92    pub const fn new(value: &'a str) -> Self {
93        Self(value)
94    }
95
96    /// Borrow the underlying string slice.
97    #[inline]
98    pub const fn as_str(&self) -> &'a str {
99        self.0
100    }
101}
102
103/// The resource an audited action was performed on (the "where").
104///
105/// Typically a resource identifier such as `"file:///etc/passwd"`,
106/// `"record:42"`, or `"tenant:acme/user:42"`.
107///
108/// # Example
109///
110/// ```
111/// use audit_trail::Target;
112///
113/// let target = Target::new("record:42");
114/// assert_eq!(target.as_str(), "record:42");
115/// ```
116#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
117pub struct Target<'a>(&'a str);
118
119impl<'a> Target<'a> {
120    /// Wrap a string slice as a [`Target`].
121    #[inline]
122    pub const fn new(value: &'a str) -> Self {
123        Self(value)
124    }
125
126    /// Borrow the underlying string slice.
127    #[inline]
128    pub const fn as_str(&self) -> &'a str {
129        self.0
130    }
131}
132
133/// Outcome of an audited action (the "result").
134///
135/// `#[non_exhaustive]` so additional outcomes may be added without a major
136/// version bump.
137///
138/// # Example
139///
140/// ```
141/// use audit_trail::Outcome;
142///
143/// assert_eq!(Outcome::Success.as_u8(), 0);
144/// ```
145#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
146#[non_exhaustive]
147#[repr(u8)]
148pub enum Outcome {
149    /// The action completed successfully.
150    Success = 0,
151    /// The action was attempted and failed for an operational reason.
152    Failure = 1,
153    /// The action was denied by policy or authorization.
154    Denied = 2,
155    /// The action errored due to an unexpected condition.
156    Error = 3,
157}
158
159impl Outcome {
160    /// Stable numeric encoding suitable for hashing.
161    #[inline]
162    pub const fn as_u8(self) -> u8 {
163        self as u8
164    }
165}
166
167/// A single audited event in the chain.
168///
169/// A record is intentionally borrowed: its string fields point at caller
170/// memory. This keeps the append hot path allocation-free. Sinks that need
171/// to persist a record across the lifetime of the borrow must encode it
172/// before returning.
173///
174/// # Example
175///
176/// ```
177/// use audit_trail::{Action, Actor, Digest, Outcome, Record, RecordId, Target, Timestamp};
178///
179/// let record = Record::new(
180///     RecordId::GENESIS,
181///     Timestamp::from_nanos(0),
182///     Actor::new("system"),
183///     Action::new("chain.init"),
184///     Target::new("chain:0"),
185///     Outcome::Success,
186///     Digest::ZERO,
187///     Digest::ZERO,
188/// );
189/// assert_eq!(record.actor().as_str(), "system");
190/// ```
191#[derive(Copy, Clone, Debug, PartialEq, Eq)]
192pub struct Record<'a> {
193    id: RecordId,
194    timestamp: Timestamp,
195    actor: Actor<'a>,
196    action: Action<'a>,
197    target: Target<'a>,
198    outcome: Outcome,
199    prev_hash: Digest,
200    hash: Digest,
201}
202
203impl<'a> Record<'a> {
204    /// Construct a record from its constituent parts.
205    ///
206    /// The crate's [`crate::Chain`] is normally responsible for producing
207    /// records. This constructor is exposed so that custom storage layers
208    /// and verifiers can reconstruct records when reading them back.
209    // `#[allow(clippy::too_many_arguments)]` is justified: a `Record` is
210    // exactly the 5W tuple (`Actor`, `Action`, `Target`, `Outcome`,
211    // `Timestamp`) plus chain links (`id`, `prev_hash`, `hash`). All eight
212    // are required; grouping them into a builder would obscure the data
213    // shape and only displace the argument count one call away.
214    #[allow(clippy::too_many_arguments)]
215    #[inline]
216    pub const fn new(
217        id: RecordId,
218        timestamp: Timestamp,
219        actor: Actor<'a>,
220        action: Action<'a>,
221        target: Target<'a>,
222        outcome: Outcome,
223        prev_hash: Digest,
224        hash: Digest,
225    ) -> Self {
226        Self {
227            id,
228            timestamp,
229            actor,
230            action,
231            target,
232            outcome,
233            prev_hash,
234            hash,
235        }
236    }
237
238    /// Return a copy of this record with `hash` replaced.
239    ///
240    /// Useful when constructing a record in two steps: build a draft with a
241    /// placeholder hash (typically [`Digest::ZERO`]), feed it through a
242    /// hasher to derive its real digest, then swap the hash in.
243    #[inline]
244    pub const fn with_hash(mut self, hash: Digest) -> Self {
245        self.hash = hash;
246        self
247    }
248
249    /// Record identifier.
250    #[inline]
251    pub const fn id(&self) -> RecordId {
252        self.id
253    }
254
255    /// Record timestamp.
256    #[inline]
257    pub const fn timestamp(&self) -> Timestamp {
258        self.timestamp
259    }
260
261    /// The actor (who).
262    #[inline]
263    pub const fn actor(&self) -> Actor<'a> {
264        self.actor
265    }
266
267    /// The action (what).
268    #[inline]
269    pub const fn action(&self) -> Action<'a> {
270        self.action
271    }
272
273    /// The target (where).
274    #[inline]
275    pub const fn target(&self) -> Target<'a> {
276        self.target
277    }
278
279    /// The outcome (result).
280    #[inline]
281    pub const fn outcome(&self) -> Outcome {
282        self.outcome
283    }
284
285    /// Hash of the immediately preceding record in the chain.
286    ///
287    /// For the genesis record this is [`Digest::ZERO`].
288    #[inline]
289    pub const fn prev_hash(&self) -> Digest {
290        self.prev_hash
291    }
292
293    /// Hash of this record, computed over all other fields.
294    #[inline]
295    pub const fn hash(&self) -> Digest {
296        self.hash
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crate::clock::Timestamp;
304    use crate::hash::{Digest, HASH_LEN};
305
306    #[test]
307    fn record_id_genesis_is_zero() {
308        assert_eq!(RecordId::GENESIS.as_u64(), 0);
309        assert_eq!(RecordId::from_u64(0), RecordId::GENESIS);
310    }
311
312    #[test]
313    fn outcome_as_u8_is_stable() {
314        // These numeric encodings are part of the on-disk codec format and
315        // must not change between releases.
316        assert_eq!(Outcome::Success.as_u8(), 0);
317        assert_eq!(Outcome::Failure.as_u8(), 1);
318        assert_eq!(Outcome::Denied.as_u8(), 2);
319        assert_eq!(Outcome::Error.as_u8(), 3);
320    }
321
322    #[test]
323    fn record_accessors_return_constructor_values() {
324        let r = Record::new(
325            RecordId::from_u64(42),
326            Timestamp::from_nanos(1_700_000_000),
327            Actor::new("user-1"),
328            Action::new("user.login"),
329            Target::new("session:abc"),
330            Outcome::Success,
331            Digest::from_bytes([0xAA; HASH_LEN]),
332            Digest::from_bytes([0xBB; HASH_LEN]),
333        );
334        assert_eq!(r.id().as_u64(), 42);
335        assert_eq!(r.timestamp().as_nanos(), 1_700_000_000);
336        assert_eq!(r.actor().as_str(), "user-1");
337        assert_eq!(r.action().as_str(), "user.login");
338        assert_eq!(r.target().as_str(), "session:abc");
339        assert_eq!(r.outcome(), Outcome::Success);
340        assert_eq!(r.prev_hash().as_bytes(), &[0xAA; HASH_LEN]);
341        assert_eq!(r.hash().as_bytes(), &[0xBB; HASH_LEN]);
342    }
343
344    #[test]
345    fn record_with_hash_swaps_only_the_hash_field() {
346        let r = Record::new(
347            RecordId::GENESIS,
348            Timestamp::EPOCH,
349            Actor::new("a"),
350            Action::new("x"),
351            Target::new("t"),
352            Outcome::Success,
353            Digest::ZERO,
354            Digest::ZERO,
355        );
356        let new_hash = Digest::from_bytes([0xCC; HASH_LEN]);
357        let r2 = r.with_hash(new_hash);
358        assert_eq!(r2.hash(), new_hash);
359        assert_eq!(r2.id(), r.id());
360        assert_eq!(r2.actor(), r.actor());
361        assert_eq!(r2.prev_hash(), r.prev_hash());
362    }
363}