Skip to main content

gunnar_core/
account_audit.rs

1//! **The account admin's audit log** — every security-relevant event in ONE
2//! account, append-only, hash-chained, kept per account, a year by default
3//! (owner's enterprise list, 2026-10-05).
4//!
5//! # Not the control plane's trail
6//!
7//! [`crate::audit`] is the SERVER's trail: one chain over every decision the
8//! gRPC gate makes, read by the operator (`Admin.AuditTrail`). This is the
9//! ACCOUNT's: what happened inside `acme` — who logged in, which passkey was
10//! added, which repository went public, who approved the operator's look —
11//! read ONLY by acme's admins. The operator sees neither its entries nor its
12//! names (the operator's aggregates, `Enterprise.Aggregates`); the two logs
13//! are separate precisely so that the one an operator reads never has to be
14//! filtered to keep that promise.
15//!
16//! # The shape
17//!
18//! ```text
19//!   entry.hash = SHA-256( "gunnar-account-audit-v1" ‖ account ‖ prev ‖ seq ‖ at ‖ kind ‖
20//!                         actor ‖ actor_role ‖ target ‖ detail ‖ outcome )   (each length-prefixed)
21//! ```
22//!
23//! The same construction as [`crate::audit`] — a chain whose tip commits to
24//! the whole history, fields length-prefixed so no value can move a boundary
25//! — with the ACCOUNT hashed in, so a line copied from one account's log into
26//! another's is a broken chain and not a quiet forgery.
27//!
28//! # Stored as one control document per account
29//!
30//! `refs/gunnar/account-audit/<account>`, in the control store beside the
31//! identity table: replicated to the twin and carried by a restore with
32//! everything else, with no new file the twin's snapshot would have to learn
33//! about. Entries are bucketed by UTC DAY in a [`ShardedMap`], so an append
34//! rewrites one day's bucket and two trie nodes, and the document root holds
35//! the chain's head and the list of days that have entries (≤ 366).
36//!
37//! # Retention, and why it does not break the chain
38//!
39//! [`DEFAULT_RETENTION_DAYS`]: a year. The append that crosses the line drops
40//! every day past it and records the last dropped entry as the ANCHOR — its
41//! seq and its hash — so the first kept entry's `prev` is checked against the
42//! anchor instead of [`GENESIS`]. A pruned log still verifies end to end.
43//!
44//! What this does NOT promise: that the operator's disk never held the bytes.
45//! The control store keeps every version of every document (its own history),
46//! so a pruned day still exists in old blobs on the operator's box until the
47//! store itself is compacted. The promise is the API's: nobody but the
48//! account's admins is SHOWN the log (`.nornir/account-audit-operator-privacy.md`).
49
50use std::collections::HashMap;
51use std::sync::atomic::{AtomicU32, Ordering};
52use std::sync::{Arc, Mutex};
53
54use sha2::Digest as _;
55
56use crate::control::ControlStore;
57use crate::doc::{impl_doc, DocRef, ShardedMap};
58use crate::error::Result;
59use crate::flow::{day_of, iso8601_utc, DAY_MS};
60
61/// The prefix every account's log document is named under.
62pub const ACCOUNT_AUDIT_REF_PREFIX: &str = "refs/gunnar/account-audit/";
63
64/// **A year**: how long an entry is kept unless the server is told otherwise.
65pub const DEFAULT_RETENTION_DAYS: u32 = 365;
66
67/// The `prev` of an account's first entry: 64 zeros, as [`crate::audit::GENESIS`].
68pub const GENESIS: &str = crate::audit::GENESIS;
69
70/// Domain separation: a hash from this chain is not a hash from the server's.
71const DOMAIN: &str = "gunnar-account-audit-v1";
72
73/// The most entries one query answers, whatever it asks for.
74pub const MAX_PAGE: usize = 1000;
75
76/// Did the thing the entry records happen, or was it refused?
77#[derive(Clone, Copy, PartialEq, Eq, Debug)]
78pub enum Outcome {
79    /// It happened.
80    Ok,
81    /// It was asked for and refused (a login that failed, a name the filter
82    /// refused, a member trying to make a repository public).
83    Refused,
84}
85
86impl Outcome {
87    /// `ok` or `refused` — the word the entry carries.
88    pub fn as_str(self) -> &'static str {
89        match self {
90            Outcome::Ok => "ok",
91            Outcome::Refused => "refused",
92        }
93    }
94}
95
96/// **One event, as a caller hands it over.** Borrowed: every field is already
97/// a `&str` where it happens.
98#[derive(Clone, Copy, Debug)]
99pub struct AccountEvent<'a> {
100    /// What happened, `family.verb`: `login`, `credential.added`,
101    /// `visibility.changed`, `repo.deleted`, `operator_access.approved` …
102    pub kind: &'a str,
103    /// The principal that did it; empty for the system (a retention sweep, an
104    /// expiry).
105    pub actor: &'a str,
106    /// What the actor is TO THIS ACCOUNT: `admin`, `member`, `operator`,
107    /// `machine`, `outsider`, `system`.
108    pub actor_role: &'a str,
109    /// What it was done to: a repository path, a principal, a credential's
110    /// label, a namespace.
111    pub target: &'a str,
112    /// One sentence more, or empty.
113    pub detail: &'a str,
114    /// Done or refused.
115    pub outcome: Outcome,
116}
117
118/// **One entry, as it is stored and as the admin reads it.**
119///
120/// Field order is the serialisation order; `deny_unknown_fields` so an entry
121/// written by a newer gunnar is refused loudly rather than verified against
122/// fields this build cannot see.
123#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
124#[serde(deny_unknown_fields)]
125pub struct AccountEntry {
126    /// 0 for the account's first entry, then +1 each time, never reused.
127    pub seq: u64,
128    /// When, Unix milliseconds (this server's clock).
129    pub at_unix_ms: i64,
130    /// [`AccountEvent::kind`].
131    pub kind: String,
132    /// [`AccountEvent::actor`].
133    pub actor: String,
134    /// [`AccountEvent::actor_role`].
135    pub actor_role: String,
136    /// [`AccountEvent::target`].
137    pub target: String,
138    /// [`AccountEvent::detail`].
139    pub detail: String,
140    /// `ok` or `refused`.
141    pub outcome: String,
142    /// The previous entry's hash, the anchor's, or [`GENESIS`].
143    pub prev: String,
144    /// This entry's own hash ([`AccountEntry::compute_hash`]).
145    pub hash: String,
146}
147
148impl AccountEntry {
149    /// The hash this entry SHOULD carry in `account`, from everything else in it.
150    pub fn compute_hash(&self, account: &str) -> String {
151        let mut hasher = sha2::Sha256::new();
152        let mut field = |bytes: &[u8]| {
153            hasher.update((bytes.len() as u64).to_le_bytes());
154            hasher.update(bytes);
155        };
156        field(DOMAIN.as_bytes());
157        field(account.as_bytes());
158        field(self.prev.as_bytes());
159        field(&self.seq.to_le_bytes());
160        field(&self.at_unix_ms.to_le_bytes());
161        field(self.kind.as_bytes());
162        field(self.actor.as_bytes());
163        field(self.actor_role.as_bytes());
164        field(self.target.as_bytes());
165        field(self.detail.as_bytes());
166        field(self.outcome.as_bytes());
167        hex::encode(hasher.finalize())
168    }
169
170    /// Does `kind` (a family like `visibility`, or a whole kind) match this entry?
171    fn is_kind(&self, kind: &str) -> bool {
172        self.kind == kind
173            || (self.kind.len() > kind.len()
174                && self.kind.starts_with(kind)
175                && self.kind.as_bytes()[kind.len()] == b'.')
176    }
177}
178
179/// **What the admin asks for**: every filter optional, newest first.
180#[derive(Clone, Debug, Default, PartialEq, Eq)]
181pub struct AuditQuery {
182    /// Kinds or families (`login`, `visibility`, `repo.deleted`); empty = all.
183    pub kinds: Vec<String>,
184    /// Only this actor; empty = anyone.
185    pub actor: String,
186    /// At or after; 0 = from the start.
187    pub since_unix_ms: i64,
188    /// At or before; 0 = up to now.
189    pub until_unix_ms: i64,
190    /// A substring of the target, the detail, the actor or the kind, any case.
191    pub text: String,
192    /// Only entries OLDER than this seq (the previous page's cursor).
193    pub before_seq: Option<u64>,
194    /// How many; 0 = 50. Capped at [`MAX_PAGE`].
195    pub limit: usize,
196}
197
198impl AuditQuery {
199    fn matches(&self, entry: &AccountEntry, text_lower: &str) -> bool {
200        if !self.kinds.is_empty() && !self.kinds.iter().any(|k| entry.is_kind(k)) {
201            return false;
202        }
203        if !self.actor.is_empty() && entry.actor != self.actor {
204            return false;
205        }
206        if self.since_unix_ms > 0 && entry.at_unix_ms < self.since_unix_ms {
207            return false;
208        }
209        if self.until_unix_ms > 0 && entry.at_unix_ms > self.until_unix_ms {
210            return false;
211        }
212        if !text_lower.is_empty() {
213            let hit = [&entry.target, &entry.detail, &entry.actor, &entry.kind]
214                .iter()
215                .any(|field| field.to_lowercase().contains(text_lower));
216            if !hit {
217                return false;
218            }
219        }
220        true
221    }
222}
223
224/// One page of the log.
225#[derive(Clone, Debug, Default, PartialEq, Eq)]
226pub struct AuditPage {
227    /// Newest first.
228    pub entries: Vec<AccountEntry>,
229    /// Ask again with `before_seq` = this for the next (older) page; `None`
230    /// when this page was the last.
231    pub next_before_seq: Option<u64>,
232}
233
234/// **Does the chain hold?**
235#[derive(Clone, Debug, Default, PartialEq, Eq)]
236pub struct ChainReport {
237    /// The chain verifies from its anchor (or genesis) to its tip.
238    pub holds: bool,
239    /// How many entries were checked.
240    pub entries: u64,
241    /// The newest entry's hash (the anchor's, or genesis, when there is none).
242    pub tip_hash: String,
243    /// The last entry retention dropped, whose hash the first kept entry names.
244    pub anchor_seq: Option<u64>,
245    /// The first entry that does not hold, when one does not.
246    pub broken_at_seq: Option<u64>,
247    /// Why, in a sentence; empty when it holds.
248    pub what: String,
249}
250
251/// **Verify `entries` (oldest first) as `account`'s chain**, from `anchor`
252/// (`(seq, hash)` of the last pruned entry) or from genesis. Pure: the tamper
253/// tests drive it with entries they edited.
254pub fn verify_entries(account: &str, anchor: Option<(u64, &str)>, entries: &[AccountEntry]) -> ChainReport {
255    let (mut want_seq, mut prev) = match anchor {
256        Some((seq, hash)) => (seq + 1, hash.to_owned()),
257        None => (0, GENESIS.to_owned()),
258    };
259    let mut report = ChainReport {
260        holds: true,
261        entries: 0,
262        tip_hash: prev.clone(),
263        anchor_seq: anchor.map(|(seq, _)| seq),
264        broken_at_seq: None,
265        what: String::new(),
266    };
267    for entry in entries {
268        let what = if entry.seq != want_seq {
269            Some(format!("entry {} is where entry {want_seq} should be: one is missing or out of order", entry.seq))
270        } else if entry.prev != prev {
271            Some(format!("entry {} does not name the entry before it", entry.seq))
272        } else if entry.hash != entry.compute_hash(account) {
273            Some(format!("entry {} was changed after it was written", entry.seq))
274        } else {
275            None
276        };
277        if let Some(what) = what {
278            report.holds = false;
279            report.broken_at_seq = Some(entry.seq);
280            report.what = what;
281            return report;
282        }
283        report.entries += 1;
284        want_seq = entry.seq + 1;
285        prev = entry.hash.clone();
286        report.tip_hash = prev.clone();
287    }
288    report
289}
290
291/// The chain's head, in the document root.
292#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
293#[serde(deny_unknown_fields)]
294struct Head {
295    /// The seq the next entry gets.
296    #[serde(default)]
297    next_seq: u64,
298    /// The newest entry's hash; empty = none yet (genesis, or the anchor).
299    #[serde(default)]
300    tip: String,
301    /// The last entry retention dropped.
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    anchor_seq: Option<u64>,
304    #[serde(default, skip_serializing_if = "String::is_empty")]
305    anchor_hash: String,
306    /// The UTC days (since the epoch) that hold entries, ascending.
307    #[serde(default)]
308    days: Vec<i64>,
309}
310
311impl Head {
312    fn prev(&self) -> String {
313        if !self.tip.is_empty() {
314            self.tip.clone()
315        } else if !self.anchor_hash.is_empty() {
316            self.anchor_hash.clone()
317        } else {
318            GENESIS.to_owned()
319        }
320    }
321}
322
323#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
324#[serde(deny_unknown_fields)]
325struct AccountAuditDoc {
326    #[serde(default)]
327    head: Head,
328    /// Day (zero-padded, so keys sort as days do) → that day's entries, oldest first.
329    #[serde(default)]
330    days: ShardedMap<Vec<AccountEntry>>,
331}
332
333impl_doc!(AccountAuditDoc { days });
334
335fn day_key(day: i64) -> String {
336    format!("{day:010}")
337}
338
339/// **Every account's log.** Cheap to share; each account's document is opened
340/// once and its handle kept, so its cache and its writers' queue are shared.
341pub struct AccountAuditLog {
342    store: Arc<dyn ControlStore>,
343    docs: Mutex<HashMap<String, DocRef<AccountAuditDoc>>>,
344    retention_days: AtomicU32,
345}
346
347impl std::fmt::Debug for AccountAuditLog {
348    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
349        f.debug_struct("AccountAuditLog")
350            .field("retention_days", &self.retention_days())
351            .finish()
352    }
353}
354
355impl AccountAuditLog {
356    /// The logs held in `store` (the server's control store).
357    pub fn new(store: Arc<dyn ControlStore>) -> Result<Self> {
358        Ok(AccountAuditLog {
359            store,
360            docs: Mutex::new(HashMap::new()),
361            retention_days: AtomicU32::new(DEFAULT_RETENTION_DAYS),
362        })
363    }
364
365    /// Keep `days` days instead of a year (at least one).
366    pub fn with_retention_days(self, days: u32) -> Self {
367        self.retention_days.store(days.max(1), Ordering::Relaxed);
368        self
369    }
370
371    /// How many days an entry is kept.
372    pub fn retention_days(&self) -> u32 {
373        self.retention_days.load(Ordering::Relaxed)
374    }
375
376    fn doc(&self, account: &str) -> Result<DocRef<AccountAuditDoc>> {
377        crate::sandbox::check_component(account, account)?;
378        let mut docs = self.docs.lock().unwrap_or_else(|p| p.into_inner());
379        if let Some(doc) = docs.get(account) {
380            return Ok(doc.clone());
381        }
382        let doc = DocRef::named(
383            Arc::clone(&self.store),
384            std::borrow::Cow::Owned(format!("{ACCOUNT_AUDIT_REF_PREFIX}{account}")),
385        )?;
386        docs.insert(account.to_owned(), doc.clone());
387        Ok(doc)
388    }
389
390    /// **Append one event to `account`'s log**, pruning past the retention
391    /// first. Returns the entry as stored.
392    pub fn append(&self, account: &str, event: AccountEvent<'_>, at_unix_ms: i64) -> Result<AccountEntry> {
393        let doc = self.doc(account)?;
394        let cutoff_day = day_of(at_unix_ms) - i64::from(self.retention_days());
395        doc.update(|doc| {
396            // Retention first, so the anchor is set before the new entry names its prev.
397            Self::drop_before(doc, cutoff_day)?;
398            let mut entry = AccountEntry {
399                seq: doc.head.next_seq,
400                at_unix_ms,
401                kind: event.kind.to_owned(),
402                actor: event.actor.to_owned(),
403                actor_role: event.actor_role.to_owned(),
404                target: event.target.to_owned(),
405                detail: event.detail.to_owned(),
406                outcome: event.outcome.as_str().to_owned(),
407                prev: doc.head.prev(),
408                hash: String::new(),
409            };
410            entry.hash = entry.compute_hash(account);
411            // A clock that stepped back files the entry under the newest day
412            // already held, so the days stay in seq order.
413            let day = day_of(at_unix_ms).max(doc.head.days.last().copied().unwrap_or(i64::MIN));
414            let key = day_key(day);
415            let mut bucket = doc.days.get(&key)?.unwrap_or_default();
416            bucket.push(entry.clone());
417            doc.days.insert(key, bucket)?;
418            if doc.head.days.last() != Some(&day) {
419                doc.head.days.push(day);
420            }
421            doc.head.next_seq += 1;
422            doc.head.tip = entry.hash.clone();
423            Ok(entry)
424        })
425    }
426
427    /// Drop every day before `cutoff_day`, moving the anchor to the last
428    /// entry dropped. Returns how many entries went.
429    fn drop_before(doc: &mut AccountAuditDoc, cutoff_day: i64) -> Result<u64> {
430        let mut dropped_count = 0;
431        while let Some(&oldest) = doc.head.days.first() {
432            if oldest >= cutoff_day {
433                break;
434            }
435            if let Some(dropped) = doc.days.remove(&day_key(oldest))? {
436                dropped_count += dropped.len() as u64;
437                if let Some(last) = dropped.last() {
438                    doc.head.anchor_seq = Some(last.seq);
439                    doc.head.anchor_hash = last.hash.clone();
440                    if doc.head.tip == last.hash {
441                        doc.head.tip.clear();
442                    }
443                }
444            }
445            doc.head.days.remove(0);
446        }
447        Ok(dropped_count)
448    }
449
450    /// **The retention sweep for one account**, at `now`: what an append does
451    /// first, for an account nothing has happened in for a while. The server
452    /// runs it over every account once a day. Returns how many entries went;
453    /// a sweep with nothing to drop writes nothing.
454    pub fn prune(&self, account: &str, now_unix_ms: i64) -> Result<u64> {
455        let doc = self.doc(account)?;
456        let cutoff_day = day_of(now_unix_ms) - i64::from(self.retention_days());
457        if doc.load()?.head.days.first().is_none_or(|&oldest| oldest >= cutoff_day) {
458            return Ok(0);
459        }
460        doc.update(|doc| Self::drop_before(doc, cutoff_day))
461    }
462
463    /// **One page of `account`'s log**, newest first.
464    pub fn query(&self, account: &str, query: &AuditQuery) -> Result<AuditPage> {
465        let limit = match query.limit {
466            0 => 50,
467            n => n.min(MAX_PAGE),
468        };
469        let mut page = AuditPage::default();
470        let mut more = false;
471        self.walk_newest_first(account, query, &mut |entry| {
472            if page.entries.len() == limit {
473                more = true;
474                return false;
475            }
476            page.entries.push(entry);
477            true
478        })?;
479        if more {
480            page.next_before_seq = page.entries.last().map(|e| e.seq);
481        }
482        Ok(page)
483    }
484
485    /// **Every entry the query matches**, newest first, no page (the export).
486    pub fn all(&self, account: &str, query: &AuditQuery) -> Result<Vec<AccountEntry>> {
487        let mut out = Vec::new();
488        self.walk_newest_first(account, query, &mut |entry| {
489            out.push(entry);
490            true
491        })?;
492        Ok(out)
493    }
494
495    fn walk_newest_first(
496        &self,
497        account: &str,
498        query: &AuditQuery,
499        take: &mut dyn FnMut(AccountEntry) -> bool,
500    ) -> Result<()> {
501        let doc = self.doc(account)?.load()?;
502        let since = query.since_unix_ms;
503        let text = query.text.to_lowercase();
504        for &day in doc.head.days.iter().rev() {
505            if query.until_unix_ms > 0 && day * DAY_MS > query.until_unix_ms {
506                continue;
507            }
508            if (day + 1) * DAY_MS <= since {
509                break;
510            }
511            let Some(bucket) = doc.days.get(&day_key(day))? else {
512                continue;
513            };
514            for entry in bucket.into_iter().rev() {
515                if query.before_seq.is_some_and(|before| entry.seq >= before) {
516                    continue;
517                }
518                if !query.matches(&entry, &text) {
519                    continue;
520                }
521                if !take(entry) {
522                    return Ok(());
523                }
524            }
525        }
526        Ok(())
527    }
528
529    /// **Does `account`'s chain hold**, from its anchor to its tip, and does
530    /// the tip the head records equal the last entry's hash?
531    pub fn verify(&self, account: &str) -> Result<ChainReport> {
532        let doc = self.doc(account)?.load()?;
533        let mut entries = Vec::new();
534        for &day in &doc.head.days {
535            if let Some(bucket) = doc.days.get(&day_key(day))? {
536                entries.extend(bucket);
537            }
538        }
539        let anchor = doc.head.anchor_seq.map(|seq| (seq, doc.head.anchor_hash.as_str()));
540        let mut report = verify_entries(account, anchor, &entries);
541        if report.holds && report.tip_hash != doc.head.prev() {
542            report.holds = false;
543            report.what = "the head names a tip the entries do not end at: entries were cut off".to_owned();
544            report.broken_at_seq = Some(doc.head.next_seq.saturating_sub(1));
545        }
546        Ok(report)
547    }
548
549    /// The tip and the number of entries ever written, without a walk.
550    pub fn head(&self, account: &str) -> Result<(u64, String)> {
551        let doc = self.doc(account)?.load()?;
552        Ok((doc.head.next_seq, doc.head.prev()))
553    }
554}
555
556/// A CSV field: quoted when it must be, and a leading `= + - @` (a formula a
557/// spreadsheet would RUN) defused with a `'`.
558fn csv_field(out: &mut String, text: &str) {
559    let defused;
560    let text = if text.starts_with(['=', '+', '-', '@', '\t', '\r']) {
561        defused = format!("'{text}");
562        defused.as_str()
563    } else {
564        text
565    };
566    if text.contains([',', '"', '\n', '\r']) {
567        out.push('"');
568        out.push_str(&text.replace('"', "\"\""));
569        out.push('"');
570    } else {
571        out.push_str(text);
572    }
573}
574
575/// **The export as CSV** (RFC 4180, a header line, UTC timestamps).
576pub fn to_csv(entries: &[AccountEntry]) -> String {
577    let mut out = String::from("seq,at_utc,kind,actor,actor_role,target,detail,outcome,hash\n");
578    for e in entries {
579        let fields = [
580            e.seq.to_string(),
581            iso8601_utc(e.at_unix_ms),
582            e.kind.clone(),
583            e.actor.clone(),
584            e.actor_role.clone(),
585            e.target.clone(),
586            e.detail.clone(),
587            e.outcome.clone(),
588            e.hash.clone(),
589        ];
590        for (i, field) in fields.iter().enumerate() {
591            if i > 0 {
592                out.push(',');
593            }
594            csv_field(&mut out, field);
595        }
596        out.push('\n');
597    }
598    out
599}
600
601/// **The export as JSON**: an array of entries, each with its UTC time beside
602/// the Unix milliseconds.
603pub fn to_json(entries: &[AccountEntry]) -> String {
604    let rows: Vec<serde_json::Value> = entries
605        .iter()
606        .map(|e| {
607            let mut row = serde_json::to_value(e).unwrap_or(serde_json::Value::Null);
608            if let Some(map) = row.as_object_mut() {
609                map.insert("at_utc".into(), serde_json::Value::String(iso8601_utc(e.at_unix_ms)));
610            }
611            row
612        })
613        .collect();
614    serde_json::to_string_pretty(&rows).unwrap_or_else(|_| "[]".to_owned())
615}