Skip to main content

unifly_api/convert/
firewall.rs

1use crate::integration_types;
2use crate::model::common::DataSource;
3use crate::model::entity_id::EntityId;
4use crate::model::firewall::{
5    AclAction, AclRule, AclRuleType, FirewallAction, FirewallGroup, FirewallGroupType,
6    FirewallPolicy, FirewallZone, IpSpec, PolicyEndpoint, PortSpec, TrafficFilter,
7};
8
9use super::helpers::origin_from_metadata;
10
11// ── Firewall Policy ──────────────────────────────────────────────
12
13impl From<integration_types::FirewallPolicyResponse> for FirewallPolicy {
14    fn from(p: integration_types::FirewallPolicyResponse) -> Self {
15        let action = p.action.get("type").and_then(|v| v.as_str()).map_or(
16            FirewallAction::Block,
17            |a| match a {
18                "ALLOW" => FirewallAction::Allow,
19                "REJECT" => FirewallAction::Reject,
20                _ => FirewallAction::Block,
21            },
22        );
23
24        #[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
25        let index = p
26            .extra
27            .get("index")
28            .and_then(serde_json::Value::as_i64)
29            .map(|i| i as i32);
30
31        let source_endpoint =
32            convert_policy_endpoint(p.source.as_ref(), p.extra.get("sourceFirewallZoneId"));
33        let destination_endpoint = convert_dest_policy_endpoint(
34            p.destination.as_ref(),
35            p.extra.get("destinationFirewallZoneId"),
36        );
37
38        let source_summary = source_endpoint.filter.as_ref().map(TrafficFilter::summary);
39        let destination_summary = destination_endpoint
40            .filter
41            .as_ref()
42            .map(TrafficFilter::summary);
43
44        let ip_version = p
45            .ip_protocol_scope
46            .as_ref()
47            .and_then(|v| v.get("ipVersion"))
48            .and_then(|v| v.as_str())
49            .map_or(crate::model::firewall::IpVersion::Both, |s| match s {
50                "IPV4_ONLY" | "IPV4" => crate::model::firewall::IpVersion::Ipv4,
51                "IPV6_ONLY" | "IPV6" => crate::model::firewall::IpVersion::Ipv6,
52                _ => crate::model::firewall::IpVersion::Both,
53            });
54
55        let ipsec_mode = p
56            .extra
57            .get("ipsecFilter")
58            .and_then(|v| v.as_str())
59            .map(String::from);
60
61        let connection_states = p
62            .extra
63            .get("connectionStateFilter")
64            .and_then(|v| v.as_array())
65            .map(|arr| {
66                arr.iter()
67                    .filter_map(|v| v.as_str().map(String::from))
68                    .collect()
69            })
70            .unwrap_or_default();
71
72        let id = p.id.map_or_else(
73            || synthesize_legacy_policy_id(&source_endpoint, &destination_endpoint, index, &p.name),
74            EntityId::Uuid,
75        );
76
77        FirewallPolicy {
78            id,
79            name: p.name,
80            description: p.description,
81            enabled: p.enabled,
82            index,
83            action,
84            ip_version,
85            source: source_endpoint,
86            destination: destination_endpoint,
87            source_summary,
88            destination_summary,
89            protocol_summary: None,
90            schedule: None,
91            ipsec_mode,
92            connection_states,
93            logging_enabled: p.logging_enabled,
94            origin: p.metadata.as_ref().and_then(origin_from_metadata),
95            data_source: DataSource::IntegrationApi,
96        }
97    }
98}
99
100fn synthesize_legacy_policy_id(
101    source: &PolicyEndpoint,
102    destination: &PolicyEndpoint,
103    index: Option<i32>,
104    name: &str,
105) -> EntityId {
106    let src = source
107        .zone_id
108        .as_ref()
109        .map_or_else(|| "none".to_owned(), ToString::to_string);
110    let dst = destination
111        .zone_id
112        .as_ref()
113        .map_or_else(|| "none".to_owned(), ToString::to_string);
114    let idx = index.map_or_else(|| "noindex".to_owned(), |i| i.to_string());
115    EntityId::Legacy(format!("fwp:legacy:{src}:{dst}:{idx}:{name}"))
116}
117
118fn convert_policy_endpoint(
119    endpoint: Option<&integration_types::FirewallPolicySource>,
120    flat_zone_id: Option<&serde_json::Value>,
121) -> PolicyEndpoint {
122    if let Some(ep) = endpoint {
123        PolicyEndpoint {
124            zone_id: ep.zone_id.map(EntityId::Uuid),
125            filter: ep
126                .traffic_filter
127                .as_ref()
128                .map(convert_source_traffic_filter),
129        }
130    } else {
131        let zone_id = flat_zone_id
132            .and_then(|v| v.as_str())
133            .and_then(|s| uuid::Uuid::parse_str(s).ok())
134            .map(EntityId::Uuid);
135        PolicyEndpoint {
136            zone_id,
137            filter: None,
138        }
139    }
140}
141
142fn convert_dest_policy_endpoint(
143    endpoint: Option<&integration_types::FirewallPolicyDestination>,
144    flat_zone_id: Option<&serde_json::Value>,
145) -> PolicyEndpoint {
146    if let Some(ep) = endpoint {
147        PolicyEndpoint {
148            zone_id: ep.zone_id.map(EntityId::Uuid),
149            filter: ep.traffic_filter.as_ref().map(convert_dest_traffic_filter),
150        }
151    } else {
152        let zone_id = flat_zone_id
153            .and_then(|v| v.as_str())
154            .and_then(|s| uuid::Uuid::parse_str(s).ok())
155            .map(EntityId::Uuid);
156        PolicyEndpoint {
157            zone_id,
158            filter: None,
159        }
160    }
161}
162
163fn convert_source_traffic_filter(f: &integration_types::SourceTrafficFilter) -> TrafficFilter {
164    use integration_types::SourceTrafficFilter as S;
165    match f {
166        S::Network {
167            network_filter,
168            mac_address_filter,
169            port_filter,
170        } => TrafficFilter::Network {
171            network_ids: network_filter
172                .network_ids
173                .iter()
174                .copied()
175                .map(EntityId::Uuid)
176                .collect(),
177            match_opposite: network_filter.match_opposite,
178            mac_addresses: mac_address_filter
179                .as_ref()
180                .map(|m| m.mac_addresses.clone())
181                .unwrap_or_default(),
182            ports: port_filter.as_ref().map(convert_port_filter),
183        },
184        S::IpAddress {
185            ip_address_filter,
186            mac_address_filter,
187            port_filter,
188        } => TrafficFilter::IpAddress {
189            addresses: convert_ip_address_filter(ip_address_filter),
190            match_opposite: ip_filter_match_opposite(ip_address_filter),
191            mac_addresses: mac_address_filter
192                .as_ref()
193                .map(|m| m.mac_addresses.clone())
194                .unwrap_or_default(),
195            ports: port_filter.as_ref().map(convert_port_filter),
196        },
197        S::MacAddress {
198            mac_address_filter,
199            port_filter,
200        } => TrafficFilter::MacAddress {
201            mac_addresses: mac_address_filter.mac_addresses.clone(),
202            ports: port_filter.as_ref().map(convert_port_filter),
203        },
204        S::Port { port_filter } => TrafficFilter::Port {
205            ports: convert_port_filter(port_filter),
206        },
207        S::Region {
208            region_filter,
209            port_filter,
210        } => TrafficFilter::Region {
211            regions: region_filter.regions.clone(),
212            ports: port_filter.as_ref().map(convert_port_filter),
213        },
214        S::Unknown => TrafficFilter::Other {
215            raw_type: "UNKNOWN".into(),
216        },
217    }
218}
219
220fn convert_dest_traffic_filter(f: &integration_types::DestTrafficFilter) -> TrafficFilter {
221    use integration_types::DestTrafficFilter as D;
222    match f {
223        D::Network {
224            network_filter,
225            port_filter,
226        } => TrafficFilter::Network {
227            network_ids: network_filter
228                .network_ids
229                .iter()
230                .copied()
231                .map(EntityId::Uuid)
232                .collect(),
233            match_opposite: network_filter.match_opposite,
234            mac_addresses: Vec::new(),
235            ports: port_filter.as_ref().map(convert_port_filter),
236        },
237        D::IpAddress {
238            ip_address_filter,
239            port_filter,
240        } => TrafficFilter::IpAddress {
241            addresses: convert_ip_address_filter(ip_address_filter),
242            match_opposite: ip_filter_match_opposite(ip_address_filter),
243            mac_addresses: Vec::new(),
244            ports: port_filter.as_ref().map(convert_port_filter),
245        },
246        D::Port { port_filter } => TrafficFilter::Port {
247            ports: convert_port_filter(port_filter),
248        },
249        D::Region {
250            region_filter,
251            port_filter,
252        } => TrafficFilter::Region {
253            regions: region_filter.regions.clone(),
254            ports: port_filter.as_ref().map(convert_port_filter),
255        },
256        D::Application {
257            application_filter,
258            port_filter,
259        } => TrafficFilter::Application {
260            application_ids: application_filter.application_ids.clone(),
261            ports: port_filter.as_ref().map(convert_port_filter),
262        },
263        D::ApplicationCategory {
264            application_category_filter,
265            port_filter,
266        } => TrafficFilter::ApplicationCategory {
267            category_ids: application_category_filter.application_category_ids.clone(),
268            ports: port_filter.as_ref().map(convert_port_filter),
269        },
270        D::Domain {
271            domain_filter,
272            port_filter,
273        } => {
274            let domains = match domain_filter {
275                integration_types::DomainFilter::Specific { domains } => domains.clone(),
276                integration_types::DomainFilter::Unknown => Vec::new(),
277            };
278            TrafficFilter::Domain {
279                domains,
280                ports: port_filter.as_ref().map(convert_port_filter),
281            }
282        }
283        D::Unknown => TrafficFilter::Other {
284            raw_type: "UNKNOWN".into(),
285        },
286    }
287}
288
289fn convert_port_filter(pf: &integration_types::PortFilter) -> PortSpec {
290    match pf {
291        integration_types::PortFilter::Ports {
292            items,
293            match_opposite,
294        } => PortSpec::Values {
295            items: items
296                .iter()
297                .map(|item| match item {
298                    integration_types::PortItem::Number { value } => value.clone(),
299                    integration_types::PortItem::Range {
300                        start_port,
301                        end_port,
302                    } => format!("{start_port}-{end_port}"),
303                    integration_types::PortItem::Unknown => "?".into(),
304                })
305                .collect(),
306            match_opposite: *match_opposite,
307        },
308        integration_types::PortFilter::TrafficMatchingList {
309            traffic_matching_list_id,
310            match_opposite,
311        } => PortSpec::MatchingList {
312            list_id: EntityId::Uuid(*traffic_matching_list_id),
313            match_opposite: *match_opposite,
314        },
315        integration_types::PortFilter::Unknown => PortSpec::Values {
316            items: Vec::new(),
317            match_opposite: false,
318        },
319    }
320}
321
322fn convert_ip_address_filter(f: &integration_types::IpAddressFilter) -> Vec<IpSpec> {
323    match f {
324        integration_types::IpAddressFilter::Specific { items, .. } => items
325            .iter()
326            .map(|item| match item {
327                integration_types::IpAddressItem::Address { value } => IpSpec::Address {
328                    value: value.clone(),
329                },
330                integration_types::IpAddressItem::Range { start, stop } => IpSpec::Range {
331                    start: start.clone(),
332                    stop: stop.clone(),
333                },
334                integration_types::IpAddressItem::Subnet { value } => IpSpec::Subnet {
335                    value: value.clone(),
336                },
337            })
338            .collect(),
339        integration_types::IpAddressFilter::TrafficMatchingList {
340            traffic_matching_list_id,
341            ..
342        } => vec![IpSpec::MatchingList {
343            list_id: EntityId::Uuid(*traffic_matching_list_id),
344        }],
345        integration_types::IpAddressFilter::Unknown => Vec::new(),
346    }
347}
348
349fn ip_filter_match_opposite(f: &integration_types::IpAddressFilter) -> bool {
350    match f {
351        integration_types::IpAddressFilter::Specific { match_opposite, .. }
352        | integration_types::IpAddressFilter::TrafficMatchingList { match_opposite, .. } => {
353            *match_opposite
354        }
355        integration_types::IpAddressFilter::Unknown => false,
356    }
357}
358
359// ── Firewall Zone ────────────────────────────────────────────────
360
361impl From<integration_types::FirewallZoneResponse> for FirewallZone {
362    fn from(z: integration_types::FirewallZoneResponse) -> Self {
363        FirewallZone {
364            id: EntityId::Uuid(z.id),
365            name: z.name,
366            network_ids: z.network_ids.into_iter().map(EntityId::Uuid).collect(),
367            origin: origin_from_metadata(&z.metadata),
368            source: DataSource::IntegrationApi,
369        }
370    }
371}
372
373// ── ACL Rule ─────────────────────────────────────────────────────
374
375impl From<integration_types::AclRuleResponse> for AclRule {
376    fn from(r: integration_types::AclRuleResponse) -> Self {
377        let rule_type = match r.rule_type.as_str() {
378            "MAC" => AclRuleType::Mac,
379            _ => AclRuleType::Ipv4,
380        };
381
382        let action = match r.action.as_str() {
383            "ALLOW" => AclAction::Allow,
384            _ => AclAction::Block,
385        };
386
387        AclRule {
388            id: EntityId::Uuid(r.id),
389            name: r.name,
390            enabled: r.enabled,
391            rule_type,
392            action,
393            source_summary: None,
394            destination_summary: None,
395            origin: origin_from_metadata(&r.metadata),
396            source: DataSource::IntegrationApi,
397        }
398    }
399}
400
401// ── Firewall Group ──────────────────────────────────────────────
402
403/// Parse a firewall group from a `rest/firewallgroup` Session API response.
404pub fn firewall_group_from_session(v: &serde_json::Value) -> Option<FirewallGroup> {
405    let id_str = v.get("_id").and_then(|v| v.as_str())?;
406    let name = v.get("name").and_then(|v| v.as_str())?.to_owned();
407    let group_type_str = v
408        .get("group_type")
409        .and_then(|v| v.as_str())
410        .unwrap_or("port-group");
411    let group_type = match group_type_str {
412        "address-group" => FirewallGroupType::AddressGroup,
413        "ipv6-address-group" => FirewallGroupType::Ipv6AddressGroup,
414        _ => FirewallGroupType::PortGroup,
415    };
416    let group_members = v
417        .get("group_members")
418        .and_then(|v| v.as_array())
419        .map(|arr| {
420            arr.iter()
421                .filter_map(|v| v.as_str().map(ToOwned::to_owned))
422                .collect()
423        })
424        .unwrap_or_default();
425    let external_id = v
426        .get("external_id")
427        .and_then(|v| v.as_str())
428        .map(ToOwned::to_owned);
429
430    Some(FirewallGroup {
431        id: EntityId::from(id_str.to_owned()),
432        external_id,
433        name,
434        group_type,
435        group_members,
436        source: DataSource::SessionApi,
437    })
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use serde_json::json;
444
445    #[test]
446    fn firewall_policy_without_id_synthesizes_legacy_id() {
447        let raw = json!({
448            "name": "DropInvalid",
449            "enabled": true,
450            "action": {"type": "DROP"},
451            "ipProtocolScope": {"ipVersion": "IPV4_AND_IPV6"},
452            "loggingEnabled": true,
453            "source": {"zoneId": "fedb7e64-ff34-4dcb-ae6d-9645807b3329"},
454            "destination": {"zoneId": "6cfd721d-0c5d-4dde-a2ee-cfdc386ebd9c"},
455            "index": 10001
456        });
457
458        let response: integration_types::FirewallPolicyResponse =
459            serde_json::from_value(raw).expect("legacy policy without id should parse");
460        assert!(response.id.is_none());
461
462        let policy = FirewallPolicy::from(response);
463        assert_eq!(
464            policy.id.to_string(),
465            "fwp:legacy:fedb7e64-ff34-4dcb-ae6d-9645807b3329:6cfd721d-0c5d-4dde-a2ee-cfdc386ebd9c:10001:DropInvalid"
466        );
467        assert_eq!(policy.name, "DropInvalid");
468        assert!(policy.logging_enabled);
469    }
470
471    #[test]
472    fn firewall_group_from_session_parses_port_group() {
473        let raw = json!({
474            "_id": "69de8b58259db29f6591fb11",
475            "external_id": "24740a56-9cb9-4890-a5ac-589d30914a55",
476            "name": "HA",
477            "group_type": "port-group",
478            "group_members": ["80", "8000-8002", "49152-65535"],
479            "site_id": "69c9360d0b7006701f3fa23f"
480        });
481        let group = firewall_group_from_session(&raw).expect("should parse");
482        assert_eq!(group.name, "HA");
483        assert_eq!(group.group_type, FirewallGroupType::PortGroup);
484        assert_eq!(
485            group.external_id.as_deref(),
486            Some("24740a56-9cb9-4890-a5ac-589d30914a55")
487        );
488        assert_eq!(group.group_members, vec!["80", "8000-8002", "49152-65535"]);
489    }
490
491    #[test]
492    fn firewall_group_from_session_parses_address_group() {
493        let raw = json!({
494            "_id": "69de9bea259db29f65921b92",
495            "external_id": "b777b27c-410c-4b40-8489-a61bf1a536d4",
496            "name": "Cloud IOT",
497            "group_type": "address-group",
498            "group_members": ["10.0.30.0/24"],
499            "site_id": "69c9360d0b7006701f3fa23f"
500        });
501        let group = firewall_group_from_session(&raw).expect("should parse");
502        assert_eq!(group.name, "Cloud IOT");
503        assert_eq!(group.group_type, FirewallGroupType::AddressGroup);
504        assert_eq!(group.group_members, vec!["10.0.30.0/24"]);
505    }
506
507    #[test]
508    fn firewall_group_from_session_returns_none_without_id() {
509        let raw = json!({
510            "name": "No ID",
511            "group_type": "port-group",
512            "group_members": ["80"]
513        });
514        assert!(firewall_group_from_session(&raw).is_none());
515    }
516
517    #[test]
518    fn firewall_group_from_session_returns_none_without_name() {
519        let raw = json!({
520            "_id": "abc123",
521            "group_type": "port-group",
522            "group_members": ["80"]
523        });
524        assert!(firewall_group_from_session(&raw).is_none());
525    }
526
527    #[test]
528    fn firewall_group_from_session_defaults_to_port_group() {
529        let raw = json!({
530            "_id": "abc123",
531            "name": "Unknown Type",
532            "group_members": ["80"]
533        });
534        let group = firewall_group_from_session(&raw).expect("should parse");
535        assert_eq!(group.group_type, FirewallGroupType::PortGroup);
536    }
537
538    #[test]
539    fn firewall_group_from_session_handles_empty_members() {
540        let raw = json!({
541            "_id": "abc123",
542            "name": "Empty",
543            "group_type": "port-group",
544            "group_members": []
545        });
546        let group = firewall_group_from_session(&raw).expect("should parse");
547        assert!(group.group_members.is_empty());
548    }
549}