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::PrincipalId;
509    use pollster::block_on;
510
511    fn rec(method: &str, request_id: &str) -> AuditRecord {
512        AuditRecord::new(
513            1_700_000_000,
514            PrincipalId::user("alice"),
515            None,
516            Some("camp-a".into()),
517            "https://kamaji.example",
518            method,
519            vec![Scope::CloudDeploy],
520            "allow",
521            request_id,
522        )
523        .unwrap()
524    }
525
526    #[test]
527    fn new_accepts_well_formed_record() {
528        let r = rec("POST /cloud/deploy", "req-1");
529        assert_eq!(r.sub, PrincipalId::user("alice"));
530        assert_eq!(r.method, "POST /cloud/deploy");
531        assert_eq!(r.result, "allow");
532        assert_eq!(r.scope, vec![Scope::CloudDeploy]);
533    }
534
535    #[test]
536    fn validate_rejects_each_empty_field() {
537        // at <= 0
538        let mut r = rec("m", "rid");
539        r.at = 0;
540        assert_eq!(r.validate(), Err(AuditValidationError::NonPositiveAt(0)));
541        r.at = -1;
542        assert_eq!(r.validate(), Err(AuditValidationError::NonPositiveAt(-1)));
543
544        // empty aud
545        let mut r = rec("m", "rid");
546        r.aud = String::new();
547        assert_eq!(r.validate(), Err(AuditValidationError::EmptyAud));
548
549        // empty method
550        let mut r = rec("m", "rid");
551        r.method = String::new();
552        assert_eq!(r.validate(), Err(AuditValidationError::EmptyMethod));
553
554        // empty result
555        let mut r = rec("m", "rid");
556        r.result = String::new();
557        assert_eq!(r.validate(), Err(AuditValidationError::EmptyResult));
558
559        // empty request_id
560        let mut r = rec("m", "rid");
561        r.request_id = String::new();
562        assert_eq!(r.validate(), Err(AuditValidationError::EmptyRequestId));
563    }
564
565    #[test]
566    fn validate_allows_empty_scope_list() {
567        // A call rejected before scope-matching legitimately has no scope.
568        let mut r = rec("m", "rid");
569        r.scope.clear();
570        r.validate().expect("empty scope is allowed");
571    }
572
573    #[test]
574    fn memory_store_insert_batch_preserves_order_and_stamps_metadata() {
575        let store = MemoryAuditStore::new();
576        let batch = vec![
577            rec("m1", "rid-1"),
578            rec("m2", "rid-2"),
579            rec("m3", "rid-3"),
580        ];
581        let rows = block_on(store.insert_batch(&batch, 5_000)).unwrap();
582        assert_eq!(rows.len(), 3);
583        assert_eq!(rows[0].record.method, "m1");
584        assert_eq!(rows[1].record.method, "m2");
585        assert_eq!(rows[2].record.method, "m3");
586        for row in &rows {
587            assert_eq!(row.ingested_at, 5_000);
588            assert!(!row.id.is_empty());
589        }
590        // Ids are unique.
591        let ids: std::collections::HashSet<_> = rows.iter().map(|r| &r.id).collect();
592        assert_eq!(ids.len(), 3);
593
594        // Snapshot reflects insertion order across batches.
595        let more = vec![rec("m4", "rid-4")];
596        block_on(store.insert_batch(&more, 6_000)).unwrap();
597        let snap = store.snapshot();
598        assert_eq!(snap.len(), 4);
599        assert_eq!(snap[3].record.method, "m4");
600        assert_eq!(snap[3].ingested_at, 6_000);
601    }
602
603    #[test]
604    fn memory_store_empty_batch_is_a_noop() {
605        let store = MemoryAuditStore::new();
606        let rows = block_on(store.insert_batch(&[], 1_000)).unwrap();
607        assert!(rows.is_empty());
608        assert!(store.snapshot().is_empty());
609    }
610
611    #[test]
612    fn audit_record_round_trips_through_serde() {
613        let r = rec("POST /x", "rid");
614        let json = serde_json::to_string(&r).unwrap();
615        let back: AuditRecord = serde_json::from_str(&json).unwrap();
616        assert_eq!(back, r);
617    }
618
619    #[test]
620    fn audit_record_serde_omits_optional_none_fields() {
621        let r = rec("m", "rid");
622        let json = serde_json::to_string(&r).unwrap();
623        // act is None for this fixture; camp_id is Some — only act should be omitted.
624        assert!(!json.contains("\"act\""), "act:None must be skipped: {json}");
625        assert!(json.contains("\"camp_id\""), "camp_id:Some(_) must serialize: {json}");
626    }
627
628    #[test]
629    fn trait_is_dyn_compatible() {
630        fn _u(_: &dyn AuditStore) {}
631    }
632
633    // -----------------------------------------------------------------
634    // R020-F14 — the read side
635    // -----------------------------------------------------------------
636
637    fn rec_for(sub: PrincipalId, at: i64, method: &str, request_id: &str) -> AuditRecord {
638        AuditRecord::new(
639            at,
640            sub,
641            None,
642            None,
643            "https://kamaji.example",
644            method,
645            vec![Scope::CloudDeploy],
646            "allow",
647            request_id,
648        )
649        .unwrap()
650    }
651
652    fn alice() -> PrincipalId {
653        PrincipalId::user("alice")
654    }
655
656    fn bob() -> PrincipalId {
657        PrincipalId::user("bob")
658    }
659
660    #[test]
661    fn query_rejects_non_user_on_behalf_of() {
662        use cheers_core::PrincipalKind;
663        assert_eq!(
664            AuditQuery::new(PrincipalId::service("kamaji")),
665            Err(AuditQueryError::OnBehalfOfNotUser(PrincipalKind::Service)),
666        );
667        assert_eq!(
668            AuditQuery::new(PrincipalId::camp("camp-a")),
669            Err(AuditQueryError::OnBehalfOfNotUser(PrincipalKind::Camp)),
670        );
671        AuditQuery::new(alice()).expect("a user principal is accepted");
672    }
673
674    #[test]
675    fn query_limit_is_clamped_to_the_ceiling_and_never_zero() {
676        let q = AuditQuery::new(alice()).unwrap();
677        assert_eq!(q.limit, DEFAULT_AUDIT_PAGE_LIMIT);
678        assert_eq!(q.clone().with_limit(0).limit, 1);
679        assert_eq!(
680            q.clone().with_limit(usize::MAX).limit,
681            MAX_AUDIT_PAGE_LIMIT
682        );
683        assert_eq!(q.with_limit(7).limit, 7);
684    }
685
686    #[test]
687    fn cursor_round_trips_through_the_opaque_wire_form() {
688        let c = AuditCursor::new(1_700_000_000, "audit-000000000000002a");
689        let wire = c.to_wire();
690        // Opaque: the raw position must not be readable straight off the wire.
691        assert!(!wire.contains(':'), "cursor must not leak its shape: {wire}");
692        assert!(!wire.contains("1700000000"), "cursor must be encoded: {wire}");
693        assert_eq!(AuditCursor::from_wire(&wire).unwrap(), c);
694    }
695
696    #[test]
697    fn cursor_rejects_malformed_wire_forms() {
698        for bad in [
699            "!!!not-base64!!!",
700            &URL_SAFE_NO_PAD.encode("no-separator"),
701            &URL_SAFE_NO_PAD.encode("notanumber:id"),
702            &URL_SAFE_NO_PAD.encode("123:"),
703        ] {
704            assert_eq!(
705                AuditCursor::from_wire(bad),
706                Err(AuditCursorError::Malformed),
707                "expected rejection for {bad:?}",
708            );
709        }
710    }
711
712    #[test]
713    fn query_filters_to_the_named_user_only() {
714        let store = MemoryAuditStore::new();
715        block_on(store.insert_batch(
716            &[
717                rec_for(alice(), 100, "cloud.deploy", "a1"),
718                rec_for(bob(), 101, "cloud.deploy", "b1"),
719                rec_for(PrincipalId::service("kamaji"), 102, "cloud.deploy", "s1"),
720                // A camp-subject row is NOT rolled up to the user the camp is
721                // bound to — camp and user trails stay distinguishable.
722                rec_for(PrincipalId::camp("camp-a"), 103, "cloud.deploy", "c1"),
723            ],
724            1,
725        ))
726        .unwrap();
727
728        let page = block_on(
729            store.query_by_on_behalf_of(&AuditQuery::new(alice()).unwrap()),
730        )
731        .unwrap();
732        assert_eq!(page.rows.len(), 1);
733        assert_eq!(page.rows[0].record.request_id, "a1");
734        assert_eq!(page.next_cursor, None);
735    }
736
737    #[test]
738    fn query_returns_newest_first_and_honours_since_and_method_prefix() {
739        let store = MemoryAuditStore::new();
740        block_on(store.insert_batch(
741            &[
742                rec_for(alice(), 100, "cloud.deploy.start", "old-deploy"),
743                rec_for(alice(), 200, "board.write", "new-other"),
744                rec_for(alice(), 300, "cloud.deploy.finish", "new-deploy"),
745            ],
746            1,
747        ))
748        .unwrap();
749
750        let all = block_on(
751            store.query_by_on_behalf_of(&AuditQuery::new(alice()).unwrap()),
752        )
753        .unwrap();
754        let order: Vec<&str> = all
755            .rows
756            .iter()
757            .map(|r| r.record.request_id.as_str())
758            .collect();
759        assert_eq!(order, ["new-deploy", "new-other", "old-deploy"]);
760
761        // since is an INCLUSIVE lower bound on record.at.
762        let since = block_on(store.query_by_on_behalf_of(
763            &AuditQuery::new(alice()).unwrap().with_since(200),
764        ))
765        .unwrap();
766        let ids: Vec<&str> = since
767            .rows
768            .iter()
769            .map(|r| r.record.request_id.as_str())
770            .collect();
771        assert_eq!(ids, ["new-deploy", "new-other"]);
772
773        // W127's actual query: "who deployed what".
774        let deploys = block_on(store.query_by_on_behalf_of(
775            &AuditQuery::new(alice())
776                .unwrap()
777                .with_method_prefix("cloud.deploy"),
778        ))
779        .unwrap();
780        let ids: Vec<&str> = deploys
781            .rows
782            .iter()
783            .map(|r| r.record.request_id.as_str())
784            .collect();
785        assert_eq!(ids, ["new-deploy", "old-deploy"]);
786    }
787
788    #[test]
789    fn cursor_paging_walks_every_row_exactly_once() {
790        let store = MemoryAuditStore::new();
791        // Deliberately all at the SAME `at`: the tie-break by row id is what
792        // makes the order total, and a page boundary landing inside a tie is
793        // exactly where an `at`-only cursor would skip or repeat rows.
794        let batch: Vec<AuditRecord> = (0..10)
795            .map(|i| rec_for(alice(), 500, "cloud.deploy", &format!("rid-{i}")))
796            .collect();
797        block_on(store.insert_batch(&batch, 1)).unwrap();
798
799        let mut seen = Vec::new();
800        let mut cursor = None;
801        let mut pages = 0;
802        loop {
803            let mut q = AuditQuery::new(alice()).unwrap().with_limit(3);
804            if let Some(c) = cursor {
805                q = q.with_cursor(c);
806            }
807            let page = block_on(store.query_by_on_behalf_of(&q)).unwrap();
808            pages += 1;
809            assert!(pages <= 5, "paging failed to terminate");
810            seen.extend(page.rows.iter().map(|r| r.record.request_id.clone()));
811            match page.next_cursor {
812                Some(c) => cursor = Some(c),
813                None => break,
814            }
815        }
816        // 10 rows at limit 3 → 4 pages, and the last page (1 row) reports no
817        // next_cursor rather than costing a fifth, empty round-trip.
818        assert_eq!(pages, 4);
819        assert_eq!(seen.len(), 10, "every row returned exactly once: {seen:?}");
820        let unique: std::collections::HashSet<_> = seen.iter().collect();
821        assert_eq!(unique.len(), 10, "no row repeated across pages");
822    }
823
824    #[test]
825    fn rows_appended_during_paging_do_not_shift_the_rows_behind_the_cursor() {
826        // The append-only property keyset pagination buys us: an offset-based
827        // pager would re-show a row here.
828        let store = MemoryAuditStore::new();
829        block_on(store.insert_batch(
830            &[
831                rec_for(alice(), 100, "m", "oldest"),
832                rec_for(alice(), 200, "m", "middle"),
833                rec_for(alice(), 300, "m", "newest"),
834            ],
835            1,
836        ))
837        .unwrap();
838
839        let first = block_on(store.query_by_on_behalf_of(
840            &AuditQuery::new(alice()).unwrap().with_limit(1),
841        ))
842        .unwrap();
843        assert_eq!(first.rows[0].record.request_id, "newest");
844        let cursor = first.next_cursor.expect("more rows remain");
845
846        // Kamaji forwards a fresh, newer batch mid-page.
847        block_on(store.insert_batch(&[rec_for(alice(), 400, "m", "arrived-later")], 2))
848            .unwrap();
849
850        let second = block_on(store.query_by_on_behalf_of(
851            &AuditQuery::new(alice())
852                .unwrap()
853                .with_limit(1)
854                .with_cursor(cursor),
855        ))
856        .unwrap();
857        assert_eq!(
858            second.rows[0].record.request_id, "middle",
859            "the newly-appended row must not push 'middle' off this page",
860        );
861    }
862
863    #[test]
864    fn query_on_an_empty_store_is_an_empty_terminal_page() {
865        let store = MemoryAuditStore::new();
866        let page = block_on(
867            store.query_by_on_behalf_of(&AuditQuery::new(alice()).unwrap()),
868        )
869        .unwrap();
870        assert!(page.rows.is_empty());
871        assert_eq!(page.next_cursor, None);
872    }
873}