Skip to main content

elasticctl_api/
cases.rs

1//! Cases: typed wrappers over the `/api/cases` family.
2//!
3//! Case identity on mutation is `id` plus the fetched `version` — the API is
4//! optimistic-concurrency and a stale version answers 409 (triage spec
5//! sections 3 and 10). Cases are collaboration records: there is no mirror,
6//! no reconciliation, and deletion is a real verb (unlike alerts).
7
8use elasticctl_core::{Error, ErrorKind, Result, Transport, urlencode};
9use serde_json::{Map, Value, json};
10
11pub const FIND_PATH: &str = "/api/cases/_find";
12pub const CASES_PATH: &str = "/api/cases";
13pub const OWNER: &str = "securitySolution";
14
15pub fn case_path(id: &str) -> String {
16    format!("/api/cases/{}", urlencode(id))
17}
18
19pub fn comments_path(id: &str) -> String {
20    format!("/api/cases/{}/comments", urlencode(id))
21}
22
23/// `DELETE /api/cases?ids=["a","b"]` — the ids parameter is a JSON array in
24/// the query string.
25pub fn delete_path(ids: &[String]) -> Result<String> {
26    let encoded = serde_json::to_string(ids)
27        .map_err(|e| Error::new(ErrorKind::Error, format!("encoding case ids: {e}")))?;
28    Ok(format!("{CASES_PATH}?ids={}", urlencode(&encoded)))
29}
30
31/// The case status vocabulary. Cases legitimately use `in-progress` (it is a
32/// filter value); the transition verbs target only `open` and `closed`.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum CaseStatus {
35    Open,
36    InProgress,
37    Closed,
38}
39
40impl CaseStatus {
41    pub fn as_str(self) -> &'static str {
42        match self {
43            CaseStatus::Open => "open",
44            CaseStatus::InProgress => "in-progress",
45            CaseStatus::Closed => "closed",
46        }
47    }
48
49    /// The verb a preview banner uses. `InProgress` is a filter value, not a
50    /// transition target, so it has no verb.
51    pub fn verb(self) -> &'static str {
52        match self {
53            CaseStatus::Open => "Open",
54            CaseStatus::InProgress => "Mark in progress",
55            CaseStatus::Closed => "Close",
56        }
57    }
58
59    pub fn parse(s: &str) -> Result<CaseStatus> {
60        match s {
61            "open" => Ok(CaseStatus::Open),
62            "in-progress" => Ok(CaseStatus::InProgress),
63            "closed" => Ok(CaseStatus::Closed),
64            other => Err(Error::new(
65                ErrorKind::Error,
66                format!("unknown case status '{other}': expected open, in-progress, or closed"),
67            )),
68        }
69    }
70}
71
72/// A case as the API returns it. The four identity/workflow fields are
73/// required (fail-closed); everything else is optional or flattened into
74/// `extra` so the full server object survives a round trip to render.
75#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
76pub struct Case {
77    pub id: String,
78    pub version: String,
79    pub title: String,
80    pub status: String,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub severity: Option<String>,
83    #[serde(default)]
84    pub tags: Vec<String>,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub description: Option<String>,
87    #[serde(default)]
88    pub assignees: Vec<Value>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub created_at: Option<String>,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub updated_at: Option<String>,
93    #[serde(
94        default,
95        rename = "totalComment",
96        skip_serializing_if = "Option::is_none"
97    )]
98    pub total_comment: Option<u64>,
99    #[serde(flatten)]
100    pub extra: Map<String, Value>,
101}
102
103pub fn decode_case(value: &Value) -> Result<Case> {
104    serde_json::from_value(value.clone())
105        .map_err(|e| Error::new(ErrorKind::Http, format!("decoding case: {e}")))
106}
107
108/// Decode `GET /api/cases/_find`: `{cases, page, per_page, total, ...}`.
109pub fn decode_find(value: &Value) -> Result<(Vec<Case>, u64)> {
110    let cases = value
111        .get("cases")
112        .and_then(Value::as_array)
113        .ok_or_else(|| Error::new(ErrorKind::Http, "decoding cases find field `cases`"))?
114        .iter()
115        .map(decode_case)
116        .collect::<Result<Vec<_>>>()?;
117    let total = value
118        .get("total")
119        .and_then(Value::as_u64)
120        .ok_or_else(|| Error::new(ErrorKind::Http, "decoding cases find field `total`"))?;
121    Ok((cases, total))
122}
123
124/// One find page; the caller builds the query string (`cases_ops::find_query`).
125pub async fn find_page(t: &Transport, query_string: &str) -> Result<(Vec<Case>, u64)> {
126    decode_find(&t.get(&format!("{FIND_PATH}?{query_string}")).await?)
127}
128
129pub async fn get(t: &Transport, id: &str) -> Result<Case> {
130    decode_case(&t.get(&case_path(id)).await?)
131}
132
133#[derive(Debug, Clone, PartialEq)]
134pub struct NewCase {
135    pub title: String,
136    pub description: Option<String>,
137    pub tags: Vec<String>,
138    pub severity: Option<String>,
139    /// Resolved profile uids (the caller resolves usernames first).
140    pub assignee_uids: Vec<String>,
141}
142
143/// Create a case. The API requires a non-empty description; when the
144/// operator gave none, or gave an empty or whitespace-only one, the title
145/// stands in — `Some("")` must fall back exactly like `None`, or it defeats
146/// the fallback and earns a server 400 on minimum length. `connector` and
147/// `settings` are required by the route; elasticctl pins the no-op connector
148/// and leaves alert-status syncing off — alert transitions stay explicit CLI
149/// actions.
150pub async fn create(t: &Transport, new: &NewCase) -> Result<Case> {
151    let description = new
152        .description
153        .as_deref()
154        .filter(|d| !d.trim().is_empty())
155        .unwrap_or(&new.title);
156    let assignees: Vec<Value> = new
157        .assignee_uids
158        .iter()
159        .map(|u| json!({"uid": u}))
160        .collect();
161    let mut body = json!({
162        "title": new.title,
163        "description": description,
164        "tags": new.tags,
165        "assignees": assignees,
166        "connector": {"id": "none", "name": "none", "type": ".none", "fields": null},
167        "settings": {"syncAlerts": false},
168        "owner": OWNER,
169    });
170    if let Some(severity) = &new.severity {
171        body["severity"] = json!(severity);
172    }
173    decode_case(&t.post(CASES_PATH, Some(&body)).await?)
174}
175
176/// Bulk status update: `PATCH /api/cases` with `{cases: [{id, version,
177/// status}]}`. The response is the array of updated cases.
178pub async fn patch_status(
179    t: &Transport,
180    updates: &[(String, String, CaseStatus)],
181) -> Result<Vec<Case>> {
182    let cases: Vec<Value> = updates
183        .iter()
184        .map(|(id, version, status)| json!({"id": id, "version": version, "status": status.as_str()}))
185        .collect();
186    let body = json!({ "cases": cases });
187    let response = t.patch(CASES_PATH, &body).await?;
188    response
189        .as_array()
190        .ok_or_else(|| Error::new(ErrorKind::Http, "decoding cases update: expected an array"))?
191        .iter()
192        .map(decode_case)
193        .collect()
194}
195
196/// Delete cases permanently. 204 with an empty body on success.
197pub async fn delete(t: &Transport, ids: &[String]) -> Result<()> {
198    t.delete(&delete_path(ids)?).await?;
199    Ok(())
200}
201
202/// Add a user comment; the response is the updated case.
203pub async fn add_comment(t: &Transport, case_id: &str, comment: &str) -> Result<Case> {
204    let body = json!({"type": "user", "comment": comment, "owner": OWNER});
205    decode_case(&t.post(&comments_path(case_id), Some(&body)).await?)
206}
207
208/// Attach alerts as one comment of type `alert`. All alerts in one call share
209/// a rule (the API takes one `rule` object per comment); the caller groups by
210/// rule. `alert_ids` and `indices` are parallel arrays.
211pub async fn attach_alerts(
212    t: &Transport,
213    case_id: &str,
214    alert_ids: &[String],
215    indices: &[String],
216    rule_id: &str,
217    rule_name: &str,
218) -> Result<Case> {
219    let body = json!({
220        "type": "alert",
221        "alertId": alert_ids,
222        "index": indices,
223        "rule": {"id": rule_id, "name": rule_name},
224        "owner": OWNER,
225    });
226    decode_case(&t.post(&comments_path(case_id), Some(&body)).await?)
227}