Skip to main content

cairn_mod/cli/
pds_admin.rs

1//! `cairn pds-admin {takedown, suspend, restore}` (#99, Phase E) —
2//! manual escape hatch for the PDS-admin bridge (#87).
3//!
4//! Production path is policy-automation + label-emission firing
5//! the bridge automatically (the writer's post-commit dispatch
6//! per #87). This subcommand exists for **testing the bridge
7//! during Phase B verification** and for **operator one-off
8//! escalations** — and explicitly does NOT skip the strike
9//! accounting / audit chain.
10//!
11//! # Routing
12//!
13//! HTTP-routed via `tools.cairn.admin.{recordAction, revokeAction}`
14//! against the running `cairn serve`. Same pattern as `cairn
15//! moderator action` / `cairn moderator revoke`. The writer task's
16//! post-commit dispatch (introduced in #87) fires the
17//! `OzoneBackend` call automatically when the action_type is
18//! takedown / temp_suspension / indef_suspension.
19//!
20//! Why HTTP not direct-DB: the bridge dispatch lives inside the
21//! writer task. Direct-DB would either bypass the dispatch (wrong
22//! semantics) or force the CLI to spawn a writer (heavyweight, and
23//! conflicts with a running `cairn serve`). HTTP routes the action
24//! through the canonical pipeline.
25//!
26//! # `--config` purpose
27//!
28//! The CLI loads the operator config to:
29//!
30//! 1. Pre-flight check `[pds_admin].enabled = true`. Without this
31//!    check, a misconfigured operator would issue a recordAction,
32//!    have it succeed, and only learn the bridge was disabled when
33//!    looking at logs — the CLI catches the case upfront.
34//! 2. Read the DB path for the post-call `pds_admin_audit` lookup
35//!    (so the CLI can show the operator the bridge outcome).
36//!
37//! The `--config` operator must point at the **same** config the
38//! running `cairn serve` is using; otherwise the pre-flight check
39//! is meaningless. v1.7 doesn't enforce this — operator
40//! responsibility.
41//!
42//! # `restore` semantics
43//!
44//! cairn-mod has no first-class "restore" action_type. Restoration
45//! is a `revoke_action` of the most recent unrevoked
46//! takedown / temp_suspension / indef_suspension. The CLI:
47//!
48//! 1. Reads the most-recent unrevoked active suspension row from
49//!    `subject_actions` (direct DB).
50//! 2. Calls `tools.cairn.admin.revokeAction` with that row's id
51//!    via HTTP.
52//! 3. The writer's post-commit dispatch fires
53//!    `OzoneBackend::restore_account`.
54//!
55//! Operators wanting a specific action_id (rather than "most
56//! recent") should use `cairn moderator revoke <action_id>` —
57//! `cairn pds-admin restore <did>` is the convenience case.
58
59use std::path::Path;
60
61use serde::Serialize;
62use sqlx::{Pool, Sqlite};
63
64use crate::config::Config;
65
66use super::error::CliError;
67use super::moderator_action::{
68    RecordActionInput, RecordResponse, RevokeActionInput, RevokeResponse, record, revoke,
69};
70use super::session::SessionFile;
71
72/// Reserved reason code recorded on every `cairn pds-admin`-driven
73/// recordAction. Operators must declare this in
74/// `[moderation_reasons]` if they want manual bridge escalations
75/// to succeed; otherwise the writer surfaces `ReasonNotFound` and
76/// the CLI prints the underlying error.
77///
78/// Mirrors the `xrpc-gateway-default` and `policy-threshold`
79/// reserved-reason pattern: a single declared name the operator
80/// must opt into.
81pub const PDS_ADMIN_DEFAULT_REASON_CODE: &str = "pds-admin-cli";
82
83/// Outcome of `cairn pds-admin {takedown, suspend}`. Wraps the
84/// recordAction response plus the just-fired pds_admin_audit row
85/// (when the bridge dispatch produced one).
86#[derive(Debug, Clone, Serialize)]
87pub struct PdsAdminTakedownOutcome {
88    /// The recordAction response. `actionId` is the new
89    /// subject_actions row.
90    pub record: RecordResponse,
91    /// Bridge dispatch outcome — `Some` when a `pds_admin_audit`
92    /// row matching the precipitating action was found within the
93    /// CLI's polling window. `None` when no audit row appeared
94    /// (bridge disabled, dispatch hadn't fired yet by the time the
95    /// CLI looked, or the operator's config disabled the bridge).
96    pub bridge: Option<PdsAdminAuditView>,
97}
98
99/// Outcome of `cairn pds-admin restore`. Wraps the revokeAction
100/// response plus the pds_admin_audit row (when a bridge-driven
101/// `restore_account` call fired).
102#[derive(Debug, Clone, Serialize)]
103pub struct PdsAdminRestoreOutcome {
104    /// The revokeAction response.
105    pub revoke: RevokeResponse,
106    /// `Some` when a pds_admin_audit row for the
107    /// restore_account dispatch was found.
108    pub bridge: Option<PdsAdminAuditView>,
109}
110
111/// Wire-shaped projection of one `pds_admin_audit` row for CLI
112/// output. Hash-chain columns (`prev_hash`, `row_hash`) and
113/// internal book-keeping are omitted; this is operator-facing.
114#[derive(Debug, Clone, Serialize)]
115pub struct PdsAdminAuditView {
116    /// `pds_admin_audit.id`.
117    pub id: i64,
118    /// `pds_admin_audit.precipitating_action_id`.
119    pub precipitating_action_id: i64,
120    /// Backend method that was attempted (e.g. `takedown_account`).
121    pub backend_method: String,
122    /// Backend-assigned identifier, when present.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub backend_action_id: Option<String>,
125    /// `success` / `network` / `auth` / `rate_limited` / etc.
126    pub outcome: String,
127    /// Backend-supplied error code, when present.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub error_code: Option<String>,
130    /// Human-readable error message, when present.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub error_message: Option<String>,
133    /// Retry-After hint, when applicable.
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub retry_after_seconds: Option<i64>,
136}
137
138// ==========================================================================
139// Config preflight
140// ==========================================================================
141
142/// Verify `[pds_admin].enabled = true` in the loaded config. The
143/// CLI calls this before issuing the recordAction so a
144/// misconfigured operator gets a precise error pointing at the
145/// config block, rather than a successful recordAction with a
146/// silently-no-op bridge.
147pub fn verify_pds_admin_enabled(config: &Config) -> Result<(), CliError> {
148    let policy = crate::pds_admin::PdsAdminPolicy::from_config(config)
149        .map_err(|e| CliError::Config(format!("[pds_admin]: {e}")))?;
150    if !policy.enabled {
151        return Err(CliError::Config(
152            "[pds_admin] bridge is disabled in this config; \
153             set [pds_admin].enabled = true (and configure a backend) \
154             before using `cairn pds-admin`"
155                .into(),
156        ));
157    }
158    Ok(())
159}
160
161// ==========================================================================
162// Direct-DB helpers
163// ==========================================================================
164
165/// Fetch the most-recent unrevoked active-suspension
166/// `subject_actions` row for a subject. Used by `restore` to
167/// resolve the action_id to revoke.
168///
169/// Returns `Ok(None)` when no active suspension exists; the
170/// caller surfaces this as a USAGE-coded CliError rather than
171/// silently no-op'ing.
172pub async fn find_active_suspension_action_id(
173    pool: &Pool<Sqlite>,
174    subject_did: &str,
175) -> sqlx::Result<Option<i64>> {
176    let row = sqlx::query!(
177        r#"SELECT id as "id!: i64"
178           FROM subject_actions
179           WHERE subject_did = ?1
180             AND action_type IN ('takedown', 'temp_suspension', 'indef_suspension')
181             AND revoked_at IS NULL
182           ORDER BY id DESC
183           LIMIT 1"#,
184        subject_did,
185    )
186    .fetch_optional(pool)
187    .await?;
188    Ok(row.map(|r| r.id))
189}
190
191/// Look up the `pds_admin_audit` row matching the precipitating
192/// action_id. Returns `Ok(None)` when no audit row is found —
193/// either the bridge hasn't dispatched yet (the CLI is racing the
194/// writer's post-commit hook), or the bridge is disabled, or the
195/// dispatch produced an `Unsupported` outcome that wasn't
196/// audit-logged.
197pub async fn find_pds_admin_audit_for_action(
198    pool: &Pool<Sqlite>,
199    precipitating_action_id: i64,
200) -> sqlx::Result<Option<PdsAdminAuditView>> {
201    let row = sqlx::query!(
202        r#"SELECT
203             id                       AS "id!: i64",
204             precipitating_action_id  AS "precipitating_action_id!: i64",
205             backend_method           AS "backend_method!: String",
206             backend_action_id,
207             outcome                  AS "outcome!: String",
208             error_code,
209             error_message,
210             retry_after_seconds
211           FROM pds_admin_audit
212           WHERE precipitating_action_id = ?1
213           ORDER BY id DESC
214           LIMIT 1"#,
215        precipitating_action_id,
216    )
217    .fetch_optional(pool)
218    .await?;
219    Ok(row.map(|r| PdsAdminAuditView {
220        id: r.id,
221        precipitating_action_id: r.precipitating_action_id,
222        backend_method: r.backend_method,
223        backend_action_id: r.backend_action_id,
224        outcome: r.outcome,
225        error_code: r.error_code,
226        error_message: r.error_message,
227        retry_after_seconds: r.retry_after_seconds,
228    }))
229}
230
231// ==========================================================================
232// Orchestrators
233// ==========================================================================
234
235/// `cairn pds-admin takedown <did>` — record a Takedown action via
236/// HTTP, then look up the bridge dispatch outcome.
237#[allow(clippy::too_many_arguments)]
238pub async fn takedown(
239    pool: &Pool<Sqlite>,
240    session: &mut SessionFile,
241    session_path: &Path,
242    subject_did: &str,
243    reason: &str,
244    notes: Option<String>,
245    cairn_server_override: Option<String>,
246) -> Result<PdsAdminTakedownOutcome, CliError> {
247    let record_resp = record(
248        session,
249        session_path,
250        RecordActionInput {
251            subject: subject_did.to_string(),
252            action_type: "takedown".to_string(),
253            reasons: vec![reason.to_string()],
254            duration: None,
255            note: notes,
256            report_ids: Vec::new(),
257            cairn_server_override,
258        },
259    )
260    .await?;
261
262    // The writer's post-commit dispatch is async vs. the HTTP
263    // response: the recordAction returns once the action is
264    // persisted, but the bridge call may still be in flight. Look
265    // up the audit row; if absent, the CLI prints "bridge dispatch
266    // pending" so operators know to re-check.
267    let bridge = find_pds_admin_audit_for_action(pool, record_resp.action_id)
268        .await
269        .map_err(|e| CliError::Startup(format!("pds_admin_audit lookup: {e}")))?;
270
271    Ok(PdsAdminTakedownOutcome {
272        record: record_resp,
273        bridge,
274    })
275}
276
277/// `cairn pds-admin suspend <did>` — record a temp_suspension (when
278/// `--duration` is set) or indef_suspension (otherwise) via HTTP.
279/// Same bridge-dispatch lookup as takedown.
280#[allow(clippy::too_many_arguments)]
281pub async fn suspend(
282    pool: &Pool<Sqlite>,
283    session: &mut SessionFile,
284    session_path: &Path,
285    subject_did: &str,
286    reason: &str,
287    duration: Option<String>,
288    notes: Option<String>,
289    cairn_server_override: Option<String>,
290) -> Result<PdsAdminTakedownOutcome, CliError> {
291    let action_type = if duration.is_some() {
292        "temp_suspension"
293    } else {
294        "indef_suspension"
295    };
296    let record_resp = record(
297        session,
298        session_path,
299        RecordActionInput {
300            subject: subject_did.to_string(),
301            action_type: action_type.to_string(),
302            reasons: vec![reason.to_string()],
303            duration,
304            note: notes,
305            report_ids: Vec::new(),
306            cairn_server_override,
307        },
308    )
309    .await?;
310
311    let bridge = find_pds_admin_audit_for_action(pool, record_resp.action_id)
312        .await
313        .map_err(|e| CliError::Startup(format!("pds_admin_audit lookup: {e}")))?;
314
315    Ok(PdsAdminTakedownOutcome {
316        record: record_resp,
317        bridge,
318    })
319}
320
321/// `cairn pds-admin restore <did>` — find the most-recent
322/// unrevoked takedown/suspension for the subject, revoke it via
323/// HTTP, then look up the bridge dispatch.
324pub async fn restore(
325    pool: &Pool<Sqlite>,
326    session: &mut SessionFile,
327    session_path: &Path,
328    subject_did: &str,
329    reason: Option<String>,
330    cairn_server_override: Option<String>,
331) -> Result<PdsAdminRestoreOutcome, CliError> {
332    let action_id = find_active_suspension_action_id(pool, subject_did)
333        .await
334        .map_err(|e| CliError::Startup(format!("subject_actions lookup: {e}")))?
335        .ok_or_else(|| {
336            CliError::Config(format!(
337                "no active takedown / suspension to restore for subject {subject_did}"
338            ))
339        })?;
340
341    let revoke_resp = revoke(
342        session,
343        session_path,
344        RevokeActionInput {
345            action_id,
346            reason,
347            cairn_server_override,
348        },
349    )
350    .await?;
351
352    let bridge = find_pds_admin_audit_for_action(pool, revoke_resp.action_id)
353        .await
354        .map_err(|e| CliError::Startup(format!("pds_admin_audit lookup: {e}")))?;
355
356    Ok(PdsAdminRestoreOutcome {
357        revoke: revoke_resp,
358        bridge,
359    })
360}
361
362// ==========================================================================
363// Output formatters
364// ==========================================================================
365
366/// Human-readable two-line output for takedown / suspend.
367pub fn format_takedown_human(out: &PdsAdminTakedownOutcome) -> String {
368    let mut s = format!(
369        "Recorded action {} (subject taken down)",
370        out.record.action_id
371    );
372    if let Some(b) = &out.bridge {
373        s.push_str(&format!(
374            "\nbridge: {} via {} (id={})",
375            b.outcome,
376            b.backend_method,
377            b.backend_action_id.as_deref().unwrap_or("-")
378        ));
379        if let Some(err) = &b.error_message {
380            s.push_str(&format!("\nerror: {err}"));
381        }
382    } else {
383        s.push_str("\nbridge: dispatch pending — check `cairn moderator events` shortly");
384    }
385    s
386}
387
388/// Single-line JSON for takedown / suspend (tooling).
389pub fn format_takedown_json(out: &PdsAdminTakedownOutcome) -> String {
390    serde_json::to_string(out).expect("PdsAdminTakedownOutcome serializes")
391}
392
393/// Human-readable two-line output for restore.
394pub fn format_restore_human(out: &PdsAdminRestoreOutcome) -> String {
395    let mut s = format!(
396        "Revoked action {} at {}",
397        out.revoke.action_id, out.revoke.revoked_at
398    );
399    if let Some(b) = &out.bridge {
400        s.push_str(&format!(
401            "\nbridge: {} via {} (id={})",
402            b.outcome,
403            b.backend_method,
404            b.backend_action_id.as_deref().unwrap_or("-")
405        ));
406        if let Some(err) = &b.error_message {
407            s.push_str(&format!("\nerror: {err}"));
408        }
409    } else {
410        s.push_str("\nbridge: dispatch pending — check `cairn moderator events` shortly");
411    }
412    s
413}
414
415/// Single-line JSON for restore (tooling).
416pub fn format_restore_json(out: &PdsAdminRestoreOutcome) -> String {
417    serde_json::to_string(out).expect("PdsAdminRestoreOutcome serializes")
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn pds_admin_default_reason_code_is_hyphenated() {
426        // §F22.1 reason-code naming convention. Pinned because the
427        // operator's [moderation_reasons] config uses the same
428        // string and a drift would silently break manual bridge
429        // calls.
430        assert_eq!(PDS_ADMIN_DEFAULT_REASON_CODE, "pds-admin-cli");
431    }
432
433    #[test]
434    fn format_takedown_human_no_bridge_marks_pending() {
435        let out = PdsAdminTakedownOutcome {
436            record: RecordResponse {
437                action_id: 7,
438                strike_value_base: 0,
439                strike_value_applied: 0,
440                was_dampened: false,
441                strikes_at_time_of_action: 0,
442            },
443            bridge: None,
444        };
445        let s = format_takedown_human(&out);
446        assert!(s.contains("Recorded action 7"));
447        assert!(s.contains("dispatch pending"));
448    }
449
450    #[test]
451    fn format_takedown_human_with_bridge_includes_outcome() {
452        let out = PdsAdminTakedownOutcome {
453            record: RecordResponse {
454                action_id: 9,
455                strike_value_base: 0,
456                strike_value_applied: 0,
457                was_dampened: false,
458                strikes_at_time_of_action: 0,
459            },
460            bridge: Some(PdsAdminAuditView {
461                id: 11,
462                precipitating_action_id: 9,
463                backend_method: "takedown_account".into(),
464                backend_action_id: Some("backend-id-42".into()),
465                outcome: "success".into(),
466                error_code: None,
467                error_message: None,
468                retry_after_seconds: None,
469            }),
470        };
471        let s = format_takedown_human(&out);
472        assert!(s.contains("Recorded action 9"));
473        assert!(s.contains("bridge: success via takedown_account"));
474        assert!(s.contains("backend-id-42"));
475    }
476
477    #[test]
478    fn format_takedown_human_with_failed_bridge_includes_error() {
479        let out = PdsAdminTakedownOutcome {
480            record: RecordResponse {
481                action_id: 1,
482                strike_value_base: 0,
483                strike_value_applied: 0,
484                was_dampened: false,
485                strikes_at_time_of_action: 0,
486            },
487            bridge: Some(PdsAdminAuditView {
488                id: 2,
489                precipitating_action_id: 1,
490                backend_method: "takedown_account".into(),
491                backend_action_id: None,
492                outcome: "auth".into(),
493                error_code: Some("AuthRequired".into()),
494                error_message: Some("invalid app password".into()),
495                retry_after_seconds: None,
496            }),
497        };
498        let s = format_takedown_human(&out);
499        assert!(s.contains("bridge: auth"));
500        assert!(s.contains("error: invalid app password"));
501    }
502
503    #[test]
504    fn format_restore_human_with_bridge_includes_outcome() {
505        let out = PdsAdminRestoreOutcome {
506            revoke: RevokeResponse {
507                action_id: 3,
508                revoked_at: "2026-04-29T00:00:00.000Z".into(),
509            },
510            bridge: Some(PdsAdminAuditView {
511                id: 4,
512                precipitating_action_id: 3,
513                backend_method: "restore_account".into(),
514                backend_action_id: None,
515                outcome: "success".into(),
516                error_code: None,
517                error_message: None,
518                retry_after_seconds: None,
519            }),
520        };
521        let s = format_restore_human(&out);
522        assert!(s.contains("Revoked action 3"));
523        assert!(s.contains("bridge: success via restore_account"));
524    }
525
526    #[test]
527    fn format_takedown_json_round_trips() {
528        let out = PdsAdminTakedownOutcome {
529            record: RecordResponse {
530                action_id: 1,
531                strike_value_base: 0,
532                strike_value_applied: 0,
533                was_dampened: false,
534                strikes_at_time_of_action: 0,
535            },
536            bridge: None,
537        };
538        let s = format_takedown_json(&out);
539        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
540        assert_eq!(v["record"]["actionId"].as_i64(), Some(1));
541        assert!(v["bridge"].is_null());
542    }
543}