diff --git a/Cargo.lock b/Cargo.lock index c83d8eb9e5..bb6512aacb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3344,9 +3344,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.24" +version = "1.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4735e9cbde5aac84a5ce588f6b23a90b9b0b528f6c5a8db8a4aff300463a0839" +checksum = "d52f4c29e2a68ac30c9087e1b772dc9f44a2b66ed44edf2266cf2be9b03dafc1" dependencies = [ "cc", "libc", @@ -5304,9 +5304,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -7803,18 +7803,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.41" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96e13bc581734df6250836c59a5f44f3c57db9f9acb9dc8e3eaabdaf6170254d" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.41" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3545ea9e86d12ab9bba9fcd99b54c1556fd3199007def5a03c375623d05fac1c" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" dependencies = [ "proc-macro2", "quote", diff --git a/crates/defguard_core/src/enterprise/db/models/acl.rs b/crates/defguard_core/src/enterprise/db/models/acl.rs index 5120e156d7..567b6b463b 100644 --- a/crates/defguard_core/src/enterprise/db/models/acl.rs +++ b/crates/defguard_core/src/enterprise/db/models/acl.rs @@ -15,7 +15,7 @@ use defguard_common::db::{ wireguard::{LocationMfaMode, ServiceLocationMode}, }, }; -use ipnetwork::{IpNetwork, IpNetworkError}; +use ipnetwork::IpNetwork; use model_derive::Model; use sqlx::{ Error as SqlxError, FromRow, PgConnection, PgExecutor, PgPool, Type, error::ErrorKind, @@ -563,6 +563,40 @@ pub(crate) struct ParsedDestination { pub(crate) ranges: Vec<(IpAddr, IpAddr)>, } +fn invalid_destination_range(range: &str) -> AclError { + error!("Failed to parse destination range token: \"{range}\""); + AclError::InvalidIpRangeError(range.to_string()) +} + +fn parse_destination_range(range: &str) -> Result<(IpAddr, IpAddr), AclError> { + let Some((start, end)) = range.split_once('-') else { + return Err(invalid_destination_range(range)); + }; + + if start.is_empty() || end.is_empty() || end.contains('-') { + return Err(invalid_destination_range(range)); + } + + if start.contains('/') || end.contains('/') { + return Err(invalid_destination_range(range)); + } + + let start = start.parse::()?; + let end = end.parse::()?; + + let is_non_increasing = match (&start, &end) { + (IpAddr::V4(start), IpAddr::V4(end)) => start.octets() >= end.octets(), + (IpAddr::V6(start), IpAddr::V6(end)) => start.octets() >= end.octets(), + _ => return Err(invalid_destination_range(range)), + }; + + if is_non_increasing { + return Err(invalid_destination_range(range)); + } + + Ok((start, end)) +} + /// Perses a destination string into singular ip addresses or networks and address /// ranges. We should be able to parse a string like this one: /// `10.0.0.1/24, 10.1.1.10-10.1.1.20, 192.168.1.10, 10.1.1.1-10.10.1.1` @@ -573,16 +607,11 @@ pub(crate) fn parse_destination_addresses( let destination: String = destination.chars().filter(|c| !c.is_whitespace()).collect(); let mut result = ParsedDestination::default(); if !destination.is_empty() { - for v in destination.split(',') { - match v.split('-').collect::>() { - l if l.len() == 1 => result.addrs.push(l[0].parse::()?), - l if l.len() == 2 => result - .ranges - .push((l[0].parse::()?, l[1].parse::()?)), - _ => { - error!("Failed to parse destination string: \"{destination}\""); - Err(IpNetworkError::InvalidAddr(destination.clone()))?; - } + for token in destination.split(',') { + if token.contains('-') { + result.ranges.push(parse_destination_range(token)?); + } else { + result.addrs.push(token.parse::()?); } } } @@ -594,6 +623,34 @@ pub(crate) fn parse_destination_addresses( /// Parses ports string into singular ports and port ranges /// We should be able to parse a string like this one: /// `22, 23, 8000-9000, 80-90` +fn invalid_ports_format(ports: &str) -> AclError { + error!("Failed to parse ports string: \"{ports}\""); + AclError::InvalidPortsFormat(ports.to_string()) +} + +fn parse_port_token(port_token: &str, ports: &str) -> Result { + if port_token.is_empty() { + return Err(invalid_ports_format(ports)); + } + + let Some((start, end)) = port_token.split_once('-') else { + let port = port_token.parse::()?; + return Ok(PortRange::new(port, port)); + }; + + if start.is_empty() || end.is_empty() || end.contains('-') { + return Err(invalid_ports_format(ports)); + } + + let start = start.parse::()?; + let end = end.parse::()?; + if start >= end { + return Err(invalid_ports_format(ports)); + } + + Ok(PortRange::new(start, end)) +} + pub fn parse_ports(ports: &str) -> Result, AclError> { debug!("Parsing ports string: {ports}"); let mut result = Vec::new(); @@ -602,22 +659,8 @@ pub fn parse_ports(ports: &str) -> Result, AclError> { .filter(|c| !c.is_whitespace()) .collect::(); if !ports.is_empty() { - for v in ports.split(',') { - match v.split('-').collect::>() { - l if l.len() == 1 => { - let start = l[0].parse::()?; - result.push(PortRange::new(start, start)); - } - l if l.len() == 2 => { - let start = l[0].parse::()?; - let end = l[1].parse::()?; - result.push(PortRange::new(start, end)); - } - _ => { - error!("Failed to parse ports string: \"{ports}\""); - return Err(AclError::InvalidPortsFormat(ports.clone())); - } - } + for port_token in ports.split(',') { + result.push(parse_port_token(port_token, &ports)?); } } @@ -745,12 +788,6 @@ impl AclRule { let destination = parse_destination_addresses(&api_rule.addresses)?; debug!("Creating related destination ranges for ACL rule {rule_id}"); for range in destination.ranges { - if range.1 <= range.0 { - return Err(AclError::InvalidIpRangeError(format!( - "{}-{}", - range.0, range.1 - ))); - } let obj = AclRuleDestinationRange { id: NoId, rule_id, diff --git a/crates/defguard_core/src/enterprise/db/models/acl/tests.rs b/crates/defguard_core/src/enterprise/db/models/acl/tests.rs index 86ddeb33a0..abbc35e50b 100644 --- a/crates/defguard_core/src/enterprise/db/models/acl/tests.rs +++ b/crates/defguard_core/src/enterprise/db/models/acl/tests.rs @@ -54,7 +54,6 @@ async fn test_allow_conflicting_sources(_: PgPoolOptions, options: PgConnectOpti // create the rule let rule = AclRule { - id: NoId, name: "rule".to_string(), enabled: true, allow_all_users: false, @@ -127,7 +126,6 @@ async fn test_rule_relations(_: PgPoolOptions, options: PgConnectOptions) { // create the rule let mut rule = AclRule { - id: NoId, name: "rule".to_string(), enabled: true, allow_all_users: false, @@ -437,7 +435,6 @@ async fn test_all_allowed_users(_: PgPoolOptions, options: PgConnectOptions) { // Create test groups let group_1 = Group { - id: NoId, name: "group_1".into(), ..Default::default() } @@ -445,7 +442,6 @@ async fn test_all_allowed_users(_: PgPoolOptions, options: PgConnectOptions) { .await .unwrap(); let group_2 = Group { - id: NoId, name: "group_2".into(), ..Default::default() } @@ -476,7 +472,6 @@ async fn test_all_allowed_users(_: PgPoolOptions, options: PgConnectOptions) { // Create ACL rule let rule = AclRule { - id: NoId, name: "test_rule".to_string(), allow_all_users: false, deny_all_users: false, @@ -552,7 +547,6 @@ async fn test_all_denied_users(_: PgPoolOptions, options: PgConnectOptions) { // Create test groups let group_1 = Group { - id: NoId, name: "group_1".into(), ..Default::default() } @@ -560,7 +554,6 @@ async fn test_all_denied_users(_: PgPoolOptions, options: PgConnectOptions) { .await .unwrap(); let group_2 = Group { - id: NoId, name: "group_2".into(), ..Default::default() } @@ -591,7 +584,6 @@ async fn test_all_denied_users(_: PgPoolOptions, options: PgConnectOptions) { // Create ACL rule let rule = AclRule { - id: NoId, name: "test_rule".to_string(), allow_all_users: false, deny_all_users: false, @@ -656,3 +648,110 @@ async fn test_all_denied_users(_: PgPoolOptions, options: PgConnectOptions) { assert!(denied_users.iter().any(|u| u.id == user_3.id)); assert!(!denied_users.iter().any(|u| u.id == user_4.id)); } + +#[test] +fn test_parse_ports_rejects_non_increasing_ranges() { + assert!(matches!( + parse_ports("200-100"), + Err(AclError::InvalidPortsFormat(input)) if input == "200-100" + )); + assert!(matches!( + parse_ports("100-100"), + Err(AclError::InvalidPortsFormat(input)) if input == "100-100" + )); +} + +#[test] +fn test_parse_ports_normalizes_whitespace_before_splitting() { + let parsed = parse_ports("10 - 20, 30, 1 2").unwrap(); + let parsed = parsed + .into_iter() + .map(|range| (range.first_port(), range.last_port())) + .collect::>(); + + assert_eq!(parsed, vec![(10, 20), (30, 30), (12, 12)]); +} + +#[test] +fn test_parse_ports_allows_duplicate_endpoints() { + let parsed = parse_ports("10,10,10-20,20").unwrap(); + let parsed = parsed + .into_iter() + .map(|range| (range.first_port(), range.last_port())) + .collect::>(); + + assert_eq!(parsed, vec![(10, 10), (10, 10), (10, 20), (20, 20)]); +} + +#[test] +fn test_parse_ports_rejects_malformed_range_tokens() { + assert!(matches!( + parse_ports("1-2-3"), + Err(AclError::InvalidPortsFormat(input)) if input == "1-2-3" + )); +} + +#[test] +fn test_parse_destination_addresses_allows_empty_and_strips_whitespace() { + let parsed = parse_destination_addresses(" \n\t ").unwrap(); + + assert!(parsed.addrs.is_empty()); + assert!(parsed.ranges.is_empty()); +} + +#[test] +fn test_parse_destination_addresses_accepts_single_ips_cidrs_and_ranges() { + let parsed = + parse_destination_addresses(" 10.0.0.1 , 10.0.0.0/24 , 2001:db8::1-2001:db8::2 ").unwrap(); + + assert_eq!( + parsed.addrs, + vec![ + "10.0.0.1".parse::().unwrap(), + "10.0.0.0/24".parse::().unwrap(), + ] + ); + assert_eq!( + parsed.ranges, + vec![( + "2001:db8::1".parse::().unwrap(), + "2001:db8::2".parse::().unwrap(), + )] + ); +} + +#[test] +fn test_parse_destination_addresses_rejects_invalid_ranges() { + for input in [ + "10.0.0.2-10.0.0.1", + "10.0.0.1-10.0.0.1", + "10.0.0.1-2001:db8::1", + "10.0.0.1-10.0.0.2-10.0.0.3", + "10.0.0.0/24-10.0.0.2", + ] { + assert!(matches!( + parse_destination_addresses(input), + Err(AclError::InvalidIpRangeError(range)) if range == input + )); + } +} + +#[test] +fn test_parse_destination_addresses_rejects_multi_slash_cidr_tokens() { + for input in ["10.0.0.1/24/25", "2001:db8::1/64/65"] { + assert!(matches!( + parse_destination_addresses(input), + Err(AclError::IpNetworkError(_)) + )); + } +} + +#[test] +fn test_parse_destination_addresses_rejects_malformed_cidr_prefix_tokens() { + for input in ["10.0.0.1/1e1", "10.0.0.1/0x18", "2001:db8::1/64foo"] { + assert!(matches!( + parse_destination_addresses(input), + Err(AclError::IpNetworkError(_)) + )); + } +} diff --git a/crates/defguard_core/src/enterprise/firewall/mod.rs b/crates/defguard_core/src/enterprise/firewall/mod.rs index 442750b8a0..02884c03bc 100644 --- a/crates/defguard_core/src/enterprise/firewall/mod.rs +++ b/crates/defguard_core/src/enterprise/firewall/mod.rs @@ -16,10 +16,7 @@ use ipnetwork::IpNetwork; use sqlx::{Error as SqlxError, PgConnection, query_as, query_scalar}; use super::{ - db::models::acl::{ - AclAliasDestinationRange, AclRule, AclRuleDestinationRange, AclRuleInfo, PortRange, - Protocol, - }, + db::models::acl::{AclRule, AclRuleDestinationRange, AclRuleInfo, PortRange, Protocol}, utils::merge_ranges, }; use crate::enterprise::{ @@ -241,7 +238,13 @@ async fn get_manual_destination_rules( let (dest_addrs_v4, dest_addrs_v6) = if any_address { (Vec::new(), Vec::new()) } else { - process_destination_addrs(&addresses, &address_ranges) + process_destination_addrs( + &addresses, + address_ranges + .iter() + .map(RangeInclusive::from) + .chain(alias_destination_ranges.iter().map(RangeInclusive::from)), + ) }; // prepare destination ports @@ -326,7 +329,10 @@ async fn get_predefined_destination_rules( let (dest_addrs_v4, dest_addrs_v6) = if destination.any_address { (Vec::new(), Vec::new()) } else { - process_alias_destination_addrs(&destination.addresses, &alias_destination_ranges) + process_destination_addrs( + &destination.addresses, + alias_destination_ranges.iter().map(RangeInclusive::from), + ) }; // process alias ports @@ -569,55 +575,13 @@ fn get_source_addrs( /// /// Return a 2-tuple of `Vec` with all IPv4 addresses in the /// first field and IPv6 addresses in the second. -fn process_destination_addrs( - dest_ipnets: &[IpNetwork], - dest_ranges: &[AclRuleDestinationRange], -) -> (Vec, Vec) { - // Separate IP v4 and v6 addresses and convert networks to intermediate range representation for merging - let ipv4_dest_net_addrs = dest_ipnets - .iter() - .filter(|dst| dst.is_ipv4()) - .map(|dst| dst.network()..=dst.broadcast()); - let ipv6_dest_net_addrs = dest_ipnets.iter().filter_map(|dst| { - if let IpNetwork::V6(subnet) = dst { - let range_start = subnet.network().into(); - let range_end = get_last_ip_in_v6_subnet(subnet); - Some(range_start..=range_end) - } else { - None - } - }); - - // Separate IP v4 and v6 ranges. - let ipv4_dest_ranges = dest_ranges - .iter() - .filter(|dst| dst.start.is_ipv4() && dst.end.is_ipv4()) - .map(RangeInclusive::from); - let ipv6_dest_ranges = dest_ranges - .iter() - .filter(|dst| dst.start.is_ipv6() && dst.end.is_ipv6()) - .map(RangeInclusive::from); - - // combine iterators - let ipv4_dest_addrs = ipv4_dest_net_addrs.chain(ipv4_dest_ranges).collect(); - let ipv6_dest_addrs = ipv6_dest_net_addrs.chain(ipv6_dest_ranges).collect(); - - (merge_addrs(ipv4_dest_addrs), merge_addrs(ipv6_dest_addrs)) -} - -/// Convert destination networks and ranges configured in an ACL alias -/// into the correct format for a firewall rule. This includes: -/// - combining all addr lists -/// - converting to gRPC IpAddress struct -/// - merging into the smallest possible list of non-overlapping ranges, -/// subnets and addresses -/// -/// Return a 2-tuple of `Vec` with all IPv4 addresses in the -/// first field and IPv6 addresses in the second. -fn process_alias_destination_addrs( +fn process_destination_addrs( dest_ipnets: &[IpNetwork], - dest_ranges: &[AclAliasDestinationRange], -) -> (Vec, Vec) { + dest_ranges: I, +) -> (Vec, Vec) +where + I: IntoIterator>, +{ // Separate IP v4 and v6 addresses and convert networks to intermediate range representation for merging let ipv4_dest_net_addrs = dest_ipnets .iter() @@ -634,14 +598,17 @@ fn process_alias_destination_addrs( }); // Separate IP v4 and v6 ranges. - let ipv4_dest_ranges = dest_ranges - .iter() - .filter(|dst| dst.start.is_ipv4() && dst.end.is_ipv4()) - .map(RangeInclusive::from); - let ipv6_dest_ranges = dest_ranges - .iter() - .filter(|dst| dst.start.is_ipv6() && dst.end.is_ipv6()) - .map(RangeInclusive::from); + let (ipv4_dest_ranges, ipv6_dest_ranges) = + dest_ranges + .into_iter() + .fold((Vec::new(), Vec::new()), |mut ranges, range| { + if range.start().is_ipv4() && range.end().is_ipv4() { + ranges.0.push(range); + } else if range.start().is_ipv6() && range.end().is_ipv6() { + ranges.1.push(range); + } + ranges + }); // combine iterators let ipv4_dest_addrs = ipv4_dest_net_addrs.chain(ipv4_dest_ranges).collect(); diff --git a/crates/defguard_core/src/enterprise/firewall/tests/all_locations.rs b/crates/defguard_core/src/enterprise/firewall/tests/all_locations.rs index fecc62ee74..fd6c780191 100644 --- a/crates/defguard_core/src/enterprise/firewall/tests/all_locations.rs +++ b/crates/defguard_core/src/enterprise/firewall/tests/all_locations.rs @@ -1,6 +1,6 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -use defguard_common::db::{NoId, models::WireguardNetwork, setup_pool}; +use defguard_common::db::{models::WireguardNetwork, setup_pool}; use ipnetwork::IpNetwork; use rand::thread_rng; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; @@ -21,7 +21,6 @@ async fn test_acl_rules_all_locations_ipv4(_: PgPoolOptions, options: PgConnectO // Create test location let location_1 = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec![IpNetwork::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0).unwrap()], ..Default::default() @@ -30,7 +29,6 @@ async fn test_acl_rules_all_locations_ipv4(_: PgPoolOptions, options: PgConnectO // Create another test location let location_2 = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec![IpNetwork::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0).unwrap()], ..Default::default() @@ -42,7 +40,6 @@ async fn test_acl_rules_all_locations_ipv4(_: PgPoolOptions, options: PgConnectO // create ACL rules let acl_rule_1 = AclRule { - id: NoId, expires: None, enabled: true, allow_all_users: true, @@ -56,7 +53,6 @@ async fn test_acl_rules_all_locations_ipv4(_: PgPoolOptions, options: PgConnectO .unwrap(); let acl_rule_2 = AclRule { - id: NoId, expires: None, enabled: true, all_locations: true, @@ -71,7 +67,6 @@ async fn test_acl_rules_all_locations_ipv4(_: PgPoolOptions, options: PgConnectO .unwrap(); let _acl_rule_3 = AclRule { - id: NoId, expires: None, enabled: true, all_locations: true, @@ -127,7 +122,6 @@ async fn test_acl_rules_all_locations_ipv6(_: PgPoolOptions, options: PgConnectO // Create test location let location_1 = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec![IpNetwork::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0).unwrap()], ..Default::default() @@ -136,7 +130,6 @@ async fn test_acl_rules_all_locations_ipv6(_: PgPoolOptions, options: PgConnectO // Create another test location let location_2 = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec![IpNetwork::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0).unwrap()], ..Default::default() @@ -148,7 +141,6 @@ async fn test_acl_rules_all_locations_ipv6(_: PgPoolOptions, options: PgConnectO // create ACL rules let acl_rule_1 = AclRule { - id: NoId, expires: None, enabled: true, allow_all_users: true, @@ -162,7 +154,6 @@ async fn test_acl_rules_all_locations_ipv6(_: PgPoolOptions, options: PgConnectO .unwrap(); let acl_rule_2 = AclRule { - id: NoId, expires: None, enabled: true, allow_all_users: true, @@ -177,7 +168,6 @@ async fn test_acl_rules_all_locations_ipv6(_: PgPoolOptions, options: PgConnectO .unwrap(); let _acl_rule_3 = AclRule { - id: NoId, expires: None, enabled: true, all_locations: true, @@ -233,7 +223,6 @@ async fn test_acl_rules_all_locations_ipv4_and_ipv6(_: PgPoolOptions, options: P // Create test location let location_1 = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec![ IpNetwork::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0).unwrap(), @@ -245,7 +234,6 @@ async fn test_acl_rules_all_locations_ipv4_and_ipv6(_: PgPoolOptions, options: P // Create another test location let location_2 = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec![ IpNetwork::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0).unwrap(), @@ -260,7 +248,6 @@ async fn test_acl_rules_all_locations_ipv4_and_ipv6(_: PgPoolOptions, options: P // create ACL rules let acl_rule_1 = AclRule { - id: NoId, expires: None, enabled: true, allow_all_users: true, @@ -277,7 +264,6 @@ async fn test_acl_rules_all_locations_ipv4_and_ipv6(_: PgPoolOptions, options: P .unwrap(); let acl_rule_2 = AclRule { - id: NoId, expires: None, enabled: true, all_locations: true, @@ -295,7 +281,6 @@ async fn test_acl_rules_all_locations_ipv4_and_ipv6(_: PgPoolOptions, options: P .unwrap(); let _acl_rule_3 = AclRule { - id: NoId, expires: None, enabled: true, all_locations: true, diff --git a/crates/defguard_core/src/enterprise/firewall/tests/destination.rs b/crates/defguard_core/src/enterprise/firewall/tests/destination.rs index 278fab120f..538510e999 100644 --- a/crates/defguard_core/src/enterprise/firewall/tests/destination.rs +++ b/crates/defguard_core/src/enterprise/firewall/tests/destination.rs @@ -1,4 +1,7 @@ -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::{ + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + ops::RangeInclusive, +}; use defguard_common::db::{NoId, models::WireguardNetwork, setup_pool}; use defguard_proto::enterprise::firewall::{ @@ -38,7 +41,10 @@ fn test_process_destination_addrs_v4() { }, ]; - let destination_addrs = process_destination_addrs(&destination_ips, &destination_ranges); + let destination_addrs = process_destination_addrs( + &destination_ips, + destination_ranges.iter().map(RangeInclusive::from), + ); assert_eq!( destination_addrs.0, @@ -62,11 +68,14 @@ fn test_process_destination_addrs_v4() { ); // Test with empty input - let empty_addrs = process_destination_addrs(&[], &[]); + let empty_addrs = process_destination_addrs(&[], std::iter::empty::>()); assert!(empty_addrs.0.is_empty()); // Test with only IPv6 addresses - should return empty result for IPv4 - let ipv6_only = process_destination_addrs(&["2001:db8::/64".parse().unwrap()], &[]); + let ipv6_only = process_destination_addrs( + &["2001:db8::/64".parse().unwrap()], + std::iter::empty::>(), + ); assert!(ipv6_only.0.is_empty()); } @@ -80,7 +89,7 @@ fn test_process_destination_addrs_v6() { "2001:db8:3::/64".parse().unwrap(), ]; - let destination_ranges = vec![ + let destination_ranges = [ AclRuleDestinationRange { start: IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 4, 0, 0, 0, 0, 1)), end: IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 4, 0, 0, 0, 0, 3)), @@ -93,7 +102,10 @@ fn test_process_destination_addrs_v6() { }, ]; - let destination_addrs = process_destination_addrs(&destination_ips, &destination_ranges); + let destination_addrs = process_destination_addrs( + &destination_ips, + destination_ranges.iter().map(RangeInclusive::from), + ); assert_eq!( destination_addrs.1, @@ -117,11 +129,14 @@ fn test_process_destination_addrs_v6() { ); // Test with empty input - let empty_addrs = process_destination_addrs(&[], &[]); + let empty_addrs = process_destination_addrs(&[], std::iter::empty::>()); assert!(empty_addrs.1.is_empty()); // Test with only IPv4 addresses - should return empty result for IPv6 - let ipv4_only = process_destination_addrs(&["192.168.1.0/24".parse().unwrap()], &[]); + let ipv4_only = process_destination_addrs( + &["192.168.1.0/24".parse().unwrap()], + std::iter::empty::>(), + ); assert!(ipv4_only.1.is_empty()); } @@ -136,7 +151,6 @@ async fn test_any_address_overwrites_manual_destination( let mut rng = thread_rng(); let location = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec!["10.0.0.0/16".parse().unwrap()], ..Default::default() @@ -148,7 +162,6 @@ async fn test_any_address_overwrites_manual_destination( create_test_users_and_devices(&mut rng, &pool, vec![&location]).await; let acl_rule = AclRule { - id: NoId, name: "any destination rule".to_string(), state: RuleState::Applied, allow_all_users: true, @@ -222,7 +235,6 @@ async fn test_any_address_overwrites_destination_alias_addrs( let mut rng = thread_rng(); let location = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec!["10.0.0.0/16".parse().unwrap()], ..Default::default() @@ -234,7 +246,6 @@ async fn test_any_address_overwrites_destination_alias_addrs( create_test_users_and_devices(&mut rng, &pool, vec![&location]).await; let destination_alias = AclAlias { - id: NoId, name: "any destination alias".to_string(), kind: AliasKind::Destination, any_address: true, @@ -258,7 +269,6 @@ async fn test_any_address_overwrites_destination_alias_addrs( .unwrap(); let acl_rule = AclRule { - id: NoId, name: "any destination alias rule".to_string(), state: RuleState::Applied, allow_all_users: true, @@ -315,3 +325,224 @@ async fn test_any_address_overwrites_destination_alias_addrs( assert!(deny_rule.source_addrs.is_empty()); assert!(deny_rule.destination_addrs.is_empty()); } + +#[sqlx::test] +async fn test_manual_destination_includes_component_alias_address_range( + _: PgPoolOptions, + options: PgConnectOptions, +) { + set_test_license_business(); + let pool = setup_pool(options).await; + + let mut rng = thread_rng(); + + let location = WireguardNetwork { + acl_enabled: true, + address: vec!["10.0.0.0/16".parse().unwrap()], + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + + create_test_users_and_devices(&mut rng, &pool, vec![&location]).await; + + let component_alias = AclAlias { + name: "component alias with destination range".to_string(), + kind: AliasKind::Component, + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + + AclAliasDestinationRange { + id: NoId, + alias_id: component_alias.id, + start: IpAddr::V4(Ipv4Addr::new(10, 2, 0, 255)), + end: IpAddr::V4(Ipv4Addr::new(10, 2, 1, 0)), + } + .save(&pool) + .await + .unwrap(); + + let acl_rule = AclRule { + name: "manual destination component alias range rule".to_string(), + state: RuleState::Applied, + allow_all_users: true, + use_manual_destination_settings: true, + any_address: false, + ..Default::default() + }; + + create_acl_rule( + &pool, + acl_rule, + vec![location.id], + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + vec![component_alias.id], + ) + .await; + + let mut conn = pool.acquire().await.unwrap(); + let generated_firewall_rules = try_get_location_firewall_config(&location, &mut conn) + .await + .unwrap() + .unwrap() + .rules; + + let expected_source_addrs = [ + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "10.0.1.1".to_string(), + end: "10.0.1.2".to_string(), + })), + }, + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "10.0.2.1".to_string(), + end: "10.0.2.2".to_string(), + })), + }, + ]; + let expected_destination_addrs = [IpAddress { + address: Some(Address::IpRange(IpRange { + start: "10.2.0.255".to_string(), + end: "10.2.1.0".to_string(), + })), + }]; + + assert_eq!(generated_firewall_rules.len(), 2); + + let allow_rule = &generated_firewall_rules[0]; + assert_eq!(allow_rule.verdict, i32::from(FirewallPolicy::Allow)); + assert_eq!(allow_rule.source_addrs, expected_source_addrs); + assert_eq!(allow_rule.destination_addrs, expected_destination_addrs); + + let deny_rule = &generated_firewall_rules[1]; + assert_eq!(deny_rule.verdict, i32::from(FirewallPolicy::Deny)); + assert!(deny_rule.source_addrs.is_empty()); + assert_eq!(deny_rule.destination_addrs, expected_destination_addrs); +} + +#[sqlx::test] +async fn test_manual_destination_merges_rule_and_component_alias_address_ranges( + _: PgPoolOptions, + options: PgConnectOptions, +) { + set_test_license_business(); + let pool = setup_pool(options).await; + + let mut rng = thread_rng(); + + let location = WireguardNetwork { + acl_enabled: true, + address: vec!["10.0.0.0/16".parse().unwrap()], + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + + create_test_users_and_devices(&mut rng, &pool, vec![&location]).await; + + let component_alias = AclAlias { + name: "component alias with destination range".to_string(), + kind: AliasKind::Component, + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + + AclAliasDestinationRange { + id: NoId, + alias_id: component_alias.id, + start: IpAddr::V4(Ipv4Addr::new(10, 2, 0, 255)), + end: IpAddr::V4(Ipv4Addr::new(10, 2, 1, 0)), + } + .save(&pool) + .await + .unwrap(); + + let acl_rule = AclRule { + name: "manual destination mixed destination ranges rule".to_string(), + state: RuleState::Applied, + allow_all_users: true, + use_manual_destination_settings: true, + any_address: false, + ..Default::default() + }; + + create_acl_rule( + &pool, + acl_rule, + vec![location.id], + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + vec![( + IpAddr::V4(Ipv4Addr::new(10, 3, 0, 255)), + IpAddr::V4(Ipv4Addr::new(10, 3, 1, 0)), + )], + vec![component_alias.id], + ) + .await; + + let mut conn = pool.acquire().await.unwrap(); + let generated_firewall_rules = try_get_location_firewall_config(&location, &mut conn) + .await + .unwrap() + .unwrap() + .rules; + + let expected_source_addrs = [ + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "10.0.1.1".to_string(), + end: "10.0.1.2".to_string(), + })), + }, + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "10.0.2.1".to_string(), + end: "10.0.2.2".to_string(), + })), + }, + ]; + let expected_destination_addrs = [ + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "10.2.0.255".to_string(), + end: "10.2.1.0".to_string(), + })), + }, + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "10.3.0.255".to_string(), + end: "10.3.1.0".to_string(), + })), + }, + ]; + + assert_eq!(generated_firewall_rules.len(), 2); + + let allow_rule = &generated_firewall_rules[0]; + assert_eq!(allow_rule.verdict, i32::from(FirewallPolicy::Allow)); + assert_eq!(allow_rule.source_addrs, expected_source_addrs); + assert_eq!(allow_rule.destination_addrs, expected_destination_addrs); + + let deny_rule = &generated_firewall_rules[1]; + assert_eq!(deny_rule.verdict, i32::from(FirewallPolicy::Deny)); + assert!(deny_rule.source_addrs.is_empty()); + assert_eq!(deny_rule.destination_addrs, expected_destination_addrs); +} diff --git a/crates/defguard_core/src/enterprise/firewall/tests/disabled_rules.rs b/crates/defguard_core/src/enterprise/firewall/tests/disabled_rules.rs index b4d6ddfeec..1643e79dd6 100644 --- a/crates/defguard_core/src/enterprise/firewall/tests/disabled_rules.rs +++ b/crates/defguard_core/src/enterprise/firewall/tests/disabled_rules.rs @@ -1,6 +1,6 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -use defguard_common::db::{NoId, models::WireguardNetwork, setup_pool}; +use defguard_common::db::{models::WireguardNetwork, setup_pool}; use ipnetwork::IpNetwork; use rand::thread_rng; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; @@ -21,7 +21,6 @@ async fn test_disabled_acl_rules_ipv4(_: PgPoolOptions, options: PgConnectOption // Create test location let location = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec![IpNetwork::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0).unwrap()], ..Default::default() @@ -33,7 +32,6 @@ async fn test_disabled_acl_rules_ipv4(_: PgPoolOptions, options: PgConnectOption // create disabled ACL rules let mut acl_rule_1 = AclRule { - id: NoId, expires: None, enabled: false, state: RuleState::Applied, @@ -45,7 +43,6 @@ async fn test_disabled_acl_rules_ipv4(_: PgPoolOptions, options: PgConnectOption .await .unwrap(); let mut acl_rule_2 = AclRule { - id: NoId, expires: None, enabled: false, state: RuleState::Applied, @@ -98,7 +95,6 @@ async fn test_disabled_acl_rules_ipv6(_: PgPoolOptions, options: PgConnectOption // Create test location let location = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec![IpNetwork::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0).unwrap()], ..Default::default() @@ -110,7 +106,6 @@ async fn test_disabled_acl_rules_ipv6(_: PgPoolOptions, options: PgConnectOption // create disabled ACL rules let mut acl_rule_1 = AclRule { - id: NoId, expires: None, enabled: false, state: RuleState::Applied, @@ -122,7 +117,6 @@ async fn test_disabled_acl_rules_ipv6(_: PgPoolOptions, options: PgConnectOption .await .unwrap(); let mut acl_rule_2 = AclRule { - id: NoId, expires: None, enabled: false, state: RuleState::Applied, @@ -175,7 +169,6 @@ async fn test_disabled_acl_rules_ipv4_and_ipv6(_: PgPoolOptions, options: PgConn // Create test location let location = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec![ IpNetwork::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0).unwrap(), @@ -190,7 +183,6 @@ async fn test_disabled_acl_rules_ipv4_and_ipv6(_: PgPoolOptions, options: PgConn // create disabled ACL rules let mut acl_rule_1 = AclRule { - id: NoId, expires: None, enabled: false, state: RuleState::Applied, @@ -202,7 +194,6 @@ async fn test_disabled_acl_rules_ipv4_and_ipv6(_: PgPoolOptions, options: PgConn .await .unwrap(); let mut acl_rule_2 = AclRule { - id: NoId, expires: None, enabled: false, state: RuleState::Applied, diff --git a/crates/defguard_core/src/enterprise/firewall/tests/expired_rules.rs b/crates/defguard_core/src/enterprise/firewall/tests/expired_rules.rs index aa4c21a7d9..2137b529cd 100644 --- a/crates/defguard_core/src/enterprise/firewall/tests/expired_rules.rs +++ b/crates/defguard_core/src/enterprise/firewall/tests/expired_rules.rs @@ -1,7 +1,7 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use chrono::{DateTime, NaiveDateTime}; -use defguard_common::db::{NoId, models::WireguardNetwork, setup_pool}; +use defguard_common::db::{models::WireguardNetwork, setup_pool}; use ipnetwork::IpNetwork; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; @@ -16,7 +16,6 @@ async fn test_expired_acl_rules_ipv4(_: PgPoolOptions, options: PgConnectOptions let pool = setup_pool(options).await; // Create test location let location = WireguardNetwork { - id: NoId, acl_enabled: true, ..Default::default() }; @@ -24,7 +23,6 @@ async fn test_expired_acl_rules_ipv4(_: PgPoolOptions, options: PgConnectOptions // create expired ACL rules let mut acl_rule_1 = AclRule { - id: NoId, expires: Some(DateTime::UNIX_EPOCH.naive_utc()), enabled: true, state: RuleState::Applied, @@ -34,7 +32,6 @@ async fn test_expired_acl_rules_ipv4(_: PgPoolOptions, options: PgConnectOptions .await .unwrap(); let mut acl_rule_2 = AclRule { - id: NoId, expires: Some(DateTime::UNIX_EPOCH.naive_utc()), enabled: true, state: RuleState::Applied, @@ -83,7 +80,6 @@ async fn test_expired_acl_rules_ipv6(_: PgPoolOptions, options: PgConnectOptions let pool = setup_pool(options).await; // Create test location let location = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec![IpNetwork::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0).unwrap()], ..Default::default() @@ -92,7 +88,6 @@ async fn test_expired_acl_rules_ipv6(_: PgPoolOptions, options: PgConnectOptions // create expired ACL rules let mut acl_rule_1 = AclRule { - id: NoId, expires: Some(DateTime::UNIX_EPOCH.naive_utc()), enabled: true, state: RuleState::Applied, @@ -102,7 +97,6 @@ async fn test_expired_acl_rules_ipv6(_: PgPoolOptions, options: PgConnectOptions .await .unwrap(); let mut acl_rule_2 = AclRule { - id: NoId, expires: Some(DateTime::UNIX_EPOCH.naive_utc()), enabled: true, state: RuleState::Applied, @@ -151,7 +145,6 @@ async fn test_expired_acl_rules_ipv4_and_ipv6(_: PgPoolOptions, options: PgConne let pool = setup_pool(options).await; // Create test location let location = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec![ IpNetwork::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0).unwrap(), @@ -163,7 +156,6 @@ async fn test_expired_acl_rules_ipv4_and_ipv6(_: PgPoolOptions, options: PgConne // create expired ACL rules let mut acl_rule_1 = AclRule { - id: NoId, expires: Some(DateTime::UNIX_EPOCH.naive_utc()), enabled: true, state: RuleState::Applied, @@ -173,7 +165,6 @@ async fn test_expired_acl_rules_ipv4_and_ipv6(_: PgPoolOptions, options: PgConne .await .unwrap(); let mut acl_rule_2 = AclRule { - id: NoId, expires: Some(DateTime::UNIX_EPOCH.naive_utc()), enabled: true, state: RuleState::Applied, diff --git a/crates/defguard_core/src/enterprise/firewall/tests/gh1868.rs b/crates/defguard_core/src/enterprise/firewall/tests/gh1868.rs index c13ad66442..0a8e7e7b45 100644 --- a/crates/defguard_core/src/enterprise/firewall/tests/gh1868.rs +++ b/crates/defguard_core/src/enterprise/firewall/tests/gh1868.rs @@ -84,7 +84,6 @@ async fn test_gh1868_ipv6_rule_is_not_created_with_v4_only_destination( // Create test location with both IPv4 and IPv6 subnet let location = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec![ IpNetwork::new(IpAddr::V4(Ipv4Addr::new(10, 0, 80, 1)), 24).unwrap(), @@ -148,7 +147,6 @@ async fn test_gh1868_ipv4_rule_is_not_created_with_v6_only_destination( // Create test location with both IPv4 and IPv6 subnet let location = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec![ IpNetwork::new(IpAddr::V4(Ipv4Addr::new(10, 0, 80, 1)), 24).unwrap(), @@ -210,7 +208,6 @@ async fn test_gh1868_ipv4_and_ipv6_rules_are_created_with_any_destination( // Create test location with both IPv4 and IPv6 subnet let location = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec![ IpNetwork::new(IpAddr::V4(Ipv4Addr::new(10, 0, 80, 1)), 24).unwrap(), diff --git a/crates/defguard_core/src/enterprise/firewall/tests/mod.rs b/crates/defguard_core/src/enterprise/firewall/tests/mod.rs index a6613cc71b..ea349a8f49 100644 --- a/crates/defguard_core/src/enterprise/firewall/tests/mod.rs +++ b/crates/defguard_core/src/enterprise/firewall/tests/mod.rs @@ -249,7 +249,6 @@ async fn test_generate_firewall_rules_ipv4(_: PgPoolOptions, options: PgConnectO // Create test location let location = WireguardNetwork { - id: NoId, acl_enabled: false, ..Default::default() }; @@ -302,13 +301,11 @@ async fn test_generate_firewall_rules_ipv4(_: PgPoolOptions, options: PgConnectO // Setup test groups let group_1 = Group { - id: NoId, name: "group_1".into(), ..Default::default() }; let group_1 = group_1.save(&pool).await.unwrap(); let group_2 = Group { - id: NoId, name: "group_2".into(), ..Default::default() }; @@ -402,7 +399,6 @@ async fn test_generate_firewall_rules_ipv4(_: PgPoolOptions, options: PgConnectO // Create first ACL rule - Web access let acl_rule_1 = AclRule { - id: NoId, name: "Web Access".into(), all_locations: false, expires: None, @@ -452,7 +448,6 @@ async fn test_generate_firewall_rules_ipv4(_: PgPoolOptions, options: PgConnectO // Create second ACL rule - DNS access let acl_rule_2 = AclRule { - id: NoId, name: "DNS Access".into(), all_locations: false, expires: None, @@ -677,7 +672,6 @@ async fn test_generate_firewall_rules_ipv6(_: PgPoolOptions, options: PgConnectO // Create test location let location = WireguardNetwork { - id: NoId, acl_enabled: false, address: vec![IpNetwork::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0).unwrap()], ..Default::default() @@ -735,13 +729,11 @@ async fn test_generate_firewall_rules_ipv6(_: PgPoolOptions, options: PgConnectO // Setup test groups let group_1 = Group { - id: NoId, name: "group_1".into(), ..Default::default() }; let group_1 = group_1.save(&pool).await.unwrap(); let group_2 = Group { - id: NoId, name: "group_2".into(), ..Default::default() }; @@ -835,7 +827,6 @@ async fn test_generate_firewall_rules_ipv6(_: PgPoolOptions, options: PgConnectO // Create first ACL rule - Web access let acl_rule_1 = AclRule { - id: NoId, name: "Web Access".into(), all_locations: false, expires: None, @@ -885,7 +876,6 @@ async fn test_generate_firewall_rules_ipv6(_: PgPoolOptions, options: PgConnectO // Create second ACL rule - DNS access let acl_rule_2 = AclRule { - id: NoId, name: "DNS Access".into(), all_locations: false, expires: None, @@ -1135,7 +1125,6 @@ async fn test_generate_firewall_rules_ipv4_and_ipv6(_: PgPoolOptions, options: P // Create test location let location = WireguardNetwork { - id: NoId, acl_enabled: false, address: vec![ IpNetwork::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0).unwrap(), @@ -1199,13 +1188,11 @@ async fn test_generate_firewall_rules_ipv4_and_ipv6(_: PgPoolOptions, options: P // Setup test groups let group_1 = Group { - id: NoId, name: "group_1".into(), ..Default::default() }; let group_1 = group_1.save(&pool).await.unwrap(); let group_2 = Group { - id: NoId, name: "group_2".into(), ..Default::default() }; @@ -1308,7 +1295,6 @@ async fn test_generate_firewall_rules_ipv4_and_ipv6(_: PgPoolOptions, options: P // Create first ACL rule - Web access let acl_rule_1 = AclRule { - id: NoId, name: "Web Access".into(), all_locations: false, expires: None, @@ -1361,7 +1347,6 @@ async fn test_generate_firewall_rules_ipv4_and_ipv6(_: PgPoolOptions, options: P // Create second ACL rule - DNS access let acl_rule_2 = AclRule { - id: NoId, name: "DNS Access".into(), all_locations: false, expires: None, @@ -1779,7 +1764,6 @@ async fn test_alias_kinds(_: PgPoolOptions, options: PgConnectOptions) { // Create test location let location = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec!["10.0.0.0/16".parse().unwrap()], ..Default::default() @@ -1793,7 +1777,6 @@ async fn test_alias_kinds(_: PgPoolOptions, options: PgConnectOptions) { // create ACL rule let acl_rule = AclRule { - id: NoId, name: "test rule".to_string(), expires: None, enabled: true, @@ -1810,7 +1793,6 @@ async fn test_alias_kinds(_: PgPoolOptions, options: PgConnectOptions) { // create different kinds of aliases and add them to the rule let destination_alias = AclAlias { - id: NoId, name: "destination alias".to_string(), kind: AliasKind::Destination, ports: vec![PortRange::new(100, 200).into()], @@ -1822,7 +1804,6 @@ async fn test_alias_kinds(_: PgPoolOptions, options: PgConnectOptions) { .await .unwrap(); let component_alias = AclAlias { - id: NoId, kind: AliasKind::Component, addresses: vec!["10.0.2.3".parse().unwrap()], ..Default::default() @@ -1937,7 +1918,6 @@ async fn test_destination_alias_only_acl(_: PgPoolOptions, options: PgConnectOpt // Create test location let location = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec!["10.0.0.0/16".parse().unwrap()], ..Default::default() @@ -1951,7 +1931,6 @@ async fn test_destination_alias_only_acl(_: PgPoolOptions, options: PgConnectOpt // create ACL rule without manually configured destination let acl_rule = AclRule { - id: NoId, name: "test rule".to_string(), expires: None, enabled: true, @@ -1967,7 +1946,6 @@ async fn test_destination_alias_only_acl(_: PgPoolOptions, options: PgConnectOpt // create different kinds of aliases and add them to the rule let destination_alias_1 = AclAlias { - id: NoId, name: "postgres".to_string(), kind: AliasKind::Destination, addresses: vec!["10.0.2.3".parse().unwrap()], @@ -1978,7 +1956,6 @@ async fn test_destination_alias_only_acl(_: PgPoolOptions, options: PgConnectOpt .await .unwrap(); let destination_alias_2 = AclAlias { - id: NoId, name: "redis".to_string(), kind: AliasKind::Destination, addresses: vec!["10.0.2.4".parse().unwrap()], @@ -2107,7 +2084,6 @@ async fn test_no_allowed_users_ipv4(_: PgPoolOptions, options: PgConnectOptions) // Create test location let location = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec![IpNetwork::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0).unwrap()], ..Default::default() @@ -2116,7 +2092,6 @@ async fn test_no_allowed_users_ipv4(_: PgPoolOptions, options: PgConnectOptions) // create ACL rules let acl_rule_1 = AclRule { - id: NoId, expires: None, enabled: true, state: RuleState::Applied, @@ -2128,7 +2103,6 @@ async fn test_no_allowed_users_ipv4(_: PgPoolOptions, options: PgConnectOptions) .await .unwrap(); let acl_rule_2 = AclRule { - id: NoId, expires: None, enabled: true, state: RuleState::Applied, @@ -2171,7 +2145,6 @@ async fn test_empty_manual_destination_only_acl(_: PgPoolOptions, options: PgCon // Create test locations with IPv4 and IPv6 addresses let location_ipv4 = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec![IpNetwork::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0).unwrap()], ..Default::default() @@ -2180,7 +2153,6 @@ async fn test_empty_manual_destination_only_acl(_: PgPoolOptions, options: PgCon .await .unwrap(); let location_ipv6 = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec![IpNetwork::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0).unwrap()], ..Default::default() @@ -2189,7 +2161,6 @@ async fn test_empty_manual_destination_only_acl(_: PgPoolOptions, options: PgCon .await .unwrap(); let location_ipv4_and_ipv6 = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec![ IpNetwork::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0).unwrap(), @@ -2281,7 +2252,6 @@ async fn test_empty_manual_destination_only_acl(_: PgPoolOptions, options: PgCon // create ACL rule without manually configured destination and no aliases let acl_rule = AclRule { - id: NoId, name: "test rule".to_string(), expires: None, enabled: true, diff --git a/crates/defguard_core/src/enterprise/firewall/tests/unapplied_rules.rs b/crates/defguard_core/src/enterprise/firewall/tests/unapplied_rules.rs index 10bf3393d2..39c39b7d5f 100644 --- a/crates/defguard_core/src/enterprise/firewall/tests/unapplied_rules.rs +++ b/crates/defguard_core/src/enterprise/firewall/tests/unapplied_rules.rs @@ -1,6 +1,6 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -use defguard_common::db::{NoId, models::WireguardNetwork, setup_pool}; +use defguard_common::db::{models::WireguardNetwork, setup_pool}; use ipnetwork::IpNetwork; use rand::thread_rng; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; @@ -21,7 +21,6 @@ async fn test_unapplied_acl_rules_ipv4(_: PgPoolOptions, options: PgConnectOptio // Create test location let location = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec![IpNetwork::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0).unwrap()], ..Default::default() @@ -33,7 +32,6 @@ async fn test_unapplied_acl_rules_ipv4(_: PgPoolOptions, options: PgConnectOptio // create unapplied ACL rules let mut acl_rule_1 = AclRule { - id: NoId, expires: None, enabled: true, allow_all_users: true, @@ -45,7 +43,6 @@ async fn test_unapplied_acl_rules_ipv4(_: PgPoolOptions, options: PgConnectOptio .await .unwrap(); let mut acl_rule_2 = AclRule { - id: NoId, expires: None, enabled: true, allow_all_users: true, @@ -98,7 +95,6 @@ async fn test_unapplied_acl_rules_ipv6(_: PgPoolOptions, options: PgConnectOptio // Create test location let location = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec![IpNetwork::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0).unwrap()], ..Default::default() @@ -110,7 +106,6 @@ async fn test_unapplied_acl_rules_ipv6(_: PgPoolOptions, options: PgConnectOptio // create unapplied ACL rules let mut acl_rule_1 = AclRule { - id: NoId, expires: None, enabled: true, allow_all_users: true, @@ -122,7 +117,6 @@ async fn test_unapplied_acl_rules_ipv6(_: PgPoolOptions, options: PgConnectOptio .await .unwrap(); let mut acl_rule_2 = AclRule { - id: NoId, expires: None, enabled: true, allow_all_users: true, @@ -175,7 +169,6 @@ async fn test_unapplied_acl_rules_ipv4_and_ipv6(_: PgPoolOptions, options: PgCon // Create test location let location = WireguardNetwork { - id: NoId, acl_enabled: true, address: vec![ IpNetwork::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0).unwrap(), @@ -190,7 +183,6 @@ async fn test_unapplied_acl_rules_ipv4_and_ipv6(_: PgPoolOptions, options: PgCon // create unapplied ACL rules let mut acl_rule_1 = AclRule { - id: NoId, expires: None, enabled: true, allow_all_users: true, @@ -202,7 +194,6 @@ async fn test_unapplied_acl_rules_ipv4_and_ipv6(_: PgPoolOptions, options: PgCon .await .unwrap(); let mut acl_rule_2 = AclRule { - id: NoId, expires: None, enabled: true, allow_all_users: true, diff --git a/crates/defguard_core/src/enterprise/handlers/acl/alias.rs b/crates/defguard_core/src/enterprise/handlers/acl/alias.rs index d6c3edc0fa..ea26f409f8 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl/alias.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl/alias.rs @@ -162,6 +162,7 @@ impl ApiAclAlias { // update the not-yet applied modification itself let mut alias = alias.with_id(id); alias.parent_id = existing_alias.parent_id; + alias.state = existing_alias.state; alias.save(&mut *transaction).await?; // recreate related objects diff --git a/crates/defguard_core/src/enterprise/handlers/acl/destination.rs b/crates/defguard_core/src/enterprise/handlers/acl/destination.rs index 9745c9ac5e..5e97646940 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl/destination.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl/destination.rs @@ -176,6 +176,7 @@ impl ApiAclDestination { // update the not-yet applied modification itself let mut alias = alias.with_id(id); alias.parent_id = existing_alias.parent_id; + alias.state = existing_alias.state; alias.save(&mut *transaction).await?; // recreate related objects diff --git a/crates/defguard_core/tests/integration/api/acl/aliases.rs b/crates/defguard_core/tests/integration/api/acl/aliases.rs index 0b80304bdf..45dab317ce 100644 --- a/crates/defguard_core/tests/integration/api/acl/aliases.rs +++ b/crates/defguard_core/tests/integration/api/acl/aliases.rs @@ -137,6 +137,91 @@ async fn test_alias_create_modify_state(_: PgPoolOptions, options: PgConnectOpti assert_eq!(alias_child.parent_id, Some(alias_parent.id)); } +#[sqlx::test] +async fn test_alias_modify_pending_child_updates_in_place( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + + let config = init_config(None, &pool).await; + let mut client = make_client_v2(pool.clone(), config).await; + authenticate_admin(&mut client).await; + + let alias = make_alias(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); + + let applied_parent_before_update: ApiAclAlias = + client.get("/api/v1/acl/alias/1").send().await.json().await; + + let mut first_update = applied_parent_before_update.clone(); + first_update.name = "alias pending child".to_string(); + let response = client + .put("/api/v1/acl/alias/1") + .json(&first_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); + + let pending_child_before_update: ApiAclAlias = + client.get("/api/v1/acl/alias/2").send().await.json().await; + assert_eq!(pending_child_before_update.state, AliasState::Modified); + assert_eq!(pending_child_before_update.parent_id, Some(1)); + + let mut pending_child_update = pending_child_before_update.clone(); + pending_child_update.name = "alias pending child updated".to_string(); + let response = client + .put("/api/v1/acl/alias/2") + .json(&pending_child_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let updated_pending_child: ApiAclAlias = response.json().await; + + let aliases = AclAlias::all(&pool).await.unwrap(); + assert_eq!(aliases.len(), 2); + assert_eq!( + aliases + .iter() + .filter(|alias| alias.state == AliasState::Applied) + .count(), + 1 + ); + assert_eq!( + aliases + .iter() + .filter(|alias| alias.state == AliasState::Modified) + .count(), + 1 + ); + + let response = client.get("/api/v1/acl/alias/3").send().await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let applied_parent_after_update: ApiAclAlias = + client.get("/api/v1/acl/alias/1").send().await.json().await; + assert_eq!(applied_parent_after_update, applied_parent_before_update); + assert_eq!(applied_parent_after_update.state, AliasState::Applied); + assert_eq!(applied_parent_after_update.parent_id, None); + + let mut expected_pending_child = pending_child_before_update.clone(); + expected_pending_child.name = "alias pending child updated".to_string(); + assert_eq!(updated_pending_child, expected_pending_child); + + let pending_child_after_update: ApiAclAlias = + client.get("/api/v1/acl/alias/2").send().await.json().await; + assert_eq!(pending_child_after_update, expected_pending_child); + assert_eq!( + pending_child_after_update.id, + pending_child_before_update.id + ); + assert_eq!(pending_child_after_update.state, AliasState::Modified); + assert_eq!(pending_child_after_update.parent_id, Some(1)); +} + #[sqlx::test] async fn test_alias_delete(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -290,6 +375,86 @@ async fn test_alias_application(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); } +#[sqlx::test] +async fn test_alias_apply_after_reedit_preserves_rule_association( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + + let config = init_config(None, &pool).await; + let mut client = make_client_v2(pool.clone(), config).await; + authenticate_admin(&mut client).await; + + let alias = make_alias(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + let mut rule = make_rule(); + rule.aliases = vec![1]; + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + let applied_parent_before_update: ApiAclAlias = + client.get("/api/v1/acl/alias/1").send().await.json().await; + assert_eq!(applied_parent_before_update.rules, vec![1]); + + let mut first_update = applied_parent_before_update.clone(); + first_update.name = "alias pending child".to_string(); + let response = client + .put("/api/v1/acl/alias/1") + .json(&first_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + let pending_child_before_reedit: ApiAclAlias = + client.get("/api/v1/acl/alias/2").send().await.json().await; + assert_eq!(pending_child_before_reedit.state, AliasState::Modified); + assert_eq!(pending_child_before_reedit.parent_id, Some(1)); + + let mut second_update = pending_child_before_reedit.clone(); + second_update.name = "alias pending child updated".to_string(); + let response = client + .put("/api/v1/acl/alias/2") + .json(&second_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let pending_child_after_reedit: ApiAclAlias = response.json().await; + assert_eq!( + pending_child_after_reedit.id, + pending_child_before_reedit.id + ); + + let response = client + .put("/api/v1/acl/alias/apply") + .json(&json!({ "aliases": [pending_child_after_reedit.id] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + let final_applied_alias: ApiAclAlias = client + .get(format!( + "/api/v1/acl/alias/{}", + pending_child_after_reedit.id + )) + .send() + .await + .json() + .await; + assert_eq!(final_applied_alias.id, pending_child_before_reedit.id); + assert_eq!(final_applied_alias.state, AliasState::Applied); + assert_eq!(final_applied_alias.parent_id, None); + assert_eq!(final_applied_alias.rules, vec![1]); + + let response = client.get("/api/v1/acl/alias/1").send().await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let applied_rule: ApiAclRule = client.get("/api/v1/acl/rule/1").send().await.json().await; + assert_eq!(applied_rule.aliases, vec![pending_child_after_reedit.id]); +} + #[sqlx::test] async fn test_multiple_aliases_application(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -357,6 +522,125 @@ async fn test_multiple_aliases_application(_: PgPoolOptions, options: PgConnectO assert_eq!(alias.state, AliasState::Applied); } +#[sqlx::test] +async fn test_alias_bulk_apply_preserves_selected_and_unselected_associations( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + + let config = init_config(None, &pool).await; + let mut client = make_client_v2(pool.clone(), config).await; + authenticate_admin(&mut client).await; + + let mut first_alias = make_alias(); + first_alias.name = "alias 1".to_string(); + let response = client + .post("/api/v1/acl/alias") + .json(&first_alias) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let mut second_alias = make_alias(); + second_alias.name = "alias 2".to_string(); + let response = client + .post("/api/v1/acl/alias") + .json(&second_alias) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let mut first_rule = make_rule(); + first_rule.name = "rule 1".to_string(); + first_rule.aliases = vec![1]; + let response = client + .post("/api/v1/acl/rule") + .json(&first_rule) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let mut second_rule = make_rule(); + second_rule.name = "rule 2".to_string(); + second_rule.aliases = vec![2]; + let response = client + .post("/api/v1/acl/rule") + .json(&second_rule) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let mut first_alias_update: ApiAclAlias = + client.get("/api/v1/acl/alias/1").send().await.json().await; + first_alias_update.name = "alias 1 updated".to_string(); + let response = client + .put("/api/v1/acl/alias/1") + .json(&first_alias_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + let mut second_alias_update: ApiAclAlias = + client.get("/api/v1/acl/alias/2").send().await.json().await; + second_alias_update.name = "alias 2 updated".to_string(); + let response = client + .put("/api/v1/acl/alias/2") + .json(&second_alias_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + let selected_child: ApiAclAlias = client.get("/api/v1/acl/alias/3").send().await.json().await; + let unselected_child: ApiAclAlias = client.get("/api/v1/acl/alias/4").send().await.json().await; + assert_eq!(selected_child.parent_id, Some(1)); + assert_eq!(unselected_child.parent_id, Some(2)); + + let response = client + .put("/api/v1/acl/alias/apply") + .json(&json!({ "aliases": [selected_child.id] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + let applied_selected_child: ApiAclAlias = client + .get(format!("/api/v1/acl/alias/{}", selected_child.id)) + .send() + .await + .json() + .await; + assert_eq!(applied_selected_child.state, AliasState::Applied); + assert_eq!(applied_selected_child.parent_id, None); + assert_eq!(applied_selected_child.rules, vec![1]); + + let response = client.get("/api/v1/acl/alias/1").send().await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let still_applied_parent: ApiAclAlias = + client.get("/api/v1/acl/alias/2").send().await.json().await; + assert_eq!(still_applied_parent.state, AliasState::Applied); + assert_eq!(still_applied_parent.parent_id, None); + assert_eq!(still_applied_parent.rules, vec![2]); + + let still_pending_child: ApiAclAlias = client + .get(format!("/api/v1/acl/alias/{}", unselected_child.id)) + .send() + .await + .json() + .await; + assert_eq!(still_pending_child.state, AliasState::Modified); + assert_eq!(still_pending_child.parent_id, Some(2)); + assert!(still_pending_child.rules.is_empty()); + + let first_rule_after_apply: ApiAclRule = + client.get("/api/v1/acl/rule/1").send().await.json().await; + assert_eq!(first_rule_after_apply.aliases, vec![selected_child.id]); + + let second_rule_after_apply: ApiAclRule = + client.get("/api/v1/acl/rule/2").send().await.json().await; + assert_eq!(second_rule_after_apply.aliases, vec![2]); +} + #[sqlx::test] async fn test_alias_requires_any_value(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -394,6 +678,149 @@ async fn test_alias_requires_any_value(_: PgPoolOptions, options: PgConnectOptio assert_eq!(response.status(), StatusCode::CREATED); } +#[sqlx::test] +async fn test_alias_port_bounds(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + let mut alias = make_alias(); + alias.name = "alias-max-port".to_string(); + alias.ports = "65535".to_string(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + let mut alias = make_alias(); + alias.name = "alias-too-large-port".to_string(); + alias.ports = "65536".to_string(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let mut alias = make_alias(); + alias.name = "alias-max-range".to_string(); + alias.ports = "65534-65535".to_string(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + let mut alias = make_alias(); + alias.name = "alias-too-large-range".to_string(); + alias.ports = "65535-65536".to_string(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[sqlx::test] +async fn test_alias_rejects_invalid_port_ranges(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + let alias = make_alias(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + let mut alias = make_alias(); + alias.name = "alias-reversed-range".to_string(); + alias.ports = "200-100".to_string(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let mut alias = make_alias(); + alias.name = "alias-malformed-range".to_string(); + alias.ports = "1-2-3".to_string(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let mut alias: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; + alias.ports = "200-100".to_string(); + let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + alias.ports = "1-2-3".to_string(); + let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[sqlx::test] +async fn test_alias_rejects_invalid_address_ranges(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + let alias = make_alias(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + for (name, addresses) in [ + ("alias-reversed-address-range", "10.0.0.2-10.0.0.1"), + ("alias-equal-address-range", "10.0.0.1-10.0.0.1"), + ("alias-mixed-address-range", "10.0.0.1-2001:db8::1"), + ( + "alias-multi-dash-address-range", + "10.0.0.1-10.0.0.2-10.0.0.3", + ), + ("alias-cidr-endpoint-range", "10.0.0.0/24-10.0.0.2"), + ("alias-multi-slash-ipv4-cidr", "10.0.0.1/24/25"), + ("alias-multi-slash-ipv6-cidr", "2001:db8::1/64/65"), + ("alias-scientific-notation-prefix", "10.0.0.1/1e1"), + ("alias-hex-prefix", "10.0.0.1/0x18"), + ("alias-trailing-text-ipv6-prefix", "2001:db8::1/64foo"), + ] { + let mut invalid_alias = make_alias(); + invalid_alias.name = name.to_string(); + invalid_alias.addresses = addresses.to_string(); + let response = client + .post("/api/v1/acl/alias") + .json(&invalid_alias) + .send() + .await; + assert_eq!( + response.status(), + StatusCode::UNPROCESSABLE_ENTITY, + "{name}" + ); + } + + let mut valid_alias = make_alias(); + valid_alias.name = "alias-valid-address-range".to_string(); + valid_alias.addresses = "10.0.0.1-10.0.0.2".to_string(); + let response = client + .post("/api/v1/acl/alias") + .json(&valid_alias) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let mut alias: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; + for addresses in [ + "10.0.0.2-10.0.0.1", + "10.0.0.1-10.0.0.1", + "10.0.0.1-2001:db8::1", + "10.0.0.1-10.0.0.2-10.0.0.3", + "10.0.0.0/24-10.0.0.2", + "10.0.0.1/24/25", + "2001:db8::1/64/65", + "10.0.0.1/1e1", + "10.0.0.1/0x18", + "2001:db8::1/64foo", + ] { + alias.addresses = addresses.to_string(); + let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; + assert_eq!( + response.status(), + StatusCode::UNPROCESSABLE_ENTITY, + "{addresses}" + ); + } + + alias.addresses = "2001:db8::1-2001:db8::2".to_string(); + let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::OK); +} + #[sqlx::test] async fn test_alias_apply_rejects_destination(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; diff --git a/crates/defguard_core/tests/integration/api/acl/destinations.rs b/crates/defguard_core/tests/integration/api/acl/destinations.rs index c653c35ca1..19270f81b8 100644 --- a/crates/defguard_core/tests/integration/api/acl/destinations.rs +++ b/crates/defguard_core/tests/integration/api/acl/destinations.rs @@ -196,6 +196,113 @@ async fn test_destination_create_modify_state(_: PgPoolOptions, options: PgConne assert_eq!(destination_child.parent_id, Some(destination_parent.id)); } +#[sqlx::test] +async fn test_destination_modify_pending_child_updates_in_place( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + + let config = init_config(None, &pool).await; + let mut client = make_client_v2(pool.clone(), config).await; + authenticate_admin(&mut client).await; + + let destination = make_destination(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!(count_destinations(&pool).await, 1); + + let applied_parent_before_update: ApiAclDestination = client + .get("/api/v1/acl/destination/1") + .send() + .await + .json() + .await; + + let mut first_update = applied_parent_before_update.clone(); + first_update.name = "destination pending child".to_string(); + let response = client + .put("/api/v1/acl/destination/1") + .json(&first_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 2); + + let pending_child_before_update: ApiAclDestination = client + .get("/api/v1/acl/destination/2") + .send() + .await + .json() + .await; + assert_eq!(pending_child_before_update.state, AliasState::Modified); + assert_eq!(pending_child_before_update.parent_id, Some(1)); + + let mut pending_child_update = pending_child_before_update.clone(); + pending_child_update.name = "destination pending child updated".to_string(); + let response = client + .put("/api/v1/acl/destination/2") + .json(&pending_child_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let updated_pending_child: ApiAclDestination = response.json().await; + + let destinations = AclAlias::all_of_kind(&pool, AliasKind::Destination) + .await + .unwrap(); + assert_eq!(destinations.len(), 2); + assert_eq!( + destinations + .iter() + .filter(|destination| destination.state == AliasState::Applied) + .count(), + 1 + ); + assert_eq!( + destinations + .iter() + .filter(|destination| destination.state == AliasState::Modified) + .count(), + 1 + ); + + let response = client.get("/api/v1/acl/destination/3").send().await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let applied_parent_after_update: ApiAclDestination = client + .get("/api/v1/acl/destination/1") + .send() + .await + .json() + .await; + assert_eq!(applied_parent_after_update, applied_parent_before_update); + assert_eq!(applied_parent_after_update.state, AliasState::Applied); + assert_eq!(applied_parent_after_update.parent_id, None); + + let mut expected_pending_child = pending_child_before_update.clone(); + expected_pending_child.name = "destination pending child updated".to_string(); + assert_eq!(updated_pending_child, expected_pending_child); + + let pending_child_after_update: ApiAclDestination = client + .get("/api/v1/acl/destination/2") + .send() + .await + .json() + .await; + assert_eq!(pending_child_after_update, expected_pending_child); + assert_eq!( + pending_child_after_update.id, + pending_child_before_update.id + ); + assert_eq!(pending_child_after_update.state, AliasState::Modified); + assert_eq!(pending_child_after_update.parent_id, Some(1)); +} + #[sqlx::test] async fn test_destination_delete(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -415,6 +522,103 @@ async fn test_destination_application(_: PgPoolOptions, options: PgConnectOption assert_eq!(count_destinations(&pool).await, 1); } +#[sqlx::test] +async fn test_destination_apply_after_delete_recreate_preserves_rule_association( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + + let config = init_config(None, &pool).await; + let mut client = make_client_v2(pool.clone(), config).await; + authenticate_admin(&mut client).await; + + let destination = make_destination(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let mut rule = make_rule(); + rule.use_manual_destination_settings = false; + rule.destinations = vec![1]; + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + let applied_parent_before_update: ApiAclDestination = client + .get("/api/v1/acl/destination/1") + .send() + .await + .json() + .await; + assert_eq!(applied_parent_before_update.rules, vec![1]); + + let mut first_update = applied_parent_before_update.clone(); + first_update.name = "destination pending child".to_string(); + let response = client + .put("/api/v1/acl/destination/1") + .json(&first_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + let pending_child_before_delete: ApiAclDestination = client + .get("/api/v1/acl/destination/2") + .send() + .await + .json() + .await; + assert_eq!(pending_child_before_delete.state, AliasState::Modified); + assert_eq!(pending_child_before_delete.parent_id, Some(1)); + + let response = client.delete("/api/v1/acl/destination/2").send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 1); + + let mut recreated_child_update: ApiAclDestination = client + .get("/api/v1/acl/destination/1") + .send() + .await + .json() + .await; + recreated_child_update.name = "destination pending child recreated".to_string(); + let response = client + .put("/api/v1/acl/destination/1") + .json(&recreated_child_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let recreated_child: ApiAclDestination = response.json().await; + assert_eq!(recreated_child.state, AliasState::Modified); + assert_eq!(recreated_child.parent_id, Some(1)); + + let response = client + .put("/api/v1/acl/destination/apply") + .json(&json!({ "destinations": [recreated_child.id] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + let applied_recreated_child: ApiAclDestination = client + .get(format!("/api/v1/acl/destination/{}", recreated_child.id)) + .send() + .await + .json() + .await; + assert_eq!(applied_recreated_child.state, AliasState::Applied); + assert_eq!(applied_recreated_child.parent_id, None); + assert_eq!(applied_recreated_child.rules, vec![1]); + + let response = client.get("/api/v1/acl/destination/1").send().await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(count_destinations(&pool).await, 1); + + let applied_rule: ApiAclRule = client.get("/api/v1/acl/rule/1").send().await.json().await; + assert_eq!(applied_rule.destinations, vec![recreated_child.id]); +} + #[sqlx::test] async fn test_multiple_destinations_application(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -633,6 +837,210 @@ async fn test_destination_requires_any_or_values(_: PgPoolOptions, options: PgCo assert_eq!(response.status(), StatusCode::CREATED); } +#[sqlx::test] +async fn test_destination_port_bounds(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + let mut destination = make_destination(); + destination.name = "destination-max-port".to_string(); + destination.ports = "65535".to_string(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let mut destination = make_destination(); + destination.name = "destination-too-large-port".to_string(); + destination.ports = "65536".to_string(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let mut destination = make_destination(); + destination.name = "destination-max-range".to_string(); + destination.ports = "65534-65535".to_string(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let mut destination = make_destination(); + destination.name = "destination-too-large-range".to_string(); + destination.ports = "65535-65536".to_string(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[sqlx::test] +async fn test_destination_rejects_invalid_port_ranges(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + let destination = make_destination(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let mut destination = make_destination(); + destination.name = "destination-reversed-range".to_string(); + destination.ports = "200-100".to_string(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let mut destination = make_destination(); + destination.name = "destination-malformed-range".to_string(); + destination.ports = "1-2-3".to_string(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let mut destination: ApiAclDestination = client + .get("/api/v1/acl/destination/1") + .send() + .await + .json() + .await; + destination.ports = "200-100".to_string(); + let response = client + .put("/api/v1/acl/destination/1") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + destination.ports = "1-2-3".to_string(); + let response = client + .put("/api/v1/acl/destination/1") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[sqlx::test] +async fn test_destination_rejects_invalid_address_ranges( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + let destination = make_destination(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + for (name, addresses) in [ + ("destination-reversed-address-range", "10.0.0.2-10.0.0.1"), + ("destination-equal-address-range", "10.0.0.1-10.0.0.1"), + ("destination-mixed-address-range", "10.0.0.1-2001:db8::1"), + ( + "destination-multi-dash-address-range", + "10.0.0.1-10.0.0.2-10.0.0.3", + ), + ("destination-cidr-endpoint-range", "10.0.0.0/24-10.0.0.2"), + ("destination-multi-slash-ipv4-cidr", "10.0.0.1/24/25"), + ("destination-multi-slash-ipv6-cidr", "2001:db8::1/64/65"), + ("destination-scientific-notation-prefix", "10.0.0.1/1e1"), + ("destination-hex-prefix", "10.0.0.1/0x18"), + ("destination-trailing-text-ipv6-prefix", "2001:db8::1/64foo"), + ] { + let mut invalid_destination = make_destination(); + invalid_destination.name = name.to_string(); + invalid_destination.addresses = addresses.to_string(); + let response = client + .post("/api/v1/acl/destination") + .json(&invalid_destination) + .send() + .await; + assert_eq!( + response.status(), + StatusCode::UNPROCESSABLE_ENTITY, + "{name}" + ); + } + + let mut valid_destination = make_destination(); + valid_destination.name = "destination-valid-address-range".to_string(); + valid_destination.addresses = "10.0.0.1-10.0.0.2".to_string(); + let response = client + .post("/api/v1/acl/destination") + .json(&valid_destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let mut destination: ApiAclDestination = client + .get("/api/v1/acl/destination/1") + .send() + .await + .json() + .await; + for addresses in [ + "10.0.0.2-10.0.0.1", + "10.0.0.1-10.0.0.1", + "10.0.0.1-2001:db8::1", + "10.0.0.1-10.0.0.2-10.0.0.3", + "10.0.0.0/24-10.0.0.2", + "10.0.0.1/24/25", + "2001:db8::1/64/65", + "10.0.0.1/1e1", + "10.0.0.1/0x18", + "2001:db8::1/64foo", + ] { + destination.addresses = addresses.to_string(); + let response = client + .put("/api/v1/acl/destination/1") + .json(&destination) + .send() + .await; + assert_eq!( + response.status(), + StatusCode::UNPROCESSABLE_ENTITY, + "{addresses}" + ); + } + + destination.addresses = "2001:db8::1-2001:db8::2".to_string(); + let response = client + .put("/api/v1/acl/destination/1") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); +} + #[sqlx::test] async fn test_destination_apply_rejects_alias(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; diff --git a/crates/defguard_core/tests/integration/api/acl/rules.rs b/crates/defguard_core/tests/integration/api/acl/rules.rs index 2ae2782bae..2e181c932e 100644 --- a/crates/defguard_core/tests/integration/api/acl/rules.rs +++ b/crates/defguard_core/tests/integration/api/acl/rules.rs @@ -1,5 +1,46 @@ use super::*; +fn assert_related_object_rules_rewritten( + related_object_name: &str, + rules: &[Id], + old_rule_id: Id, + new_rule_id: Id, +) { + assert!( + !rules.contains(&old_rule_id), + "{related_object_name} should no longer reference the old applied rule {old_rule_id}; got {rules:?}", + ); + assert!( + rules.contains(&new_rule_id), + "{related_object_name} should reference the new applied rule {new_rule_id}; got {rules:?}", + ); + assert_eq!( + rules, + [new_rule_id], + "{related_object_name} should reference only the new applied rule", + ); +} + +fn assert_pending_related_object_rules_not_exposed( + related_object_name: &str, + rules: &[Id], + old_rule_id: Id, + new_rule_id: Id, +) { + assert!( + !rules.contains(&old_rule_id), + "{related_object_name} should not expose the stale applied rule {old_rule_id}; got {rules:?}", + ); + assert!( + !rules.contains(&new_rule_id), + "{related_object_name} should not expose the rewritten applied rule {new_rule_id} before the pending row itself is applied; got {rules:?}", + ); + assert!( + rules.is_empty(), + "{related_object_name} currently exposes an empty rules array for pending rows; got {rules:?}", + ); +} + #[sqlx::test] async fn test_rule_crud(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -551,6 +592,42 @@ async fn test_invalid_data(_: PgPoolOptions, options: PgConnectOptions) { rule.ports = "65535".into(); let response = client.post("/api/v1/acl/rule").json(&rule).send().await; assert_eq!(response.status(), StatusCode::CREATED); + let mut created_rule: ApiAclRule = response.json().await; + + let mut rule = make_rule(); + rule.ports = "65534-65535".into(); + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + let mut rule = make_rule(); + rule.ports = "65535-65536".into(); + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + created_rule.ports = "200-100".into(); + let response = client + .put(format!("/api/v1/acl/rule/{}", created_rule.id)) + .json(&created_rule) + .send() + .await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + created_rule.ports = "1-2-3".into(); + let response = client + .put(format!("/api/v1/acl/rule/{}", created_rule.id)) + .json(&created_rule) + .send() + .await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let mut rule = make_rule(); + rule.ports = "200-100".into(); + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + rule.ports = "1-2-3".into(); + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); // invalid ip range let mut rule = make_rule(); @@ -558,9 +635,49 @@ async fn test_invalid_data(_: PgPoolOptions, options: PgConnectOptions) { let response = client.post("/api/v1/acl/rule").json(&rule).send().await; assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + rule.addresses = "10.10.10.10-10.10.10.10".into(); + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + rule.addresses = "10.10.10.10-2001:db8::1".into(); + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + rule.addresses = "10.10.10.10-10.10.10.20-10.10.10.30".into(); + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + rule.addresses = "10.10.10.0/24-10.10.10.20".into(); + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + rule.addresses = "10.0.0.1/24/25".into(); + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + rule.addresses = "2001:db8::1/64/65".into(); + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + rule.addresses = "10.0.0.1/1e1".into(); + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + rule.addresses = "10.0.0.1/0x18".into(); + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + rule.addresses = "2001:db8::1/64foo".into(); + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + rule.addresses = "10.10.10.10-10.10.10.20".into(); let response = client.post("/api/v1/acl/rule").json(&rule).send().await; assert_eq!(response.status(), StatusCode::CREATED); + + rule.addresses = "2001:db8::1-2001:db8::2".into(); + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::CREATED); } #[sqlx::test] @@ -618,6 +735,98 @@ async fn test_rule_create_modify_state(_: PgPoolOptions, options: PgConnectOptio assert_eq!(rule_child.parent_id, Some(rule_parent.id)); } +#[sqlx::test] +async fn test_rule_modify_pending_child_updates_in_place( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + + let config = init_config(None, &pool).await; + let mut client = make_client_v2(pool.clone(), config).await; + authenticate_admin(&mut client).await; + + let rule = make_rule(); + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + let response = client + .put("/api/v1/acl/rule/apply") + .json(&json!({ "rules": [1] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + let applied_parent_before_update: ApiAclRule = + client.get("/api/v1/acl/rule/1").send().await.json().await; + assert_eq!(applied_parent_before_update.state, RuleState::Applied); + + let mut first_update = applied_parent_before_update.clone(); + first_update.name = "rule pending child".to_string(); + let response = client + .put("/api/v1/acl/rule/1") + .json(&first_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclRule::all(&pool).await.unwrap().len(), 2); + + let pending_child_before_update: ApiAclRule = + client.get("/api/v1/acl/rule/2").send().await.json().await; + assert_eq!(pending_child_before_update.state, RuleState::Modified); + assert_eq!(pending_child_before_update.parent_id, Some(1)); + + let mut pending_child_update = pending_child_before_update.clone(); + pending_child_update.name = "rule pending child updated".to_string(); + let response = client + .put("/api/v1/acl/rule/2") + .json(&pending_child_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let updated_pending_child: ApiAclRule = response.json().await; + + let rules = AclRule::all(&pool).await.unwrap(); + assert_eq!(rules.len(), 2); + assert_eq!( + rules + .iter() + .filter(|rule| rule.state == RuleState::Applied) + .count(), + 1 + ); + assert_eq!( + rules + .iter() + .filter(|rule| rule.state == RuleState::Modified) + .count(), + 1 + ); + + let response = client.get("/api/v1/acl/rule/3").send().await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let applied_parent_after_update: ApiAclRule = + client.get("/api/v1/acl/rule/1").send().await.json().await; + assert_eq!(applied_parent_after_update, applied_parent_before_update); + assert_eq!(applied_parent_after_update.state, RuleState::Applied); + assert_eq!(applied_parent_after_update.parent_id, None); + + let mut expected_pending_child = pending_child_before_update.clone(); + expected_pending_child.name = "rule pending child updated".to_string(); + assert_eq!(updated_pending_child, expected_pending_child); + + let pending_child_after_update: ApiAclRule = + client.get("/api/v1/acl/rule/2").send().await.json().await; + assert_eq!(pending_child_after_update, expected_pending_child); + assert_eq!( + pending_child_after_update.id, + pending_child_before_update.id + ); + assert_eq!(pending_child_after_update.state, RuleState::Modified); + assert_eq!(pending_child_after_update.parent_id, Some(1)); +} + #[sqlx::test] async fn test_rule_delete_state_new(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -823,6 +1032,247 @@ async fn test_rule_application(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(AclRule::all(&pool).await.unwrap().len(), 0); } +#[sqlx::test] +async fn test_rule_apply_rewrites_related_alias_and_destination_rule_ids( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + + let config = init_config(None, &pool).await; + let mut client = make_client_v2(pool.clone(), config).await; + authenticate_admin(&mut client).await; + + let mut alias = make_alias(); + alias.name = "applied alias".to_string(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + let applied_alias: ApiAclAlias = response.json().await; + + let mut destination = make_destination(); + destination.name = "applied destination".to_string(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let applied_destination: ApiAclDestination = response.json().await; + + let mut rule = make_rule(); + rule.name = "rule parent".to_string(); + rule.use_manual_destination_settings = false; + rule.addresses = String::new(); + rule.ports = String::new(); + rule.protocols = Vec::new(); + rule.any_address = false; + rule.any_port = false; + rule.any_protocol = false; + rule.aliases = vec![applied_alias.id]; + rule.destinations = vec![applied_destination.id]; + + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + let created_rule: ApiAclRule = response.json().await; + + let response = client + .put("/api/v1/acl/rule/apply") + .json(&json!({ "rules": [created_rule.id] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + let applied_parent_rule: ApiAclRule = client + .get(format!("/api/v1/acl/rule/{}", created_rule.id)) + .send() + .await + .json() + .await; + assert_eq!(applied_parent_rule.state, RuleState::Applied); + assert_eq!(applied_parent_rule.parent_id, None); + assert_eq!(applied_parent_rule.aliases, vec![applied_alias.id]); + assert_eq!( + applied_parent_rule.destinations, + vec![applied_destination.id] + ); + + let deployed_alias_before_rule_reapply: ApiAclAlias = client + .get(format!("/api/v1/acl/alias/{}", applied_alias.id)) + .send() + .await + .json() + .await; + assert_eq!( + deployed_alias_before_rule_reapply.rules, + vec![applied_parent_rule.id], + "deployed alias should initially reference the applied parent rule", + ); + + let deployed_destination_before_rule_reapply: ApiAclDestination = client + .get(format!( + "/api/v1/acl/destination/{}", + applied_destination.id + )) + .send() + .await + .json() + .await; + assert_eq!( + deployed_destination_before_rule_reapply.rules, + vec![applied_parent_rule.id], + "deployed destination should initially reference the applied parent rule", + ); + + let mut alias_update = deployed_alias_before_rule_reapply.clone(); + alias_update.name = "pending alias child".to_string(); + let response = client + .put(format!("/api/v1/acl/alias/{}", applied_alias.id)) + .json(&alias_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let pending_alias_child: ApiAclAlias = response.json().await; + assert_eq!(pending_alias_child.state, AliasState::Modified); + assert_eq!(pending_alias_child.parent_id, Some(applied_alias.id)); + + let mut destination_update = deployed_destination_before_rule_reapply.clone(); + destination_update.name = "pending destination child".to_string(); + let response = client + .put(format!( + "/api/v1/acl/destination/{}", + applied_destination.id + )) + .json(&destination_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let pending_destination_child: ApiAclDestination = response.json().await; + assert_eq!(pending_destination_child.state, AliasState::Modified); + assert_eq!( + pending_destination_child.parent_id, + Some(applied_destination.id) + ); + + let mut modified_rule = applied_parent_rule.clone(); + modified_rule.name = "rule child".to_string(); + let response = client + .put(format!("/api/v1/acl/rule/{}", applied_parent_rule.id)) + .json(&modified_rule) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let pending_rule_child: ApiAclRule = response.json().await; + assert_eq!(pending_rule_child.state, RuleState::Modified); + assert_eq!(pending_rule_child.parent_id, Some(applied_parent_rule.id)); + assert_eq!(pending_rule_child.aliases, vec![applied_alias.id]); + assert_eq!( + pending_rule_child.destinations, + vec![applied_destination.id] + ); + + let response = client + .put("/api/v1/acl/rule/apply") + .json(&json!({ "rules": [pending_rule_child.id] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + let response = client + .get(format!("/api/v1/acl/rule/{}", applied_parent_rule.id)) + .send() + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let applied_rule_child: ApiAclRule = client + .get(format!("/api/v1/acl/rule/{}", pending_rule_child.id)) + .send() + .await + .json() + .await; + assert_eq!(applied_rule_child.state, RuleState::Applied); + assert_eq!(applied_rule_child.parent_id, None); + assert_eq!(applied_rule_child.aliases, vec![applied_alias.id]); + assert_eq!( + applied_rule_child.destinations, + vec![applied_destination.id] + ); + + let deployed_alias_after_rule_apply: ApiAclAlias = client + .get(format!("/api/v1/acl/alias/{}", applied_alias.id)) + .send() + .await + .json() + .await; + assert_related_object_rules_rewritten( + "deployed alias details", + &deployed_alias_after_rule_apply.rules, + applied_parent_rule.id, + applied_rule_child.id, + ); + + let deployed_destination_after_rule_apply: ApiAclDestination = client + .get(format!( + "/api/v1/acl/destination/{}", + applied_destination.id + )) + .send() + .await + .json() + .await; + assert_related_object_rules_rewritten( + "deployed destination details", + &deployed_destination_after_rule_apply.rules, + applied_parent_rule.id, + applied_rule_child.id, + ); + + let pending_alias_after_rule_apply: ApiAclAlias = client + .get(format!("/api/v1/acl/alias/{}", pending_alias_child.id)) + .send() + .await + .json() + .await; + assert_eq!(pending_alias_after_rule_apply.state, AliasState::Modified); + assert_eq!( + pending_alias_after_rule_apply.parent_id, + Some(applied_alias.id) + ); + // Pending alias rows expose a `rules` field, but the backend keeps rule associations on the + // deployed alias row until the alias child itself is applied. + assert_pending_related_object_rules_not_exposed( + "pending alias details", + &pending_alias_after_rule_apply.rules, + applied_parent_rule.id, + applied_rule_child.id, + ); + + let pending_destination_after_rule_apply: ApiAclDestination = client + .get(format!( + "/api/v1/acl/destination/{}", + pending_destination_child.id + )) + .send() + .await + .json() + .await; + assert_eq!( + pending_destination_after_rule_apply.state, + AliasState::Modified + ); + assert_eq!( + pending_destination_after_rule_apply.parent_id, + Some(applied_destination.id) + ); + // Pending destination rows expose a `rules` field, but the backend keeps rule associations on + // the deployed destination row until the destination child itself is applied. + assert_pending_related_object_rules_not_exposed( + "pending destination details", + &pending_destination_after_rule_apply.rules, + applied_parent_rule.id, + applied_rule_child.id, + ); +} + #[sqlx::test] async fn test_multiple_rules_application(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; diff --git a/flake.lock b/flake.lock index 710502cbcc..4542de30ed 100644 --- a/flake.lock +++ b/flake.lock @@ -32,11 +32,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1772624091, - "narHash": "sha256-QKyJ0QGWBn6r0invrMAK8dmJoBYWoOWy7lN+UHzW1jc=", + "lastModified": 1772963539, + "narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "80bdc1e5ce51f56b19791b52b2901187931f5353", + "rev": "9dcb002ca1690658be4a04645215baea8b95f31d", "type": "github" }, "original": { @@ -74,11 +74,11 @@ ] }, "locked": { - "lastModified": 1772775058, - "narHash": "sha256-i+I9RYN8kYb9/9kibkxd0avkkislD1tyWojSVgIy160=", + "lastModified": 1773115373, + "narHash": "sha256-bfK9FJFcQth6f3ydYggS5m0z2NRGF/PY6Y2XgZDJ6pg=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "629bbb7f9d02787a54e28398b411da849246253b", + "rev": "1924b4672a2b8e4aee6e6652ec2e59a8d3c5648e", "type": "github" }, "original": { diff --git a/web/package.json b/web/package.json index 78bb032d6f..84fa7f1719 100644 --- a/web/package.json +++ b/web/package.json @@ -15,14 +15,14 @@ "dependencies": { "@axa-ch/react-polymorphic-types": "^1.4.1", "@floating-ui/react": "^0.27.19", - "@inlang/paraglide-js": "^2.13.2", + "@inlang/paraglide-js": "^2.14.0", "@react-hook/resize-observer": "^2.0.2", "@shortercode/webzip": "1.1.1-0", "@stablelib/base64": "^2.0.1", "@stablelib/x25519": "^2.0.1", "@tanstack/react-form": "^1.28.4", "@tanstack/react-query": "^5.90.21", - "@tanstack/react-router": "^1.166.3", + "@tanstack/react-router": "^1.166.6", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.21", "@uidotdev/usehooks": "^2.4.1", @@ -34,7 +34,7 @@ "humanize-duration": "^3.33.2", "ipaddr.js": "^2.3.0", "lodash-es": "^4.17.23", - "motion": "^12.35.1", + "motion": "^12.35.2", "qrcode.react": "^4.2.0", "qs": "^6.15.0", "radashi": "^12.7.2", @@ -56,12 +56,12 @@ "@tanstack/devtools-vite": "^0.5.3", "@tanstack/react-devtools": "^0.9.10", "@tanstack/react-query-devtools": "^5.91.3", - "@tanstack/react-router-devtools": "^1.166.3", - "@tanstack/router-plugin": "^1.166.3", + "@tanstack/react-router-devtools": "^1.166.6", + "@tanstack/router-plugin": "^1.166.6", "@types/byte-size": "^8.1.2", "@types/humanize-duration": "^3.27.4", "@types/lodash-es": "^4.17.12", - "@types/node": "^25.3.5", + "@types/node": "^25.4.0", "@types/qs": "^6.15.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index a1c5f77b57..d22c04d202 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^0.27.19 version: 0.27.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@inlang/paraglide-js': - specifier: ^2.13.2 - version: 2.13.2 + specifier: ^2.14.0 + version: 2.14.0 '@react-hook/resize-observer': specifier: ^2.0.2 version: 2.0.2(react@19.2.4) @@ -36,8 +36,8 @@ importers: specifier: ^5.90.21 version: 5.90.21(react@19.2.4) '@tanstack/react-router': - specifier: ^1.166.3 - version: 1.166.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^1.166.6 + version: 1.166.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -72,8 +72,8 @@ importers: specifier: ^4.17.23 version: 4.17.23 motion: - specifier: ^12.35.1 - version: 12.35.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^12.35.2 + version: 12.35.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) qrcode.react: specifier: ^4.2.0 version: 4.2.0(react@19.2.4) @@ -122,7 +122,7 @@ importers: version: 2.4.5 '@tanstack/devtools-vite': specifier: ^0.5.3 - version: 0.5.3(vite@7.3.1(@types/node@25.3.5)(sass@1.97.3)(tsx@4.21.0)) + version: 0.5.3(vite@7.3.1(@types/node@25.4.0)(sass@1.97.3)(tsx@4.21.0)) '@tanstack/react-devtools': specifier: ^0.9.10 version: 0.9.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.9) @@ -130,11 +130,11 @@ importers: specifier: ^5.91.3 version: 5.91.3(@tanstack/react-query@5.90.21(react@19.2.4))(react@19.2.4) '@tanstack/react-router-devtools': - specifier: ^1.166.3 - version: 1.166.3(@tanstack/react-router@1.166.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.166.2)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^1.166.6 + version: 1.166.6(@tanstack/react-router@1.166.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.166.6)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/router-plugin': - specifier: ^1.166.3 - version: 1.166.3(@tanstack/react-router@1.166.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.3.5)(sass@1.97.3)(tsx@4.21.0)) + specifier: ^1.166.6 + version: 1.166.6(@tanstack/react-router@1.166.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.4.0)(sass@1.97.3)(tsx@4.21.0)) '@types/byte-size': specifier: ^8.1.2 version: 8.1.2 @@ -145,8 +145,8 @@ importers: specifier: ^4.17.12 version: 4.17.12 '@types/node': - specifier: ^25.3.5 - version: 25.3.5 + specifier: ^25.4.0 + version: 25.4.0 '@types/qs': specifier: ^6.15.0 version: 6.15.0 @@ -158,7 +158,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react-swc': specifier: ^4.2.3 - version: 4.2.3(vite@7.3.1(@types/node@25.3.5)(sass@1.97.3)(tsx@4.21.0)) + version: 4.2.3(vite@7.3.1(@types/node@25.4.0)(sass@1.97.3)(tsx@4.21.0)) autoprefixer: specifier: ^10.4.27 version: 10.4.27(postcss@8.5.8) @@ -188,10 +188,10 @@ importers: version: 5.9.3 vite: specifier: ^7.3.1 - version: 7.3.1(@types/node@25.3.5)(sass@1.97.3)(tsx@4.21.0) + version: 7.3.1(@types/node@25.4.0)(sass@1.97.3)(tsx@4.21.0) vite-plugin-image-optimizer: specifier: ^2.0.3 - version: 2.0.3(sharp@0.34.5)(vite@7.3.1(@types/node@25.3.5)(sass@1.97.3)(tsx@4.21.0)) + version: 2.0.3(sharp@0.34.5)(vite@7.3.1(@types/node@25.4.0)(sass@1.97.3)(tsx@4.21.0)) packages: @@ -306,28 +306,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.4.5': resolution: {integrity: sha512-U1GAG6FTjhAO04MyH4xn23wRNBkT6H7NentHh+8UxD6ShXKBm5SY4RedKJzkUThANxb9rUKIPc7B8ew9Xo/cWg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.5': resolution: {integrity: sha512-NlKa7GpbQmNhZf9kakQeddqZyT7itN7jjWdakELeXyTU3pg/83fTysRRDPJD0akTfKDl6vZYNT9Zqn4MYZVBOA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.4.5': resolution: {integrity: sha512-NdODlSugMzTlENPTa4z0xB82dTUlCpsrOxc43///aNkTLblIYH4XpYflBbf5ySlQuP8AA4AZd1qXhV07IdrHdQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.4.5': resolution: {integrity: sha512-EBfrTqRIWOFSd7CQb/0ttjHMR88zm3hGravnDwUA9wHAaCAYsULKDebWcN5RmrEo1KBtl/gDVJMrFjNR0pdGUw==} @@ -596,105 +592,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -719,8 +699,8 @@ packages: cpu: [x64] os: [win32] - '@inlang/paraglide-js@2.13.2': - resolution: {integrity: sha512-ecxw95pmMbasVj7M/B6pu5wqYHomYQBcu3QzDl1svwAkbnRqRmsdrH4IizzFwqeVWd+uluibMIy1VOGywin94A==} + '@inlang/paraglide-js@2.14.0': + resolution: {integrity: sha512-6Tno8RvEhnALdgueWNQACiEm3YM6hAfbxnYB+JWML9p5s1O4t0DOqgU9YD8fwpixOnZbU6cJRkvt4v9acXDioA==} hasBin: true '@inlang/recommend-sherlock@0.2.1': @@ -803,42 +783,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} @@ -925,79 +899,66 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -1126,28 +1087,24 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [glibc] '@swc/core-linux-arm64-musl@1.15.18': resolution: {integrity: sha512-0a+Lix+FSSHBSBOA0XznCcHo5/1nA6oLLjcnocvzXeqtdjnPb+SvchItHI+lfeiuj1sClYPDvPMLSLyXFaiIKw==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [musl] '@swc/core-linux-x64-gnu@1.15.18': resolution: {integrity: sha512-wG9J8vReUlpaHz4KOD/5UE1AUgirimU4UFT9oZmupUDEofxJKYb1mTA/DrMj0s78bkBiNI+7Fo2EgPuvOJfuAA==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [glibc] '@swc/core-linux-x64-musl@1.15.18': resolution: {integrity: sha512-4nwbVvCphKzicwNWRmvD5iBaZj8JYsRGa4xOxJmOyHlMDpsvvJ2OR2cODlvWyGFH6BYL1MfIAK3qph3hp0Az6g==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [musl] '@swc/core-win32-arm64-msvc@1.15.18': resolution: {integrity: sha512-zk0RYO+LjiBCat2RTMHzAWaMky0cra9loH4oRrLKLLNuL+jarxKLFDA8xTZWEkCPLjUTwlRN7d28eDLLMgtUcQ==} @@ -1258,20 +1215,20 @@ packages: peerDependencies: react: ^18 || ^19 - '@tanstack/react-router-devtools@1.166.3': - resolution: {integrity: sha512-tmQMxCXBlaTjUfG5zlYPsB1bF9gFKULAOF1q6ePvFpsvhWz5bTmEdKPF2XIjd4D7alGM0MVB1DJGghmqigw7oA==} + '@tanstack/react-router-devtools@1.166.6': + resolution: {integrity: sha512-TheVyOgo8ljD8wHHLceFsnKrX7nhTIQv9WokSrPjNTP4H3synUMADxh8yZafVYdr6lS2CBvldd5s7JI8DcwBUg==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/react-router': ^1.166.3 - '@tanstack/router-core': ^1.166.2 + '@tanstack/react-router': ^1.166.6 + '@tanstack/router-core': ^1.166.6 react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' peerDependenciesMeta: '@tanstack/router-core': optional: true - '@tanstack/react-router@1.166.3': - resolution: {integrity: sha512-5NOwAnEp+koHYaRkK5+biYiuOxnQe/7q8R7LLAJ5Ryk6hXoIimOv6gWimPxANwhCWg9spfRZCNswi8EQaidYBg==} + '@tanstack/react-router@1.166.6': + resolution: {integrity: sha512-lfymPCfTkLQaNj/KIPElt+6B9REwPw2/Of3KtMwhNINs7h2xFQMSAOYk+ItCv8i93lBczlg89BRHtRS99qmzyA==} engines: {node: '>=20.19'} peerDependencies: react: '>=18.0.0 || >=19.0.0' @@ -1296,30 +1253,30 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/router-core@1.166.2': - resolution: {integrity: sha512-zn3NhENOAX9ToQiX077UV2OH3aJKOvV2ZMNZZxZ3gDG3i3WqL8NfWfEgetEAfMN37/Mnt90PpotYgf7IyuoKqQ==} + '@tanstack/router-core@1.166.6': + resolution: {integrity: sha512-SwVPMQxjoY4jwiNgD9u5kDFp/iSaf3wgf1t93xRCC6qDHmv/xLaawhvwEPNIJaPepWuSYRpywpJWH9hGlBqVbw==} engines: {node: '>=20.19'} - '@tanstack/router-devtools-core@1.166.2': - resolution: {integrity: sha512-Ke8HquuwMhLYpo/6nxNgrzi9Ns2lsK9uwDba6WKA8I0K7fyYZoAUu+7AD6gdEcVU4NF6LjtMPfUCHmVtYYRTDw==} + '@tanstack/router-devtools-core@1.166.6': + resolution: {integrity: sha512-ndPnCDeSGW3bd33u3EMe3+EJGLiFOHZaIKRJRLdZClOB6J6pvzKMELJgizBtjaR6X56FdCsC/STgjPkOeR9paA==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/router-core': ^1.166.2 + '@tanstack/router-core': ^1.166.6 csstype: ^3.0.10 peerDependenciesMeta: csstype: optional: true - '@tanstack/router-generator@1.166.2': - resolution: {integrity: sha512-wbvdyP1PKKQKk4aVlGeK9S5uDy8zodTr3tEZ2gRKNavJLusXbEWqtoo42JxHFFNB6dtguehFMt8PyZPAtkgWwQ==} + '@tanstack/router-generator@1.166.6': + resolution: {integrity: sha512-D7Z6oLP2IfflXUzOOxIgeCD8v3/SXU//cgBon0pbF13HkKdf9Zlt97kQqcaOkbnruJJ6i5xtUIsoAQbMmj+EsQ==} engines: {node: '>=20.19'} - '@tanstack/router-plugin@1.166.3': - resolution: {integrity: sha512-yhnJRohpdKB24Fh7fW5mwgffpOcERZlXdk3i8PjXn+OYgAiG/cpuXXOJpZZ6An68vDW+Z5zBuTynXsDi2ZE4JQ==} + '@tanstack/router-plugin@1.166.6': + resolution: {integrity: sha512-07ZwOMNDlKIoaRtrfP5zO3VfqXNg2Zm7qvqZOBaTbbqgMvaKclW0ylqakweXtDwiNs9GPf/+lH/xyc+CgLGUyg==} engines: {node: '>=20.19'} peerDependencies: '@rsbuild/core': '>=1.0.2' - '@tanstack/react-router': ^1.166.3 + '@tanstack/react-router': ^1.166.6 vite: '>=5.0.0 || >=6.0.0 || >=7.0.0' vite-plugin-solid: ^2.11.10 webpack: '>=5.92.0' @@ -1410,8 +1367,8 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@25.3.5': - resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==} + '@types/node@25.4.0': + resolution: {integrity: sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==} '@types/qs@6.15.0': resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} @@ -1841,8 +1798,8 @@ packages: flat-cache@6.1.20: resolution: {integrity: sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ==} - flatted@3.4.0: - resolution: {integrity: sha512-kC6Bb+ooptOIvWj5B63EQWkF0FEnNjV2ZNkLMLZRDDduIiWeFF4iKnslwhiWxjAdbg4NzTNo6h0qLuvFrcx+Sw==} + flatted@3.4.1: + resolution: {integrity: sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==} follow-redirects@1.15.11: resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} @@ -1860,8 +1817,8 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - framer-motion@12.35.1: - resolution: {integrity: sha512-rL8cLrjYZNShZqKV3U0Qj6Y5WDiZXYEM5giiTLfEqsIZxtspzMDCkKmrO5po76jWfvOg04+Vk+sfBvTD0iMmLw==} + framer-motion@12.35.2: + resolution: {integrity: sha512-dhfuEMaNo0hc+AEqyHiIfiJRNb9U9UQutE9FoKm5pjf7CMitp9xPEF1iWZihR1q86LBmo6EJ7S8cN8QXEy49AA==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -2261,14 +2218,14 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - motion-dom@12.35.1: - resolution: {integrity: sha512-7n6r7TtNOsH2UFSAXzTkfzOeO5616v9B178qBIjmu/WgEyJK0uqwytCEhwKBTuM/HJA40ptAw7hLFpxtPAMRZQ==} + motion-dom@12.35.2: + resolution: {integrity: sha512-pWXFMTwvGDbx1Fe9YL5HZebv2NhvGBzRtiNUv58aoK7+XrsuaydQ0JGRKK2r+bTKlwgSWwWxHbP5249Qr/BNpg==} motion-utils@12.29.2: resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} - motion@12.35.1: - resolution: {integrity: sha512-yEt/49kWC0VU/IEduDfeZw82eDemlPwa1cyo/gcEEUCN4WgpSJpUcxz6BUwakGabvJiTzLQ58J73515I5tfykQ==} + motion@12.35.2: + resolution: {integrity: sha512-8zCi1DkNyU6a/tgEHn/GnnXZDcaMpDHbDOGORY1Rg/6lcNMSOuvwDB3i4hMSOvxqMWArc/vrGaw/Xek1OP69/A==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -2524,8 +2481,8 @@ packages: peerDependencies: seroval: ^1.0 - seroval-plugins@1.5.0: - resolution: {integrity: sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==} + seroval-plugins@1.5.1: + resolution: {integrity: sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==} engines: {node: '>=10'} peerDependencies: seroval: ^1.0 @@ -2534,8 +2491,8 @@ packages: resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} engines: {node: '>=10'} - seroval@1.5.0: - resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==} + seroval@1.5.1: + resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==} engines: {node: '>=10'} sharp@0.34.5: @@ -3350,7 +3307,7 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@inlang/paraglide-js@2.13.2': + '@inlang/paraglide-js@2.14.0': dependencies: '@inlang/recommend-sherlock': 0.2.1 '@inlang/sdk': 2.7.0 @@ -3740,7 +3697,7 @@ snapshots: transitivePeerDependencies: - csstype - '@tanstack/devtools-vite@0.5.3(vite@7.3.1(@types/node@25.3.5)(sass@1.97.3)(tsx@4.21.0))': + '@tanstack/devtools-vite@0.5.3(vite@7.3.1(@types/node@25.4.0)(sass@1.97.3)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/generator': 7.29.1 @@ -3752,7 +3709,7 @@ snapshots: chalk: 5.6.2 launch-editor: 2.13.1 picomatch: 4.0.3 - vite: 7.3.1(@types/node@25.3.5)(sass@1.97.3)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.4.0)(sass@1.97.3)(tsx@4.21.0) transitivePeerDependencies: - bufferutil - supports-color @@ -3820,22 +3777,22 @@ snapshots: '@tanstack/query-core': 5.90.20 react: 19.2.4 - '@tanstack/react-router-devtools@1.166.3(@tanstack/react-router@1.166.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.166.2)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-router-devtools@1.166.6(@tanstack/react-router@1.166.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.166.6)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/react-router': 1.166.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/router-devtools-core': 1.166.2(@tanstack/router-core@1.166.2)(csstype@3.2.3) + '@tanstack/react-router': 1.166.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-devtools-core': 1.166.6(@tanstack/router-core@1.166.6)(csstype@3.2.3) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) optionalDependencies: - '@tanstack/router-core': 1.166.2 + '@tanstack/router-core': 1.166.6 transitivePeerDependencies: - csstype - '@tanstack/react-router@1.166.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-router@1.166.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/history': 1.161.4 '@tanstack/react-store': 0.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/router-core': 1.166.2 + '@tanstack/router-core': 1.166.6 isbot: 5.1.35 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -3861,28 +3818,28 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@tanstack/router-core@1.166.2': + '@tanstack/router-core@1.166.6': dependencies: '@tanstack/history': 1.161.4 '@tanstack/store': 0.9.2 cookie-es: 2.0.0 - seroval: 1.5.0 - seroval-plugins: 1.5.0(seroval@1.5.0) + seroval: 1.5.1 + seroval-plugins: 1.5.1(seroval@1.5.1) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/router-devtools-core@1.166.2(@tanstack/router-core@1.166.2)(csstype@3.2.3)': + '@tanstack/router-devtools-core@1.166.6(@tanstack/router-core@1.166.6)(csstype@3.2.3)': dependencies: - '@tanstack/router-core': 1.166.2 + '@tanstack/router-core': 1.166.6 clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) tiny-invariant: 1.3.3 optionalDependencies: csstype: 3.2.3 - '@tanstack/router-generator@1.166.2': + '@tanstack/router-generator@1.166.6': dependencies: - '@tanstack/router-core': 1.166.2 + '@tanstack/router-core': 1.166.6 '@tanstack/router-utils': 1.161.4 '@tanstack/virtual-file-routes': 1.161.4 prettier: 3.8.1 @@ -3893,7 +3850,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.166.3(@tanstack/react-router@1.166.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.3.5)(sass@1.97.3)(tsx@4.21.0))': + '@tanstack/router-plugin@1.166.6(@tanstack/react-router@1.166.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.4.0)(sass@1.97.3)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -3901,16 +3858,16 @@ snapshots: '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 - '@tanstack/router-core': 1.166.2 - '@tanstack/router-generator': 1.166.2 + '@tanstack/router-core': 1.166.6 + '@tanstack/router-generator': 1.166.6 '@tanstack/router-utils': 1.161.4 '@tanstack/virtual-file-routes': 1.161.4 chokidar: 3.6.0 unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: - '@tanstack/react-router': 1.166.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - vite: 7.3.1(@types/node@25.3.5)(sass@1.97.3)(tsx@4.21.0) + '@tanstack/react-router': 1.166.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + vite: 7.3.1(@types/node@25.4.0)(sass@1.97.3)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -3990,7 +3947,7 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@25.3.5': + '@types/node@25.4.0': dependencies: undici-types: 7.18.2 @@ -4017,11 +3974,11 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react-swc@4.2.3(vite@7.3.1(@types/node@25.3.5)(sass@1.97.3)(tsx@4.21.0))': + '@vitejs/plugin-react-swc@4.2.3(vite@7.3.1(@types/node@25.4.0)(sass@1.97.3)(tsx@4.21.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 '@swc/core': 1.15.18 - vite: 7.3.1(@types/node@25.3.5)(sass@1.97.3)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.4.0)(sass@1.97.3)(tsx@4.21.0) transitivePeerDependencies: - '@swc/helpers' @@ -4381,10 +4338,10 @@ snapshots: flat-cache@6.1.20: dependencies: cacheable: 2.3.3 - flatted: 3.4.0 + flatted: 3.4.1 hookified: 1.15.1 - flatted@3.4.0: {} + flatted@3.4.1: {} follow-redirects@1.15.11: {} @@ -4398,9 +4355,9 @@ snapshots: fraction.js@5.3.4: {} - framer-motion@12.35.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + framer-motion@12.35.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - motion-dom: 12.35.1 + motion-dom: 12.35.2 motion-utils: 12.29.2 tslib: 2.8.1 optionalDependencies: @@ -4920,15 +4877,15 @@ snapshots: dependencies: mime-db: 1.52.0 - motion-dom@12.35.1: + motion-dom@12.35.2: dependencies: motion-utils: 12.29.2 motion-utils@12.29.2: {} - motion@12.35.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + motion@12.35.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - framer-motion: 12.35.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + framer-motion: 12.35.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tslib: 2.8.1 optionalDependencies: react: 19.2.4 @@ -5203,13 +5160,13 @@ snapshots: dependencies: seroval: 1.3.2 - seroval-plugins@1.5.0(seroval@1.5.0): + seroval-plugins@1.5.1(seroval@1.5.1): dependencies: - seroval: 1.5.0 + seroval: 1.5.1 seroval@1.3.2: {} - seroval@1.5.0: {} + seroval@1.5.1: {} sharp@0.34.5: dependencies: @@ -5648,15 +5605,15 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-plugin-image-optimizer@2.0.3(sharp@0.34.5)(vite@7.3.1(@types/node@25.3.5)(sass@1.97.3)(tsx@4.21.0)): + vite-plugin-image-optimizer@2.0.3(sharp@0.34.5)(vite@7.3.1(@types/node@25.4.0)(sass@1.97.3)(tsx@4.21.0)): dependencies: ansi-colors: 4.1.3 pathe: 2.0.3 - vite: 7.3.1(@types/node@25.3.5)(sass@1.97.3)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.4.0)(sass@1.97.3)(tsx@4.21.0) optionalDependencies: sharp: 0.34.5 - vite@7.3.1(@types/node@25.3.5)(sass@1.97.3)(tsx@4.21.0): + vite@7.3.1(@types/node@25.4.0)(sass@1.97.3)(tsx@4.21.0): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -5665,7 +5622,7 @@ snapshots: rollup: 4.59.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.3.5 + '@types/node': 25.4.0 fsevents: 2.3.3 sass: 1.97.3 tsx: 4.21.0 diff --git a/web/src/pages/AliasesPage/AliasTable.tsx b/web/src/pages/AliasesPage/AliasTable.tsx index d497871048..e35911bc5b 100644 --- a/web/src/pages/AliasesPage/AliasTable.tsx +++ b/web/src/pages/AliasesPage/AliasTable.tsx @@ -57,7 +57,7 @@ export const AliasTable = ({ data: rowData, rules, disableBlockedModal }: Props) const { mutate: applyAliases } = useMutation({ mutationFn: api.acl.alias.applyAliases, meta: { - invalidate: ['acl', 'alias'], + invalidate: ['acl'], }, }); diff --git a/web/src/pages/AliasesPage/tabs/AliasesDeployedTab.tsx b/web/src/pages/AliasesPage/tabs/AliasesDeployedTab.tsx index d0f52c6184..7d787b104a 100644 --- a/web/src/pages/AliasesPage/tabs/AliasesDeployedTab.tsx +++ b/web/src/pages/AliasesPage/tabs/AliasesDeployedTab.tsx @@ -29,6 +29,21 @@ export const AliasesDeployedTab = () => { getLicenseInfoQueryOptions, ); const { data: rules } = useSuspenseQuery(getRulesQueryOptions); + const rulesByAliasId = useMemo(() => { + const map: Record = {}; + + rules.forEach((rule) => { + rule.aliases.forEach((aliasId) => { + if (!map[aliasId]) { + map[aliasId] = []; + } + + map[aliasId].push(rule.name); + }); + }); + + return map; + }, [rules]); const addButtonProps = useMemo( (): ButtonProps => ({ @@ -47,17 +62,28 @@ export const AliasesDeployedTab = () => { [navigate, licenseFetching, licenseInfo], ); - const distilledAliases = useMemo(() => { - let res = aliases; - if (search?.length) { - res = res.filter((alias) => - alias.name.toLowerCase().includes(search.toLowerCase()), - ); + const filteredAliases = useMemo(() => { + if (!search.length) { + return aliases; } - return res; - }, [aliases, search]); - const visibleEmpty = distilledAliases.length === 0; + const normalizedSearch = search.toLowerCase(); + + return aliases.filter((alias) => { + if (alias.name.toLowerCase().includes(normalizedSearch)) { + return true; + } + + const aliasId = alias.parent_id ?? alias.id; + const ruleNames = rulesByAliasId[aliasId] ?? []; + + return ruleNames.some((ruleName) => + ruleName.toLowerCase().includes(normalizedSearch), + ); + }); + }, [aliases, rulesByAliasId, search]); + + const visibleEmpty = filteredAliases.length === 0; return ( <> @@ -81,7 +107,7 @@ export const AliasesDeployedTab = () => { />