1use 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#[derive(Debug, Clone, Deserialize, Serialize)]
45pub struct AuditEntry {
46 pub id: i64,
48 #[serde(rename = "createdAt")]
50 pub created_at: String,
51 pub action: String,
54 #[serde(rename = "actorDid")]
57 pub actor_did: String,
58 #[serde(skip_serializing_if = "Option::is_none", default)]
61 pub target: Option<String>,
62 #[serde(rename = "targetCid", skip_serializing_if = "Option::is_none", default)]
65 pub target_cid: Option<String>,
66 pub outcome: String,
68 #[serde(skip_serializing_if = "Option::is_none", default)]
71 pub reason: Option<String>,
72}
73
74#[derive(Debug, Deserialize, Serialize)]
76pub struct AuditListResponse {
77 pub entries: Vec<AuditEntry>,
79 #[serde(skip_serializing_if = "Option::is_none", default)]
82 pub cursor: Option<String>,
83}
84
85#[derive(Debug, Clone, Default)]
87pub struct AuditListInput {
88 pub actor: Option<String>,
90 pub action: Option<String>,
93 pub outcome: Option<String>,
96 pub since: Option<String>,
98 pub until: Option<String>,
100 pub limit: Option<i64>,
102 pub cursor: Option<String>,
104 pub cairn_server_override: Option<String>,
106}
107
108pub 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
183pub 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
255pub fn format_list_json(resp: &AuditListResponse) -> String {
257 serde_json::to_string_pretty(resp).expect("AuditListResponse serializes")
258}
259
260#[derive(Debug, Clone)]
268pub struct AuditShowInput {
269 pub id: i64,
271 pub cairn_server_override: Option<String>,
273}
274
275pub 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
327pub 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
354pub 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}