Skip to main content

cheers_server/
audit.rs

1//! Centralized audit table — the [`AuditStore`] trait.
2//!
3//! Cheers's audit table is the durable, queryable copy of every MCP-mediated
4//! action kamaji observes on its host. Kamaji retains a local JSONL as
5//! the source of truth and forwards batches here via `POST /audit/ingest`;
6//! cheers's responsibility ends at "accepted and durable on cheers's side"
7//! (see `.yah/docs/working/mcp-auth-and-ownership.md` §Audit ingest).
8//!
9//! Append-only. There is no in-place edit or delete — the only writes are
10//! [`AuditStore::insert_batch`]. The read side is
11//! [`AuditStore::query_by_on_behalf_of`] (R020-F14): cursor-paged, filtered
12//! by the human the action is attributable to.
13//!
14//! ## What "on behalf of" means here
15//!
16//! The wire record has no `on_behalf_of` column — it is *derived* from `sub`.
17//! Per RFC 8693 (and [`Actor`]'s own contract) `sub` is the primary subject
18//! and `act` is the agent acting on that subject's behalf, so a row is
19//! attributable to user `U` exactly when `record.sub == user:U`, whether or
20//! not an agent carried the call out. That is the projection W159 §Audit
21//! journal describes as
22//! `SELECT * FROM audit WHERE method LIKE … AND on_behalf_of = $1`.
23//!
24//! Camp-subject rows are deliberately NOT rolled up to the user the camp is
25//! `bound_to`: `.yah/docs/working/mcp-auth-and-ownership.md` §Principal kinds
26//! requires "camp X took this action" and "user U took this action" to stay
27//! unambiguously distinguishable in the audit trail. A camp rollup, if W127
28//! ever wants one, is a separate query — not a widening of this one.
29//!
30//! Record shape per W159 §Audit journal: `{ at, sub, act, camp_id, aud,
31//! method, scope, result, request_id }`. The wire and storage shapes match
32//! one-to-one — cheers doesn't repackage what kamaji sends, it just
33//! durably appends. The only field cheers contributes is the row `id` and
34//! the `ingested_at` server timestamp.
35
36use 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/// Why an [`AuditRecord`] failed validation before reaching the store.
47///
48/// The HTTP layer surfaces these as `400 audit_invalid`. They are the
49/// "forbidden shape" the F13 verify item calls out — a malformed batch is
50/// rejected wholesale so kamaji's bounded-backoff retry sees a clean
51/// 4xx, not a partial commit.
52#[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/// One audit record — verbatim with W159 §Audit journal.
67///
68/// Required fields: `at`, `sub`, `aud`, `method`, `result`, `request_id`.
69/// `scope` MAY be empty (e.g. a call rejected before scope-matching). `act`
70/// and `camp_id` are set when an agent acted on the subject's behalf and/or
71/// the call was scoped to a camp; both `None` for a direct user-principal
72/// action with no camp context.
73///
74/// Mirrors the conditional-claim shape of [`McpClaims`](cheers_core::McpClaims)
75/// so a verified token's metadata can flow into the record without lossy
76/// repackaging.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78#[non_exhaustive]
79pub struct AuditRecord {
80    /// Unix-seconds timestamp the action happened (kamaji's local clock).
81    pub at: i64,
82    /// Acting principal — the `sub` claim from the verified token.
83    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    /// Target resource URI (the verified token's `aud`).
89    pub aud: String,
90    /// Free-form method identifier (`POST /cloud/deploy`, `mcp.tools/call`, …)
91    /// — kamaji supplies a stable string per call shape.
92    pub method: String,
93    /// Scope list the call presented. Stored as a typed `Vec<Scope>` so a
94    /// future wildcard or unknown-scope ingest is rejected at parse time
95    /// (per composition rule 1 — no wildcards on the wire).
96    #[serde(default)]
97    pub scope: Vec<Scope>,
98    /// Outcome string kamaji produced (`allow` | `deny` | `error` is the
99    /// usual taxonomy, but cheers does not constrain it — see audit-reader
100    /// docs in F14 for the canonical vocabulary). Cheers is durable storage,
101    /// not the source of result semantics.
102    pub result: String,
103    /// Correlator into kamaji's local JSONL — same id appears in both
104    /// places so an operator can match a cheers row to kamaji's source.
105    pub request_id: String,
106}
107
108impl AuditRecord {
109    /// Construct + validate. Returns the same error variants the wire layer
110    /// surfaces as `400 audit_invalid`.
111    #[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    /// Re-check the wire-shape invariants. Called from [`Self::new`]; the
139    /// HTTP layer calls it again on records that arrived via `Deserialize`
140    /// so a deserialized record can't side-step the constructor.
141    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/// One row in the audit table — an [`AuditRecord`] plus the bits cheers
162/// contributes (`id` and `ingested_at`).
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164#[non_exhaustive]
165pub struct AuditRow {
166    pub id: String,
167    pub record: AuditRecord,
168    /// Unix-seconds timestamp cheers received and durably appended the row.
169    /// Distinct from `record.at` (kamaji's clock) — having both lets an
170    /// operator see ingest latency without joining clocks at query time.
171    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
184/// Default page size when the caller doesn't ask for one.
185pub const DEFAULT_AUDIT_PAGE_LIMIT: usize = 100;
186
187/// Hard ceiling on a page — a caller asking for more gets this. The audit
188/// table is large and append-only; an unbounded page is a denial-of-service
189/// shape, not a feature.
190pub const MAX_AUDIT_PAGE_LIMIT: usize = 500;
191
192/// Keyset position in the audit result order.
193///
194/// The order is `(at DESC, id DESC)` — `at` alone is kamaji's clock and is
195/// not unique, so the row `id` breaks ties and makes the order total. That
196/// totality is what lets this be a *keyset* cursor rather than an offset:
197/// rows appended while a client pages never shift the rows behind it, so no
198/// row is skipped or repeated.
199///
200/// Opaque on the wire — see [`to_wire`](Self::to_wire) /
201/// [`from_wire`](Self::from_wire). Clients must treat the string as a token
202/// to echo back, never as a parseable position.
203#[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    /// Position of this row in the result order.
215    pub fn from_row(row: &AuditRow) -> Self {
216        Self::new(row.record.at, row.id.clone())
217    }
218
219    /// Encode for the wire — base64url-no-pad over `"<at>:<id>"`. The
220    /// encoding is deliberately opaque, not signed: the cursor names a
221    /// public position in a result set the caller is already authorized to
222    /// read, so forging one grants nothing the caller couldn't ask for
223    /// directly with `since=`. Authorization is re-checked per request and
224    /// never carried in the cursor.
225    pub fn to_wire(&self) -> String {
226        URL_SAFE_NO_PAD.encode(format!("{}:{}", self.at, self.id))
227    }
228
229    /// Inverse of [`to_wire`](Self::to_wire).
230    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    /// `true` if `row` sorts strictly after this cursor under
244    /// `(at DESC, id DESC)` — i.e. the row belongs on a later page.
245    fn precedes(&self, row: &AuditRow) -> bool {
246        (row.record.at, row.id.as_str()) < (self.at, self.id.as_str())
247    }
248}
249
250/// A cursor string that didn't decode. One variant on purpose — the wire
251/// form is opaque, so distinguishing "bad base64" from "bad payload" tells a
252/// client nothing actionable beyond "you didn't echo back what we gave you".
253#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
254pub enum AuditCursorError {
255    #[error("malformed audit cursor")]
256    Malformed,
257}
258
259/// Why an [`AuditQuery`] could not be constructed.
260#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
261pub enum AuditQueryError {
262    /// `on_behalf_of` names a non-user principal. The `on_behalf_of` lane is
263    /// user-only by the same invariant the ownership table enforces in SQL
264    /// (`CHECK (on_behalf_of IS NULL OR on_behalf_of LIKE 'user:%')`), and
265    /// widening it would collapse the camp-vs-user audit distinction the
266    /// design doc requires.
267    #[error("on_behalf_of must be a user principal; got {0}")]
268    OnBehalfOfNotUser(PrincipalKind),
269}
270
271/// One page request against the audit table.
272///
273/// Build with [`new`](Self::new) (which enforces the user-principal
274/// invariant) and narrow with the `with_*` combinators. Every filter is
275/// conjunctive.
276#[derive(Debug, Clone, PartialEq, Eq)]
277#[non_exhaustive]
278pub struct AuditQuery {
279    /// The human the rows must be attributable to — matched against
280    /// `record.sub`. Always a [`PrincipalKind::User`].
281    pub on_behalf_of: PrincipalId,
282    /// Inclusive lower bound on `record.at` (kamaji's clock), when set.
283    pub since: Option<i64>,
284    /// Literal prefix `record.method` must start with, when set. Not a glob
285    /// and not a regex — W127's "who deployed what" needs `cloud.deploy`,
286    /// nothing richer.
287    pub method_prefix: Option<String>,
288    /// Resume position from a previous page's `next_cursor`.
289    pub cursor: Option<AuditCursor>,
290    /// Page size, already clamped to `1..=MAX_AUDIT_PAGE_LIMIT`.
291    pub limit: usize,
292}
293
294impl AuditQuery {
295    /// New query for `on_behalf_of`, at the default page size.
296    ///
297    /// Rejects a non-user principal — see
298    /// [`AuditQueryError::OnBehalfOfNotUser`].
299    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    /// Set the page size, clamped to `1..=MAX_AUDIT_PAGE_LIMIT`. Clamps
328    /// rather than erroring: an over-large `?limit=` is a client that wants
329    /// "as much as you'll give me", and failing the whole request over it
330    /// buys nothing.
331    pub fn with_limit(mut self, limit: usize) -> Self {
332        self.limit = limit.clamp(1, MAX_AUDIT_PAGE_LIMIT);
333        self
334    }
335
336    /// `true` if `row` satisfies every filter except the cursor position.
337    /// Shared by the in-memory impl and available to any impl that can't
338    /// push the whole predicate into its backend.
339    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/// One page of audit rows, newest first.
358#[derive(Debug, Clone, PartialEq, Eq)]
359#[non_exhaustive]
360pub struct AuditPage {
361    /// Rows in `(at DESC, id DESC)` order.
362    pub rows: Vec<AuditRow>,
363    /// Position to pass as the next request's cursor. `None` means this was
364    /// the last page — impls set it only when a further row is known to
365    /// exist (by over-fetching one), so a client never has to make a final
366    /// empty round-trip to discover the end.
367    pub next_cursor: Option<AuditCursor>,
368}
369
370impl AuditPage {
371    /// Build a page from `limit + 1` candidate rows already in result order:
372    /// truncates to `limit` and derives `next_cursor` iff the extra row was
373    /// present.
374    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/// Append-only persistence for audit records.
386///
387/// Only one mutation method — [`insert_batch`](Self::insert_batch). A batch
388/// is rejected wholesale if any record fails [`AuditRecord::validate`]; no
389/// partial commits, so kamaji's retry-with-backoff sees a clean 4xx.
390///
391/// The read side is [`query_by_on_behalf_of`](Self::query_by_on_behalf_of).
392#[async_trait]
393pub trait AuditStore: Send + Sync {
394    /// Insert a batch of records atomically. The impl assigns each row a
395    /// fresh opaque id and stamps `ingested_at`. Returns the resulting
396    /// [`AuditRow`]s in the same order as the input.
397    ///
398    /// Validation is the caller's responsibility — pass already-validated
399    /// [`AuditRecord`]s (the HTTP layer runs [`AuditRecord::validate`] over
400    /// the batch before calling here). The trait does not re-validate; it
401    /// just durably appends.
402    async fn insert_batch(
403        &self,
404        records: &[AuditRecord],
405        ingested_at: i64,
406    ) -> Result<Vec<AuditRow>, StoreError>;
407
408    /// One page of rows attributable to `query.on_behalf_of`, newest first
409    /// (`at DESC, id DESC`), honouring the `since` / `method_prefix` filters
410    /// and resuming from `query.cursor`.
411    ///
412    /// Impls must over-fetch one row beyond `query.limit` and hand the result
413    /// to [`AuditPage::from_overfetch`], so `next_cursor` is `Some` only when
414    /// a further row genuinely exists.
415    async fn query_by_on_behalf_of(&self, query: &AuditQuery)
416    -> Result<AuditPage, StoreError>;
417}
418
419/// In-memory [`AuditStore`] for tests and single-node bootstrapping. Cheap
420/// to `clone` — shares one backing queue. Records keep insertion order.
421#[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    /// Snapshot of every row inserted so far, in insertion order. Test-only
438    /// accessor — `AuditStore` proper exposes reads through F14's surface.
439    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        // (at DESC, id DESC) — the same total order the SQL impls index on.
494        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        // at <= 0
539        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        // empty aud
546        let mut r = rec("m", "rid");
547        r.aud = String::new();
548        assert_eq!(r.validate(), Err(AuditValidationError::EmptyAud));
549
550        // empty method
551        let mut r = rec("m", "rid");
552        r.method = String::new();
553        assert_eq!(r.validate(), Err(AuditValidationError::EmptyMethod));
554
555        // empty result
556        let mut r = rec("m", "rid");
557        r.result = String::new();
558        assert_eq!(r.validate(), Err(AuditValidationError::EmptyResult));
559
560        // empty request_id
561        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        // A call rejected before scope-matching legitimately has no scope.
569        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        // Ids are unique.
592        let ids: std::collections::HashSet<_> = rows.iter().map(|r| &r.id).collect();
593        assert_eq!(ids.len(), 3);
594
595        // Snapshot reflects insertion order across batches.
596        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        // act is None for this fixture; camp_id is Some — only act should be omitted.
625        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    // -----------------------------------------------------------------
635    // R020-F14 — the read side
636    // -----------------------------------------------------------------
637
638    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        // Opaque: the raw position must not be readable straight off the wire.
692        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                // A camp-subject row is NOT rolled up to the user the camp is
722                // bound to — camp and user trails stay distinguishable.
723                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        // since is an INCLUSIVE lower bound on record.at.
763        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        // W127's actual query: "who deployed what".
775        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        // Deliberately all at the SAME `at`: the tie-break by row id is what
793        // makes the order total, and a page boundary landing inside a tie is
794        // exactly where an `at`-only cursor would skip or repeat rows.
795        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        // 10 rows at limit 3 → 4 pages, and the last page (1 row) reports no
818        // next_cursor rather than costing a fifth, empty round-trip.
819        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        // The append-only property keyset pagination buys us: an offset-based
828        // pager would re-show a row here.
829        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        // Kamaji forwards a fresh, newer batch mid-page.
848        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}