Skip to main content

security_client_rs/
exceptions.rs

1//! Detection exception lists and items. `Single` lists are scoped to a Kibana
2//! space; `Agnostic` lists are shared across spaces. Referenced value-list
3//! contents are managed separately and are not included in exception exports.
4//!
5//! Create lists and items from [`NewList`] and [`NewItem`]. Change existing ones
6//! through [`ExceptionList::edit`] and [`ExceptionItem::edit`], which start from
7//! the retrieved state, so fields that are not changed are sent back as they were.
8//! Retrieved resources also carry their namespace: pass them to `get_*`,
9//! `delete_*` and `find_items` instead of repeating a selector and namespace.
10use serde::{Deserialize, Serialize};
11use serde_json::{Map, Value};
12
13use crate::{
14    Kibana, Request, Scope, SortOrder,
15    http::{Method, Raw},
16    pagination::paginated,
17    request::endpoint,
18};
19
20/// Whether an exception list or item belongs to one space or is shared across spaces.
21#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
22#[serde(rename_all = "snake_case")]
23pub enum NamespaceType {
24    #[default]
25    Single,
26    Agnostic,
27}
28
29impl NamespaceType {
30    fn as_str(self) -> &'static str {
31        match self {
32            Self::Single => "single",
33            Self::Agnostic => "agnostic",
34        }
35    }
36}
37
38#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
39#[serde(rename_all = "lowercase")]
40#[non_exhaustive]
41pub enum OsType {
42    Linux,
43    Macos,
44    Windows,
45}
46
47/// Identifies a list for rule associations and exports.
48#[derive(Clone, Debug, Deserialize, Serialize)]
49#[non_exhaustive]
50pub struct ListReference {
51    pub id: String,
52    pub list_id: String,
53    pub namespace_type: NamespaceType,
54    #[serde(rename = "type")]
55    pub list_type: String,
56}
57
58impl ListReference {
59    pub fn new(
60        id: impl Into<String>,
61        list_id: impl Into<String>,
62        namespace_type: NamespaceType,
63        list_type: impl Into<String>,
64    ) -> Self {
65        Self {
66            id: id.into(),
67            list_id: list_id.into(),
68            namespace_type,
69            list_type: list_type.into(),
70        }
71    }
72}
73
74/// A retrieved exception list. Use [`Self::edit`] to retain its writable fields on update.
75#[derive(Clone, Debug, Deserialize, Serialize)]
76#[non_exhaustive]
77pub struct ExceptionList {
78    /// Kibana saved-object ID, distinct from the portable [`list_id`](Self::list_id).
79    pub id: String,
80    /// Stable list identifier used in rule associations and item membership.
81    pub list_id: String,
82    pub name: String,
83    pub description: String,
84    pub namespace_type: NamespaceType,
85    #[serde(rename = "type")]
86    pub list_type: String,
87    /// Opaque concurrency token, distinct from the user-defined `version`.
88    #[serde(rename = "_version", default)]
89    pub revision: Option<String>,
90    #[serde(default)]
91    pub tags: Vec<String>,
92    #[serde(default)]
93    pub os_types: Vec<OsType>,
94    #[serde(default)]
95    pub meta: Option<Map<String, Value>>,
96    #[serde(flatten)]
97    pub extra: Map<String, Value>,
98}
99
100impl ExceptionList {
101    pub fn reference(&self) -> ListReference {
102        ListReference::new(
103            &self.id,
104            &self.list_id,
105            self.namespace_type,
106            &self.list_type,
107        )
108    }
109
110    /// A replacement for this list that keeps its current editable fields until
111    /// changed, bound to its ID, namespace and concurrency token.
112    /// Sending the edit fails locally if the retrieved token is missing or empty.
113    ///
114    /// ```no_run
115    /// use security_client_rs::exceptions::ListSelector;
116    ///
117    /// # async fn rename(client: &security_client_rs::Kibana) -> security_client_rs::Result<()> {
118    /// let exceptions = client.exceptions();
119    /// let list = exceptions.get_list(ListSelector::Id("list-id")).send().await?.json().await?;
120    /// let edit = list.edit().name("Renamed list");
121    /// exceptions.update_list(&edit).send().await?;
122    /// # Ok(())
123    /// # }
124    /// ```
125    pub fn edit(&self) -> ListEdit {
126        ListEdit {
127            id: self.id.clone(),
128            revision: self.revision.clone(),
129            namespace_type: self.namespace_type,
130            list_type: self.list_type.clone(),
131            name: self.name.clone(),
132            description: self.description.clone(),
133            tags: self.tags.clone(),
134            os_types: self.os_types.clone(),
135            meta: self.meta.clone(),
136            version: None,
137        }
138    }
139}
140
141/// A list definition for [`Exceptions::create_list`]. To change an existing
142/// list, use [`ExceptionList::edit`].
143#[derive(Clone, Debug, Serialize)]
144pub struct NewList {
145    name: String,
146    description: String,
147    #[serde(rename = "type")]
148    list_type: String,
149    namespace_type: NamespaceType,
150    #[serde(skip_serializing_if = "Option::is_none")]
151    list_id: Option<String>,
152    tags: Vec<String>,
153    #[serde(skip_serializing_if = "Vec::is_empty")]
154    os_types: Vec<OsType>,
155    #[serde(skip_serializing_if = "Option::is_none")]
156    version: Option<u64>,
157    #[serde(skip_serializing_if = "Option::is_none")]
158    meta: Option<Map<String, Value>>,
159}
160
161impl NewList {
162    /// `list_type` is `detection`, `rule_default` or an Endpoint artifact type.
163    pub fn new(
164        name: impl Into<String>,
165        description: impl Into<String>,
166        list_type: impl Into<String>,
167    ) -> Self {
168        Self {
169            name: name.into(),
170            description: description.into(),
171            list_type: list_type.into(),
172            namespace_type: NamespaceType::Single,
173            list_id: None,
174            tags: Vec::new(),
175            os_types: Vec::new(),
176            version: None,
177            meta: None,
178        }
179    }
180
181    /// A shared detection list in the current space.
182    pub fn detection(name: impl Into<String>, description: impl Into<String>) -> Self {
183        Self::new(name, description, "detection")
184    }
185
186    pub fn namespace_type(mut self, namespace: NamespaceType) -> Self {
187        self.namespace_type = namespace;
188        self
189    }
190
191    /// A stable human-readable identifier, generated by Kibana when omitted.
192    pub fn list_id(mut self, list_id: impl Into<String>) -> Self {
193        self.list_id = Some(list_id.into());
194        self
195    }
196
197    pub fn tags<I: IntoIterator<Item = S>, S: Into<String>>(mut self, tags: I) -> Self {
198        self.tags = tags.into_iter().map(Into::into).collect();
199        self
200    }
201
202    pub fn os_types(mut self, os_types: Vec<OsType>) -> Self {
203        self.os_types = os_types;
204        self
205    }
206
207    /// A user-defined version number, unrelated to the concurrency token.
208    pub fn version(mut self, version: u64) -> Self {
209        self.version = Some(version);
210        self
211    }
212
213    pub fn meta(mut self, meta: Map<String, Value>) -> Self {
214        self.meta = Some(meta);
215        self
216    }
217}
218
219/// Changes to an existing list for [`Exceptions::update_list`], created by
220/// [`ExceptionList::edit`].
221///
222/// The edit keeps the retrieved writable fields, so changing the name also
223/// retains tags and metadata. A list changed since then fails with HTTP 409;
224/// read it again and reapply the change rather than retrying the same edit.
225#[derive(Clone, Debug, Serialize)]
226pub struct ListEdit {
227    id: String,
228    #[serde(rename = "_version", skip_serializing_if = "Option::is_none")]
229    revision: Option<String>,
230    namespace_type: NamespaceType,
231    #[serde(rename = "type")]
232    list_type: String,
233    name: String,
234    description: String,
235    tags: Vec<String>,
236    os_types: Vec<OsType>,
237    #[serde(skip_serializing_if = "Option::is_none")]
238    meta: Option<Map<String, Value>>,
239    #[serde(skip_serializing_if = "Option::is_none")]
240    version: Option<u64>,
241}
242
243impl ListEdit {
244    pub fn name(mut self, name: impl Into<String>) -> Self {
245        self.name = name.into();
246        self
247    }
248
249    pub fn description(mut self, description: impl Into<String>) -> Self {
250        self.description = description.into();
251        self
252    }
253
254    pub fn tags<I: IntoIterator<Item = S>, S: Into<String>>(mut self, tags: I) -> Self {
255        self.tags = tags.into_iter().map(Into::into).collect();
256        self
257    }
258
259    pub fn os_types(mut self, os_types: Vec<OsType>) -> Self {
260        self.os_types = os_types;
261        self
262    }
263
264    pub fn meta(mut self, meta: Map<String, Value>) -> Self {
265        self.meta = Some(meta);
266        self
267    }
268
269    /// A user-defined version number. Kibana increments it when unset.
270    pub fn version(mut self, version: u64) -> Self {
271        self.version = Some(version);
272        self
273    }
274}
275
276#[derive(Clone, Copy, Debug)]
277pub enum ListSelector<'a> {
278    Id(&'a str),
279    ListId(&'a str),
280}
281
282impl<'a> ListSelector<'a> {
283    fn pair(self) -> (&'static str, &'a str) {
284        match self {
285            Self::Id(id) => ("id", id),
286            Self::ListId(id) => ("list_id", id),
287        }
288    }
289}
290
291/// A list to read, delete or summarize.
292///
293/// A bare [`ListSelector`] uses the server's default namespace unless the
294/// builder's `namespace_type` is set. A retrieved [`ExceptionList`] or a
295/// [`ListReference`] also selects its own namespace.
296#[derive(Clone, Copy, Debug)]
297pub struct ListTarget<'a> {
298    selector: ListSelector<'a>,
299    namespace: Option<NamespaceType>,
300}
301
302impl<'a> ListTarget<'a> {
303    pub fn new(selector: ListSelector<'a>, namespace: NamespaceType) -> Self {
304        Self {
305            selector,
306            namespace: Some(namespace),
307        }
308    }
309
310    fn apply(self, request: Request<'_>) -> Request<'_> {
311        let request = request.selector(self.selector.pair());
312        match self.namespace {
313            Some(namespace) => request.param("namespace_type", namespace.as_str()),
314            None => request,
315        }
316    }
317}
318
319impl<'a> From<ListSelector<'a>> for ListTarget<'a> {
320    fn from(selector: ListSelector<'a>) -> Self {
321        Self {
322            selector,
323            namespace: None,
324        }
325    }
326}
327
328impl<'a> From<&'a ExceptionList> for ListTarget<'a> {
329    fn from(list: &'a ExceptionList) -> Self {
330        Self::new(ListSelector::Id(&list.id), list.namespace_type)
331    }
332}
333
334impl<'a> From<&'a ListReference> for ListTarget<'a> {
335    fn from(list: &'a ListReference) -> Self {
336        Self::new(ListSelector::Id(&list.id), list.namespace_type)
337    }
338}
339
340/// The list whose items [`Exceptions::find_items`] searches: a `list_id`, or a
341/// retrieved [`ExceptionList`], which also selects its namespace.
342#[derive(Clone, Copy, Debug)]
343pub struct ItemsOf<'a> {
344    list_id: &'a str,
345    namespace: Option<NamespaceType>,
346}
347
348impl<'a> From<&'a str> for ItemsOf<'a> {
349    fn from(list_id: &'a str) -> Self {
350        Self {
351            list_id,
352            namespace: None,
353        }
354    }
355}
356
357impl<'a> From<&'a String> for ItemsOf<'a> {
358    fn from(list_id: &'a String) -> Self {
359        list_id.as_str().into()
360    }
361}
362
363impl<'a> From<&'a ExceptionList> for ItemsOf<'a> {
364    fn from(list: &'a ExceptionList) -> Self {
365        Self {
366            list_id: &list.list_id,
367            namespace: Some(list.namespace_type),
368        }
369    }
370}
371
372#[derive(Clone, Copy, Debug)]
373pub enum ItemSelector<'a> {
374    Id(&'a str),
375    ItemId(&'a str),
376}
377
378impl<'a> ItemSelector<'a> {
379    fn pair(self) -> (&'static str, &'a str) {
380        match self {
381            Self::Id(id) => ("id", id),
382            Self::ItemId(id) => ("item_id", id),
383        }
384    }
385}
386
387/// An item to read or delete. See [`ListTarget`] for how the namespace is chosen.
388#[derive(Clone, Copy, Debug)]
389pub struct ItemTarget<'a> {
390    selector: ItemSelector<'a>,
391    namespace: Option<NamespaceType>,
392}
393
394impl<'a> ItemTarget<'a> {
395    pub fn new(selector: ItemSelector<'a>, namespace: NamespaceType) -> Self {
396        Self {
397            selector,
398            namespace: Some(namespace),
399        }
400    }
401
402    fn apply(self, request: Request<'_>) -> Request<'_> {
403        let request = request.selector(self.selector.pair());
404        match self.namespace {
405            Some(namespace) => request.param("namespace_type", namespace.as_str()),
406            None => request,
407        }
408    }
409}
410
411impl<'a> From<ItemSelector<'a>> for ItemTarget<'a> {
412    fn from(selector: ItemSelector<'a>) -> Self {
413        Self {
414            selector,
415            namespace: None,
416        }
417    }
418}
419
420impl<'a> From<&'a ExceptionItem> for ItemTarget<'a> {
421    fn from(item: &'a ExceptionItem) -> Self {
422        Self::new(ItemSelector::Id(&item.id), item.namespace_type)
423    }
424}
425
426#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
427#[serde(rename_all = "lowercase")]
428pub enum Operator {
429    Included,
430    Excluded,
431}
432
433/// Conditions inside [`Entry::Nested`]. Nesting is one level deep.
434#[derive(Clone, Debug, Serialize, Deserialize)]
435#[serde(tag = "type", rename_all = "snake_case")]
436#[non_exhaustive]
437pub enum NestedEntry {
438    Match {
439        field: String,
440        operator: Operator,
441        value: String,
442    },
443    MatchAny {
444        field: String,
445        operator: Operator,
446        value: Vec<String>,
447    },
448    Exists {
449        field: String,
450        operator: Operator,
451    },
452}
453
454/// A value list referenced by [`Entry::List`].
455#[derive(Clone, Debug, Serialize, Deserialize)]
456#[non_exhaustive]
457pub struct ValueListReference {
458    pub id: String,
459    /// The value type, such as `ip`, `keyword` or `text`.
460    #[serde(rename = "type")]
461    pub list_type: String,
462}
463
464impl ValueListReference {
465    pub fn new(id: impl Into<String>, list_type: impl Into<String>) -> Self {
466        Self {
467            id: id.into(),
468            list_type: list_type.into(),
469        }
470    }
471}
472
473/// One condition in an exception item. The item's entries are combined with AND.
474#[derive(Clone, Debug, Serialize, Deserialize)]
475#[serde(tag = "type", rename_all = "snake_case")]
476#[non_exhaustive]
477pub enum Entry {
478    Match {
479        field: String,
480        operator: Operator,
481        value: String,
482    },
483    MatchAny {
484        field: String,
485        operator: Operator,
486        value: Vec<String>,
487    },
488    Exists {
489        field: String,
490        operator: Operator,
491    },
492    Wildcard {
493        field: String,
494        operator: Operator,
495        value: String,
496    },
497    List {
498        field: String,
499        operator: Operator,
500        list: ValueListReference,
501    },
502    Nested {
503        field: String,
504        entries: Vec<NestedEntry>,
505    },
506}
507
508/// A new comment. Kibana records its author and time.
509#[derive(Clone, Debug, Serialize)]
510pub struct Comment {
511    comment: String,
512}
513
514impl Comment {
515    pub fn new(comment: impl Into<String>) -> Self {
516        Self {
517            comment: comment.into(),
518        }
519    }
520}
521
522/// A comment stored on an item.
523#[derive(Clone, Debug, Deserialize, Serialize)]
524#[non_exhaustive]
525pub struct ItemComment {
526    pub id: String,
527    pub comment: String,
528    #[serde(default)]
529    pub created_at: Option<String>,
530    #[serde(default)]
531    pub created_by: Option<String>,
532    #[serde(flatten)]
533    pub extra: Map<String, Value>,
534}
535
536/// An item definition for [`Exceptions::create_item`]. To change an existing
537/// item, use [`ExceptionItem::edit`].
538#[derive(Clone, Debug, Serialize)]
539pub struct NewItem {
540    name: String,
541    description: String,
542    list_id: String,
543    #[serde(skip_serializing_if = "Option::is_none")]
544    item_id: Option<String>,
545    #[serde(rename = "type")]
546    item_type: &'static str,
547    namespace_type: NamespaceType,
548    entries: Vec<Entry>,
549    tags: Vec<String>,
550    #[serde(skip_serializing_if = "Vec::is_empty")]
551    os_types: Vec<OsType>,
552    comments: Vec<Comment>,
553    #[serde(skip_serializing_if = "Option::is_none")]
554    expire_time: Option<String>,
555    #[serde(skip_serializing_if = "Option::is_none")]
556    meta: Option<Map<String, Value>>,
557}
558
559impl NewItem {
560    /// An item in `list`. Its entries are combined with AND.
561    pub fn new(list: &ExceptionList, name: impl Into<String>, entries: Vec<Entry>) -> Self {
562        Self {
563            name: name.into(),
564            description: String::new(),
565            list_id: list.list_id.clone(),
566            item_id: None,
567            item_type: "simple",
568            namespace_type: list.namespace_type,
569            entries,
570            tags: Vec::new(),
571            os_types: Vec::new(),
572            comments: Vec::new(),
573            expire_time: None,
574            meta: None,
575        }
576    }
577
578    pub fn description(mut self, description: impl Into<String>) -> Self {
579        self.description = description.into();
580        self
581    }
582
583    /// A stable human-readable identifier, generated by Kibana when omitted.
584    pub fn item_id(mut self, item_id: impl Into<String>) -> Self {
585        self.item_id = Some(item_id.into());
586        self
587    }
588
589    pub fn tags<I: IntoIterator<Item = S>, S: Into<String>>(mut self, tags: I) -> Self {
590        self.tags = tags.into_iter().map(Into::into).collect();
591        self
592    }
593
594    pub fn os_types(mut self, os_types: Vec<OsType>) -> Self {
595        self.os_types = os_types;
596        self
597    }
598
599    pub fn comments(mut self, comments: Vec<Comment>) -> Self {
600        self.comments = comments;
601        self
602    }
603
604    /// An ISO 8601 time after which the item no longer applies.
605    pub fn expire_time(mut self, time: impl Into<String>) -> Self {
606        self.expire_time = Some(time.into());
607        self
608    }
609
610    pub fn meta(mut self, meta: Map<String, Value>) -> Self {
611        self.meta = Some(meta);
612        self
613    }
614}
615
616/// A retrieved exception item and its current entries and comments.
617/// Use [`Self::edit`] to change it without dropping unchanged writable fields.
618#[derive(Clone, Debug, Deserialize, Serialize)]
619#[non_exhaustive]
620pub struct ExceptionItem {
621    /// Kibana saved-object ID, distinct from [`item_id`](Self::item_id).
622    pub id: String,
623    /// Stable item identifier, retained across exports and imports.
624    pub item_id: String,
625    pub list_id: String,
626    pub name: String,
627    pub description: String,
628    pub namespace_type: NamespaceType,
629    /// Opaque concurrency token, sent by [`ExceptionItem::edit`].
630    #[serde(rename = "_version", default)]
631    pub revision: Option<String>,
632    #[serde(rename = "type", default = "simple")]
633    pub item_type: String,
634    /// JSON preserves entry variants added by newer deployments.
635    pub entries: Vec<Value>,
636    #[serde(default)]
637    pub tags: Vec<String>,
638    #[serde(default)]
639    pub os_types: Vec<OsType>,
640    #[serde(default)]
641    pub comments: Vec<ItemComment>,
642    #[serde(default)]
643    pub expire_time: Option<String>,
644    #[serde(default)]
645    pub meta: Option<Map<String, Value>>,
646    #[serde(flatten)]
647    pub extra: Map<String, Value>,
648}
649
650fn simple() -> String {
651    "simple".into()
652}
653
654impl ExceptionItem {
655    /// A replacement for this item that keeps its current editable fields,
656    /// including entries of types this client does not model, until changed.
657    /// It is bound to the item's ID, namespace and concurrency token.
658    /// Sending the edit fails locally if the retrieved token is missing or empty.
659    /// Existing comments stay on the server; only newly added comments are sent.
660    ///
661    /// ```
662    /// use security_client_rs::exceptions::ExceptionItem;
663    /// use serde_json::{from_value, json, to_value};
664    ///
665    /// let item: ExceptionItem = from_value(json!({
666    ///     "id": "item-a", "item_id": "scanner", "list_id": "allowlist",
667    ///     "name": "Scanner", "description": "Known host", "namespace_type": "single",
668    ///     "_version": "opaque-token", "entries": [], "tags": ["reviewed"],
669    ///     "comments": [{"id": "comment-a", "comment": "Already reviewed"}]
670    /// }))?;
671    /// let body = to_value(item.edit().name("Approved scanner").add_comment("Checked again"))?;
672    /// assert_eq!(body["_version"], "opaque-token");
673    /// assert_eq!(body["tags"], json!(["reviewed"]));
674    /// assert_eq!(body["comments"], json!([{"comment": "Checked again"}]));
675    /// # Ok::<(), serde_json::Error>(())
676    /// ```
677    pub fn edit(&self) -> ItemEdit {
678        ItemEdit {
679            id: self.id.clone(),
680            revision: self.revision.clone(),
681            namespace_type: self.namespace_type,
682            item_type: self.item_type.clone(),
683            name: self.name.clone(),
684            description: self.description.clone(),
685            entries: self.entries.clone(),
686            tags: self.tags.clone(),
687            os_types: self.os_types.clone(),
688            comments: Vec::new(),
689            expire_time: self.expire_time.clone(),
690            meta: self.meta.clone(),
691        }
692    }
693}
694
695/// Changes to an existing item for [`Exceptions::update_item`], created by
696/// [`ExceptionItem::edit`].
697///
698/// Kibana overwrites the item, so the edit starts from its writable fields as
699/// they were read. Comments are append-only: existing comments are always kept,
700/// and only comments added with [`add_comment`](Self::add_comment) are sent. An item
701/// changed since it was read fails with HTTP 409; read it again and reapply the
702/// change rather than retrying the same edit.
703#[derive(Clone, Debug, Serialize)]
704pub struct ItemEdit {
705    id: String,
706    #[serde(rename = "_version", skip_serializing_if = "Option::is_none")]
707    revision: Option<String>,
708    namespace_type: NamespaceType,
709    #[serde(rename = "type")]
710    item_type: String,
711    name: String,
712    description: String,
713    entries: Vec<Value>,
714    tags: Vec<String>,
715    os_types: Vec<OsType>,
716    comments: Vec<Comment>,
717    #[serde(skip_serializing_if = "Option::is_none")]
718    expire_time: Option<String>,
719    #[serde(skip_serializing_if = "Option::is_none")]
720    meta: Option<Map<String, Value>>,
721}
722
723impl ItemEdit {
724    pub fn name(mut self, name: impl Into<String>) -> Self {
725        self.name = name.into();
726        self
727    }
728
729    pub fn description(mut self, description: impl Into<String>) -> Self {
730        self.description = description.into();
731        self
732    }
733
734    /// Replaces every entry. Entries are combined with AND.
735    pub fn entries(mut self, entries: impl IntoIterator<Item = Entry>) -> Self {
736        self.entries = entries
737            .into_iter()
738            .map(|entry| serde_json::to_value(&entry).expect("entries serialize as JSON"))
739            .collect();
740        self
741    }
742
743    pub fn tags<I: IntoIterator<Item = S>, S: Into<String>>(mut self, tags: I) -> Self {
744        self.tags = tags.into_iter().map(Into::into).collect();
745        self
746    }
747
748    pub fn os_types(mut self, os_types: Vec<OsType>) -> Self {
749        self.os_types = os_types;
750        self
751    }
752
753    /// Appends a comment to the item's existing comments.
754    pub fn add_comment(mut self, comment: impl Into<String>) -> Self {
755        self.comments.push(Comment::new(comment));
756        self
757    }
758
759    /// An ISO 8601 time after which the item no longer applies.
760    pub fn expire_time(mut self, time: impl Into<String>) -> Self {
761        self.expire_time = Some(time.into());
762        self
763    }
764
765    pub fn meta(mut self, meta: Map<String, Value>) -> Self {
766        self.meta = Some(meta);
767        self
768    }
769}
770
771#[derive(Clone, Debug, Deserialize, Serialize)]
772#[non_exhaustive]
773pub struct ExceptionPage<T> {
774    pub data: Vec<T>,
775    pub page: u32,
776    pub per_page: u32,
777    pub total: u64,
778    #[serde(flatten)]
779    pub extra: Map<String, Value>,
780}
781
782/// HTTP 200 can contain failed list or item imports; inspect `success` and `errors`.
783#[derive(Clone, Debug, Deserialize, Serialize)]
784#[non_exhaustive]
785pub struct ImportResult {
786    pub success: bool,
787    pub success_count: u64,
788    pub errors: Vec<crate::ImportFailure>,
789    #[serde(flatten)]
790    pub extra: Map<String, Value>,
791}
792
793#[derive(Clone, Copy, Debug)]
794pub struct Exceptions<'a>(pub(crate) &'a Kibana);
795
796impl<'a> Exceptions<'a> {
797    /// Creates a list from a [`NewList`] or equivalent JSON.
798    pub fn create_list<B: Serialize + ?Sized>(&self, list: &B) -> CreateList<'a> {
799        CreateList(
800            self.0
801                .request(Method::POST, Scope::Space, &["api", "exception_lists"])
802                .json(list),
803        )
804    }
805
806    /// Reads a list by [`ListSelector`] or from a retrieved [`ExceptionList`].
807    pub fn get_list<'l>(&self, list: impl Into<ListTarget<'l>>) -> GetList<'a> {
808        GetList(
809            list.into().apply(self.0.request(
810                Method::GET,
811                Scope::Space,
812                &["api", "exception_lists"],
813            )),
814        )
815    }
816
817    /// Replaces the editable fields of the list the edit was created from. A
818    /// list changed since it was read fails with HTTP 409. A missing or empty
819    /// concurrency token fails locally with [`crate::Error::InvalidRequest`].
820    pub fn update_list(&self, list: &ListEdit) -> UpdateList<'a> {
821        UpdateList(
822            self.0
823                .request(Method::PUT, Scope::Space, &["api", "exception_lists"])
824                .nonempty("_version", list.revision.as_deref().unwrap_or_default())
825                .json(list),
826        )
827    }
828
829    /// Deletes the list and its items. Detach it from rules first.
830    pub fn delete_list<'l>(&self, list: impl Into<ListTarget<'l>>) -> DeleteList<'a> {
831        DeleteList(list.into().apply(self.0.request(
832            Method::DELETE,
833            Scope::Space,
834            &["api", "exception_lists"],
835        )))
836    }
837
838    pub fn find_lists(&self) -> FindLists<'a> {
839        FindLists(self.0.request(
840            Method::GET,
841            Scope::Space,
842            &["api", "exception_lists", "_find"],
843        ))
844    }
845
846    /// Creates an item from a [`NewItem`] or equivalent JSON.
847    pub fn create_item<B: Serialize + ?Sized>(&self, item: &B) -> CreateItem<'a> {
848        CreateItem(
849            self.0
850                .request(
851                    Method::POST,
852                    Scope::Space,
853                    &["api", "exception_lists", "items"],
854                )
855                .json(item),
856        )
857    }
858
859    /// Reads an item by [`ItemSelector`] or from a retrieved [`ExceptionItem`].
860    pub fn get_item<'i>(&self, item: impl Into<ItemTarget<'i>>) -> GetItem<'a> {
861        GetItem(item.into().apply(self.0.request(
862            Method::GET,
863            Scope::Space,
864            &["api", "exception_lists", "items"],
865        )))
866    }
867
868    /// Replaces the editable fields of the item the edit was created from and
869    /// appends its new comments. An item changed since it was read fails with HTTP 409.
870    /// A missing or empty concurrency token fails locally with [`crate::Error::InvalidRequest`].
871    pub fn update_item(&self, item: &ItemEdit) -> UpdateItem<'a> {
872        UpdateItem(
873            self.0
874                .request(
875                    Method::PUT,
876                    Scope::Space,
877                    &["api", "exception_lists", "items"],
878                )
879                .nonempty("_version", item.revision.as_deref().unwrap_or_default())
880                .json(item),
881        )
882    }
883
884    pub fn delete_item<'i>(&self, item: impl Into<ItemTarget<'i>>) -> DeleteItem<'a> {
885        DeleteItem(item.into().apply(self.0.request(
886            Method::DELETE,
887            Scope::Space,
888            &["api", "exception_lists", "items"],
889        )))
890    }
891
892    /// One page of items from one list, given by `list_id` or as a retrieved
893    /// [`ExceptionList`], which also selects its namespace.
894    ///
895    /// Kibana splits list IDs and filters on commas after URL decoding, so
896    /// literal commas in these values are not supported.
897    pub fn find_items<'l>(&self, list: impl Into<ItemsOf<'l>>) -> FindItems<'a> {
898        let list = list.into();
899        let request = self
900            .0
901            .request(
902                Method::GET,
903                Scope::Space,
904                &["api", "exception_lists", "items", "_find"],
905            )
906            .selector(("list_id", list.list_id));
907        FindItems(match list.namespace {
908            Some(namespace) => request.param("namespace_type", namespace.as_str()),
909            None => request,
910        })
911    }
912
913    /// Item counts per operating system. Lists without OS-tagged items can report
914    /// `total: 0`; use [`find_items`](Self::find_items) for the item count.
915    pub fn summary<'l>(&self, list: impl Into<ListTarget<'l>>) -> Summary<'a> {
916        Summary(list.into().apply(self.0.request(
917            Method::GET,
918            Scope::Space,
919            &["api", "exception_lists", "summary"],
920        )))
921    }
922
923    pub fn duplicate_list(
924        &self,
925        list_id: &str,
926        namespace: NamespaceType,
927        include_expired: bool,
928    ) -> DuplicateList<'a> {
929        DuplicateList(
930            self.0
931                .request(
932                    Method::POST,
933                    Scope::Space,
934                    &["api", "exception_lists", "_duplicate"],
935                )
936                .selector(("list_id", list_id))
937                .param("namespace_type", namespace.as_str())
938                .param("include_expired_exceptions", include_expired),
939        )
940    }
941
942    /// Exports the list and its items as NDJSON.
943    pub fn export_list(&self, list: &ListReference, include_expired: bool) -> ExportList<'a> {
944        ExportList(
945            self.0
946                .request(
947                    Method::POST,
948                    Scope::Space,
949                    &["api", "exception_lists", "_export"],
950                )
951                .param("id", &list.id)
952                .param("list_id", &list.list_id)
953                .param("namespace_type", list.namespace_type.as_str())
954                .param("include_expired_exceptions", include_expired),
955        )
956    }
957
958    /// Imports NDJSON lists and items. Partial failures are reported with HTTP 200.
959    /// Imports can regenerate saved-object IDs; read back by `list_id` afterwards.
960    pub fn import_lists(&self, ndjson: impl Into<Vec<u8>>) -> ImportLists<'a> {
961        ImportLists(
962            self.0
963                .request(
964                    Method::POST,
965                    Scope::Space,
966                    &["api", "exception_lists", "_import"],
967                )
968                .file("exceptions.ndjson", "application/ndjson", ndjson.into()),
969        )
970    }
971}
972
973macro_rules! namespace_setter {
974    ($($name:ident),*) => {$(
975        impl $name<'_> {
976            /// Defaults to [`NamespaceType::Single`] on the server.
977            pub fn namespace_type(self, namespace: NamespaceType) -> Self {
978                Self(self.0.param("namespace_type", namespace.as_str()))
979            }
980        }
981    )*};
982}
983
984macro_rules! find_setters {
985    ($($name:ident),*) => {$(
986        impl $name<'_> {
987            /// One-based page number.
988            pub fn page(self, page: u32) -> Self {
989                Self(self.0.positive_param("page", page))
990            }
991
992            pub fn per_page(self, per_page: u32) -> Self {
993                Self(self.0.positive_param("per_page", per_page))
994            }
995
996            /// A KQL filter over list or item attributes.
997            pub fn filter(self, filter: &str) -> Self {
998                Self(self.0.param("filter", filter))
999            }
1000
1001            pub fn sort_field(self, field: &str) -> Self {
1002                Self(self.0.param("sort_field", field))
1003            }
1004
1005            pub fn sort_order(self, order: SortOrder) -> Self {
1006                Self(self.0.param("sort_order", order.as_str()))
1007            }
1008        }
1009    )*};
1010}
1011
1012endpoint! {
1013    /// `POST /api/exception_lists`
1014    CreateList => ExceptionList
1015}
1016
1017endpoint! {
1018    /// `GET /api/exception_lists`
1019    GetList => ExceptionList
1020}
1021
1022endpoint! {
1023    /// `PUT /api/exception_lists`
1024    UpdateList => ExceptionList
1025}
1026
1027endpoint! {
1028    /// `DELETE /api/exception_lists`
1029    DeleteList => ExceptionList
1030}
1031
1032endpoint! {
1033    /// `GET /api/exception_lists/_find`
1034    FindLists => ExceptionPage<ExceptionList>
1035}
1036
1037endpoint! {
1038    /// `POST /api/exception_lists/items`
1039    CreateItem => ExceptionItem
1040}
1041
1042endpoint! {
1043    /// `GET /api/exception_lists/items`
1044    GetItem => ExceptionItem
1045}
1046
1047endpoint! {
1048    /// `PUT /api/exception_lists/items`
1049    UpdateItem => ExceptionItem
1050}
1051
1052endpoint! {
1053    /// `DELETE /api/exception_lists/items`
1054    DeleteItem => ExceptionItem
1055}
1056
1057endpoint! {
1058    /// `GET /api/exception_lists/items/_find`
1059    FindItems => ExceptionPage<ExceptionItem>
1060}
1061
1062impl FindItems<'_> {
1063    /// Free-text search over item fields.
1064    pub fn search(self, text: &str) -> Self {
1065        Self(self.0.param("search", text))
1066    }
1067}
1068
1069endpoint! {
1070    /// `GET /api/exception_lists/summary`
1071    Summary => Value
1072}
1073
1074impl Summary<'_> {
1075    /// A KQL filter applied before counting.
1076    pub fn filter(self, filter: &str) -> Self {
1077        Self(self.0.param("filter", filter))
1078    }
1079}
1080
1081endpoint! {
1082    /// `POST /api/exception_lists/_duplicate`
1083    DuplicateList => ExceptionList
1084}
1085
1086endpoint! {
1087    /// `POST /api/exception_lists/_export`
1088    ExportList => Raw
1089}
1090
1091endpoint! {
1092    /// `POST /api/exception_lists/_import`
1093    ImportLists => ImportResult
1094}
1095
1096impl ImportLists<'_> {
1097    /// Replaces existing lists and items with the same IDs.
1098    pub fn overwrite(self, overwrite: bool) -> Self {
1099        Self(self.0.param("overwrite", overwrite))
1100    }
1101
1102    /// Imports under newly generated list IDs.
1103    pub fn as_new_list(self, enabled: bool) -> Self {
1104        Self(self.0.param("as_new_list", enabled))
1105    }
1106}
1107
1108namespace_setter!(
1109    GetList, DeleteList, FindLists, GetItem, DeleteItem, FindItems, Summary
1110);
1111find_setters!(FindLists, FindItems);
1112paginated!(
1113    FindLists => ExceptionPage<ExceptionList>,
1114    FindItems => ExceptionPage<ExceptionItem>,
1115);