1use 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
23pub 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#[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 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#[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
108pub 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
124pub 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 pub assignee_uids: Vec<String>,
141}
142
143pub 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
176pub 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
196pub async fn delete(t: &Transport, ids: &[String]) -> Result<()> {
198 t.delete(&delete_path(ids)?).await?;
199 Ok(())
200}
201
202pub 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
208pub 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}