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}