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}
73
74/// `listAuditLog` response envelope.
75#[derive(Debug, Deserialize, Serialize)]
76pub struct AuditListResponse {
77    /// Matched entries, newest first.
78    pub entries: Vec<AuditEntry>,
79    /// Opaque next-page cursor. Present iff more results
80    /// available.
81    #[serde(skip_serializing_if = "Option::is_none", default)]
82    pub cursor: Option<String>,
83}
84
85/// Input to `cairn audit list`.
86#[derive(Debug, Clone, Default)]
87pub struct AuditListInput {
88    /// Filter by actor DID. Server matches exact equality.
89    pub actor: Option<String>,
90    /// Filter by action discriminator (e.g. `"label_applied"`).
91    /// Server validates against `AUDIT_ACTION_VALUES`.
92    pub action: Option<String>,
93    /// Filter by outcome (`"success"` or `"failure"`). Server
94    /// validates against `AUDIT_OUTCOME_VALUES`.
95    pub outcome: Option<String>,
96    /// RFC-3339 inclusive lower bound on `created_at`.
97    pub since: Option<String>,
98    /// RFC-3339 inclusive upper bound on `created_at`.
99    pub until: Option<String>,
100    /// Max rows to return. Server clamps to [1, 250]; default 50.
101    pub limit: Option<i64>,
102    /// Opaque pagination cursor from a prior response.
103    pub cursor: Option<String>,
104    /// Per-invocation override of the session's stored Cairn URL.
105    pub cairn_server_override: Option<String>,
106}
107
108/// Query the audit log via the admin HTTP endpoint.
109///
110/// Wraps the `tools.cairn.admin.listAuditLog` handler at
111/// [src/server/admin/list_audit_log.rs](..) — GET with
112/// query-string filters; server enforces ADMIN role
113/// (`verify_and_authorize_admin_only`), validates the
114/// `action` / `outcome` enums, parses `since` / `until` from
115/// RFC-3339, and returns a newest-first page with optional
116/// `cursor` for the next call.
117pub async fn list(
118    session: &mut SessionFile,
119    session_path: &Path,
120    input: AuditListInput,
121) -> Result<AuditListResponse, CliError> {
122    let cairn_server = input
123        .cairn_server_override
124        .as_deref()
125        .unwrap_or(&session.cairn_server_url)
126        .trim_end_matches('/')
127        .to_string();
128    let pds = PdsClient::new(&session.pds_url)?;
129    let token = acquire_service_auth(&pds, session, session_path, LIST_AUDIT_LOG_LXM).await?;
130
131    let url = format!("{cairn_server}/xrpc/{LIST_AUDIT_LOG_LXM}");
132    let limit_owned = input.limit.map(|n| n.to_string());
133    let mut query: Vec<(&str, &str)> = Vec::new();
134    if let Some(a) = &input.actor {
135        query.push(("actor", a.as_str()));
136    }
137    if let Some(a) = &input.action {
138        query.push(("action", a.as_str()));
139    }
140    if let Some(o) = &input.outcome {
141        query.push(("outcome", o.as_str()));
142    }
143    if let Some(s) = &input.since {
144        query.push(("since", s.as_str()));
145    }
146    if let Some(u) = &input.until {
147        query.push(("until", u.as_str()));
148    }
149    if let Some(n) = &limit_owned {
150        query.push(("limit", n.as_str()));
151    }
152    if let Some(c) = &input.cursor {
153        query.push(("cursor", c.as_str()));
154    }
155
156    let client = Client::builder()
157        .timeout(Duration::from_secs(30))
158        .build()
159        .expect("reqwest build");
160    let resp = client
161        .get(&url)
162        .bearer_auth(&token)
163        .query(&query)
164        .send()
165        .await
166        .map_err(|source| CliError::Http {
167            url: url.clone(),
168            source,
169        })?;
170    if !resp.status().is_success() {
171        let status = resp.status().as_u16();
172        let body = resp.text().await.unwrap_or_default();
173        return Err(CliError::CairnStatus { url, status, body });
174    }
175    let bytes = resp.bytes().await.map_err(|source| CliError::Http {
176        url: url.clone(),
177        source,
178    })?;
179    serde_json::from_slice::<AuditListResponse>(&bytes)
180        .map_err(|source| CliError::MalformedResponse { url, source })
181}
182
183/// Tabular human output for `cairn audit list`. Columns: id,
184/// created_at, action, actor_did (truncated), outcome. Trailing
185/// `next cursor: …` line when a next page exists. Reason
186/// payloads are not included in the table — they're often
187/// multi-line JSON; use `--json` for full inspection.
188pub fn format_list_human(resp: &AuditListResponse) -> String {
189    use std::fmt::Write;
190    if resp.entries.is_empty() {
191        let mut s = "(no audit entries)".to_string();
192        if let Some(c) = &resp.cursor {
193            let _ = write!(s, "\nnext cursor: {c}");
194        }
195        return s;
196    }
197    let id_w = resp
198        .entries
199        .iter()
200        .map(|e| e.id.to_string().len())
201        .max()
202        .unwrap_or(2)
203        .max(2);
204    let action_w = resp
205        .entries
206        .iter()
207        .map(|e| e.action.len().min(28))
208        .max()
209        .unwrap_or(6)
210        .max(6);
211    let actor_w = resp
212        .entries
213        .iter()
214        .map(|e| e.actor_did.len().min(40))
215        .max()
216        .unwrap_or(8)
217        .max(8);
218    let mut s = String::new();
219    let _ = writeln!(
220        s,
221        "{:>id_w$}  {:<24}  {:<action_w$}  {:<actor_w$}  {:<7}",
222        "ID",
223        "CREATED_AT",
224        "ACTION",
225        "ACTOR_DID",
226        "OUTCOME",
227        id_w = id_w,
228        action_w = action_w,
229        actor_w = actor_w,
230    );
231    for e in &resp.entries {
232        let action = truncate(&e.action, 28);
233        let actor = truncate(&e.actor_did, 40);
234        let _ = writeln!(
235            s,
236            "{:>id_w$}  {:<24}  {:<action_w$}  {:<actor_w$}  {:<7}",
237            e.id,
238            e.created_at,
239            action,
240            actor,
241            e.outcome,
242            id_w = id_w,
243            action_w = action_w,
244            actor_w = actor_w,
245        );
246    }
247    if let Some(c) = &resp.cursor {
248        let _ = write!(s, "next cursor: {c}");
249    } else if s.ends_with('\n') {
250        s.pop();
251    }
252    s
253}
254
255/// JSON envelope for `cairn audit list`.
256pub fn format_list_json(resp: &AuditListResponse) -> String {
257    serde_json::to_string_pretty(resp).expect("AuditListResponse serializes")
258}
259
260// ============================================================
261// `cairn audit show <id>` — wraps tools.cairn.admin.getAuditLog
262// (src/server/admin/get_audit_log.rs). Admin role required.
263// Returns the bare auditEntry shape (no envelope).
264// ============================================================
265
266/// Input to `cairn audit show`.
267#[derive(Debug, Clone)]
268pub struct AuditShowInput {
269    /// Audit row primary key to fetch.
270    pub id: i64,
271    /// Per-invocation override of the session's stored Cairn URL.
272    pub cairn_server_override: Option<String>,
273}
274
275/// Fetch a single audit log entry by id via the admin HTTP endpoint.
276///
277/// Wraps the `tools.cairn.admin.getAuditLog` handler at
278/// [src/server/admin/get_audit_log.rs](..) — GET with `id` query
279/// param; server enforces ADMIN role
280/// (`verify_and_authorize_admin_only`) and returns
281/// `AuditEntryNotFound` 404 on unknown id, surfaced here as
282/// `CliError::CairnStatus { status: 404, body: ... }` with the
283/// server's typed error name in the body.
284pub async fn show(
285    session: &mut SessionFile,
286    session_path: &Path,
287    input: AuditShowInput,
288) -> Result<AuditEntry, CliError> {
289    let cairn_server = input
290        .cairn_server_override
291        .as_deref()
292        .unwrap_or(&session.cairn_server_url)
293        .trim_end_matches('/')
294        .to_string();
295    let pds = PdsClient::new(&session.pds_url)?;
296    let token = acquire_service_auth(&pds, session, session_path, GET_AUDIT_LOG_LXM).await?;
297
298    let url = format!("{cairn_server}/xrpc/{GET_AUDIT_LOG_LXM}");
299    let id_str = input.id.to_string();
300    let client = Client::builder()
301        .timeout(Duration::from_secs(30))
302        .build()
303        .expect("reqwest build");
304    let resp = client
305        .get(&url)
306        .bearer_auth(&token)
307        .query(&[("id", id_str.as_str())])
308        .send()
309        .await
310        .map_err(|source| CliError::Http {
311            url: url.clone(),
312            source,
313        })?;
314    if !resp.status().is_success() {
315        let status = resp.status().as_u16();
316        let body = resp.text().await.unwrap_or_default();
317        return Err(CliError::CairnStatus { url, status, body });
318    }
319    let bytes = resp.bytes().await.map_err(|source| CliError::Http {
320        url: url.clone(),
321        source,
322    })?;
323    serde_json::from_slice::<AuditEntry>(&bytes)
324        .map_err(|source| CliError::MalformedResponse { url, source })
325}
326
327/// Multi-line field/value output for `cairn audit show`. Includes
328/// every field present on the entry; omits absent optionals (target,
329/// targetCid, reason). `reason` is rendered as-is — when it's
330/// structured JSON, use `--json` to pipe through `jq`.
331pub fn format_show_human(entry: &AuditEntry) -> String {
332    use std::fmt::Write;
333    let mut s = String::new();
334    let _ = writeln!(s, "Audit entry {}", entry.id);
335    let _ = writeln!(s, "  created_at:  {}", entry.created_at);
336    let _ = writeln!(s, "  action:      {}", entry.action);
337    let _ = writeln!(s, "  actor_did:   {}", entry.actor_did);
338    let _ = writeln!(s, "  outcome:     {}", entry.outcome);
339    if let Some(t) = &entry.target {
340        let _ = writeln!(s, "  target:      {t}");
341    }
342    if let Some(c) = &entry.target_cid {
343        let _ = writeln!(s, "  target_cid:  {c}");
344    }
345    if let Some(r) = &entry.reason {
346        let _ = writeln!(s, "  reason:      {r}");
347    }
348    if s.ends_with('\n') {
349        s.pop();
350    }
351    s
352}
353
354/// JSON output for `cairn audit show`. Pretty-printed for shell
355/// readability; `jq` consumers see the canonical wire shape.
356pub fn format_show_json(entry: &AuditEntry) -> String {
357    serde_json::to_string_pretty(entry).expect("AuditEntry serializes")
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    fn sample(reason: Option<&str>) -> AuditEntry {
365        AuditEntry {
366            id: 42,
367            created_at: "2026-04-23T00:00:00.000Z".into(),
368            action: "label_applied".into(),
369            actor_did: "did:plc:moderator0000000000000000".into(),
370            target: Some("at://did:plc:target/col/rec".into()),
371            target_cid: Some("bafytest".into()),
372            outcome: "success".into(),
373            reason: reason.map(str::to_string),
374        }
375    }
376
377    #[test]
378    fn format_show_human_includes_all_present_fields() {
379        let s = format_show_human(&sample(Some(r#"{"val":"spam"}"#)));
380        assert!(s.contains("Audit entry 42"));
381        assert!(s.contains("created_at:  2026-04-23T00:00:00.000Z"));
382        assert!(s.contains("action:      label_applied"));
383        assert!(s.contains("actor_did:   did:plc:moderator0000000000000000"));
384        assert!(s.contains("outcome:     success"));
385        assert!(s.contains("target:      at://did:plc:target/col/rec"));
386        assert!(s.contains("target_cid:  bafytest"));
387        assert!(s.contains(r#"reason:      {"val":"spam"}"#));
388    }
389
390    #[test]
391    fn format_show_human_omits_absent_optionals() {
392        let mut e = sample(None);
393        e.target = None;
394        e.target_cid = None;
395        let s = format_show_human(&e);
396        assert!(!s.contains("target:"));
397        assert!(!s.contains("target_cid:"));
398        assert!(!s.contains("reason:"));
399    }
400
401    #[test]
402    fn format_show_json_round_trips() {
403        let e = sample(Some(r#"{"val":"spam","neg":false}"#));
404        let json = format_show_json(&e);
405        let parsed: AuditEntry = serde_json::from_str(&json).expect("round trip");
406        assert_eq!(parsed.id, 42);
407        assert_eq!(parsed.action, "label_applied");
408        assert_eq!(
409            parsed.target.as_deref(),
410            Some("at://did:plc:target/col/rec")
411        );
412        assert_eq!(
413            parsed.reason.as_deref(),
414            Some(r#"{"val":"spam","neg":false}"#)
415        );
416    }
417}