1use std::collections::VecDeque;
37use std::sync::{Arc, Mutex};
38
39use async_trait::async_trait;
40use serde::{Deserialize, Serialize};
41
42use base64::Engine as _;
43use base64::engine::general_purpose::URL_SAFE_NO_PAD;
44use cheers_core::{Actor, PrincipalId, PrincipalKind, Scope, StoreError};
45
46#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
53pub enum AuditValidationError {
54 #[error("audit record: `at` must be > 0; got {0}")]
55 NonPositiveAt(i64),
56 #[error("audit record: `aud` must not be empty")]
57 EmptyAud,
58 #[error("audit record: `method` must not be empty")]
59 EmptyMethod,
60 #[error("audit record: `result` must not be empty")]
61 EmptyResult,
62 #[error("audit record: `request_id` must not be empty")]
63 EmptyRequestId,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78#[non_exhaustive]
79pub struct AuditRecord {
80 pub at: i64,
82 pub sub: PrincipalId,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub act: Option<Actor>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub camp_id: Option<String>,
88 pub aud: String,
90 pub method: String,
93 #[serde(default)]
97 pub scope: Vec<Scope>,
98 pub result: String,
103 pub request_id: String,
106}
107
108impl AuditRecord {
109 #[allow(clippy::too_many_arguments)]
112 pub fn new(
113 at: i64,
114 sub: PrincipalId,
115 act: Option<Actor>,
116 camp_id: Option<String>,
117 aud: impl Into<String>,
118 method: impl Into<String>,
119 scope: Vec<Scope>,
120 result: impl Into<String>,
121 request_id: impl Into<String>,
122 ) -> Result<Self, AuditValidationError> {
123 let rec = Self {
124 at,
125 sub,
126 act,
127 camp_id,
128 aud: aud.into(),
129 method: method.into(),
130 scope,
131 result: result.into(),
132 request_id: request_id.into(),
133 };
134 rec.validate()?;
135 Ok(rec)
136 }
137
138 pub fn validate(&self) -> Result<(), AuditValidationError> {
142 if self.at <= 0 {
143 return Err(AuditValidationError::NonPositiveAt(self.at));
144 }
145 if self.aud.is_empty() {
146 return Err(AuditValidationError::EmptyAud);
147 }
148 if self.method.is_empty() {
149 return Err(AuditValidationError::EmptyMethod);
150 }
151 if self.result.is_empty() {
152 return Err(AuditValidationError::EmptyResult);
153 }
154 if self.request_id.is_empty() {
155 return Err(AuditValidationError::EmptyRequestId);
156 }
157 Ok(())
158 }
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164#[non_exhaustive]
165pub struct AuditRow {
166 pub id: String,
167 pub record: AuditRecord,
168 pub ingested_at: i64,
172}
173
174impl AuditRow {
175 pub fn new(id: String, record: AuditRecord, ingested_at: i64) -> Self {
176 Self {
177 id,
178 record,
179 ingested_at,
180 }
181 }
182}
183
184pub const DEFAULT_AUDIT_PAGE_LIMIT: usize = 100;
186
187pub const MAX_AUDIT_PAGE_LIMIT: usize = 500;
191
192#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct AuditCursor {
205 pub at: i64,
206 pub id: String,
207}
208
209impl AuditCursor {
210 pub fn new(at: i64, id: impl Into<String>) -> Self {
211 Self { at, id: id.into() }
212 }
213
214 pub fn from_row(row: &AuditRow) -> Self {
216 Self::new(row.record.at, row.id.clone())
217 }
218
219 pub fn to_wire(&self) -> String {
226 URL_SAFE_NO_PAD.encode(format!("{}:{}", self.at, self.id))
227 }
228
229 pub fn from_wire(s: &str) -> Result<Self, AuditCursorError> {
231 let raw = URL_SAFE_NO_PAD
232 .decode(s.as_bytes())
233 .map_err(|_| AuditCursorError::Malformed)?;
234 let text = String::from_utf8(raw).map_err(|_| AuditCursorError::Malformed)?;
235 let (at, id) = text.split_once(':').ok_or(AuditCursorError::Malformed)?;
236 let at: i64 = at.parse().map_err(|_| AuditCursorError::Malformed)?;
237 if id.is_empty() {
238 return Err(AuditCursorError::Malformed);
239 }
240 Ok(Self::new(at, id))
241 }
242
243 fn precedes(&self, row: &AuditRow) -> bool {
246 (row.record.at, row.id.as_str()) < (self.at, self.id.as_str())
247 }
248}
249
250#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
254pub enum AuditCursorError {
255 #[error("malformed audit cursor")]
256 Malformed,
257}
258
259#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
261pub enum AuditQueryError {
262 #[error("on_behalf_of must be a user principal; got {0}")]
268 OnBehalfOfNotUser(PrincipalKind),
269}
270
271#[derive(Debug, Clone, PartialEq, Eq)]
277#[non_exhaustive]
278pub struct AuditQuery {
279 pub on_behalf_of: PrincipalId,
282 pub since: Option<i64>,
284 pub method_prefix: Option<String>,
288 pub cursor: Option<AuditCursor>,
290 pub limit: usize,
292}
293
294impl AuditQuery {
295 pub fn new(on_behalf_of: PrincipalId) -> Result<Self, AuditQueryError> {
300 if on_behalf_of.kind != PrincipalKind::User {
301 return Err(AuditQueryError::OnBehalfOfNotUser(on_behalf_of.kind));
302 }
303 Ok(Self {
304 on_behalf_of,
305 since: None,
306 method_prefix: None,
307 cursor: None,
308 limit: DEFAULT_AUDIT_PAGE_LIMIT,
309 })
310 }
311
312 pub fn with_since(mut self, since: i64) -> Self {
313 self.since = Some(since);
314 self
315 }
316
317 pub fn with_method_prefix(mut self, prefix: impl Into<String>) -> Self {
318 self.method_prefix = Some(prefix.into());
319 self
320 }
321
322 pub fn with_cursor(mut self, cursor: AuditCursor) -> Self {
323 self.cursor = Some(cursor);
324 self
325 }
326
327 pub fn with_limit(mut self, limit: usize) -> Self {
332 self.limit = limit.clamp(1, MAX_AUDIT_PAGE_LIMIT);
333 self
334 }
335
336 pub fn matches(&self, row: &AuditRow) -> bool {
340 if row.record.sub != self.on_behalf_of {
341 return false;
342 }
343 if let Some(since) = self.since {
344 if row.record.at < since {
345 return false;
346 }
347 }
348 if let Some(prefix) = &self.method_prefix {
349 if !row.record.method.starts_with(prefix.as_str()) {
350 return false;
351 }
352 }
353 true
354 }
355}
356
357#[derive(Debug, Clone, PartialEq, Eq)]
359#[non_exhaustive]
360pub struct AuditPage {
361 pub rows: Vec<AuditRow>,
363 pub next_cursor: Option<AuditCursor>,
368}
369
370impl AuditPage {
371 pub fn from_overfetch(mut rows: Vec<AuditRow>, limit: usize) -> Self {
375 let next_cursor = if rows.len() > limit {
376 rows.truncate(limit);
377 rows.last().map(AuditCursor::from_row)
378 } else {
379 None
380 };
381 Self { rows, next_cursor }
382 }
383}
384
385#[async_trait]
393pub trait AuditStore: Send + Sync {
394 async fn insert_batch(
403 &self,
404 records: &[AuditRecord],
405 ingested_at: i64,
406 ) -> Result<Vec<AuditRow>, StoreError>;
407
408 async fn query_by_on_behalf_of(&self, query: &AuditQuery)
416 -> Result<AuditPage, StoreError>;
417}
418
419#[derive(Default, Clone)]
422pub struct MemoryAuditStore {
423 inner: Arc<Mutex<MemoryAuditInner>>,
424}
425
426#[derive(Default)]
427struct MemoryAuditInner {
428 next_seq: u64,
429 rows: VecDeque<AuditRow>,
430}
431
432impl MemoryAuditStore {
433 pub fn new() -> Self {
434 Self::default()
435 }
436
437 pub fn snapshot(&self) -> Vec<AuditRow> {
440 self.inner
441 .lock()
442 .expect("audit store mutex poisoned")
443 .rows
444 .iter()
445 .cloned()
446 .collect()
447 }
448}
449
450impl std::fmt::Debug for MemoryAuditStore {
451 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
452 f.debug_struct("MemoryAuditStore").finish_non_exhaustive()
453 }
454}
455
456#[async_trait]
457impl AuditStore for MemoryAuditStore {
458 async fn insert_batch(
459 &self,
460 records: &[AuditRecord],
461 ingested_at: i64,
462 ) -> Result<Vec<AuditRow>, StoreError> {
463 let mut g = self.inner.lock().expect("audit store mutex poisoned");
464 let mut out = Vec::with_capacity(records.len());
465 for rec in records {
466 g.next_seq += 1;
467 let id = format!("audit-{:016x}", g.next_seq);
468 let row = AuditRow::new(id, rec.clone(), ingested_at);
469 g.rows.push_back(row.clone());
470 out.push(row);
471 }
472 Ok(out)
473 }
474
475 async fn query_by_on_behalf_of(
476 &self,
477 query: &AuditQuery,
478 ) -> Result<AuditPage, StoreError> {
479 let g = self.inner.lock().expect("audit store mutex poisoned");
480 let mut hits: Vec<AuditRow> = g
481 .rows
482 .iter()
483 .filter(|row| query.matches(row))
484 .filter(|row| {
485 query
486 .cursor
487 .as_ref()
488 .map(|c| c.precedes(row))
489 .unwrap_or(true)
490 })
491 .cloned()
492 .collect();
493 hits.sort_by(|a, b| {
495 b.record
496 .at
497 .cmp(&a.record.at)
498 .then_with(|| b.id.cmp(&a.id))
499 });
500 hits.truncate(query.limit + 1);
501 Ok(AuditPage::from_overfetch(hits, query.limit))
502 }
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508 use cheers_core::yah_scopes;
509 use cheers_core::PrincipalId;
510 use pollster::block_on;
511
512 fn rec(method: &str, request_id: &str) -> AuditRecord {
513 AuditRecord::new(
514 1_700_000_000,
515 PrincipalId::user("alice"),
516 None,
517 Some("camp-a".into()),
518 "https://kamaji.example",
519 method,
520 vec![yah_scopes::CLOUD_DEPLOY],
521 "allow",
522 request_id,
523 )
524 .unwrap()
525 }
526
527 #[test]
528 fn new_accepts_well_formed_record() {
529 let r = rec("POST /cloud/deploy", "req-1");
530 assert_eq!(r.sub, PrincipalId::user("alice"));
531 assert_eq!(r.method, "POST /cloud/deploy");
532 assert_eq!(r.result, "allow");
533 assert_eq!(r.scope, vec![yah_scopes::CLOUD_DEPLOY]);
534 }
535
536 #[test]
537 fn validate_rejects_each_empty_field() {
538 let mut r = rec("m", "rid");
540 r.at = 0;
541 assert_eq!(r.validate(), Err(AuditValidationError::NonPositiveAt(0)));
542 r.at = -1;
543 assert_eq!(r.validate(), Err(AuditValidationError::NonPositiveAt(-1)));
544
545 let mut r = rec("m", "rid");
547 r.aud = String::new();
548 assert_eq!(r.validate(), Err(AuditValidationError::EmptyAud));
549
550 let mut r = rec("m", "rid");
552 r.method = String::new();
553 assert_eq!(r.validate(), Err(AuditValidationError::EmptyMethod));
554
555 let mut r = rec("m", "rid");
557 r.result = String::new();
558 assert_eq!(r.validate(), Err(AuditValidationError::EmptyResult));
559
560 let mut r = rec("m", "rid");
562 r.request_id = String::new();
563 assert_eq!(r.validate(), Err(AuditValidationError::EmptyRequestId));
564 }
565
566 #[test]
567 fn validate_allows_empty_scope_list() {
568 let mut r = rec("m", "rid");
570 r.scope.clear();
571 r.validate().expect("empty scope is allowed");
572 }
573
574 #[test]
575 fn memory_store_insert_batch_preserves_order_and_stamps_metadata() {
576 let store = MemoryAuditStore::new();
577 let batch = vec![
578 rec("m1", "rid-1"),
579 rec("m2", "rid-2"),
580 rec("m3", "rid-3"),
581 ];
582 let rows = block_on(store.insert_batch(&batch, 5_000)).unwrap();
583 assert_eq!(rows.len(), 3);
584 assert_eq!(rows[0].record.method, "m1");
585 assert_eq!(rows[1].record.method, "m2");
586 assert_eq!(rows[2].record.method, "m3");
587 for row in &rows {
588 assert_eq!(row.ingested_at, 5_000);
589 assert!(!row.id.is_empty());
590 }
591 let ids: std::collections::HashSet<_> = rows.iter().map(|r| &r.id).collect();
593 assert_eq!(ids.len(), 3);
594
595 let more = vec![rec("m4", "rid-4")];
597 block_on(store.insert_batch(&more, 6_000)).unwrap();
598 let snap = store.snapshot();
599 assert_eq!(snap.len(), 4);
600 assert_eq!(snap[3].record.method, "m4");
601 assert_eq!(snap[3].ingested_at, 6_000);
602 }
603
604 #[test]
605 fn memory_store_empty_batch_is_a_noop() {
606 let store = MemoryAuditStore::new();
607 let rows = block_on(store.insert_batch(&[], 1_000)).unwrap();
608 assert!(rows.is_empty());
609 assert!(store.snapshot().is_empty());
610 }
611
612 #[test]
613 fn audit_record_round_trips_through_serde() {
614 let r = rec("POST /x", "rid");
615 let json = serde_json::to_string(&r).unwrap();
616 let back: AuditRecord = serde_json::from_str(&json).unwrap();
617 assert_eq!(back, r);
618 }
619
620 #[test]
621 fn audit_record_serde_omits_optional_none_fields() {
622 let r = rec("m", "rid");
623 let json = serde_json::to_string(&r).unwrap();
624 assert!(!json.contains("\"act\""), "act:None must be skipped: {json}");
626 assert!(json.contains("\"camp_id\""), "camp_id:Some(_) must serialize: {json}");
627 }
628
629 #[test]
630 fn trait_is_dyn_compatible() {
631 fn _u(_: &dyn AuditStore) {}
632 }
633
634 fn rec_for(sub: PrincipalId, at: i64, method: &str, request_id: &str) -> AuditRecord {
639 AuditRecord::new(
640 at,
641 sub,
642 None,
643 None,
644 "https://kamaji.example",
645 method,
646 vec![yah_scopes::CLOUD_DEPLOY],
647 "allow",
648 request_id,
649 )
650 .unwrap()
651 }
652
653 fn alice() -> PrincipalId {
654 PrincipalId::user("alice")
655 }
656
657 fn bob() -> PrincipalId {
658 PrincipalId::user("bob")
659 }
660
661 #[test]
662 fn query_rejects_non_user_on_behalf_of() {
663 use cheers_core::PrincipalKind;
664 assert_eq!(
665 AuditQuery::new(PrincipalId::service("kamaji")),
666 Err(AuditQueryError::OnBehalfOfNotUser(PrincipalKind::Service)),
667 );
668 assert_eq!(
669 AuditQuery::new(PrincipalId::camp("camp-a")),
670 Err(AuditQueryError::OnBehalfOfNotUser(PrincipalKind::Camp)),
671 );
672 AuditQuery::new(alice()).expect("a user principal is accepted");
673 }
674
675 #[test]
676 fn query_limit_is_clamped_to_the_ceiling_and_never_zero() {
677 let q = AuditQuery::new(alice()).unwrap();
678 assert_eq!(q.limit, DEFAULT_AUDIT_PAGE_LIMIT);
679 assert_eq!(q.clone().with_limit(0).limit, 1);
680 assert_eq!(
681 q.clone().with_limit(usize::MAX).limit,
682 MAX_AUDIT_PAGE_LIMIT
683 );
684 assert_eq!(q.with_limit(7).limit, 7);
685 }
686
687 #[test]
688 fn cursor_round_trips_through_the_opaque_wire_form() {
689 let c = AuditCursor::new(1_700_000_000, "audit-000000000000002a");
690 let wire = c.to_wire();
691 assert!(!wire.contains(':'), "cursor must not leak its shape: {wire}");
693 assert!(!wire.contains("1700000000"), "cursor must be encoded: {wire}");
694 assert_eq!(AuditCursor::from_wire(&wire).unwrap(), c);
695 }
696
697 #[test]
698 fn cursor_rejects_malformed_wire_forms() {
699 for bad in [
700 "!!!not-base64!!!",
701 &URL_SAFE_NO_PAD.encode("no-separator"),
702 &URL_SAFE_NO_PAD.encode("notanumber:id"),
703 &URL_SAFE_NO_PAD.encode("123:"),
704 ] {
705 assert_eq!(
706 AuditCursor::from_wire(bad),
707 Err(AuditCursorError::Malformed),
708 "expected rejection for {bad:?}",
709 );
710 }
711 }
712
713 #[test]
714 fn query_filters_to_the_named_user_only() {
715 let store = MemoryAuditStore::new();
716 block_on(store.insert_batch(
717 &[
718 rec_for(alice(), 100, "cloud.deploy", "a1"),
719 rec_for(bob(), 101, "cloud.deploy", "b1"),
720 rec_for(PrincipalId::service("kamaji"), 102, "cloud.deploy", "s1"),
721 rec_for(PrincipalId::camp("camp-a"), 103, "cloud.deploy", "c1"),
724 ],
725 1,
726 ))
727 .unwrap();
728
729 let page = block_on(
730 store.query_by_on_behalf_of(&AuditQuery::new(alice()).unwrap()),
731 )
732 .unwrap();
733 assert_eq!(page.rows.len(), 1);
734 assert_eq!(page.rows[0].record.request_id, "a1");
735 assert_eq!(page.next_cursor, None);
736 }
737
738 #[test]
739 fn query_returns_newest_first_and_honours_since_and_method_prefix() {
740 let store = MemoryAuditStore::new();
741 block_on(store.insert_batch(
742 &[
743 rec_for(alice(), 100, "cloud.deploy.start", "old-deploy"),
744 rec_for(alice(), 200, "board.write", "new-other"),
745 rec_for(alice(), 300, "cloud.deploy.finish", "new-deploy"),
746 ],
747 1,
748 ))
749 .unwrap();
750
751 let all = block_on(
752 store.query_by_on_behalf_of(&AuditQuery::new(alice()).unwrap()),
753 )
754 .unwrap();
755 let order: Vec<&str> = all
756 .rows
757 .iter()
758 .map(|r| r.record.request_id.as_str())
759 .collect();
760 assert_eq!(order, ["new-deploy", "new-other", "old-deploy"]);
761
762 let since = block_on(store.query_by_on_behalf_of(
764 &AuditQuery::new(alice()).unwrap().with_since(200),
765 ))
766 .unwrap();
767 let ids: Vec<&str> = since
768 .rows
769 .iter()
770 .map(|r| r.record.request_id.as_str())
771 .collect();
772 assert_eq!(ids, ["new-deploy", "new-other"]);
773
774 let deploys = block_on(store.query_by_on_behalf_of(
776 &AuditQuery::new(alice())
777 .unwrap()
778 .with_method_prefix("cloud.deploy"),
779 ))
780 .unwrap();
781 let ids: Vec<&str> = deploys
782 .rows
783 .iter()
784 .map(|r| r.record.request_id.as_str())
785 .collect();
786 assert_eq!(ids, ["new-deploy", "old-deploy"]);
787 }
788
789 #[test]
790 fn cursor_paging_walks_every_row_exactly_once() {
791 let store = MemoryAuditStore::new();
792 let batch: Vec<AuditRecord> = (0..10)
796 .map(|i| rec_for(alice(), 500, "cloud.deploy", &format!("rid-{i}")))
797 .collect();
798 block_on(store.insert_batch(&batch, 1)).unwrap();
799
800 let mut seen = Vec::new();
801 let mut cursor = None;
802 let mut pages = 0;
803 loop {
804 let mut q = AuditQuery::new(alice()).unwrap().with_limit(3);
805 if let Some(c) = cursor {
806 q = q.with_cursor(c);
807 }
808 let page = block_on(store.query_by_on_behalf_of(&q)).unwrap();
809 pages += 1;
810 assert!(pages <= 5, "paging failed to terminate");
811 seen.extend(page.rows.iter().map(|r| r.record.request_id.clone()));
812 match page.next_cursor {
813 Some(c) => cursor = Some(c),
814 None => break,
815 }
816 }
817 assert_eq!(pages, 4);
820 assert_eq!(seen.len(), 10, "every row returned exactly once: {seen:?}");
821 let unique: std::collections::HashSet<_> = seen.iter().collect();
822 assert_eq!(unique.len(), 10, "no row repeated across pages");
823 }
824
825 #[test]
826 fn rows_appended_during_paging_do_not_shift_the_rows_behind_the_cursor() {
827 let store = MemoryAuditStore::new();
830 block_on(store.insert_batch(
831 &[
832 rec_for(alice(), 100, "m", "oldest"),
833 rec_for(alice(), 200, "m", "middle"),
834 rec_for(alice(), 300, "m", "newest"),
835 ],
836 1,
837 ))
838 .unwrap();
839
840 let first = block_on(store.query_by_on_behalf_of(
841 &AuditQuery::new(alice()).unwrap().with_limit(1),
842 ))
843 .unwrap();
844 assert_eq!(first.rows[0].record.request_id, "newest");
845 let cursor = first.next_cursor.expect("more rows remain");
846
847 block_on(store.insert_batch(&[rec_for(alice(), 400, "m", "arrived-later")], 2))
849 .unwrap();
850
851 let second = block_on(store.query_by_on_behalf_of(
852 &AuditQuery::new(alice())
853 .unwrap()
854 .with_limit(1)
855 .with_cursor(cursor),
856 ))
857 .unwrap();
858 assert_eq!(
859 second.rows[0].record.request_id, "middle",
860 "the newly-appended row must not push 'middle' off this page",
861 );
862 }
863
864 #[test]
865 fn query_on_an_empty_store_is_an_empty_terminal_page() {
866 let store = MemoryAuditStore::new();
867 let page = block_on(
868 store.query_by_on_behalf_of(&AuditQuery::new(alice()).unwrap()),
869 )
870 .unwrap();
871 assert!(page.rows.is_empty());
872 assert_eq!(page.next_cursor, None);
873 }
874}