1use 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
61pub const ACCOUNT_AUDIT_REF_PREFIX: &str = "refs/gunnar/account-audit/";
63
64pub const DEFAULT_RETENTION_DAYS: u32 = 365;
66
67pub const GENESIS: &str = crate::audit::GENESIS;
69
70const DOMAIN: &str = "gunnar-account-audit-v1";
72
73pub const MAX_PAGE: usize = 1000;
75
76#[derive(Clone, Copy, PartialEq, Eq, Debug)]
78pub enum Outcome {
79 Ok,
81 Refused,
84}
85
86impl Outcome {
87 pub fn as_str(self) -> &'static str {
89 match self {
90 Outcome::Ok => "ok",
91 Outcome::Refused => "refused",
92 }
93 }
94}
95
96#[derive(Clone, Copy, Debug)]
99pub struct AccountEvent<'a> {
100 pub kind: &'a str,
103 pub actor: &'a str,
106 pub actor_role: &'a str,
109 pub target: &'a str,
112 pub detail: &'a str,
114 pub outcome: Outcome,
116}
117
118#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
124#[serde(deny_unknown_fields)]
125pub struct AccountEntry {
126 pub seq: u64,
128 pub at_unix_ms: i64,
130 pub kind: String,
132 pub actor: String,
134 pub actor_role: String,
136 pub target: String,
138 pub detail: String,
140 pub outcome: String,
142 pub prev: String,
144 pub hash: String,
146}
147
148impl AccountEntry {
149 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 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
181pub struct AuditQuery {
182 pub kinds: Vec<String>,
184 pub actor: String,
186 pub since_unix_ms: i64,
188 pub until_unix_ms: i64,
190 pub text: String,
192 pub before_seq: Option<u64>,
194 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
226pub struct AuditPage {
227 pub entries: Vec<AccountEntry>,
229 pub next_before_seq: Option<u64>,
232}
233
234#[derive(Clone, Debug, Default, PartialEq, Eq)]
236pub struct ChainReport {
237 pub holds: bool,
239 pub entries: u64,
241 pub tip_hash: String,
243 pub anchor_seq: Option<u64>,
245 pub broken_at_seq: Option<u64>,
247 pub what: String,
249}
250
251pub 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#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
306#[serde(deny_unknown_fields)]
307struct Head {
308 #[serde(default)]
310 next_seq: u64,
311 #[serde(default)]
313 tip: String,
314 #[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 #[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 #[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
352pub 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 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 pub fn with_retention_days(self, days: u32) -> Self {
380 self.retention_days.store(days.max(1), Ordering::Relaxed);
381 self
382 }
383
384 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 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 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 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 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 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 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 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 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 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
584fn 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
603pub 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
629pub 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}