Skip to main content

security_client_rs/
cases.rs

1//! Cases and their comments. Updates use optimistic concurrency through case versions.
2//!
3//! Create a [`NewCase`], then build [`CasePatch`] values with the IDs and versions
4//! from the latest read. The owner identifies the Kibana solution handling the
5//! case; comments must use the same owner. [`NewCase::security`] and
6//! [`SECURITY_OWNER`] select the Security solution.
7use serde::{Deserialize, Serialize};
8use serde_json::{Map, Value, json};
9
10use crate::{
11    Error, Kibana, Result, Scope, SortOrder,
12    http::{Empty, Method},
13    pagination::paginated,
14    request::endpoint,
15    security::Severity,
16};
17
18/// The case owner used by the Security solution.
19pub const SECURITY_OWNER: &str = "securitySolution";
20
21/// A case returned by Kibana. Use its ID and current version to build a [`CasePatch`].
22#[derive(Clone, Debug, Serialize, Deserialize)]
23#[non_exhaustive]
24pub struct Case {
25    pub id: String,
26    /// Opaque concurrency token required by [`CasePatch`].
27    pub version: String,
28    pub title: String,
29    pub description: String,
30    pub owner: String,
31    pub status: String,
32    pub severity: String,
33    pub tags: Vec<String>,
34    pub created_at: String,
35    #[serde(default)]
36    pub updated_at: Option<String>,
37    #[serde(rename = "totalComment", default)]
38    pub total_comments: u64,
39    #[serde(flatten)]
40    pub extra: Map<String, Value>,
41}
42
43#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
44#[serde(rename_all = "kebab-case")]
45pub enum CaseStatus {
46    Open,
47    InProgress,
48    Closed,
49}
50
51impl CaseStatus {
52    fn as_str(self) -> &'static str {
53        match self {
54            Self::Open => "open",
55            Self::InProgress => "in-progress",
56            Self::Closed => "closed",
57        }
58    }
59}
60
61/// A case definition for [`Cases::create`]. Cases have no external connector
62/// and do not sync alert status unless configured.
63#[derive(Clone, Debug, Serialize)]
64pub struct NewCase {
65    title: String,
66    description: String,
67    owner: String,
68    severity: Severity,
69    tags: Vec<String>,
70    connector: Value,
71    settings: CaseSettings,
72}
73
74#[derive(Clone, Debug, Serialize)]
75struct CaseSettings {
76    #[serde(rename = "syncAlerts")]
77    sync_alerts: bool,
78}
79
80impl NewCase {
81    pub fn new(
82        title: impl Into<String>,
83        description: impl Into<String>,
84        owner: impl Into<String>,
85    ) -> Self {
86        Self {
87            title: title.into(),
88            description: description.into(),
89            owner: owner.into(),
90            severity: Severity::Low,
91            tags: Vec::new(),
92            connector: json!({"id": "none", "name": "none", "type": ".none", "fields": null}),
93            settings: CaseSettings { sync_alerts: false },
94        }
95    }
96
97    /// A low-severity case owned by the Security solution.
98    pub fn security(title: impl Into<String>, description: impl Into<String>) -> Self {
99        Self::new(title, description, SECURITY_OWNER)
100    }
101
102    pub fn severity(mut self, severity: Severity) -> Self {
103        self.severity = severity;
104        self
105    }
106
107    pub fn tags<I: IntoIterator<Item = S>, S: Into<String>>(mut self, tags: I) -> Self {
108        self.tags = tags.into_iter().map(Into::into).collect();
109        self
110    }
111
112    /// Keeps attached alert statuses in step with the case status.
113    pub fn sync_alerts(mut self, enabled: bool) -> Self {
114        self.settings.sync_alerts = enabled;
115        self
116    }
117
118    /// An external incident connector as Kibana's `{id, name, type, fields}` object.
119    pub fn connector(mut self, connector: Value) -> Self {
120        self.connector = connector;
121        self
122    }
123}
124
125/// Changes to one case. Only the fields set here are sent.
126/// Unset fields retain their server values; an empty tag list removes all tags.
127#[derive(Clone, Debug, Serialize)]
128pub struct CasePatch {
129    id: String,
130    version: String,
131    #[serde(flatten)]
132    changes: Map<String, Value>,
133}
134
135impl CasePatch {
136    /// `version` must come from the most recent read; stale versions fail with HTTP 409.
137    pub fn new(id: impl Into<String>, version: impl Into<String>) -> Self {
138        Self {
139            id: id.into(),
140            version: version.into(),
141            changes: Map::new(),
142        }
143    }
144
145    pub fn status(self, status: CaseStatus) -> Self {
146        self.set("status", status.as_str().into())
147    }
148
149    pub fn title(self, title: impl Into<String>) -> Self {
150        self.set("title", Value::String(title.into()))
151    }
152
153    pub fn description(self, description: impl Into<String>) -> Self {
154        self.set("description", Value::String(description.into()))
155    }
156
157    pub fn severity(self, severity: Severity) -> Self {
158        self.set("severity", severity.as_str().into())
159    }
160
161    pub fn tags<I: IntoIterator<Item = S>, S: Into<String>>(self, tags: I) -> Self {
162        self.set(
163            "tags",
164            tags.into_iter().map(|t| Value::String(t.into())).collect(),
165        )
166    }
167
168    /// Sets an additional patchable field. The case ID and concurrency version
169    /// are reserved; supply them through [`Self::new`].
170    ///
171    /// ```
172    /// use security_client_rs::{Error, cases::CasePatch};
173    /// use serde_json::{json, to_value};
174    ///
175    /// let patch = CasePatch::new("case-a", "version-1");
176    /// assert!(matches!(patch.clone().field("id", "case-b"), Err(Error::InvalidRequest(_))));
177    /// assert!(matches!(patch.clone().field("version", "version-2"), Err(Error::InvalidRequest(_))));
178    /// let body = to_value(patch.field("assignees", json!([]))?)?;
179    /// assert_eq!(body["id"], "case-a");
180    /// assert_eq!(body["version"], "version-1");
181    /// # Ok::<(), Box<dyn std::error::Error>>(())
182    /// ```
183    pub fn field(self, name: &str, value: impl Serialize) -> Result<Self> {
184        if matches!(name, "id" | "version") {
185            return Err(Error::InvalidRequest(format!(
186                "case patch field {name:?} is reserved"
187            )));
188        }
189        Ok(self.set(name, serde_json::to_value(value).map_err(Error::serialize)?))
190    }
191
192    fn set(mut self, key: &str, value: Value) -> Self {
193        self.changes.insert(key.into(), value);
194        self
195    }
196}
197
198/// A comment body for [`Cases::add_comment`].
199#[derive(Clone, Debug, Serialize)]
200pub struct CaseComment {
201    #[serde(rename = "type")]
202    kind: &'static str,
203    owner: String,
204    comment: String,
205}
206
207impl CaseComment {
208    /// A free-text comment. `owner` must match the case owner.
209    pub fn user(owner: impl Into<String>, comment: impl Into<String>) -> Self {
210        Self {
211            kind: "user",
212            owner: owner.into(),
213            comment: comment.into(),
214        }
215    }
216}
217
218#[derive(Clone, Debug, Serialize, Deserialize)]
219#[non_exhaustive]
220pub struct CasePage {
221    pub cases: Vec<Case>,
222    pub page: u32,
223    pub per_page: u32,
224    pub total: u64,
225    /// Status counts such as `count_open_cases`.
226    #[serde(flatten)]
227    pub extra: Map<String, Value>,
228}
229
230#[derive(Clone, Debug, Serialize, Deserialize)]
231#[non_exhaustive]
232pub struct CommentPage {
233    /// User comments, alert attachments and other attachment types.
234    pub comments: Vec<Value>,
235    pub page: u32,
236    pub per_page: u32,
237    pub total: u64,
238}
239
240#[derive(Clone, Copy, Debug)]
241pub struct Cases<'a>(pub(crate) &'a Kibana);
242
243impl<'a> Cases<'a> {
244    /// One page of cases visible to the user, across owners unless filtered.
245    pub fn find(&self) -> FindCases<'a> {
246        FindCases(
247            self.0
248                .request(Method::GET, Scope::Space, &["api", "cases", "_find"]),
249        )
250    }
251
252    pub fn get(&self, id: &str) -> GetCase<'a> {
253        GetCase(
254            self.0
255                .request(Method::GET, Scope::Space, &["api", "cases", id]),
256        )
257    }
258
259    /// Creates a case from a [`NewCase`] or equivalent JSON.
260    pub fn create<B: Serialize + ?Sized>(&self, case: &B) -> CreateCase<'a> {
261        CreateCase(
262            self.0
263                .request(Method::POST, Scope::Space, &["api", "cases"])
264                .json(case),
265        )
266    }
267
268    /// Applies several versioned patches in one request.
269    pub fn update(&self, patches: impl IntoIterator<Item = CasePatch>) -> UpdateCases<'a> {
270        let cases: Vec<CasePatch> = patches.into_iter().collect();
271        UpdateCases(
272            self.0
273                .request(Method::PATCH, Scope::Space, &["api", "cases"])
274                .field("cases", cases),
275        )
276    }
277
278    /// Deletes cases and their comments.
279    pub fn delete<I: IntoIterator<Item = S>, S: AsRef<str>>(&self, ids: I) -> DeleteCases<'a> {
280        let ids: Vec<String> = ids.into_iter().map(|id| id.as_ref().to_owned()).collect();
281        DeleteCases(
282            self.0
283                .request(Method::DELETE, Scope::Space, &["api", "cases"])
284                .param("ids", Value::from(ids)),
285        )
286    }
287
288    /// Adds a [`CaseComment`] or other attachment and returns the updated case.
289    pub fn add_comment<B: Serialize + ?Sized>(&self, case_id: &str, comment: &B) -> AddComment<'a> {
290        AddComment(
291            self.0
292                .request(
293                    Method::POST,
294                    Scope::Space,
295                    &["api", "cases", case_id, "comments"],
296                )
297                .json(comment),
298        )
299    }
300
301    pub fn find_comments(&self, case_id: &str) -> FindComments<'a> {
302        FindComments(self.0.request(
303            Method::GET,
304            Scope::Space,
305            &["api", "cases", case_id, "comments", "_find"],
306        ))
307    }
308}
309
310endpoint! {
311    /// `GET /api/cases/_find`
312    FindCases => CasePage
313}
314
315impl FindCases<'_> {
316    /// One-based page number.
317    pub fn page(self, page: u32) -> Self {
318        Self(self.0.positive_param("page", page))
319    }
320
321    pub fn per_page(self, per_page: u32) -> Self {
322        Self(self.0.positive_param("perPage", per_page))
323    }
324
325    /// Restricts results to an owner such as [`SECURITY_OWNER`]. Repeat for several owners.
326    pub fn owner(self, owner: &str) -> Self {
327        Self(self.0.append_param("owner", owner))
328    }
329
330    pub fn search(self, text: &str) -> Self {
331        Self(self.0.param("search", text))
332    }
333
334    pub fn status(self, status: CaseStatus) -> Self {
335        Self(self.0.param("status", status.as_str()))
336    }
337
338    pub fn severity(self, severity: Severity) -> Self {
339        Self(self.0.param("severity", severity.as_str()))
340    }
341
342    /// Restricts results to cases with this tag. Repeat for several tags.
343    pub fn tag(self, tag: &str) -> Self {
344        Self(self.0.append_param("tags", tag))
345    }
346
347    /// A case field such as `createdAt`, `updatedAt`, `title` or `severity`.
348    pub fn sort_field(self, field: &str) -> Self {
349        Self(self.0.param("sortField", field))
350    }
351
352    pub fn sort_order(self, order: SortOrder) -> Self {
353        Self(self.0.param("sortOrder", order.as_str()))
354    }
355}
356
357endpoint! {
358    /// `GET /api/cases/{caseId}`
359    GetCase => Case
360}
361
362endpoint! {
363    /// `POST /api/cases`
364    CreateCase => Case
365}
366
367endpoint! {
368    /// `PATCH /api/cases`
369    UpdateCases => Vec<Case>
370}
371
372endpoint! {
373    /// `DELETE /api/cases`
374    DeleteCases => Empty
375}
376
377endpoint! {
378    /// `POST /api/cases/{caseId}/comments`
379    AddComment => Case
380}
381
382endpoint! {
383    /// `GET /api/cases/{caseId}/comments/_find`
384    FindComments => CommentPage
385}
386
387impl FindComments<'_> {
388    /// One-based page number.
389    pub fn page(self, page: u32) -> Self {
390        Self(self.0.positive_param("page", page))
391    }
392
393    pub fn per_page(self, per_page: u32) -> Self {
394        Self(self.0.positive_param("perPage", per_page))
395    }
396
397    pub fn sort_order(self, order: SortOrder) -> Self {
398        Self(self.0.param("sortOrder", order.as_str()))
399    }
400}
401
402paginated!(FindCases => CasePage, FindComments => CommentPage);