Skip to main content

cairn_mod/cli/
audit.rs

1//! `cairn audit list` (#6) and `cairn audit show <id>` (#26) —
2//! admin-side audit log queries.
3//!
4//! Wrap `tools.cairn.admin.listAuditLog`
5//! (src/server/admin/list_audit_log.rs) and
6//! `tools.cairn.admin.getAuditLog` (src/server/admin/get_audit_log.rs).
7//! **Admin role only** — the server's auth check uses
8//! `verify_and_authorize_admin_only`, so a moderator-role session
9//! file produces a 403 surfaced as `CliError::CairnStatus { status: 403, .. }`.
10//!
11//! `list` query parameters mirror the handler's `Params`: optional
12//! `actor`, `action`, `outcome`, `since` (RFC-3339, server parses
13//! to ms via `parse_rfc3339_ms`), `until` (same), `limit`, `cursor`.
14//! The CLI passes RFC-3339 strings through; no client-side
15//! conversion. `outcome` is `"success"` or `"failure"`.
16//!
17//! `show` takes a single `id` query param and returns the bare
18//! `auditEntry` shape (no envelope). On an unknown id the server
19//! returns `AuditEntryNotFound` 404, which surfaces as
20//! `CliError::CairnStatus { status: 404, body: "{\"error\":\"AuditEntryNotFound\",..}" }`
21//! — distinct from the 403 mod-role posture.
22//!
23//! Pattern matches `cli/report.rs` exactly: typed `Input` →
24//! `list()` / `show()` orchestrator → typed `Response` → pure
25//! `format_*` functions.
26
27use std::path::Path;
28use std::time::Duration;
29
30use reqwest::Client;
31use serde::{Deserialize, Serialize};
32
33use super::auth::acquire_service_auth;
34use super::error::CliError;
35use super::output::truncate;
36use super::pds::PdsClient;
37use super::session::SessionFile;
38
39const LIST_AUDIT_LOG_LXM: &str = "tools.cairn.admin.listAuditLog";
40const GET_AUDIT_LOG_LXM: &str = "tools.cairn.admin.getAuditLog";
41
42/// Wire-shape of one row in a `listAuditLog` response. Mirrors
43/// the server's `AuditEntry` projection.
44#[derive(Debug, Clone, Deserialize, Serialize)]
45pub struct AuditEntry {
46    /// Audit row primary key (monotonic, also the cursor unit).
47    pub id: i64,
48    /// RFC-3339 UTC timestamp the row was committed.
49    #[serde(rename = "createdAt")]
50    pub created_at: String,
51    /// Audit action discriminator (e.g. `"label_applied"`,
52    /// `"report_resolved"`, `"reporter_flagged"`).
53    pub action: String,
54    /// DID of the moderator/admin/operator that triggered the
55    /// action.
56    #[serde(rename = "actorDid")]
57    pub actor_did: String,
58    /// Optional target identifier (DID or AT-URI; depends on
59    /// action).
60    #[serde(skip_serializing_if = "Option::is_none", default)]
61    pub target: Option<String>,
62    /// Optional CID pin on the target (for record-targeted
63    /// actions like label-apply with strongRef).
64    #[serde(rename = "targetCid", skip_serializing_if = "Option::is_none", default)]
65    pub target_cid: Option<String>,
66    /// `"success"` or `"failure"`.
67    pub outcome: String,
68    /// Free-text or JSON payload describing the action; schema
69    /// per-action (see `crate::writer::AUDIT_REASON_*`).
70    #[serde(skip_serializing_if = "Option::is_none", default)]
71    pub reason: Option<String>,
72    /// Hex-encoded SHA-256 (64 lowercase chars) of the prior row's
73    /// row_hash. Absent for pre-v1.3 / pre-rebuild rows — the
74    /// trust-horizon convention from #39/#40. Present rows always
75    /// carry both this and `row_hash`.
76    #[serde(rename = "prevHash", skip_serializing_if = "Option::is_none", default)]
77    pub prev_hash: Option<String>,
78    /// Hex-encoded SHA-256 (64 lowercase chars) of
79    /// `prevHash || dag_cbor_canonical(row_content)`. Absent for
80    /// pre-v1.3 / pre-rebuild rows.
81    #[serde(rename = "rowHash", skip_serializing_if = "Option::is_none", default)]
82    pub row_hash: Option<String>,
83}
84
85/// `listAuditLog` response envelope.
86#[derive(Debug, Deserialize, Serialize)]
87pub struct AuditListResponse {
88    /// Matched entries, newest first.
89    pub entries: Vec<AuditEntry>,
90    /// Opaque next-page cursor. Present iff more results
91    /// available.
92    #[serde(skip_serializing_if = "Option::is_none", default)]
93    pub cursor: Option<String>,
94}
95
96/// Input to `cairn audit list`.
97#[derive(Debug, Clone, Default)]
98pub struct AuditListInput {
99    /// Filter by actor DID. Server matches exact equality.
100    pub actor: Option<String>,
101    /// Filter by action discriminator (e.g. `"label_applied"`).
102    /// Server validates against `AUDIT_ACTION_VALUES`.
103    pub action: Option<String>,
104    /// Filter by outcome (`"success"` or `"failure"`). Server
105    /// validates against `AUDIT_OUTCOME_VALUES`.
106    pub outcome: Option<String>,
107    /// RFC-3339 inclusive lower bound on `created_at`.
108    pub since: Option<String>,
109    /// RFC-3339 inclusive upper bound on `created_at`.
110    pub until: Option<String>,
111    /// Max rows to return. Server clamps to [1, 250]; default 50.
112    pub limit: Option<i64>,
113    /// Opaque pagination cursor from a prior response.
114    pub cursor: Option<String>,
115    /// Per-invocation override of the session's stored Cairn URL.
116    pub cairn_server_override: Option<String>,
117}
118
119/// Query the audit log via the admin HTTP endpoint.
120///
121/// Wraps the `tools.cairn.admin.listAuditLog` handler at
122/// [src/server/admin/list_audit_log.rs](..) — GET with
123/// query-string filters; server enforces ADMIN role
124/// (`verify_and_authorize_admin_only`), validates the
125/// `action` / `outcome` enums, parses `since` / `until` from
126/// RFC-3339, and returns a newest-first page with optional
127/// `cursor` for the next call.
128pub async fn list(
129    session: &mut SessionFile,
130    session_path: &Path,
131    input: AuditListInput,
132) -> Result<AuditListResponse, CliError> {
133    let cairn_server = input
134        .cairn_server_override
135        .as_deref()
136        .unwrap_or(&session.cairn_server_url)
137        .trim_end_matches('/')
138        .to_string();
139    let pds = PdsClient::new(&session.pds_url)?;
140    let token = acquire_service_auth(&pds, session, session_path, LIST_AUDIT_LOG_LXM).await?;
141
142    let url = format!("{cairn_server}/xrpc/{LIST_AUDIT_LOG_LXM}");
143    let limit_owned = input.limit.map(|n| n.to_string());
144    let mut query: Vec<(&str, &str)> = Vec::new();
145    if let Some(a) = &input.actor {
146        query.push(("actor", a.as_str()));
147    }
148    if let Some(a) = &input.action {
149        query.push(("action", a.as_str()));
150    }
151    if let Some(o) = &input.outcome {
152        query.push(("outcome", o.as_str()));
153    }
154    if let Some(s) = &input.since {
155        query.push(("since", s.as_str()));
156    }
157    if let Some(u) = &input.until {
158        query.push(("until", u.as_str()));
159    }
160    if let Some(n) = &limit_owned {
161        query.push(("limit", n.as_str()));
162    }
163    if let Some(c) = &input.cursor {
164        query.push(("cursor", c.as_str()));
165    }
166
167    let client = Client::builder()
168        .timeout(Duration::from_secs(30))
169        .build()
170        .expect("reqwest build");
171    let resp = client
172        .get(&url)
173        .bearer_auth(&token)
174        .query(&query)
175        .send()
176        .await
177        .map_err(|source| CliError::Http {
178            url: url.clone(),
179            source,
180        })?;
181    if !resp.status().is_success() {
182        let status = resp.status().as_u16();
183        let body = resp.text().await.unwrap_or_default();
184        return Err(CliError::CairnStatus { url, status, body });
185    }
186    let bytes = resp.bytes().await.map_err(|source| CliError::Http {
187        url: url.clone(),
188        source,
189    })?;
190    serde_json::from_slice::<AuditListResponse>(&bytes)
191        .map_err(|source| CliError::MalformedResponse { url, source })
192}
193
194/// Tabular human output for `cairn audit list`. Columns: id,
195/// created_at, action, actor_did (truncated), outcome. Trailing
196/// `next cursor: …` line when a next page exists. Reason
197/// payloads are not included in the table — they're often
198/// multi-line JSON; use `--json` for full inspection.
199pub fn format_list_human(resp: &AuditListResponse) -> String {
200    use std::fmt::Write;
201    if resp.entries.is_empty() {
202        let mut s = "(no audit entries)".to_string();
203        if let Some(c) = &resp.cursor {
204            let _ = write!(s, "\nnext cursor: {c}");
205        }
206        return s;
207    }
208    let id_w = resp
209        .entries
210        .iter()
211        .map(|e| e.id.to_string().len())
212        .max()
213        .unwrap_or(2)
214        .max(2);
215    let action_w = resp
216        .entries
217        .iter()
218        .map(|e| e.action.len().min(28))
219        .max()
220        .unwrap_or(6)
221        .max(6);
222    let actor_w = resp
223        .entries
224        .iter()
225        .map(|e| e.actor_did.len().min(40))
226        .max()
227        .unwrap_or(8)
228        .max(8);
229    let mut s = String::new();
230    let _ = writeln!(
231        s,
232        "{:>id_w$}  {:<24}  {:<action_w$}  {:<actor_w$}  {:<7}",
233        "ID",
234        "CREATED_AT",
235        "ACTION",
236        "ACTOR_DID",
237        "OUTCOME",
238        id_w = id_w,
239        action_w = action_w,
240        actor_w = actor_w,
241    );
242    for e in &resp.entries {
243        let action = truncate(&e.action, 28);
244        let actor = truncate(&e.actor_did, 40);
245        let _ = writeln!(
246            s,
247            "{:>id_w$}  {:<24}  {:<action_w$}  {:<actor_w$}  {:<7}",
248            e.id,
249            e.created_at,
250            action,
251            actor,
252            e.outcome,
253            id_w = id_w,
254            action_w = action_w,
255            actor_w = actor_w,
256        );
257    }
258    if let Some(c) = &resp.cursor {
259        let _ = write!(s, "next cursor: {c}");
260    } else if s.ends_with('\n') {
261        s.pop();
262    }
263    s
264}
265
266/// JSON envelope for `cairn audit list`.
267pub fn format_list_json(resp: &AuditListResponse) -> String {
268    serde_json::to_string_pretty(resp).expect("AuditListResponse serializes")
269}
270
271// ============================================================
272// `cairn audit show <id>` — wraps tools.cairn.admin.getAuditLog
273// (src/server/admin/get_audit_log.rs). Admin role required.
274// Returns the bare auditEntry shape (no envelope).
275// ============================================================
276
277/// Input to `cairn audit show`.
278#[derive(Debug, Clone)]
279pub struct AuditShowInput {
280    /// Audit row primary key to fetch.
281    pub id: i64,
282    /// Per-invocation override of the session's stored Cairn URL.
283    pub cairn_server_override: Option<String>,
284}
285
286/// Fetch a single audit log entry by id via the admin HTTP endpoint.
287///
288/// Wraps the `tools.cairn.admin.getAuditLog` handler at
289/// [src/server/admin/get_audit_log.rs](..) — GET with `id` query
290/// param; server enforces ADMIN role
291/// (`verify_and_authorize_admin_only`) and returns
292/// `AuditEntryNotFound` 404 on unknown id, surfaced here as
293/// `CliError::CairnStatus { status: 404, body: ... }` with the
294/// server's typed error name in the body.
295pub async fn show(
296    session: &mut SessionFile,
297    session_path: &Path,
298    input: AuditShowInput,
299) -> Result<AuditEntry, CliError> {
300    let cairn_server = input
301        .cairn_server_override
302        .as_deref()
303        .unwrap_or(&session.cairn_server_url)
304        .trim_end_matches('/')
305        .to_string();
306    let pds = PdsClient::new(&session.pds_url)?;
307    let token = acquire_service_auth(&pds, session, session_path, GET_AUDIT_LOG_LXM).await?;
308
309    let url = format!("{cairn_server}/xrpc/{GET_AUDIT_LOG_LXM}");
310    let id_str = input.id.to_string();
311    let client = Client::builder()
312        .timeout(Duration::from_secs(30))
313        .build()
314        .expect("reqwest build");
315    let resp = client
316        .get(&url)
317        .bearer_auth(&token)
318        .query(&[("id", id_str.as_str())])
319        .send()
320        .await
321        .map_err(|source| CliError::Http {
322            url: url.clone(),
323            source,
324        })?;
325    if !resp.status().is_success() {
326        let status = resp.status().as_u16();
327        let body = resp.text().await.unwrap_or_default();
328        return Err(CliError::CairnStatus { url, status, body });
329    }
330    let bytes = resp.bytes().await.map_err(|source| CliError::Http {
331        url: url.clone(),
332        source,
333    })?;
334    serde_json::from_slice::<AuditEntry>(&bytes)
335        .map_err(|source| CliError::MalformedResponse { url, source })
336}
337
338/// Multi-line field/value output for `cairn audit show`. Includes
339/// every field present on the entry; omits absent payload optionals
340/// (target, targetCid, reason). `reason` is rendered as-is — when
341/// it's structured JSON, use `--json` to pipe through `jq`.
342///
343/// `prev_hash` and `row_hash` (#42, v1.3) are always rendered: if
344/// the row is pre-attestation (NULL hashes), the value is shown as
345/// `(pre-attestation)` so the operator sees the trust horizon
346/// explicitly rather than inferring it from a missing line. Hex
347/// values are full 64-char lowercase strings; the genesis row's
348/// `prev_hash` is the all-zeros sentinel (see #39).
349pub fn format_show_human(entry: &AuditEntry) -> String {
350    use std::fmt::Write;
351    let mut s = String::new();
352    let _ = writeln!(s, "Audit entry {}", entry.id);
353    let _ = writeln!(s, "  created_at:  {}", entry.created_at);
354    let _ = writeln!(s, "  action:      {}", entry.action);
355    let _ = writeln!(s, "  actor_did:   {}", entry.actor_did);
356    let _ = writeln!(s, "  outcome:     {}", entry.outcome);
357    if let Some(t) = &entry.target {
358        let _ = writeln!(s, "  target:      {t}");
359    }
360    if let Some(c) = &entry.target_cid {
361        let _ = writeln!(s, "  target_cid:  {c}");
362    }
363    if let Some(r) = &entry.reason {
364        let _ = writeln!(s, "  reason:      {r}");
365    }
366    let _ = writeln!(
367        s,
368        "  prev_hash:   {}",
369        entry
370            .prev_hash
371            .as_deref()
372            .unwrap_or(PRE_ATTESTATION_DISPLAY)
373    );
374    let _ = write!(
375        s,
376        "  row_hash:    {}",
377        entry.row_hash.as_deref().unwrap_or(PRE_ATTESTATION_DISPLAY)
378    );
379    s
380}
381
382/// Sentinel rendered in `format_show_human` for pre-attestation
383/// rows (those with NULL `prev_hash` / `row_hash`). Chosen over
384/// `null` / `<empty>` because absence is operationally meaningful —
385/// the trust horizon — and a sentinel that reads as a deliberate
386/// label rather than a missing field surfaces that meaning.
387const PRE_ATTESTATION_DISPLAY: &str = "(pre-attestation)";
388
389/// JSON output for `cairn audit show`. Pretty-printed for shell
390/// readability; `jq` consumers see the canonical wire shape.
391pub fn format_show_json(entry: &AuditEntry) -> String {
392    serde_json::to_string_pretty(entry).expect("AuditEntry serializes")
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    fn sample(reason: Option<&str>) -> AuditEntry {
400        AuditEntry {
401            id: 42,
402            created_at: "2026-04-23T00:00:00.000Z".into(),
403            action: "label_applied".into(),
404            actor_did: "did:plc:moderator0000000000000000".into(),
405            target: Some("at://did:plc:target/col/rec".into()),
406            target_cid: Some("bafytest".into()),
407            outcome: "success".into(),
408            reason: reason.map(str::to_string),
409            // Mid-chain row: both hashes present as 64-char hex.
410            prev_hash: Some("a".repeat(64)),
411            row_hash: Some("b".repeat(64)),
412        }
413    }
414
415    #[test]
416    fn format_show_human_includes_all_present_fields() {
417        let s = format_show_human(&sample(Some(r#"{"val":"spam"}"#)));
418        assert!(s.contains("Audit entry 42"));
419        assert!(s.contains("created_at:  2026-04-23T00:00:00.000Z"));
420        assert!(s.contains("action:      label_applied"));
421        assert!(s.contains("actor_did:   did:plc:moderator0000000000000000"));
422        assert!(s.contains("outcome:     success"));
423        assert!(s.contains("target:      at://did:plc:target/col/rec"));
424        assert!(s.contains("target_cid:  bafytest"));
425        assert!(s.contains(r#"reason:      {"val":"spam"}"#));
426        assert!(s.contains(&format!("prev_hash:   {}", "a".repeat(64))));
427        assert!(s.contains(&format!("row_hash:    {}", "b".repeat(64))));
428    }
429
430    #[test]
431    fn format_show_human_omits_absent_optionals() {
432        let mut e = sample(None);
433        e.target = None;
434        e.target_cid = None;
435        let s = format_show_human(&e);
436        assert!(!s.contains("target:"));
437        assert!(!s.contains("target_cid:"));
438        assert!(!s.contains("reason:"));
439    }
440
441    #[test]
442    fn format_show_human_pre_attestation_row_renders_sentinel() {
443        // Pre-v1.3 / pre-rebuild row: NULL hashes. Display must show
444        // the sentinel so operators see the trust horizon explicitly
445        // — never silently omit (which would read as "field missing"
446        // rather than "row pre-dates attestation").
447        let mut e = sample(None);
448        e.prev_hash = None;
449        e.row_hash = None;
450        let s = format_show_human(&e);
451        assert!(
452            s.contains("prev_hash:   (pre-attestation)"),
453            "missing pre-attestation sentinel for prev_hash: {s}"
454        );
455        assert!(
456            s.contains("row_hash:    (pre-attestation)"),
457            "missing pre-attestation sentinel for row_hash: {s}"
458        );
459    }
460
461    #[test]
462    fn format_show_human_genesis_row_renders_zero_sentinel_verbatim() {
463        // Row id=1 (genesis) carries the all-zeros prev_hash sentinel
464        // (see #39). Display it as-is — keen-eyed operators recognize
465        // the sentinel; special-casing creates yet another display
466        // path to maintain.
467        let mut e = sample(None);
468        e.id = 1;
469        e.prev_hash = Some("0".repeat(64));
470        e.row_hash = Some("c".repeat(64));
471        let s = format_show_human(&e);
472        assert!(s.contains("Audit entry 1"));
473        assert!(s.contains(&format!("prev_hash:   {}", "0".repeat(64))));
474        assert!(s.contains(&format!("row_hash:    {}", "c".repeat(64))));
475        assert!(
476            !s.contains("genesis"),
477            "genesis row must not be special-cased in display: {s}"
478        );
479    }
480
481    #[test]
482    fn format_show_json_round_trips_with_hashes() {
483        let e = sample(Some(r#"{"val":"spam","neg":false}"#));
484        let json = format_show_json(&e);
485        let parsed: AuditEntry = serde_json::from_str(&json).expect("round trip");
486        assert_eq!(parsed.id, 42);
487        assert_eq!(parsed.action, "label_applied");
488        assert_eq!(
489            parsed.target.as_deref(),
490            Some("at://did:plc:target/col/rec")
491        );
492        assert_eq!(
493            parsed.reason.as_deref(),
494            Some(r#"{"val":"spam","neg":false}"#)
495        );
496        assert_eq!(parsed.prev_hash.as_deref(), Some("a".repeat(64).as_str()));
497        assert_eq!(parsed.row_hash.as_deref(), Some("b".repeat(64).as_str()));
498        // The on-wire JSON must use camelCase per the lexicon.
499        assert!(
500            json.contains("\"prevHash\""),
501            "wire JSON must use camelCase prevHash: {json}"
502        );
503        assert!(
504            json.contains("\"rowHash\""),
505            "wire JSON must use camelCase rowHash: {json}"
506        );
507    }
508
509    #[test]
510    fn format_show_json_pre_attestation_row_omits_hash_fields() {
511        let mut e = sample(None);
512        e.prev_hash = None;
513        e.row_hash = None;
514        let json = format_show_json(&e);
515        assert!(
516            !json.contains("prevHash"),
517            "pre-attestation: prevHash must be field-absent: {json}"
518        );
519        assert!(
520            !json.contains("rowHash"),
521            "pre-attestation: rowHash must be field-absent: {json}"
522        );
523    }
524}