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}