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(
255    account: &str,
256    anchor: Option<(u64, &str)>,
257    entries: &[AccountEntry],
258) -> ChainReport {
259    let (mut want_seq, mut prev) = match anchor {
260        Some((seq, hash)) => (seq + 1, hash.to_owned()),
261        None => (0, GENESIS.to_owned()),
262    };
263    let mut report = ChainReport {
264        holds: true,
265        entries: 0,
266        tip_hash: prev.clone(),
267        anchor_seq: anchor.map(|(seq, _)| seq),
268        broken_at_seq: None,
269        what: String::new(),
270    };
271    for entry in entries {
272        let what = if entry.seq != want_seq {
273            Some(format!(
274                "entry {} is where entry {want_seq} should be: one is missing or out of order",
275                entry.seq
276            ))
277        } else if entry.prev != prev {
278            Some(format!(
279                "entry {} does not name the entry before it",
280                entry.seq
281            ))
282        } else if entry.hash != entry.compute_hash(account) {
283            Some(format!(
284                "entry {} was changed after it was written",
285                entry.seq
286            ))
287        } else {
288            None
289        };
290        if let Some(what) = what {
291            report.holds = false;
292            report.broken_at_seq = Some(entry.seq);
293            report.what = what;
294            return report;
295        }
296        report.entries += 1;
297        want_seq = entry.seq + 1;
298        prev = entry.hash.clone();
299        report.tip_hash = prev.clone();
300    }
301    report
302}
303
304/// The chain's head, in the document root.
305#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
306#[serde(deny_unknown_fields)]
307struct Head {
308    /// The seq the next entry gets.
309    #[serde(default)]
310    next_seq: u64,
311    /// The newest entry's hash; empty = none yet (genesis, or the anchor).
312    #[serde(default)]
313    tip: String,
314    /// The last entry retention dropped.
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    anchor_seq: Option<u64>,
317    #[serde(default, skip_serializing_if = "String::is_empty")]
318    anchor_hash: String,
319    /// The UTC days (since the epoch) that hold entries, ascending.
320    #[serde(default)]
321    days: Vec<i64>,
322}
323
324impl Head {
325    fn prev(&self) -> String {
326        if !self.tip.is_empty() {
327            self.tip.clone()
328        } else if !self.anchor_hash.is_empty() {
329            self.anchor_hash.clone()
330        } else {
331            GENESIS.to_owned()
332        }
333    }
334}
335
336#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
337#[serde(deny_unknown_fields)]
338struct AccountAuditDoc {
339    #[serde(default)]
340    head: Head,
341    /// Day (zero-padded, so keys sort as days do) → that day's entries, oldest first.
342    #[serde(default)]
343    days: ShardedMap<Vec<AccountEntry>>,
344}
345
346impl_doc!(AccountAuditDoc { days });
347
348fn day_key(day: i64) -> String {
349    format!("{day:010}")
350}
351
352/// **Every account's log.** Cheap to share; each account's document is opened
353/// once and its handle kept, so its cache and its writers' queue are shared.
354pub struct AccountAuditLog {
355    store: Arc<dyn ControlStore>,
356    docs: Mutex<HashMap<String, DocRef<AccountAuditDoc>>>,
357    retention_days: AtomicU32,
358}
359
360impl std::fmt::Debug for AccountAuditLog {
361    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362        f.debug_struct("AccountAuditLog")
363            .field("retention_days", &self.retention_days())
364            .finish()
365    }
366}
367
368impl AccountAuditLog {
369    /// The logs held in `store` (the server's control store).
370    pub fn new(store: Arc<dyn ControlStore>) -> Result<Self> {
371        Ok(AccountAuditLog {
372            store,
373            docs: Mutex::new(HashMap::new()),
374            retention_days: AtomicU32::new(DEFAULT_RETENTION_DAYS),
375        })
376    }
377
378    /// Keep `days` days instead of a year (at least one).
379    pub fn with_retention_days(self, days: u32) -> Self {
380        self.retention_days.store(days.max(1), Ordering::Relaxed);
381        self
382    }
383
384    /// How many days an entry is kept.
385    pub fn retention_days(&self) -> u32 {
386        self.retention_days.load(Ordering::Relaxed)
387    }
388
389    fn doc(&self, account: &str) -> Result<DocRef<AccountAuditDoc>> {
390        crate::sandbox::check_component(account, account)?;
391        let mut docs = self.docs.lock().unwrap_or_else(|p| p.into_inner());
392        if let Some(doc) = docs.get(account) {
393            return Ok(doc.clone());
394        }
395        let doc = DocRef::named(
396            Arc::clone(&self.store),
397            std::borrow::Cow::Owned(format!("{ACCOUNT_AUDIT_REF_PREFIX}{account}")),
398        )?;
399        docs.insert(account.to_owned(), doc.clone());
400        Ok(doc)
401    }
402
403    /// **Append one event to `account`'s log**, pruning past the retention
404    /// first. Returns the entry as stored.
405    pub fn append(
406        &self,
407        account: &str,
408        event: AccountEvent<'_>,
409        at_unix_ms: i64,
410    ) -> Result<AccountEntry> {
411        let doc = self.doc(account)?;
412        let cutoff_day = day_of(at_unix_ms) - i64::from(self.retention_days());
413        doc.update(|doc| {
414            // Retention first, so the anchor is set before the new entry names its prev.
415            Self::drop_before(doc, cutoff_day)?;
416            let mut entry = AccountEntry {
417                seq: doc.head.next_seq,
418                at_unix_ms,
419                kind: event.kind.to_owned(),
420                actor: event.actor.to_owned(),
421                actor_role: event.actor_role.to_owned(),
422                target: event.target.to_owned(),
423                detail: event.detail.to_owned(),
424                outcome: event.outcome.as_str().to_owned(),
425                prev: doc.head.prev(),
426                hash: String::new(),
427            };
428            entry.hash = entry.compute_hash(account);
429            // A clock that stepped back files the entry under the newest day
430            // already held, so the days stay in seq order.
431            let day = day_of(at_unix_ms).max(doc.head.days.last().copied().unwrap_or(i64::MIN));
432            let key = day_key(day);
433            let mut bucket = doc.days.get(&key)?.unwrap_or_default();
434            bucket.push(entry.clone());
435            doc.days.insert(key, bucket)?;
436            if doc.head.days.last() != Some(&day) {
437                doc.head.days.push(day);
438            }
439            doc.head.next_seq += 1;
440            doc.head.tip = entry.hash.clone();
441            Ok(entry)
442        })
443    }
444
445    /// Drop every day before `cutoff_day`, moving the anchor to the last
446    /// entry dropped. Returns how many entries went.
447    fn drop_before(doc: &mut AccountAuditDoc, cutoff_day: i64) -> Result<u64> {
448        let mut dropped_count = 0;
449        while let Some(&oldest) = doc.head.days.first() {
450            if oldest >= cutoff_day {
451                break;
452            }
453            if let Some(dropped) = doc.days.remove(&day_key(oldest))? {
454                dropped_count += dropped.len() as u64;
455                if let Some(last) = dropped.last() {
456                    doc.head.anchor_seq = Some(last.seq);
457                    doc.head.anchor_hash = last.hash.clone();
458                    if doc.head.tip == last.hash {
459                        doc.head.tip.clear();
460                    }
461                }
462            }
463            doc.head.days.remove(0);
464        }
465        Ok(dropped_count)
466    }
467
468    /// **The retention sweep for one account**, at `now`: what an append does
469    /// first, for an account nothing has happened in for a while. The server
470    /// runs it over every account once a day. Returns how many entries went;
471    /// a sweep with nothing to drop writes nothing.
472    pub fn prune(&self, account: &str, now_unix_ms: i64) -> Result<u64> {
473        let doc = self.doc(account)?;
474        let cutoff_day = day_of(now_unix_ms) - i64::from(self.retention_days());
475        if doc
476            .load()?
477            .head
478            .days
479            .first()
480            .is_none_or(|&oldest| oldest >= cutoff_day)
481        {
482            return Ok(0);
483        }
484        doc.update(|doc| Self::drop_before(doc, cutoff_day))
485    }
486
487    /// **One page of `account`'s log**, newest first.
488    pub fn query(&self, account: &str, query: &AuditQuery) -> Result<AuditPage> {
489        let limit = match query.limit {
490            0 => 50,
491            n => n.min(MAX_PAGE),
492        };
493        let mut page = AuditPage::default();
494        let mut more = false;
495        self.walk_newest_first(account, query, &mut |entry| {
496            if page.entries.len() == limit {
497                more = true;
498                return false;
499            }
500            page.entries.push(entry);
501            true
502        })?;
503        if more {
504            page.next_before_seq = page.entries.last().map(|e| e.seq);
505        }
506        Ok(page)
507    }
508
509    /// **Every entry the query matches**, newest first, no page (the export).
510    pub fn all(&self, account: &str, query: &AuditQuery) -> Result<Vec<AccountEntry>> {
511        let mut out = Vec::new();
512        self.walk_newest_first(account, query, &mut |entry| {
513            out.push(entry);
514            true
515        })?;
516        Ok(out)
517    }
518
519    fn walk_newest_first(
520        &self,
521        account: &str,
522        query: &AuditQuery,
523        take: &mut dyn FnMut(AccountEntry) -> bool,
524    ) -> Result<()> {
525        let doc = self.doc(account)?.load()?;
526        let since = query.since_unix_ms;
527        let text = query.text.to_lowercase();
528        for &day in doc.head.days.iter().rev() {
529            if query.until_unix_ms > 0 && day * DAY_MS > query.until_unix_ms {
530                continue;
531            }
532            if (day + 1) * DAY_MS <= since {
533                break;
534            }
535            let Some(bucket) = doc.days.get(&day_key(day))? else {
536                continue;
537            };
538            for entry in bucket.into_iter().rev() {
539                if query.before_seq.is_some_and(|before| entry.seq >= before) {
540                    continue;
541                }
542                if !query.matches(&entry, &text) {
543                    continue;
544                }
545                if !take(entry) {
546                    return Ok(());
547                }
548            }
549        }
550        Ok(())
551    }
552
553    /// **Does `account`'s chain hold**, from its anchor to its tip, and does
554    /// the tip the head records equal the last entry's hash?
555    pub fn verify(&self, account: &str) -> Result<ChainReport> {
556        let doc = self.doc(account)?.load()?;
557        let mut entries = Vec::new();
558        for &day in &doc.head.days {
559            if let Some(bucket) = doc.days.get(&day_key(day))? {
560                entries.extend(bucket);
561            }
562        }
563        let anchor = doc
564            .head
565            .anchor_seq
566            .map(|seq| (seq, doc.head.anchor_hash.as_str()));
567        let mut report = verify_entries(account, anchor, &entries);
568        if report.holds && report.tip_hash != doc.head.prev() {
569            report.holds = false;
570            report.what =
571                "the head names a tip the entries do not end at: entries were cut off".to_owned();
572            report.broken_at_seq = Some(doc.head.next_seq.saturating_sub(1));
573        }
574        Ok(report)
575    }
576
577    /// The tip and the number of entries ever written, without a walk.
578    pub fn head(&self, account: &str) -> Result<(u64, String)> {
579        let doc = self.doc(account)?.load()?;
580        Ok((doc.head.next_seq, doc.head.prev()))
581    }
582}
583
584/// A CSV field: quoted when it must be, and a leading `= + - @` (a formula a
585/// spreadsheet would RUN) defused with a `'`.
586fn csv_field(out: &mut String, text: &str) {
587    let defused;
588    let text = if text.starts_with(['=', '+', '-', '@', '\t', '\r']) {
589        defused = format!("'{text}");
590        defused.as_str()
591    } else {
592        text
593    };
594    if text.contains([',', '"', '\n', '\r']) {
595        out.push('"');
596        out.push_str(&text.replace('"', "\"\""));
597        out.push('"');
598    } else {
599        out.push_str(text);
600    }
601}
602
603/// **The export as CSV** (RFC 4180, a header line, UTC timestamps).
604pub fn to_csv(entries: &[AccountEntry]) -> String {
605    let mut out = String::from("seq,at_utc,kind,actor,actor_role,target,detail,outcome,hash\n");
606    for e in entries {
607        let fields = [
608            e.seq.to_string(),
609            iso8601_utc(e.at_unix_ms),
610            e.kind.clone(),
611            e.actor.clone(),
612            e.actor_role.clone(),
613            e.target.clone(),
614            e.detail.clone(),
615            e.outcome.clone(),
616            e.hash.clone(),
617        ];
618        for (i, field) in fields.iter().enumerate() {
619            if i > 0 {
620                out.push(',');
621            }
622            csv_field(&mut out, field);
623        }
624        out.push('\n');
625    }
626    out
627}
628
629/// **The export as JSON**: an array of entries, each with its UTC time beside
630/// the Unix milliseconds.
631pub fn to_json(entries: &[AccountEntry]) -> String {
632    let rows: Vec<serde_json::Value> = entries
633        .iter()
634        .map(|e| {
635            let mut row = serde_json::to_value(e).unwrap_or(serde_json::Value::Null);
636            if let Some(map) = row.as_object_mut() {
637                map.insert(
638                    "at_utc".into(),
639                    serde_json::Value::String(iso8601_utc(e.at_unix_ms)),
640                );
641            }
642            row
643        })
644        .collect();
645    serde_json::to_string_pretty(&rows).unwrap_or_else(|_| "[]".to_owned())
646}