Skip to main content

elasticctl_api/fleet/integration_policy_ops/
normalization.rs

1use super::common::*;
2
3const PORTABLE_OPTIONAL: [&str; 6] = [
4    "description",
5    "namespace",
6    "vars",
7    "var_group_selections",
8    "condition",
9    "additional_datastreams_permissions",
10];
11
12const REMOVED_FIELDS: [&str; 19] = [
13    "agents",
14    "cloud_connector_id",
15    "cloud_connector_name",
16    "created_at",
17    "created_by",
18    "elasticsearch",
19    "enabled",
20    "is_managed",
21    "output_id",
22    "package_agent_version_condition",
23    "policy_id",
24    "revision",
25    "secret_references",
26    "spaceIds",
27    "supports_agentless",
28    "supports_cloud_connector",
29    "updated_at",
30    "updated_by",
31    "version",
32];
33
34/// Build a fresh portable policy from a simplified live Fleet response.
35pub fn normalize(item: &Map<String, Value>, active_space: &str) -> Result<IntegrationPolicySpec> {
36    let id = required_string(item, "id", "integration policy")?;
37    portability_check(item, &id, active_space)?;
38    reject_unknown_top_level(item, &id)?;
39
40    let mut portable = Map::new();
41    for field in ["id", "name"] {
42        if let Some(value) = item.get(field) {
43            portable.insert(field.to_owned(), value.clone());
44        }
45    }
46    portable.insert(
47        "policy_ids".to_owned(),
48        Value::Array(
49            read_parents(&id, item)?
50                .into_iter()
51                .map(Value::String)
52                .collect(),
53        ),
54    );
55    portable.insert("package".to_owned(), normalize_package(item, &id)?);
56    portable.insert("inputs".to_owned(), normalize_inputs(item, &id)?);
57    for field in PORTABLE_OPTIONAL {
58        if let Some(value) = item.get(field)
59            && !value.is_null()
60        {
61            portable.insert(field.to_owned(), value.clone());
62        }
63    }
64    IntegrationPolicySpec::try_from(Value::Object(portable)).map_err(|error| {
65        http(format!(
66            "decoding integration policy '{id}': {}",
67            error.message
68        ))
69    })
70}
71
72fn normalize_package(item: &Map<String, Value>, id: &str) -> Result<Value> {
73    let package = item
74        .get("package")
75        .and_then(Value::as_object)
76        .ok_or_else(|| {
77            http(format!(
78                "decoding integration policy '{id}': package must be an object"
79            ))
80        })?;
81    for (field, expected) in [("title", "a string")] {
82        if let Some(value) = package.get(field)
83            && !value.is_null()
84            && !value.is_string()
85        {
86            return Err(http(format!(
87                "decoding integration policy '{id}': package.{field} must be {expected} or null"
88            )));
89        }
90    }
91    for field in ["requires_root", "fips_compatible"] {
92        if let Some(value) = package.get(field)
93            && !value.is_null()
94            && !value.is_boolean()
95        {
96            return Err(http(format!(
97                "decoding integration policy '{id}': package.{field} must be a boolean or null"
98            )));
99        }
100    }
101    let mut portable = Map::new();
102    for field in ["name", "version"] {
103        if let Some(value) = package.get(field) {
104            portable.insert(field.to_owned(), value.clone());
105        }
106    }
107    let known: BTreeSet<&str> = [
108        "name",
109        "version",
110        "title",
111        "requires_root",
112        "fips_compatible",
113    ]
114    .into_iter()
115    .collect();
116    if let Some(field) = package
117        .keys()
118        .map(String::as_str)
119        .filter(|field| !known.contains(field))
120        .min()
121    {
122        return Err(Error::new(
123            ErrorKind::Unsupported,
124            format!("integration policy '{id}' carries unknown package field '{field}'"),
125        ));
126    }
127    Ok(Value::Object(portable))
128}
129
130fn normalize_inputs(item: &Map<String, Value>, id: &str) -> Result<Value> {
131    let inputs = item
132        .get("inputs")
133        .and_then(Value::as_object)
134        .ok_or_else(|| {
135            http(format!(
136                "decoding integration policy '{id}': inputs must be an object"
137            ))
138        })?;
139    let mut normalized = Map::new();
140    for (input_id, input) in inputs {
141        normalized.insert(
142            input_id.clone(),
143            normalize_package_map(input, "compiled_input")?,
144        );
145    }
146    Ok(Value::Object(normalized))
147}
148
149/// Keep package-defined input and stream maps open while rebuilding them
150/// without Fleet-generated ids or compiled content.
151fn normalize_package_map(value: &Value, compiled_field: &str) -> Result<Value> {
152    let object = value
153        .as_object()
154        .ok_or_else(|| http("decoding integration policy: input must be an object"))?;
155    let mut normalized = Map::new();
156    for (field, value) in object {
157        if field == "id" {
158            if !value.is_string() {
159                return Err(http(
160                    "decoding integration policy: generated id must be a string",
161                ));
162            }
163            continue;
164        }
165        if field == compiled_field {
166            if !value.is_object() {
167                return Err(http(format!(
168                    "decoding integration policy: {compiled_field} must be an object"
169                )));
170            }
171            continue;
172        }
173        if field == "streams" {
174            let streams = value.as_object().ok_or_else(|| {
175                http("decoding integration policy: input streams must be an object")
176            })?;
177            let mut normalized_streams = Map::new();
178            for (stream_id, stream) in streams {
179                normalized_streams.insert(
180                    stream_id.clone(),
181                    normalize_package_map(stream, "compiled_stream")?,
182                );
183            }
184            normalized.insert(field.clone(), Value::Object(normalized_streams));
185        } else {
186            normalized.insert(field.clone(), value.clone());
187        }
188    }
189    Ok(Value::Object(normalized))
190}
191
192fn portability_check(item: &Map<String, Value>, id: &str, active_space: &str) -> Result<()> {
193    let mut reasons = BTreeSet::new();
194    required_true(item, "enabled", id)?;
195    if let Some(value) = item.get("elasticsearch")
196        && !value.is_object()
197    {
198        return Err(http(format!(
199            "decoding integration policy '{id}': elasticsearch must be an object"
200        )));
201    }
202    if let Some(value) = item.get("package_agent_version_condition")
203        && !value.is_null()
204        && !value.is_string()
205    {
206        return Err(http(format!(
207            "decoding integration policy '{id}': package_agent_version_condition must be a string or null"
208        )));
209    }
210    for field in [
211        "is_managed",
212        "supports_agentless",
213        "supports_cloud_connector",
214    ] {
215        if let Some(true) = optional_bool(item, field, id)? {
216            reasons.insert(field);
217        }
218    }
219    for field in ["output_id", "cloud_connector_id", "cloud_connector_name"] {
220        match item.get(field) {
221            None | Some(Value::Null) | Some(Value::Bool(false)) => {}
222            Some(Value::String(_)) => {
223                reasons.insert(field);
224            }
225            Some(_) => {
226                return Err(http(format!(
227                    "decoding integration policy '{id}': {field} must be a string or null"
228                )));
229            }
230        }
231    }
232    match item.get("secret_references") {
233        None | Some(Value::Null) => {}
234        Some(Value::Array(references)) if references.is_empty() => {}
235        Some(Value::Array(_)) => {
236            reasons.insert("secret_references");
237        }
238        Some(_) => {
239            return Err(http(format!(
240                "decoding integration policy '{id}': secret_references must be an array or null"
241            )));
242        }
243    }
244    let active = if active_space.is_empty() {
245        "default"
246    } else {
247        active_space
248    };
249    match item.get("spaceIds") {
250        None | Some(Value::Null) => {}
251        Some(Value::Array(spaces)) => {
252            for space in spaces {
253                let space = space.as_str().filter(|space| !space.is_empty()).ok_or_else(|| {
254                    http(format!(
255                        "decoding integration policy '{id}': spaceIds must contain non-empty strings"
256                    ))
257                })?;
258                if space != active {
259                    reasons.insert("spaceIds");
260                }
261            }
262        }
263        Some(_) => {
264            return Err(http(format!(
265                "decoding integration policy '{id}': spaceIds must be an array or null"
266            )));
267        }
268    }
269    if let Some(policy_id) = item.get("policy_id").filter(|value| !value.is_null()) {
270        let policy_id = policy_id
271            .as_str()
272            .filter(|value| !value.is_empty())
273            .ok_or_else(|| {
274                http(format!(
275                    "decoding integration policy '{id}': policy_id must be a non-empty string"
276                ))
277            })?;
278        let policy_ids = item
279            .get("policy_ids")
280            .and_then(Value::as_array)
281            .ok_or_else(|| {
282                http(format!(
283                    "decoding integration policy '{id}': policy_ids must be an array"
284                ))
285            })?;
286        if policy_ids.first().and_then(Value::as_str) != Some(policy_id) {
287            return Err(http(format!(
288                "decoding integration policy '{id}': policy_id must equal policy_ids[0]"
289            )));
290        }
291    }
292    if reasons.is_empty() {
293        Ok(())
294    } else {
295        unsupported(format!(
296            "integration policy '{id}' is not portable: {}",
297            reasons.into_iter().collect::<Vec<_>>().join(", ")
298        ))
299    }
300}
301
302fn required_true(item: &Map<String, Value>, field: &str, id: &str) -> Result<()> {
303    match item.get(field) {
304        Some(Value::Bool(true)) => Ok(()),
305        Some(Value::Bool(false)) => unsupported(format!(
306            "integration policy '{id}' is not portable: {field}"
307        )),
308        _ => Err(http(format!(
309            "decoding integration policy '{id}': {field} must be true"
310        ))),
311    }
312}
313
314fn reject_unknown_top_level(item: &Map<String, Value>, id: &str) -> Result<()> {
315    let known: BTreeSet<&str> = ["id", "name", "policy_ids", "package", "inputs"]
316        .into_iter()
317        .chain(PORTABLE_OPTIONAL)
318        .chain(REMOVED_FIELDS)
319        .collect();
320    if let Some(field) = item
321        .keys()
322        .map(String::as_str)
323        .filter(|field| !known.contains(field))
324        .min()
325    {
326        return unsupported(format!(
327            "integration policy '{id}' carries unknown field '{field}'"
328        ));
329    }
330    Ok(())
331}