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(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#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
293#[serde(deny_unknown_fields)]
294struct Head {
295 #[serde(default)]
297 next_seq: u64,
298 #[serde(default)]
300 tip: String,
301 #[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 #[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 #[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
339pub 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 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 pub fn with_retention_days(self, days: u32) -> Self {
367 self.retention_days.store(days.max(1), Ordering::Relaxed);
368 self
369 }
370
371 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 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 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 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 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 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 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 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 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 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
556fn 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
575pub 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
601pub 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}