Skip to main content

elasticctl_api/
model.rs

1//! The rule, exception-list, and exception-item representations.
2//!
3//! A measured create response has 36 fields, which vary by rule type and
4//! Elastic version. A JSON map preserves unknown fields; a fixed struct would
5//! break round trips.
6
7use elasticctl_core::{Error, ErrorKind, Result};
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value, json};
10
11/// Server-owned rule fields excluded from comparison to avoid false drift.
12pub const VOLATILE_FIELDS: [&str; 8] = [
13    "id",
14    "created_at",
15    "created_by",
16    "updated_at",
17    "updated_by",
18    "revision",
19    "version",
20    "execution_summary",
21];
22
23/// Fields the server fills when a create request omits them.
24///
25/// Measured: a 13-field create returned 36 fields. Fill these defaults before
26/// comparison so omitted values do not appear as drift.
27pub fn server_defaults() -> Map<String, Value> {
28    let mut m = Map::new();
29    m.insert("actions".into(), json!([]));
30    m.insert("author".into(), json!([]));
31    m.insert("exceptions_list".into(), json!([]));
32    m.insert("false_positives".into(), json!([]));
33    m.insert("immutable".into(), json!(false));
34    m.insert("max_signals".into(), json!(100));
35    m.insert("output_index".into(), json!(""));
36    m.insert("references".into(), json!([]));
37    m.insert("related_integrations".into(), json!([]));
38    m.insert("required_fields".into(), json!([]));
39    m.insert("risk_score_mapping".into(), json!([]));
40    m.insert("rule_source".into(), json!({"type": "internal"}));
41    m.insert("setup".into(), json!(""));
42    m.insert("severity_mapping".into(), json!([]));
43    m.insert("threat".into(), json!([]));
44    m.insert("to".into(), json!("now"));
45    m
46}
47
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49#[serde(transparent)]
50pub struct Rule(Map<String, Value>);
51
52impl Rule {
53    pub fn from_value(v: Value) -> Result<Rule> {
54        let map = match v {
55            Value::Object(m) => m,
56            _ => return Err(Error::new(ErrorKind::Error, "a rule must be a JSON object")),
57        };
58        // Validate identity at the shared construction path. A non-string
59        // `rule_id` cannot match, name a file, or be reported.
60        match map.get("rule_id") {
61            Some(Value::String(_)) => {}
62            Some(_) => {
63                return Err(Error::new(
64                    ErrorKind::Error,
65                    "a rule's rule_id must be a string",
66                ));
67            }
68            None => return Err(Error::new(ErrorKind::Error, "a rule must have a rule_id")),
69        }
70        Ok(Rule(map))
71    }
72
73    pub fn into_value(self) -> Value {
74        Value::Object(self.0)
75    }
76
77    pub fn as_map(&self) -> &Map<String, Value> {
78        &self.0
79    }
80
81    pub fn as_map_mut(&mut self) -> &mut Map<String, Value> {
82        &mut self.0
83    }
84
85    fn str_field(&self, key: &str) -> &str {
86        self.0.get(key).and_then(Value::as_str).unwrap_or("")
87    }
88
89    /// The stable identity used for state matching.
90    ///
91    /// `from_value` requires a string `rule_id`, but transparent `Deserialize`
92    /// and `as_map_mut` can bypass that validation. Return an error rather than
93    /// silently matching the wrong remote rule.
94    pub fn rule_id(&self) -> Result<&str> {
95        self.0
96            .get("rule_id")
97            .and_then(Value::as_str)
98            .ok_or_else(|| Error::new(ErrorKind::Error, "rule is missing rule_id"))
99    }
100
101    pub fn name(&self) -> &str {
102        self.str_field("name")
103    }
104
105    pub fn rule_type(&self) -> &str {
106        self.str_field("type")
107    }
108
109    pub fn severity(&self) -> &str {
110        self.str_field("severity")
111    }
112
113    pub fn enabled(&self) -> bool {
114        self.0
115            .get("enabled")
116            .and_then(Value::as_bool)
117            .unwrap_or(false)
118    }
119
120    pub fn risk_score(&self) -> i64 {
121        self.0
122            .get("risk_score")
123            .and_then(Value::as_i64)
124            .unwrap_or(0)
125    }
126
127    pub fn tags(&self) -> Vec<&str> {
128        self.0
129            .get("tags")
130            .and_then(Value::as_array)
131            .map(|a| a.iter().filter_map(Value::as_str).collect())
132            .unwrap_or_default()
133    }
134}
135
136/// The trailer Kibana appends to an NDJSON export. It is the entire body for
137/// a zero-rule export, so it must not be parsed as a rule.
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139pub struct ExportSummary {
140    #[serde(default)]
141    pub exported_count: u64,
142    #[serde(default)]
143    pub exported_rules_count: u64,
144    #[serde(default)]
145    pub missing_rules_count: u64,
146    /// Rules selected for export but not returned, likely deleted after
147    /// selection. Kept as raw server values.
148    #[serde(default)]
149    pub missing_rules: Vec<Value>,
150    #[serde(default)]
151    pub exported_exception_list_count: u64,
152    #[serde(default)]
153    pub exported_exception_list_item_count: u64,
154    #[serde(default)]
155    pub missing_exception_lists: Vec<Value>,
156    #[serde(default)]
157    pub missing_exception_list_items: Vec<Value>,
158}
159
160/// Server-owned exception-list fields excluded from comparison to avoid false
161/// drift.
162pub const LIST_VOLATILE_FIELDS: [&str; 8] = [
163    "id",
164    "_version",
165    "tie_breaker_id",
166    "version",
167    "created_at",
168    "created_by",
169    "updated_at",
170    "updated_by",
171];
172
173/// Server-owned exception-item fields excluded from comparison. This is the
174/// container set less `version`: a measured item carries `_version` but no
175/// `version`.
176pub const ITEM_VOLATILE_FIELDS: [&str; 7] = [
177    "id",
178    "_version",
179    "tie_breaker_id",
180    "created_at",
181    "created_by",
182    "updated_at",
183    "updated_by",
184];
185
186/// Server-minted fields on an exception item comment. `id`, `created_at`, and
187/// `created_by` are measured; `updated_at` and `updated_by` were absent on a
188/// freshly created comment but name the same class on every other object in
189/// this API, and removing an absent key costs nothing.
190pub const COMMENT_VOLATILE_FIELDS: [&str; 5] =
191    ["id", "created_at", "created_by", "updated_at", "updated_by"];
192
193#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
194#[serde(transparent)]
195pub struct ExceptionList(Map<String, Value>);
196
197impl ExceptionList {
198    pub fn from_value(v: Value) -> Result<ExceptionList> {
199        let map = match v {
200            Value::Object(m) => m,
201            _ => {
202                return Err(Error::new(
203                    ErrorKind::Error,
204                    "an exception list must be a JSON object",
205                ));
206            }
207        };
208        match map.get("list_id") {
209            Some(Value::String(_)) => {}
210            Some(_) => {
211                return Err(Error::new(
212                    ErrorKind::Error,
213                    "an exception list's list_id must be a string",
214                ));
215            }
216            None => {
217                return Err(Error::new(
218                    ErrorKind::Error,
219                    "an exception list must have a list_id",
220                ));
221            }
222        }
223        Ok(ExceptionList(map))
224    }
225
226    pub fn list_id(&self) -> Result<&str> {
227        self.0
228            .get("list_id")
229            .and_then(Value::as_str)
230            .ok_or_else(|| Error::new(ErrorKind::Error, "exception list is missing list_id"))
231    }
232
233    /// `single` is the API's own default and the value every measured response
234    /// carried. An absent value must resolve, or identity would depend on
235    /// whether a response happened to include the field.
236    pub fn namespace_type(&self) -> &str {
237        self.0
238            .get("namespace_type")
239            .and_then(Value::as_str)
240            .unwrap_or("single")
241    }
242
243    pub fn list_type(&self) -> &str {
244        self.0.get("type").and_then(Value::as_str).unwrap_or("")
245    }
246
247    pub fn name(&self) -> &str {
248        self.0.get("name").and_then(Value::as_str).unwrap_or("")
249    }
250
251    pub fn tags(&self) -> Vec<&str> {
252        self.0
253            .get("tags")
254            .and_then(Value::as_array)
255            .map(|a| a.iter().filter_map(Value::as_str).collect())
256            .unwrap_or_default()
257    }
258
259    pub fn key(&self) -> Result<ListKey> {
260        Ok(ListKey {
261            list_id: self.list_id()?.to_string(),
262            namespace_type: self.namespace_type().to_string(),
263        })
264    }
265
266    pub fn as_map(&self) -> &Map<String, Value> {
267        &self.0
268    }
269
270    pub fn as_map_mut(&mut self) -> &mut Map<String, Value> {
271        &mut self.0
272    }
273
274    pub fn into_value(self) -> Value {
275        Value::Object(self.0)
276    }
277}
278
279#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
280#[serde(transparent)]
281pub struct ExceptionItem(Map<String, Value>);
282
283impl ExceptionItem {
284    pub fn from_value(v: Value) -> Result<ExceptionItem> {
285        let map = match v {
286            Value::Object(m) => m,
287            _ => {
288                return Err(Error::new(
289                    ErrorKind::Error,
290                    "an exception item must be a JSON object",
291                ));
292            }
293        };
294        match map.get("item_id") {
295            Some(Value::String(_)) => {}
296            Some(_) => {
297                return Err(Error::new(
298                    ErrorKind::Error,
299                    "an exception item's item_id must be a string",
300                ));
301            }
302            None => {
303                return Err(Error::new(
304                    ErrorKind::Error,
305                    "an exception item must have an item_id",
306                ));
307            }
308        }
309        Ok(ExceptionItem(map))
310    }
311
312    pub fn item_id(&self) -> Result<&str> {
313        self.0
314            .get("item_id")
315            .and_then(Value::as_str)
316            .ok_or_else(|| Error::new(ErrorKind::Error, "exception item is missing item_id"))
317    }
318
319    /// An item without a list has no home, so absence is an error rather than
320    /// a default.
321    pub fn list_id(&self) -> Result<&str> {
322        self.0
323            .get("list_id")
324            .and_then(Value::as_str)
325            .ok_or_else(|| Error::new(ErrorKind::Error, "exception item is missing list_id"))
326    }
327
328    pub fn namespace_type(&self) -> &str {
329        self.0
330            .get("namespace_type")
331            .and_then(Value::as_str)
332            .unwrap_or("single")
333    }
334
335    pub fn as_map(&self) -> &Map<String, Value> {
336        &self.0
337    }
338
339    pub fn as_map_mut(&mut self) -> &mut Map<String, Value> {
340        &mut self.0
341    }
342
343    pub fn into_value(self) -> Value {
344        Value::Object(self.0)
345    }
346}
347
348#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Serialize, Deserialize)]
349pub struct ListKey {
350    pub list_id: String,
351    pub namespace_type: String,
352}
353
354/// The `{id, list_id, type, namespace_type}` reference a rule carries.
355pub struct ExceptionRef {
356    pub list_id: String,
357    pub namespace_type: String,
358    pub ref_type: String,
359    pub id: Option<String>,
360}
361
362/// Read a rule's `exceptions_list` array. A malformed entry is skipped rather
363/// than failing the rule: the field is server-owned and unknown shapes must
364/// survive a round trip.
365pub fn exception_refs(rule: &Rule) -> Vec<ExceptionRef> {
366    let entries = match rule
367        .as_map()
368        .get("exceptions_list")
369        .and_then(Value::as_array)
370    {
371        Some(a) => a,
372        None => return Vec::new(),
373    };
374    entries
375        .iter()
376        .filter_map(|entry| {
377            let obj = entry.as_object()?;
378            let list_id = obj.get("list_id")?.as_str()?.to_string();
379            Some(ExceptionRef {
380                list_id,
381                namespace_type: obj
382                    .get("namespace_type")
383                    .and_then(Value::as_str)
384                    .unwrap_or("single")
385                    .to_string(),
386                ref_type: obj
387                    .get("type")
388                    .and_then(Value::as_str)
389                    .unwrap_or("")
390                    .to_string(),
391                id: obj.get("id").and_then(Value::as_str).map(str::to_string),
392            })
393        })
394        .collect()
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use serde_json::json;
401
402    fn probe_rule() -> Value {
403        // Trimmed from a Serverless Security 9.6.0 create response.
404        json!({
405            "rule_id": "elasticctl-schema-probe",
406            "name": "elasticctl schema probe",
407            "description": "Temporary rule.",
408            "type": "query",
409            "language": "kuery",
410            "query": "event.category:process",
411            "index": ["logs-*"],
412            "severity": "low",
413            "risk_score": 21,
414            "enabled": false,
415            "from": "now-6m",
416            "interval": "5m",
417            "tags": ["elasticctl", "temporary"],
418            "id": "6b796e42-99fa-4296-8dc1-a693dd455dd0",
419            "created_at": "2026-08-12T17:49:01.682Z",
420            "created_by": "2XTe9p8BLjNicQlhfc9W",
421            "updated_at": "2026-08-12T17:49:01.682Z",
422            "updated_by": "2XTe9p8BLjNicQlhfc9W",
423            "revision": 0,
424            "version": 1,
425            "max_signals": 100,
426            "to": "now",
427            "rule_source": {"type": "internal"}
428        })
429    }
430
431    #[test]
432    fn accessors_read_the_measured_fields() {
433        let r = Rule::from_value(probe_rule()).unwrap();
434        assert_eq!(r.rule_id().unwrap(), "elasticctl-schema-probe");
435        assert_eq!(r.name(), "elasticctl schema probe");
436        assert_eq!(r.rule_type(), "query");
437        assert!(!r.enabled());
438        assert_eq!(r.severity(), "low");
439        assert_eq!(r.risk_score(), 21);
440        assert_eq!(r.tags(), vec!["elasticctl", "temporary"]);
441    }
442
443    #[test]
444    fn every_unknown_field_survives_a_round_trip() {
445        let original = probe_rule();
446        let r = Rule::from_value(original.clone()).unwrap();
447        assert_eq!(r.into_value(), original, "no field may be dropped");
448    }
449
450    #[test]
451    fn a_rule_without_rule_id_is_rejected() {
452        let err = Rule::from_value(json!({"name": "x"})).unwrap_err();
453        assert_eq!(err.kind, elasticctl_core::ErrorKind::Error);
454        assert!(err.message.contains("rule_id"));
455    }
456
457    #[test]
458    fn a_non_string_rule_id_is_rejected() {
459        // State matching requires a readable identity, so construction rejects
460        // non-string `rule_id` values.
461        let err = Rule::from_value(json!({"rule_id": 123, "name": "x"})).unwrap_err();
462        assert_eq!(err.kind, elasticctl_core::ErrorKind::Error);
463        assert!(err.message.contains("string"), "{}", err.message);
464    }
465
466    #[test]
467    fn a_null_rule_id_is_rejected() {
468        // `rule_id: null` has no identity and must fail like a numeric value.
469        assert!(Rule::from_value(json!({"rule_id": null})).is_err());
470    }
471
472    /// `from_value` validates construction, but transparent `Deserialize`
473    /// bypasses that validation. Read sites must still handle unreadable IDs.
474    #[test]
475    fn deserialize_bypasses_the_construction_check() {
476        let r: Rule = serde_json::from_value(json!({"rule_id": 123})).unwrap();
477        assert!(r.rule_id().is_err());
478    }
479
480    #[test]
481    fn a_non_object_is_rejected() {
482        assert!(Rule::from_value(json!(["not", "an", "object"])).is_err());
483    }
484
485    #[test]
486    fn missing_optional_fields_read_as_sane_defaults() {
487        let r = Rule::from_value(json!({"rule_id": "x"})).unwrap();
488        assert_eq!(r.name(), "");
489        assert_eq!(r.rule_type(), "");
490        assert!(!r.enabled());
491        assert_eq!(r.risk_score(), 0);
492        assert!(r.tags().is_empty());
493    }
494
495    #[test]
496    fn volatile_field_list_matches_the_measured_set() {
497        let mut got = VOLATILE_FIELDS.to_vec();
498        got.sort_unstable();
499        assert_eq!(
500            got,
501            [
502                "created_at",
503                "created_by",
504                "execution_summary",
505                "id",
506                "revision",
507                "updated_at",
508                "updated_by",
509                "version"
510            ]
511        );
512    }
513
514    #[test]
515    fn server_defaults_cover_the_sixteen_measured_fields() {
516        let d = server_defaults();
517        assert_eq!(d.len(), 16);
518        assert_eq!(d["max_signals"], json!(100));
519        assert_eq!(d["to"], json!("now"));
520        assert_eq!(d["actions"], json!([]));
521        assert_eq!(d["rule_source"], json!({"type": "internal"}));
522        assert_eq!(d["immutable"], json!(false));
523        assert_eq!(d["setup"], json!(""));
524        assert_eq!(d["output_index"], json!(""));
525    }
526
527    #[test]
528    fn volatile_and_default_field_sets_do_not_overlap() {
529        // A field cannot be both stripped and filled.
530        for v in VOLATILE_FIELDS {
531            assert!(!server_defaults().contains_key(v), "{v} is in both sets");
532        }
533    }
534
535    /// Trimmed from the measured create response, 2026-08-14, Serverless 9.6.0.
536    fn probe_list() -> Value {
537        json!({
538            "id": "3724d409-4c0f-4630-a1ef-706499730808",
539            "list_id": "elasticctl-sample-exceptions",
540            "type": "detection",
541            "name": "elasticctl sample exceptions",
542            "description": "elasticctl sample exception list",
543            "immutable": false,
544            "namespace_type": "single",
545            "os_types": [],
546            "tags": ["elasticctl-sample"],
547            "version": 1,
548            "_version": "WzU3NDksMV0=",
549            "tie_breaker_id": "100fd2bc-b559-4c7f-9838-ef9b195c4369",
550            "created_at": "2026-08-13T23:38:39.519Z",
551            "created_by": "452295856",
552            "updated_at": "2026-08-13T23:38:39.519Z",
553            "updated_by": "452295856"
554        })
555    }
556
557    #[test]
558    fn a_list_reads_its_identity_and_keeps_unknown_fields() {
559        let l = ExceptionList::from_value(probe_list()).unwrap();
560        assert_eq!(l.list_id().unwrap(), "elasticctl-sample-exceptions");
561        assert_eq!(l.namespace_type(), "single");
562        assert_eq!(l.list_type(), "detection");
563        assert_eq!(l.tags(), vec!["elasticctl-sample"]);
564        assert_eq!(
565            l.clone().into_value(),
566            probe_list(),
567            "no field may be dropped"
568        );
569    }
570
571    #[test]
572    fn a_list_without_list_id_is_rejected() {
573        let err = ExceptionList::from_value(json!({"name": "x"})).unwrap_err();
574        assert!(err.message.contains("list_id"), "{}", err.message);
575    }
576
577    #[test]
578    fn namespace_type_defaults_to_single_when_absent() {
579        let l = ExceptionList::from_value(json!({"list_id": "x"})).unwrap();
580        assert_eq!(
581            l.namespace_type(),
582            "single",
583            "the API omits it on some responses; identity must still resolve"
584        );
585    }
586
587    #[test]
588    fn list_volatile_fields_match_the_measured_set() {
589        let mut got = LIST_VOLATILE_FIELDS.to_vec();
590        got.sort_unstable();
591        assert_eq!(
592            got,
593            [
594                "_version",
595                "created_at",
596                "created_by",
597                "id",
598                "tie_breaker_id",
599                "updated_at",
600                "updated_by",
601                "version"
602            ]
603        );
604    }
605
606    #[test]
607    fn item_volatile_fields_match_the_measured_set() {
608        // Measured: an item carries `_version` but no `version`.
609        let mut got = ITEM_VOLATILE_FIELDS.to_vec();
610        got.sort_unstable();
611        assert_eq!(
612            got,
613            [
614                "_version",
615                "created_at",
616                "created_by",
617                "id",
618                "tie_breaker_id",
619                "updated_at",
620                "updated_by"
621            ]
622        );
623    }
624
625    #[test]
626    fn exception_refs_reads_the_measured_reference_shape() {
627        let r = Rule::from_value(json!({
628            "rule_id": "x",
629            "exceptions_list": [{
630                "id": "3724d409-4c0f-4630-a1ef-706499730808",
631                "list_id": "elasticctl-sample-exceptions",
632                "type": "detection",
633                "namespace_type": "single"
634            }]
635        }))
636        .unwrap();
637        let refs = exception_refs(&r);
638        assert_eq!(refs.len(), 1);
639        assert_eq!(refs[0].list_id, "elasticctl-sample-exceptions");
640        assert_eq!(
641            refs[0].id.as_deref(),
642            Some("3724d409-4c0f-4630-a1ef-706499730808")
643        );
644    }
645
646    #[test]
647    fn exception_refs_skips_a_malformed_entry_without_failing() {
648        let r = Rule::from_value(json!({
649            "rule_id": "x",
650            "exceptions_list": ["not an object", {"list_id": "good"}]
651        }))
652        .unwrap();
653        let refs = exception_refs(&r);
654        assert_eq!(refs.len(), 1, "the readable entry survives");
655        assert_eq!(refs[0].list_id, "good");
656    }
657}