Skip to main content

elasticctl_api/
cases.rs

1use elasticctl_core::{Error, ErrorKind, Result, Transport, urlencode};
2use serde_json::{Map, Value, json};
3
4pub const FIND_PATH: &str = "/api/cases/_find";
5pub const CASES_PATH: &str = "/api/cases";
6pub const OWNER: &str = "securitySolution";
7
8pub fn case_path(id: &str) -> String {
9    format!("/api/cases/{}", urlencode(id))
10}
11
12pub fn comments_path(id: &str) -> String {
13    format!("/api/cases/{}/comments", urlencode(id))
14}
15
16pub fn delete_path(ids: &[String]) -> Result<String> {
17    let encoded = serde_json::to_string(ids)
18        .map_err(|e| Error::new(ErrorKind::Error, format!("encoding case ids: {e}")))?;
19    Ok(format!("{CASES_PATH}?ids={}", urlencode(&encoded)))
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum CaseStatus {
24    Open,
25    InProgress,
26    Closed,
27}
28
29impl CaseStatus {
30    pub fn as_str(self) -> &'static str {
31        match self {
32            CaseStatus::Open => "open",
33            CaseStatus::InProgress => "in-progress",
34            CaseStatus::Closed => "closed",
35        }
36    }
37
38    pub fn verb(self) -> &'static str {
39        match self {
40            CaseStatus::Open => "Open",
41            CaseStatus::InProgress => "Mark in progress",
42            CaseStatus::Closed => "Close",
43        }
44    }
45
46    pub fn parse(s: &str) -> Result<CaseStatus> {
47        match s {
48            "open" => Ok(CaseStatus::Open),
49            "in-progress" => Ok(CaseStatus::InProgress),
50            "closed" => Ok(CaseStatus::Closed),
51            other => Err(Error::new(
52                ErrorKind::Error,
53                format!("unknown case status '{other}': expected open, in-progress, or closed"),
54            )),
55        }
56    }
57}
58
59#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
60pub struct Case {
61    pub id: String,
62    pub version: String,
63    pub title: String,
64    pub status: String,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub severity: Option<String>,
67    #[serde(default)]
68    pub tags: Vec<String>,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub description: Option<String>,
71    #[serde(default)]
72    pub assignees: Vec<Value>,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub created_at: Option<String>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub updated_at: Option<String>,
77    #[serde(
78        default,
79        rename = "totalComment",
80        skip_serializing_if = "Option::is_none"
81    )]
82    pub total_comment: Option<u64>,
83    #[serde(flatten)]
84    pub extra: Map<String, Value>,
85}
86
87pub fn decode_case(value: &Value) -> Result<Case> {
88    serde_json::from_value(value.clone())
89        .map_err(|e| Error::new(ErrorKind::Http, format!("decoding case: {e}")))
90}
91
92pub fn decode_find(value: &Value) -> Result<(Vec<Case>, u64)> {
93    let cases = value
94        .get("cases")
95        .and_then(Value::as_array)
96        .ok_or_else(|| Error::new(ErrorKind::Http, "decoding cases find field `cases`"))?
97        .iter()
98        .map(decode_case)
99        .collect::<Result<Vec<_>>>()?;
100    let total = value
101        .get("total")
102        .and_then(Value::as_u64)
103        .ok_or_else(|| Error::new(ErrorKind::Http, "decoding cases find field `total`"))?;
104    Ok((cases, total))
105}
106
107pub async fn find_page(t: &Transport, query_string: &str) -> Result<(Vec<Case>, u64)> {
108    decode_find(&t.get(&format!("{FIND_PATH}?{query_string}")).await?)
109}
110
111pub async fn get(t: &Transport, id: &str) -> Result<Case> {
112    decode_case(&t.get(&case_path(id)).await?)
113}
114
115#[derive(Debug, Clone, PartialEq)]
116pub struct NewCase {
117    pub title: String,
118    pub description: Option<String>,
119    pub tags: Vec<String>,
120    pub severity: Option<String>,
121    pub assignee_uids: Vec<String>,
122}
123
124pub async fn create(t: &Transport, new: &NewCase) -> Result<Case> {
125    let description = new
126        .description
127        .as_deref()
128        .filter(|d| !d.trim().is_empty())
129        .unwrap_or(&new.title);
130    let assignees: Vec<Value> = new
131        .assignee_uids
132        .iter()
133        .map(|u| json!({"uid": u}))
134        .collect();
135    let mut body = json!({
136        "title": new.title,
137        "description": description,
138        "tags": new.tags,
139        "assignees": assignees,
140        "connector": {"id": "none", "name": "none", "type": ".none", "fields": null},
141        "settings": {"syncAlerts": false},
142        "owner": OWNER,
143    });
144    if let Some(severity) = &new.severity {
145        body["severity"] = json!(severity);
146    }
147    decode_case(&t.post(CASES_PATH, Some(&body)).await?)
148}
149
150pub async fn patch_status(
151    t: &Transport,
152    updates: &[(String, String, CaseStatus)],
153) -> Result<Vec<Case>> {
154    let cases: Vec<Value> = updates
155        .iter()
156        .map(|(id, version, status)| json!({"id": id, "version": version, "status": status.as_str()}))
157        .collect();
158    let body = json!({ "cases": cases });
159    let response = t.patch(CASES_PATH, &body).await?;
160    response
161        .as_array()
162        .ok_or_else(|| Error::new(ErrorKind::Http, "decoding cases update: expected an array"))?
163        .iter()
164        .map(decode_case)
165        .collect()
166}
167
168pub async fn delete(t: &Transport, ids: &[String]) -> Result<()> {
169    t.delete(&delete_path(ids)?).await?;
170    Ok(())
171}
172
173pub async fn add_comment(t: &Transport, case_id: &str, comment: &str) -> Result<Case> {
174    crate::compose::require_path_segment(case_id)?;
175    let body = json!({"type": "user", "comment": comment, "owner": OWNER});
176    decode_case(&t.post(&comments_path(case_id), Some(&body)).await?)
177}
178
179pub async fn attach_alerts(
180    t: &Transport,
181    case_id: &str,
182    alert_ids: &[String],
183    indices: &[String],
184    rule_id: &str,
185    rule_name: &str,
186) -> Result<Case> {
187    crate::compose::require_path_segment(case_id)?;
188    let body = json!({
189        "type": "alert",
190        "alertId": alert_ids,
191        "index": indices,
192        "rule": {"id": rule_id, "name": rule_name},
193        "owner": OWNER,
194    });
195    decode_case(&t.post(&comments_path(case_id), Some(&body)).await?)
196}
197
198#[derive(Debug)]
199pub struct CaseRef {
200    pub id: String,
201    pub title: Option<String>,
202}
203pub async fn by_alert(t: &Transport, alert_id: &str) -> Result<Vec<CaseRef>> {
204    if !crate::compose::is_path_segment(alert_id) {
205        return Err(Error::new(ErrorKind::Error, "invalid alert id"));
206    }
207    let body = t
208        .get(&format!("/api/cases/alerts/{}", urlencode(alert_id)))
209        .await?;
210    let invalid = || Error::new(ErrorKind::Http, "invalid cases by alert response");
211    body.as_array()
212        .ok_or_else(invalid)?
213        .iter()
214        .map(|row| {
215            let id = row
216                .get("id")
217                .and_then(Value::as_str)
218                .filter(|id| crate::compose::is_path_segment(id))
219                .ok_or_else(invalid)?
220                .to_owned();
221            let title = row
222                .get("title")
223                .map(|v| v.as_str().map(str::to_owned).ok_or_else(invalid))
224                .transpose()?;
225            Ok(CaseRef { id, title })
226        })
227        .collect()
228}