elasticctl_api/fleet/integration_policy_ops/
normalization.rs1use 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
34pub 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
149fn 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}