diff --git a/Cargo.lock b/Cargo.lock index e9530c1277..5288a2c92d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6901,9 +6901,9 @@ checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d" [[package]] name = "uuid" -version = "1.21.0" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -7604,9 +7604,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] diff --git a/crates/defguard_core/src/enterprise/db/models/acl.rs b/crates/defguard_core/src/enterprise/db/models/acl.rs index efdbca7d5a..5120e156d7 100644 --- a/crates/defguard_core/src/enterprise/db/models/acl.rs +++ b/crates/defguard_core/src/enterprise/db/models/acl.rs @@ -53,12 +53,18 @@ pub enum AclError { RuleNotFoundError(Id), #[error("AliasNotFoundError: {0}")] AliasNotFoundError(Id), + #[error("DestinationNotFoundError: {0}")] + DestinationNotFoundError(Id), #[error("RuleAlreadyAppliedError: {0}")] RuleAlreadyAppliedError(Id), #[error("AliasAlreadyAppliedError: {0}")] AliasAlreadyAppliedError(Id), + #[error("DestinationAlreadyAppliedError: {0}")] + DestinationAlreadyAppliedError(Id), #[error("AliasUsedByRulesError: {0}")] AliasUsedByRulesError(Id), + #[error("DestinationUsedByRulesError: {0}")] + DestinationUsedByRulesError(Id), #[error(transparent)] FirewallError(#[from] FirewallError), #[error("InvalidIpRangeError: {0}")] @@ -1520,16 +1526,23 @@ impl AclAlias { /// 2. The alias itself is deleted from the database /// /// Since these aliases were not yet applied, we can safely remove them. - pub(crate) async fn delete_from_api(pool: &PgPool, id: Id) -> Result<(), AclError> { - debug!("Deleting alias {id}"); + pub(crate) async fn delete_by_kind( + pool: &PgPool, + id: Id, + kind: AliasKind, + ) -> Result<(), AclError> { + debug!("Deleting alias {id} of kind {kind:?}"); let mut transaction = pool.begin().await?; // find the existing alias - let existing_alias = AclAlias::find_by_id(&mut *transaction, id) + let existing_alias = AclAlias::find_by_id_and_kind(&mut *transaction, id, kind.clone()) .await? .ok_or_else(|| { error!("Deletion of nonexistent alias ({id}) failed"); - AclError::AliasNotFoundError(id) + match kind { + AliasKind::Component => AclError::AliasNotFoundError(id), + AliasKind::Destination => AclError::DestinationNotFoundError(id), + } })?; // check if any rules are using this alias @@ -1538,7 +1551,10 @@ impl AclAlias { error!( "Deletion of alias ({id}) failed. Alias is currently used by following ACL rules: {rules:?}" ); - return Err(AclError::AliasUsedByRulesError(id)); + return Err(match kind { + AliasKind::Component => AclError::AliasUsedByRulesError(id), + AliasKind::Destination => AclError::DestinationUsedByRulesError(id), + }); } // delete all modifications of this alias if any exist @@ -1560,13 +1576,20 @@ impl AclAlias { Ok(()) } - /// Applies pending changes for all specified aliases + /// Applies pending changes for all specified aliases of a given kind /// /// # Errors /// /// - `AclError::AliasNotFoundError` - pub(crate) async fn apply_aliases(aliases: &[Id], appstate: &AppState) -> Result<(), AclError> { - debug!("Applying {} ACL aliases: {aliases:?}", aliases.len()); + pub(crate) async fn apply_by_kind( + aliases: &[Id], + kind: AliasKind, + appstate: &AppState, + ) -> Result<(), AclError> { + debug!( + "Applying {} ACL aliases of kind {kind:?}: {aliases:?}", + aliases.len(), + ); let mut transaction = appstate.pool.begin().await?; // prepare variable for collecting affected rules @@ -1574,9 +1597,12 @@ impl AclAlias { let mut affected_rules = Vec::new(); for id in aliases { - let alias = AclAlias::find_by_id(&mut *transaction, *id) + let alias = AclAlias::find_by_id_and_kind(&mut *transaction, *id, kind.clone()) .await? - .ok_or_else(|| AclError::AliasNotFoundError(*id))?; + .ok_or_else(|| match kind { + AliasKind::Component => AclError::AliasNotFoundError(*id), + AliasKind::Destination => AclError::DestinationNotFoundError(*id), + })?; // run `apply` before fetching relations, since they'll get updated alias.clone().apply(&mut transaction).await?; @@ -1653,10 +1679,7 @@ impl TryFrom<&EditAclAlias> for AclAlias { impl AclAlias { /// Fetch [`AclAlias`] of a given kind. - pub(crate) async fn all_of_kind<'e, E>( - executor: E, - kind: AliasKind, - ) -> Result, sqlx::Error> + pub async fn all_of_kind<'e, E>(executor: E, kind: AliasKind) -> Result, sqlx::Error> where E: PgExecutor<'e>, { @@ -1844,7 +1867,10 @@ impl AclAlias { } AliasState::Applied => { error!("ACL alias {alias_id} already applied"); - return Err(AclError::AliasAlreadyAppliedError(self.id)); + return Err(match self.kind { + AliasKind::Component => AclError::AliasAlreadyAppliedError(self.id), + AliasKind::Destination => AclError::DestinationAlreadyAppliedError(self.id), + }); } } diff --git a/crates/defguard_core/src/enterprise/handlers/acl.rs b/crates/defguard_core/src/enterprise/handlers/acl.rs index 5bb9973d2d..3230a72b07 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl.rs @@ -1,5 +1,5 @@ pub mod alias; -pub(crate) mod destination; +pub mod destination; use axum::{ Json, @@ -16,7 +16,7 @@ use super::LicenseInfo; use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, - enterprise::db::models::acl::{AclAlias, AclRule, AclRuleInfo, Protocol, RuleState}, + enterprise::db::models::acl::{AclRule, AclRuleInfo, Protocol, RuleState}, error::WebError, handlers::{ApiResponse, ApiResult}, }; @@ -204,11 +204,6 @@ pub(crate) struct ApplyAclRulesData { rules: Vec, } -#[derive(Debug, Deserialize, ToSchema)] -pub(crate) struct ApplyAclAliasesData { - aliases: Vec, -} - #[derive(Debug, Serialize, ToSchema, sqlx::FromRow)] pub struct AclStateCount { pub applied: i64, @@ -443,36 +438,3 @@ pub(crate) async fn apply_acl_rules( ); Ok(ApiResponse::default()) } - -/// Apply ACL aliases. -#[utoipa::path( - put, - path = "/api/v1/acl/alias/apply", - request_body = ApplyAclAliasesData, - responses( - (status = OK, description = "ACL alias"), - ) -)] -pub(crate) async fn apply_acl_aliases( - _license: LicenseInfo, - _admin: AdminRole, - State(appstate): State, - session: SessionInfo, - Json(data): Json, -) -> ApiResult { - debug!( - "User {} applying ACL aliases: {:?}", - session.user.username, data.aliases - ); - AclAlias::apply_aliases(&data.aliases, &appstate) - .await - .map_err(|err| { - error!("Error applying ACL aliases {data:?}: {err}"); - err - })?; - info!( - "User {} applied ACL aliases: {:?}", - session.user.username, data.aliases - ); - Ok(ApiResponse::default()) -} diff --git a/crates/defguard_core/src/enterprise/handlers/acl/alias.rs b/crates/defguard_core/src/enterprise/handlers/acl/alias.rs index e314b3e0ab..d6c3edc0fa 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl/alias.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl/alias.rs @@ -16,6 +16,7 @@ use crate::{ AclAlias, AclAliasDestinationRange, AclAliasInfo, AclError, AliasKind, AliasState, Protocol, acl_delete_related_objects, parse_destination_addresses, }, + error::WebError, handlers::{ApiResponse, ApiResult}, }; @@ -29,6 +30,18 @@ pub struct EditAclAlias { } impl EditAclAlias { + fn validate(&self) -> Result<(), WebError> { + if self.addresses.trim().is_empty() + && self.ports.trim().is_empty() + && self.protocols.is_empty() + { + return Err(WebError::BadRequest( + "Must provide alias addresses, ports, or protocols".to_string(), + )); + } + Ok(()) + } + /// Creates relation objects for a given [`AclAlias`] based on [`AclAliasInfo`] object. pub(crate) async fn create_related_objects( &self, @@ -69,6 +82,11 @@ pub struct ApiAclAlias { pub rules: Vec, } +#[derive(Debug, Deserialize, ToSchema)] +pub(crate) struct ApplyAclAliasesData { + aliases: Vec, +} + impl ApiAclAlias { /// Creates new [`AclAlias`] with all related objects based on [`AclAliasInfo`]. pub(crate) async fn create_from_api( @@ -289,6 +307,7 @@ pub(crate) async fn create_acl_alias( Json(data): Json, ) -> ApiResult { debug!("User {} creating ACL alias {data:?}", session.user.username); + data.validate()?; let alias = ApiAclAlias::create_from_api(&appstate.pool, &data) .await .map_err(|err| { @@ -324,6 +343,7 @@ pub(crate) async fn update_acl_alias( Json(data): Json, ) -> ApiResult { debug!("User {} updating ACL alias {data:?}", session.user.username); + data.validate()?; let alias = ApiAclAlias::update_from_api(&appstate.pool, id, &data) .await .map_err(|err| { @@ -353,7 +373,7 @@ pub(crate) async fn delete_acl_alias( Path(id): Path, ) -> ApiResult { debug!("User {} deleting ACL alias {id}", session.user.username); - AclAlias::delete_from_api(&appstate.pool, id) + AclAlias::delete_by_kind(&appstate.pool, id, AliasKind::Component) .await .map_err(|err| { error!("Error deleting ACL alias {id}: {err}"); @@ -362,3 +382,36 @@ pub(crate) async fn delete_acl_alias( info!("User {} deleted ACL alias {id}", session.user.username); Ok(ApiResponse::default()) } + +/// Apply ACL aliases. +#[utoipa::path( + put, + path = "/api/v1/acl/alias/apply", + request_body = ApplyAclAliasesData, + responses( + (status = OK, description = "ACL alias"), + ) +)] +pub(crate) async fn apply_acl_aliases( + _license: LicenseInfo, + _admin: AdminRole, + State(appstate): State, + session: SessionInfo, + Json(data): Json, +) -> ApiResult { + debug!( + "User {} applying ACL aliases: {:?}", + session.user.username, data.aliases + ); + AclAlias::apply_by_kind(&data.aliases, AliasKind::Component, &appstate) + .await + .map_err(|err| { + error!("Error applying ACL aliases {data:?}: {err}"); + err + })?; + info!( + "User {} applied ACL aliases: {:?}", + session.user.username, data.aliases + ); + Ok(ApiResponse::default()) +} diff --git a/crates/defguard_core/src/enterprise/handlers/acl/destination.rs b/crates/defguard_core/src/enterprise/handlers/acl/destination.rs index f26172e973..9745c9ac5e 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl/destination.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl/destination.rs @@ -22,7 +22,7 @@ use crate::{ /// API representation of [`AclAlias`] used in API requests for modification operations #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, ToSchema)] -pub(crate) struct EditAclDestination { +pub struct EditAclDestination { pub name: String, pub addresses: String, pub ports: String, @@ -80,7 +80,7 @@ impl EditAclDestination { /// API representation of [`AclAlias`] for "Destination" (not "Alias Component"). /// All relations represented as arrays of IDs. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, ToSchema)] -pub(crate) struct ApiAclDestination { +pub struct ApiAclDestination { #[serde(default)] pub id: Id, pub parent_id: Option, @@ -96,6 +96,11 @@ pub(crate) struct ApiAclDestination { pub any_protocol: bool, } +#[derive(Debug, Deserialize, ToSchema)] +pub(crate) struct ApplyAclDestinationsData { + destinations: Vec, +} + impl ApiAclDestination { /// Creates new [`AclAlias`] with all related objects based on [`AclAliasInfo`]. pub(crate) async fn create_from_api( @@ -400,7 +405,7 @@ pub(crate) async fn delete_acl_destination( "User {} deleting ACL destination {id}", session.user.username ); - AclAlias::delete_from_api(&appstate.pool, id) + AclAlias::delete_by_kind(&appstate.pool, id, AliasKind::Destination) .await .map_err(|err| { error!("Error deleting ACL destination {id}: {err}"); @@ -412,3 +417,37 @@ pub(crate) async fn delete_acl_destination( ); Ok(ApiResponse::default()) } + +/// Apply ACL destinations. +#[utoipa::path( + put, + path = "/api/v1/acl/destination/apply", + request_body = ApplyAclDestinationsData, + responses( + (status = OK, description = "ACL destination"), + ) +)] +pub(crate) async fn apply_acl_destinations( + _license: LicenseInfo, + _admin: AdminRole, + State(appstate): State, + session: SessionInfo, + Json(data): Json, +) -> ApiResult { + debug!( + "User {} applying ACL destinations: {:?}", + session.user.username, data.destinations + ); + + AclAlias::apply_by_kind(&data.destinations, AliasKind::Destination, &appstate) + .await + .map_err(|err| { + error!("Error applying ACL destinations {data:?}: {err}"); + err + })?; + info!( + "User {} applied ACL destinations: {:?}", + session.user.username, data.destinations + ); + Ok(ApiResponse::default()) +} diff --git a/crates/defguard_core/src/handlers/mod.rs b/crates/defguard_core/src/handlers/mod.rs index 2d5e1f4850..da040d548e 100644 --- a/crates/defguard_core/src/handlers/mod.rs +++ b/crates/defguard_core/src/handlers/mod.rs @@ -167,14 +167,28 @@ impl From for ApiResponse { json!({"msg": format!("Alias {id} not found")}), StatusCode::NOT_FOUND, ), + AclError::DestinationNotFoundError(id) => ApiResponse::new( + json!({"msg": format!("Destination {id} not found")}), + StatusCode::NOT_FOUND, + ), AclError::AliasAlreadyAppliedError(id) => ApiResponse::new( json!({"msg": format!("Alias {id} already applied")}), StatusCode::BAD_REQUEST, ), + AclError::DestinationAlreadyAppliedError(id) => ApiResponse::new( + json!({"msg": format!("Destination {id} already applied")}), + StatusCode::BAD_REQUEST, + ), AclError::AliasUsedByRulesError(id) => ApiResponse::new( json!({"msg": format!("Alias {id} is used by some existing ACL rules")}), StatusCode::BAD_REQUEST, ), + AclError::DestinationUsedByRulesError(id) => ApiResponse::new( + json!({ + "msg": format!("Destination {id} is used by some existing ACL rules") + }), + StatusCode::BAD_REQUEST, + ), AclError::DbError(_) | AclError::FirewallError(_) => { error!("{err}"); ApiResponse::new( diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index 362366d7d7..753c01040f 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -82,14 +82,14 @@ use crate::{ handlers::{ acl::{ alias::{ - count_acl_aliases, create_acl_alias, delete_acl_alias, get_acl_alias, - list_acl_aliases, update_acl_alias, + apply_acl_aliases, count_acl_aliases, create_acl_alias, delete_acl_alias, + get_acl_alias, list_acl_aliases, update_acl_alias, }, - apply_acl_aliases, apply_acl_rules, count_acl_rules, create_acl_rule, - delete_acl_rule, + apply_acl_rules, count_acl_rules, create_acl_rule, delete_acl_rule, destination::{ - count_acl_destinations, create_acl_destination, delete_acl_destination, - get_acl_destination, list_acl_destinations, update_acl_destination, + apply_acl_destinations, count_acl_destinations, create_acl_destination, + delete_acl_destination, get_acl_destination, list_acl_destinations, + update_acl_destination, }, get_acl_rule, list_acl_rules, update_acl_rule, }, @@ -479,7 +479,8 @@ pub fn build_webapp( get(get_acl_destination) .put(update_acl_destination) .delete(delete_acl_destination), - ), + ) + .route("/destination/apply", put(apply_acl_destinations)), ); let webapp = webapp.nest( diff --git a/crates/defguard_core/src/openapi.rs b/crates/defguard_core/src/openapi.rs index f38234204e..6017ec6399 100644 --- a/crates/defguard_core/src/openapi.rs +++ b/crates/defguard_core/src/openapi.rs @@ -97,7 +97,7 @@ use super::{ acl::alias::get_acl_alias, acl::alias::update_acl_alias, acl::alias::delete_acl_alias, - acl::apply_acl_aliases, + acl::alias::apply_acl_aliases, // /acl/destination acl::destination::list_acl_destinations, acl::destination::count_acl_destinations, @@ -105,6 +105,7 @@ use super::{ acl::destination::get_acl_destination, acl::destination::update_acl_destination, acl::destination::delete_acl_destination, + acl::destination::apply_acl_destinations, ), components( schemas( diff --git a/crates/defguard_core/tests/integration/api/acl/aliases.rs b/crates/defguard_core/tests/integration/api/acl/aliases.rs new file mode 100644 index 0000000000..0b80304bdf --- /dev/null +++ b/crates/defguard_core/tests/integration/api/acl/aliases.rs @@ -0,0 +1,419 @@ +use super::*; + +#[sqlx::test] +async fn test_alias_crud(_: 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(); + + // create + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + let response_alias: ApiAclAlias = response.json().await; + let expected_response = edit_alias_data_into_api_response( + alias, + response_alias.id, + None, + AliasState::Applied, + AliasKind::Component, + Vec::new(), + ); + assert_eq!(response_alias, expected_response); + + // list + let response = client.get("/api/v1/acl/alias").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response_aliases: Vec = response.json().await; + assert_eq!(response_aliases.len(), 1); + let response_alias = response_aliases[0].clone(); + assert_eq!(response_alias, expected_response); + + // retrieve + let response = client.get("/api/v1/acl/alias/1").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response_alias: ApiAclAlias = response.json().await; + assert_eq!(response_alias, expected_response); + + // update + let mut alias: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; + alias.name = "modified".to_string(); + let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::OK); + let response_alias: ApiAclAlias = response.json().await; + let alias: ApiAclAlias = client.get("/api/v1/acl/alias/2").send().await.json().await; + assert_eq!(response_alias, alias); + + // delete + let response = client.delete("/api/v1/acl/alias/1").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response = client.get("/api/v1/acl/alias").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response_aliases: Vec = response.json().await; + assert_eq!(response_aliases.len(), 0); +} + +#[sqlx::test] +async fn test_alias_enterprise(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + exceed_enterprise_limits(&client).await; + + // unset the license + let license = get_cached_license().clone(); + set_cached_license(None); + + // try to use ACL api + let alias = make_alias(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + // GET should be allowed + let response = client.get("/api/v1/acl/alias").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response = client.delete("/api/v1/acl/alias/1").send().await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + // restore valid license and try again + set_cached_license(license); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + let response = client.get("/api/v1/acl/alias").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response_aliases: Vec = response.json().await; + assert_eq!(response_aliases.len(), 1); + let response = client.get("/api/v1/acl/alias").send().await; + assert_eq!(response.status(), StatusCode::OK); + let alias: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; + let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::OK); + let response = client.delete("/api/v1/acl/alias/1").send().await; + assert_eq!(response.status(), StatusCode::OK); +} + +#[sqlx::test] +async fn test_alias_create_modify_state(_: 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(); + + // assert created alias has correct state + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + let dbalias = AclAlias::find_by_id(&pool, 1).await.unwrap().unwrap(); + assert_eq!(dbalias.state, AliasState::Applied); + assert_eq!(dbalias.parent_id, None); + + // test APPLIED alias modification + let alias_before_mods: ApiAclAlias = + client.get("/api/v1/acl/alias/1").send().await.json().await; + let mut alias_modified = alias_before_mods.clone(); + let response = client + .put("/api/v1/acl/alias/1") + .json(&alias_modified) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); + let alias_parent: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; + let alias_child: ApiAclAlias = client.get("/api/v1/acl/alias/2").send().await.json().await; + assert_eq!(alias_parent, alias_before_mods); + assert_eq!(alias_parent.state, AliasState::Applied); + alias_modified.id = 2; + alias_modified.state = AliasState::Modified; + alias_modified.parent_id = Some(1); + assert_eq!(alias_child, alias_modified); + assert_eq!(alias_child.state, AliasState::Modified); + assert_eq!(alias_child.parent_id, Some(alias_parent.id)); +} + +#[sqlx::test] +async fn test_alias_delete(_: 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; + + // create alias + 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); + + // use alias in a new rule + 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); + + // cannot delete alias if used by a rule + let response = client.delete("/api/v1/acl/alias/1").send().await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); + + // remove alias from rule + rule.aliases = Vec::new(); + let response = client.put("/api/v1/acl/rule/1").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); + + // delete alias + let response = client.delete("/api/v1/acl/alias/1").send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 0); + + // create another alias + let mut 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); + + // modify alias + alias.name = "modified".to_string(); + let response = client.put("/api/v1/acl/alias/2").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); + + // delete pending modification + let response = client.delete("/api/v1/acl/alias/3").send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); + + // modify alias again + alias.name = "modified again".to_string(); + let response = client.put("/api/v1/acl/alias/2").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); + + // delete original alias + let response = client.delete("/api/v1/acl/alias/2").send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 0); +} + +#[sqlx::test] +async fn test_alias_duplication(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + // each modification of parent alias should remove the child and create a new one + 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); + + // ensure we don't duplicate already modified / deleted aliass + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); + let alias: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; + let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); + let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); + let response = client.delete("/api/v1/acl/alias/1").send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 0); +} + +#[sqlx::test] +async fn test_alias_application(_: 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; + + // create new alias + let alias = make_alias(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + // verify initial status + let alias: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; + assert_eq!(alias.state, AliasState::Applied); + + // use alias in a new rule + 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); + + // verify rule assignment + let mut alias: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; + assert_eq!(alias.rules, vec![1]); + + // modify alias + alias.name = "modified".to_string(); + let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); + + // cannot apply already applied alias + let response = client + .put("/api/v1/acl/alias/apply") + .json(&json!({ "aliases": [1] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // apply modification + let response = client + .put("/api/v1/acl/alias/apply") + .json(&json!({ "aliases": [2] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // verify alias was applied + let alias: ApiAclAlias = client.get("/api/v1/acl/alias/2").send().await.json().await; + assert_eq!(alias.state, AliasState::Applied); + assert_eq!(alias.parent_id, None); + assert_eq!(alias.rules, vec![1]); + + // initial alias was deleted + let response = client.get("/api/v1/acl/alias/1").send().await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); +} + +#[sqlx::test] +async fn test_multiple_aliases_application(_: 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_1 = make_alias(); + let alias_2 = make_alias(); + let alias_3 = make_alias(); + + // create new aliass + let response = client.post("/api/v1/acl/alias").json(&alias_1).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + let response = client.post("/api/v1/acl/alias").json(&alias_2).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + let response = client.post("/api/v1/acl/alias").json(&alias_3).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + // modify aliases + let mut alias_1: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; + alias_1.name = "modified 1".to_string(); + let response = client + .put("/api/v1/acl/alias/1") + .json(&alias_1) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let mut alias_2: ApiAclAlias = client.get("/api/v1/acl/alias/2").send().await.json().await; + alias_2.name = "modified 2".to_string(); + let response = client + .put("/api/v1/acl/alias/2") + .json(&alias_2) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let mut alias_3: ApiAclAlias = client.get("/api/v1/acl/alias/3").send().await.json().await; + alias_3.name = "modified 3".to_string(); + let response = client + .put("/api/v1/acl/alias/3") + .json(&alias_3) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 6); + + // apply multiple aliases + let response = client + .put("/api/v1/acl/alias/apply") + .json(&json!({ "aliases": [4, 6] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 4); + + // verify alias state + let alias: ApiAclAlias = client.get("/api/v1/acl/alias/2").send().await.json().await; + assert_eq!(alias.state, AliasState::Applied); + let alias: ApiAclAlias = client.get("/api/v1/acl/alias/4").send().await.json().await; + assert_eq!(alias.state, AliasState::Applied); + let alias: ApiAclAlias = client.get("/api/v1/acl/alias/5").send().await.json().await; + assert_eq!(alias.state, AliasState::Modified); + let alias: ApiAclAlias = client.get("/api/v1/acl/alias/6").send().await.json().await; + assert_eq!(alias.state, AliasState::Applied); +} + +#[sqlx::test] +async fn test_alias_requires_any_value(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + // all fields empty + let mut alias = make_alias(); + alias.addresses = String::new(); + alias.ports = String::new(); + alias.protocols = Vec::new(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // only addresses set + let mut alias = make_alias(); + alias.ports = String::new(); + alias.protocols = Vec::new(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + // only ports set + let mut alias = make_alias(); + alias.addresses = String::new(); + alias.protocols = Vec::new(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + // only protocols set + let mut alias = make_alias(); + alias.addresses = String::new(); + alias.ports = String::new(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); +} + +#[sqlx::test] +async fn test_alias_apply_rejects_destination(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = init_config(None, &pool).await; + let mut client = make_client_v2(pool, 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 response = client + .put("/api/v1/acl/alias/apply") + .json(&json!({ "aliases": [1] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} diff --git a/crates/defguard_core/tests/integration/api/acl/destinations.rs b/crates/defguard_core/tests/integration/api/acl/destinations.rs new file mode 100644 index 0000000000..c653c35ca1 --- /dev/null +++ b/crates/defguard_core/tests/integration/api/acl/destinations.rs @@ -0,0 +1,654 @@ +use super::*; + +#[sqlx::test] +async fn test_destination_crud(_: 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(); + + // create + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let response_destination: ApiAclDestination = response.json().await; + let expected_response = edit_destination_data_into_api_response( + destination, + response_destination.id, + None, + AliasState::Applied, + Vec::new(), + ); + assert_eq!(response_destination, expected_response); + + // list + let response = client.get("/api/v1/acl/destination").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response_destinations: Vec = response.json().await; + assert_eq!(response_destinations.len(), 1); + let response_destination = response_destinations[0].clone(); + assert_eq!(response_destination, expected_response); + + // retrieve + let response = client.get("/api/v1/acl/destination/1").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response_destination: ApiAclDestination = response.json().await; + assert_eq!(response_destination, expected_response); + + // update + let mut destination: ApiAclDestination = client + .get("/api/v1/acl/destination/1") + .send() + .await + .json() + .await; + destination.name = "modified".to_string(); + let response = client + .put("/api/v1/acl/destination/1") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let response_destination: ApiAclDestination = response.json().await; + let destination: ApiAclDestination = client + .get("/api/v1/acl/destination/2") + .send() + .await + .json() + .await; + assert_eq!(response_destination, destination); + + // delete + let response = client.delete("/api/v1/acl/destination/1").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response = client.get("/api/v1/acl/destination").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response_destinations: Vec = response.json().await; + assert_eq!(response_destinations.len(), 0); +} + +#[sqlx::test] +async fn test_destination_enterprise(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + exceed_enterprise_limits(&client).await; + + // unset the license + let license = get_cached_license().clone(); + set_cached_license(None); + + // try to use ACL api + let destination = make_destination(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let response = client + .put("/api/v1/acl/destination/1") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + // GET should be allowed + let response = client.get("/api/v1/acl/destination").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response = client.delete("/api/v1/acl/destination/1").send().await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + // restore valid license and try again + set_cached_license(license); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let response = client.get("/api/v1/acl/destination").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response_destinations: Vec = response.json().await; + assert_eq!(response_destinations.len(), 1); + let response = client.get("/api/v1/acl/destination").send().await; + assert_eq!(response.status(), StatusCode::OK); + let destination: ApiAclDestination = client + .get("/api/v1/acl/destination/1") + .send() + .await + .json() + .await; + let response = client + .put("/api/v1/acl/destination/1") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let response = client.delete("/api/v1/acl/destination/1").send().await; + assert_eq!(response.status(), StatusCode::OK); +} + +#[sqlx::test] +async fn test_destination_create_modify_state(_: 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(); + + // assert created destination has correct state + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let dbdestination = AclAlias::find_by_id_and_kind(&pool, 1, AliasKind::Destination) + .await + .unwrap() + .unwrap(); + assert_eq!(dbdestination.state, AliasState::Applied); + assert_eq!(dbdestination.parent_id, None); + + // test APPLIED destination modification + let destination_before_mods: ApiAclDestination = client + .get("/api/v1/acl/destination/1") + .send() + .await + .json() + .await; + let mut destination_modified = destination_before_mods.clone(); + let response = client + .put("/api/v1/acl/destination/1") + .json(&destination_modified) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 2); + let destination_parent: ApiAclDestination = client + .get("/api/v1/acl/destination/1") + .send() + .await + .json() + .await; + let destination_child: ApiAclDestination = client + .get("/api/v1/acl/destination/2") + .send() + .await + .json() + .await; + assert_eq!(destination_parent, destination_before_mods); + assert_eq!(destination_parent.state, AliasState::Applied); + destination_modified.id = 2; + destination_modified.state = AliasState::Modified; + destination_modified.parent_id = Some(1); + assert_eq!(destination_child, destination_modified); + assert_eq!(destination_child.state, AliasState::Modified); + assert_eq!(destination_child.parent_id, Some(destination_parent.id)); +} + +#[sqlx::test] +async fn test_destination_delete(_: 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; + + // create destination + let destination = make_destination(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let destination: Value = response.json().await; + let destination_id = destination["id"].as_i64().unwrap(); + assert_eq!(count_destinations(&pool).await, 1); + + // use destination in a new rule + let mut rule = make_rule(); + rule.use_manual_destination_settings = false; + rule.destinations = vec![destination_id]; + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + // cannot delete destination if used by a rule + let response = client + .delete(format!("/api/v1/acl/destination/{destination_id}")) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(count_destinations(&pool).await, 1); + + // remove destination from rule + rule.use_manual_destination_settings = true; + rule.destinations = Vec::new(); + let response = client.put("/api/v1/acl/rule/1").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // delete alias + let response = client + .delete(format!("/api/v1/acl/destination/{destination_id}")) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 0); + + // create another destination + let mut 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); + + // modify destination + destination.name = "modified".to_string(); + let response = client + .put("/api/v1/acl/destination/2") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 2); + + // delete pending modification + let response = client.delete("/api/v1/acl/destination/3").send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 1); + + // modify destination again + destination.name = "modified again".to_string(); + let response = client + .put("/api/v1/acl/destination/2") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 2); + + // delete original destination + let response = client.delete("/api/v1/acl/destination/2").send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 0); +} + +#[sqlx::test] +async fn test_destination_duplication(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + // each modification of parent destination should remove the child and create a new one + 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); + + // ensure we don't duplicate already modified / deleted destinations + assert_eq!(count_destinations(&pool).await, 1); + let destination: ApiAclDestination = client + .get("/api/v1/acl/destination/1") + .send() + .await + .json() + .await; + let response = client + .put("/api/v1/acl/destination/1") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 2); + let response = client + .put("/api/v1/acl/destination/1") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 2); + let response = client.delete("/api/v1/acl/destination/1").send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 0); +} + +#[sqlx::test] +async fn test_destination_application(_: 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; + + // create new destination + let destination = make_destination(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + // verify initial status + let destination: ApiAclDestination = client + .get("/api/v1/acl/destination/1") + .send() + .await + .json() + .await; + assert_eq!(destination.state, AliasState::Applied); + + // use destination in a new rule + 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); + + // verify rule assignment + let mut destination: ApiAclDestination = client + .get("/api/v1/acl/destination/1") + .send() + .await + .json() + .await; + assert_eq!(destination.rules, vec![1]); + + // modify destination + destination.name = "modified".to_string(); + let response = client + .put("/api/v1/acl/destination/1") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 2); + + // cannot apply already applied destination + let response = client + .put("/api/v1/acl/destination/apply") + .json(&json!({ "destinations": [1] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // apply modification + let response = client + .put("/api/v1/acl/destination/apply") + .json(&json!({ "destinations": [2] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // verify destination was applied + let destination: ApiAclDestination = client + .get("/api/v1/acl/destination/2") + .send() + .await + .json() + .await; + assert_eq!(destination.state, AliasState::Applied); + assert_eq!(destination.parent_id, None); + assert_eq!(destination.rules, vec![1]); + + // initial destination was deleted + 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); +} + +#[sqlx::test] +async fn test_multiple_destinations_application(_: 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_1 = make_destination(); + let destination_2 = make_destination(); + let destination_3 = make_destination(); + + // create new destinations + let response = client + .post("/api/v1/acl/destination") + .json(&destination_1) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let response = client + .post("/api/v1/acl/destination") + .json(&destination_2) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let response = client + .post("/api/v1/acl/destination") + .json(&destination_3) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + // modify destinations + let mut destination_1: ApiAclDestination = client + .get("/api/v1/acl/destination/1") + .send() + .await + .json() + .await; + destination_1.name = "modified 1".to_string(); + let response = client + .put("/api/v1/acl/destination/1") + .json(&destination_1) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let mut destination_2: ApiAclDestination = client + .get("/api/v1/acl/destination/2") + .send() + .await + .json() + .await; + destination_2.name = "modified 2".to_string(); + let response = client + .put("/api/v1/acl/destination/2") + .json(&destination_2) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let mut destination_3: ApiAclDestination = client + .get("/api/v1/acl/destination/3") + .send() + .await + .json() + .await; + destination_3.name = "modified 3".to_string(); + let response = client + .put("/api/v1/acl/destination/3") + .json(&destination_3) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 6); + + // apply multiple destinations + let response = client + .put("/api/v1/acl/destination/apply") + .json(&json!({ "destinations": [4, 6] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 4); + + // verify destination state + let destination: ApiAclDestination = client + .get("/api/v1/acl/destination/2") + .send() + .await + .json() + .await; + assert_eq!(destination.state, AliasState::Applied); + let destination: ApiAclDestination = client + .get("/api/v1/acl/destination/4") + .send() + .await + .json() + .await; + assert_eq!(destination.state, AliasState::Applied); + let destination: ApiAclDestination = client + .get("/api/v1/acl/destination/5") + .send() + .await + .json() + .await; + assert_eq!(destination.state, AliasState::Modified); + let destination: ApiAclDestination = client + .get("/api/v1/acl/destination/6") + .send() + .await + .json() + .await; + assert_eq!(destination.state, AliasState::Applied); +} + +#[sqlx::test] +async fn test_destination_requires_any_or_values(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + // create destination with empty fields and no any flags + let invalid_destination = json!({ + "name": "invalid destination", + "addresses": "", + "ports": "", + "protocols": [], + "any_address": false, + "any_port": false, + "any_protocol": false + }); + let response = client + .post("/api/v1/acl/destination") + .json(&invalid_destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // try to create destinations with only some destination fields set + let invalid_destination = json!({ + "name": "invalid destination", + "addresses": "", + "ports": "22, 443", + "protocols": [], + "any_address": false, + "any_port": false, + "any_protocol": true + }); + let response = client + .post("/api/v1/acl/destination") + .json(&invalid_destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // create valid destination + let destination = make_destination(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let destination: Value = response.json().await; + let destination_id = destination["id"].as_i64().unwrap(); + + // update destination with empty fields and no any flags + let invalid_update = json!({ + "name": "invalid update", + "addresses": "", + "ports": "", + "protocols": [], + "any_address": false, + "any_port": false, + "any_protocol": false + }); + let response = client + .put(format!("/api/v1/acl/destination/{destination_id}")) + .json(&invalid_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // update destination with some destination fields set + let invalid_update = json!({ + "name": "invalid update", + "addresses": "", + "ports": "5432", + "protocols": [], + "any_address": true, + "any_port": false, + "any_protocol": false + }); + let response = client + .put(format!("/api/v1/acl/destination/{destination_id}")) + .json(&invalid_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // create valid destination with only "any" flags enabled + let destination = json!({ + "name": "valid destination", + "addresses": "", + "ports": "", + "protocols": [], + "any_address": true, + "any_port": true, + "any_protocol": true + }); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); +} + +#[sqlx::test] +async fn test_destination_apply_rejects_alias(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = init_config(None, &pool).await; + let mut client = make_client_v2(pool, 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 response = client + .put("/api/v1/acl/destination/apply") + .json(&json!({ "destinations": [1] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} diff --git a/crates/defguard_core/tests/integration/api/acl/mod.rs b/crates/defguard_core/tests/integration/api/acl/mod.rs new file mode 100644 index 0000000000..952b762302 --- /dev/null +++ b/crates/defguard_core/tests/integration/api/acl/mod.rs @@ -0,0 +1,202 @@ +use defguard_common::{ + config::DefGuardConfig, + db::{ + Id, + models::{ + Device, DeviceType, User, WireguardNetwork, + group::Group, + settings::initialize_current_settings, + wireguard::{DEFAULT_WIREGUARD_MTU, LocationMfaMode, ServiceLocationMode}, + }, + }, +}; +use defguard_core::{ + enterprise::{ + db::models::acl::{AclAlias, AclRule, AliasKind, AliasState, RuleState}, + handlers::acl::{ + ApiAclRule, EditAclRule, + alias::{ApiAclAlias, EditAclAlias}, + destination::{ApiAclDestination, EditAclDestination}, + }, + license::{get_cached_license, set_cached_license}, + }, + handlers::Auth, +}; +use reqwest::StatusCode; +use serde_json::{Value, json}; +use sqlx::{ + PgPool, + postgres::{PgConnectOptions, PgPoolOptions}, +}; +use tokio::net::TcpListener; + +use super::common::{ + authenticate_admin, client::TestClient, exceed_enterprise_limits, make_base_client, + make_test_client, setup_pool, +}; +use crate::common::{init_config, initialize_users}; + +mod aliases; +mod destinations; +mod rules; + +async fn make_client_v2(pool: PgPool, config: DefGuardConfig) -> TestClient { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("Could not bind ephemeral socket"); + initialize_users(&pool).await; + initialize_current_settings(&pool) + .await + .expect("Could not initialize settings"); + let (client, _) = make_base_client(pool, config, listener).await; + client +} + +fn make_rule() -> EditAclRule { + EditAclRule { + name: "rule".to_string(), + all_locations: false, + locations: Vec::new(), + expires: None, + allow_all_users: false, + deny_all_users: false, + allow_all_groups: false, + deny_all_groups: false, + allow_all_network_devices: false, + deny_all_network_devices: false, + allowed_users: vec![1], + denied_users: Vec::new(), + allowed_groups: Vec::new(), + denied_groups: Vec::new(), + allowed_network_devices: Vec::new(), + denied_network_devices: Vec::new(), + addresses: "10.2.2.2, 10.0.0.1/24, 10.0.10.1-10.0.20.1".to_string(), + aliases: Vec::new(), + destinations: Vec::new(), + enabled: true, + protocols: vec![6, 17], + ports: "1, 2, 3, 10-20, 30-40".to_string(), + any_address: false, + any_port: false, + any_protocol: false, + use_manual_destination_settings: true, + } +} + +async fn set_rule_state(pool: &PgPool, id: Id, state: RuleState, parent_id: Option) { + let mut rule = AclRule::find_by_id(pool, id).await.unwrap().unwrap(); + rule.state = state; + rule.parent_id = parent_id; + rule.save(pool).await.unwrap(); +} + +fn make_alias() -> EditAclAlias { + EditAclAlias { + name: "alias".to_string(), + addresses: "10.2.2.2, 10.0.0.1/24, 10.0.10.1-10.0.20.1".to_string(), + protocols: vec![6, 17], + ports: "1, 2, 3, 10-20, 30-40".to_string(), + } +} + +fn make_destination() -> EditAclDestination { + EditAclDestination { + name: "destination".to_string(), + addresses: "10.20.30.40, 10.0.0.1/24, 10.0.10.1-10.0.20.1".to_string(), + ports: "1, 2, 3, 10-20, 30-40".to_string(), + protocols: vec![6, 17], + any_address: false, + any_port: false, + any_protocol: false, + } +} + +async fn count_destinations(pool: &PgPool) -> usize { + AclAlias::all_of_kind(pool, AliasKind::Destination) + .await + .unwrap() + .len() +} + +fn edit_rule_data_into_api_response( + data: &EditAclRule, + id: Id, + parent_id: Option, + state: RuleState, +) -> ApiAclRule { + ApiAclRule { + id, + parent_id, + state, + name: data.name.clone(), + all_locations: data.all_locations, + locations: data.locations.clone(), + expires: data.expires, + enabled: data.enabled, + allow_all_users: data.allow_all_users, + deny_all_users: data.deny_all_users, + allow_all_groups: data.allow_all_groups, + deny_all_groups: data.deny_all_groups, + allow_all_network_devices: data.allow_all_network_devices, + deny_all_network_devices: data.deny_all_network_devices, + allowed_users: data.allowed_users.clone(), + denied_users: data.denied_users.clone(), + allowed_groups: data.allowed_groups.clone(), + denied_groups: data.denied_groups.clone(), + allowed_network_devices: data.allowed_network_devices.clone(), + denied_network_devices: data.denied_network_devices.clone(), + addresses: data.addresses.clone(), + aliases: data.aliases.clone(), + destinations: data.destinations.clone(), + ports: data.ports.clone(), + protocols: data.protocols.clone(), + any_address: data.any_address, + any_port: data.any_port, + any_protocol: data.any_protocol, + use_manual_destination_settings: data.use_manual_destination_settings, + } +} + +fn edit_alias_data_into_api_response( + data: EditAclAlias, + id: Id, + parent_id: Option, + state: AliasState, + kind: AliasKind, + rules: Vec, +) -> ApiAclAlias { + ApiAclAlias { + id, + parent_id, + state, + name: data.name, + kind, + addresses: data.addresses, + ports: data.ports, + protocols: data.protocols, + rules, + } +} + +fn edit_destination_data_into_api_response( + data: EditAclDestination, + id: Id, + parent_id: Option, + state: AliasState, + rules: Vec, +) -> ApiAclDestination { + ApiAclDestination { + id, + parent_id, + state, + name: data.name, + kind: AliasKind::Destination, + addresses: data.addresses, + ports: data.ports, + protocols: data.protocols, + rules, + any_address: data.any_address, + any_port: data.any_port, + any_protocol: data.any_protocol, + } +} diff --git a/crates/defguard_core/tests/integration/api/acl.rs b/crates/defguard_core/tests/integration/api/acl/rules.rs similarity index 60% rename from crates/defguard_core/tests/integration/api/acl.rs rename to crates/defguard_core/tests/integration/api/acl/rules.rs index 95a5681ac1..2ae2782bae 100644 --- a/crates/defguard_core/tests/integration/api/acl.rs +++ b/crates/defguard_core/tests/integration/api/acl/rules.rs @@ -1,170 +1,4 @@ -use defguard_common::{ - config::DefGuardConfig, - db::{ - Id, - models::{ - Device, DeviceType, User, WireguardNetwork, - group::Group, - settings::initialize_current_settings, - wireguard::{DEFAULT_WIREGUARD_MTU, LocationMfaMode, ServiceLocationMode}, - }, - }, -}; -use defguard_core::{ - enterprise::{ - db::models::acl::{AclAlias, AclRule, AliasKind, AliasState, RuleState}, - handlers::acl::{ - ApiAclRule, EditAclRule, - alias::{ApiAclAlias, EditAclAlias}, - }, - license::{get_cached_license, set_cached_license}, - }, - handlers::Auth, -}; -use reqwest::StatusCode; -use serde_json::{Value, json}; -use sqlx::{ - PgPool, - postgres::{PgConnectOptions, PgPoolOptions}, -}; -use tokio::net::TcpListener; - -use super::common::{ - authenticate_admin, client::TestClient, exceed_enterprise_limits, make_base_client, - make_test_client, setup_pool, -}; -use crate::common::{init_config, initialize_users}; - -async fn make_client_v2(pool: PgPool, config: DefGuardConfig) -> TestClient { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("Could not bind ephemeral socket"); - initialize_users(&pool).await; - initialize_current_settings(&pool) - .await - .expect("Could not initialize settings"); - let (client, _) = make_base_client(pool, config, listener).await; - client -} - -fn make_rule() -> EditAclRule { - EditAclRule { - name: "rule".to_string(), - all_locations: false, - locations: Vec::new(), - expires: None, - allow_all_users: false, - deny_all_users: false, - allow_all_groups: false, - deny_all_groups: false, - allow_all_network_devices: false, - deny_all_network_devices: false, - allowed_users: vec![1], - denied_users: Vec::new(), - allowed_groups: Vec::new(), - denied_groups: Vec::new(), - allowed_network_devices: Vec::new(), - denied_network_devices: Vec::new(), - addresses: "10.2.2.2, 10.0.0.1/24, 10.0.10.1-10.0.20.1".to_string(), - aliases: Vec::new(), - destinations: Vec::new(), - enabled: true, - protocols: vec![6, 17], - ports: "1, 2, 3, 10-20, 30-40".to_string(), - any_address: false, - any_port: false, - any_protocol: false, - use_manual_destination_settings: true, - } -} - -async fn set_rule_state(pool: &PgPool, id: Id, state: RuleState, parent_id: Option) { - let mut rule = AclRule::find_by_id(pool, id).await.unwrap().unwrap(); - rule.state = state; - rule.parent_id = parent_id; - rule.save(pool).await.unwrap(); -} - -fn make_alias() -> EditAclAlias { - EditAclAlias { - name: "alias".to_string(), - addresses: "10.2.2.2, 10.0.0.1/24, 10.0.10.1-10.0.20.1".to_string(), - protocols: vec![6, 17], - ports: "1, 2, 3, 10-20, 30-40".to_string(), - } -} - -fn make_destination() -> Value { - json!({ - "name": "destination", - "addresses": "10.20.30.40, 10.0.0.1/24, 10.0.10.1-10.0.20.1", - "ports": "1, 2, 3, 10-20, 30-40", - "protocols": [6, 17], - "any_address": false, - "any_port": false, - "any_protocol": false - }) -} - -fn edit_rule_data_into_api_response( - data: &EditAclRule, - id: Id, - parent_id: Option, - state: RuleState, -) -> ApiAclRule { - ApiAclRule { - id, - parent_id, - state, - name: data.name.clone(), - all_locations: data.all_locations, - locations: data.locations.clone(), - expires: data.expires, - enabled: data.enabled, - allow_all_users: data.allow_all_users, - deny_all_users: data.deny_all_users, - allow_all_groups: data.allow_all_groups, - deny_all_groups: data.deny_all_groups, - allow_all_network_devices: data.allow_all_network_devices, - deny_all_network_devices: data.deny_all_network_devices, - allowed_users: data.allowed_users.clone(), - denied_users: data.denied_users.clone(), - allowed_groups: data.allowed_groups.clone(), - denied_groups: data.denied_groups.clone(), - allowed_network_devices: data.allowed_network_devices.clone(), - denied_network_devices: data.denied_network_devices.clone(), - addresses: data.addresses.clone(), - aliases: data.aliases.clone(), - destinations: data.destinations.clone(), - ports: data.ports.clone(), - protocols: data.protocols.clone(), - any_address: data.any_address, - any_port: data.any_port, - any_protocol: data.any_protocol, - use_manual_destination_settings: data.use_manual_destination_settings, - } -} - -fn edit_alias_data_into_api_response( - data: EditAclAlias, - id: Id, - parent_id: Option, - state: AliasState, - kind: AliasKind, - rules: Vec, -) -> ApiAclAlias { - ApiAclAlias { - id, - parent_id, - state, - name: data.name, - kind, - addresses: data.addresses, - ports: data.ports, - protocols: data.protocols, - rules, - } -} +use super::*; #[sqlx::test] async fn test_rule_crud(_: PgPoolOptions, options: PgConnectOptions) { @@ -365,103 +199,6 @@ async fn test_rule_enterprise(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(response.status(), StatusCode::OK); } -#[sqlx::test] -async fn test_alias_crud(_: 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(); - - // create - let response = client.post("/api/v1/acl/alias").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::CREATED); - let response_alias: ApiAclAlias = response.json().await; - let expected_response = edit_alias_data_into_api_response( - alias, - response_alias.id, - None, - AliasState::Applied, - AliasKind::Component, - Vec::new(), - ); - assert_eq!(response_alias, expected_response); - - // list - let response = client.get("/api/v1/acl/alias").send().await; - assert_eq!(response.status(), StatusCode::OK); - let response_aliases: Vec = response.json().await; - assert_eq!(response_aliases.len(), 1); - let response_alias = response_aliases[0].clone(); - assert_eq!(response_alias, expected_response); - - // retrieve - let response = client.get("/api/v1/acl/alias/1").send().await; - assert_eq!(response.status(), StatusCode::OK); - let response_alias: ApiAclAlias = response.json().await; - assert_eq!(response_alias, expected_response); - - // update - let mut alias: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; - alias.name = "modified".to_string(); - let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::OK); - let response_alias: ApiAclAlias = response.json().await; - let alias: ApiAclAlias = client.get("/api/v1/acl/alias/2").send().await.json().await; - assert_eq!(response_alias, alias); - - // delete - let response = client.delete("/api/v1/acl/alias/1").send().await; - assert_eq!(response.status(), StatusCode::OK); - let response = client.get("/api/v1/acl/alias").send().await; - assert_eq!(response.status(), StatusCode::OK); - let response_aliases: Vec = response.json().await; - assert_eq!(response_aliases.len(), 0); -} - -#[sqlx::test] -async fn test_alias_enterprise(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let (mut client, _) = make_test_client(pool).await; - authenticate_admin(&mut client).await; - - exceed_enterprise_limits(&client).await; - - // unset the license - let license = get_cached_license().clone(); - set_cached_license(None); - - // try to use ACL api - let alias = make_alias(); - let response = client.post("/api/v1/acl/alias").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::FORBIDDEN); - let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::FORBIDDEN); - // GET should be allowed - let response = client.get("/api/v1/acl/alias").send().await; - assert_eq!(response.status(), StatusCode::OK); - let response = client.delete("/api/v1/acl/alias/1").send().await; - assert_eq!(response.status(), StatusCode::FORBIDDEN); - - // restore valid license and try again - set_cached_license(license); - let response = client.post("/api/v1/acl/alias").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::CREATED); - let response = client.get("/api/v1/acl/alias").send().await; - assert_eq!(response.status(), StatusCode::OK); - let response_aliases: Vec = response.json().await; - assert_eq!(response_aliases.len(), 1); - let response = client.get("/api/v1/acl/alias").send().await; - assert_eq!(response.status(), StatusCode::OK); - let alias: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; - let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::OK); - let response = client.delete("/api/v1/acl/alias/1").send().await; - assert_eq!(response.status(), StatusCode::OK); -} - #[sqlx::test] async fn test_empty_strings(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -1124,266 +861,6 @@ async fn test_multiple_rules_application(_: PgPoolOptions, options: PgConnectOpt assert_eq!(rule.state, RuleState::Applied); } -#[sqlx::test] -async fn test_alias_create_modify_state(_: 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(); - - // assert created alias has correct state - let response = client.post("/api/v1/acl/alias").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::CREATED); - let dbalias = AclAlias::find_by_id(&pool, 1).await.unwrap().unwrap(); - assert_eq!(dbalias.state, AliasState::Applied); - assert_eq!(dbalias.parent_id, None); - - // test APPLIED alias modification - let alias_before_mods: ApiAclAlias = - client.get("/api/v1/acl/alias/1").send().await.json().await; - let mut alias_modified = alias_before_mods.clone(); - let response = client - .put("/api/v1/acl/alias/1") - .json(&alias_modified) - .send() - .await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); - let alias_parent: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; - let alias_child: ApiAclAlias = client.get("/api/v1/acl/alias/2").send().await.json().await; - assert_eq!(alias_parent, alias_before_mods); - assert_eq!(alias_parent.state, AliasState::Applied); - alias_modified.id = 2; - alias_modified.state = AliasState::Modified; - alias_modified.parent_id = Some(1); - assert_eq!(alias_child, alias_modified); - assert_eq!(alias_child.state, AliasState::Modified); - assert_eq!(alias_child.parent_id, Some(alias_parent.id)); -} - -#[sqlx::test] -async fn test_alias_delete(_: 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; - - // create alias - 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); - - // use alias in a new rule - 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); - - // cannot delete alias if used by a rule - let response = client.delete("/api/v1/acl/alias/1").send().await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); - - // remove alias from rule - rule.aliases = Vec::new(); - let response = client.put("/api/v1/acl/rule/1").json(&rule).send().await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); - - // delete alias - let response = client.delete("/api/v1/acl/alias/1").send().await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 0); - - // create another alias - let mut 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); - - // modify alias - alias.name = "modified".to_string(); - let response = client.put("/api/v1/acl/alias/2").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); - - // delete pending modification - let response = client.delete("/api/v1/acl/alias/3").send().await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); - - // modify alias again - alias.name = "modified again".to_string(); - let response = client.put("/api/v1/acl/alias/2").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); - - // delete original alias - let response = client.delete("/api/v1/acl/alias/2").send().await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 0); -} - -#[sqlx::test] -async fn test_alias_duplication(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - // each modification of parent alias should remove the child and create a new one - 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); - - // ensure we don't duplicate already modified / deleted aliass - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); - let alias: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; - let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); - let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); - let response = client.delete("/api/v1/acl/alias/1").send().await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 0); -} - -#[sqlx::test] -async fn test_alias_application(_: 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; - - // create new alias - let alias = make_alias(); - let response = client.post("/api/v1/acl/alias").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::CREATED); - - // verify initial status - let alias: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; - assert_eq!(alias.state, AliasState::Applied); - - // use alias in a new rule - 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); - - // verify rule assignment - let mut alias: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; - assert_eq!(alias.rules, vec![1]); - - // modify alias - alias.name = "modified".to_string(); - let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); - - // cannot apply already applied alias - let response = client - .put("/api/v1/acl/alias/apply") - .json(&json!({ "aliases": [1] })) - .send() - .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - - // apply modification - let response = client - .put("/api/v1/acl/alias/apply") - .json(&json!({ "aliases": [2] })) - .send() - .await; - assert_eq!(response.status(), StatusCode::OK); - - // verify alias was applied - let alias: ApiAclAlias = client.get("/api/v1/acl/alias/2").send().await.json().await; - assert_eq!(alias.state, AliasState::Applied); - assert_eq!(alias.parent_id, None); - assert_eq!(alias.rules, vec![1]); - - // initial alias was deleted - let response = client.get("/api/v1/acl/alias/1").send().await; - assert_eq!(response.status(), StatusCode::NOT_FOUND); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); -} - -#[sqlx::test] -async fn test_multiple_aliases_application(_: 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_1 = make_alias(); - let alias_2 = make_alias(); - let alias_3 = make_alias(); - - // create new aliass - let response = client.post("/api/v1/acl/alias").json(&alias_1).send().await; - assert_eq!(response.status(), StatusCode::CREATED); - let response = client.post("/api/v1/acl/alias").json(&alias_2).send().await; - assert_eq!(response.status(), StatusCode::CREATED); - let response = client.post("/api/v1/acl/alias").json(&alias_3).send().await; - assert_eq!(response.status(), StatusCode::CREATED); - - // modify aliases - let mut alias_1: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; - alias_1.name = "modified 1".to_string(); - let response = client - .put("/api/v1/acl/alias/1") - .json(&alias_1) - .send() - .await; - assert_eq!(response.status(), StatusCode::OK); - let mut alias_2: ApiAclAlias = client.get("/api/v1/acl/alias/2").send().await.json().await; - alias_2.name = "modified 2".to_string(); - let response = client - .put("/api/v1/acl/alias/2") - .json(&alias_2) - .send() - .await; - assert_eq!(response.status(), StatusCode::OK); - let mut alias_3: ApiAclAlias = client.get("/api/v1/acl/alias/3").send().await.json().await; - alias_3.name = "modified 3".to_string(); - let response = client - .put("/api/v1/acl/alias/3") - .json(&alias_3) - .send() - .await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 6); - - // apply multiple aliases - let response = client - .put("/api/v1/acl/alias/apply") - .json(&json!({ "aliases": [4, 6] })) - .send() - .await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 4); - - // verify alias state - let alias: ApiAclAlias = client.get("/api/v1/acl/alias/2").send().await.json().await; - assert_eq!(alias.state, AliasState::Applied); - let alias: ApiAclAlias = client.get("/api/v1/acl/alias/4").send().await.json().await; - assert_eq!(alias.state, AliasState::Applied); - let alias: ApiAclAlias = client.get("/api/v1/acl/alias/5").send().await.json().await; - assert_eq!(alias.state, AliasState::Modified); - let alias: ApiAclAlias = client.get("/api/v1/acl/alias/6").send().await.json().await; - assert_eq!(alias.state, AliasState::Applied); -} - #[sqlx::test] async fn test_acl_count_endpoints(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -1433,8 +910,8 @@ async fn test_acl_count_endpoints(_: PgPoolOptions, options: PgConnectOptions) { .await; assert_eq!(response.status(), StatusCode::CREATED); - let mut destination_to_update = destination_1; - destination_to_update["name"] = json!("updated destination"); + let mut destination_to_update = destination.clone(); + destination_to_update.name = "updated destination".to_string(); let response = client .put(format!("/api/v1/acl/destination/{destination_1_id}")) .json(&destination_to_update) @@ -1469,107 +946,3 @@ async fn test_acl_count_endpoints(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(counts["applied"], json!(2)); assert_eq!(counts["pending"], json!(1)); } - -#[sqlx::test] -async fn test_destination_requires_any_or_values(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let (mut client, _) = make_test_client(pool).await; - authenticate_admin(&mut client).await; - - // create destination with empty fields and no any flags - let invalid_destination = json!({ - "name": "invalid destination", - "addresses": "", - "ports": "", - "protocols": [], - "any_address": false, - "any_port": false, - "any_protocol": false - }); - let response = client - .post("/api/v1/acl/destination") - .json(&invalid_destination) - .send() - .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - - // try to create destinations with only some destination fields set - let invalid_destination = json!({ - "name": "invalid destination", - "addresses": "", - "ports": "22, 443", - "protocols": [], - "any_address": false, - "any_port": false, - "any_protocol": true - }); - let response = client - .post("/api/v1/acl/destination") - .json(&invalid_destination) - .send() - .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - - // create valid destination - let destination = make_destination(); - let response = client - .post("/api/v1/acl/destination") - .json(&destination) - .send() - .await; - assert_eq!(response.status(), StatusCode::CREATED); - let destination: Value = response.json().await; - let destination_id = destination["id"].as_i64().unwrap(); - - // update destination with empty fields and no any flags - let invalid_update = json!({ - "name": "invalid update", - "addresses": "", - "ports": "", - "protocols": [], - "any_address": false, - "any_port": false, - "any_protocol": false - }); - let response = client - .put(format!("/api/v1/acl/destination/{destination_id}")) - .json(&invalid_update) - .send() - .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - - // update destination with some destination fields set - let invalid_update = json!({ - "name": "invalid update", - "addresses": "", - "ports": "5432", - "protocols": [], - "any_address": true, - "any_port": false, - "any_protocol": false - }); - let response = client - .put(format!("/api/v1/acl/destination/{destination_id}")) - .json(&invalid_update) - .send() - .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - - // create valid destination with only "any" flags enabled - let destination = json!({ - "name": "valid destination", - "addresses": "", - "ports": "", - "protocols": [], - "any_address": true, - "any_port": true, - "any_protocol": true - }); - let response = client - .post("/api/v1/acl/destination") - .json(&destination) - .send() - .await; - assert_eq!(response.status(), StatusCode::CREATED); -} diff --git a/flake.lock b/flake.lock index 9b2d3e5625..710502cbcc 100644 --- a/flake.lock +++ b/flake.lock @@ -32,11 +32,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1771848320, - "narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=", + "lastModified": 1772624091, + "narHash": "sha256-QKyJ0QGWBn6r0invrMAK8dmJoBYWoOWy7lN+UHzW1jc=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "2fc6539b481e1d2569f25f8799236694180c0993", + "rev": "80bdc1e5ce51f56b19791b52b2901187931f5353", "type": "github" }, "original": { @@ -74,11 +74,11 @@ ] }, "locked": { - "lastModified": 1771988922, - "narHash": "sha256-Fc6FHXtfEkLtuVJzd0B6tFYMhmcPLuxr90rWfb/2jtQ=", + "lastModified": 1772775058, + "narHash": "sha256-i+I9RYN8kYb9/9kibkxd0avkkislD1tyWojSVgIy160=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "f4443dc3f0b6c5e6b77d923156943ce816d1fcb9", + "rev": "629bbb7f9d02787a54e28398b411da849246253b", "type": "github" }, "original": { diff --git a/web/messages/en/components.json b/web/messages/en/components.json index 8f9858108a..5237298d31 100644 --- a/web/messages/en/components.json +++ b/web/messages/en/components.json @@ -38,5 +38,9 @@ "cmp_sync_behavior_target_users": "Users", "cmp_sync_behavior_target_groups": "Groups", "cmp_copy_button": "Copy to clipboard", - "cmp_copy_button_tooltip": "Content copied to clipboard." + "cmp_copy_button_tooltip": "Content copied to clipboard.", + "acl_alias_delete_success": "Alias deleted", + "acl_alias_delete_failed": "Failed to delete alias", + "acl_destination_delete_success": "Destination deleted", + "acl_destination_delete_failed": "Failed to delete destination" } diff --git a/web/messages/en/modal.json b/web/messages/en/modal.json index 47c887d8e9..d8dbef2e91 100644 --- a/web/messages/en/modal.json +++ b/web/messages/en/modal.json @@ -10,6 +10,10 @@ "modal_delete_gateway_body": "Deleting gateway {name} may cause location {locationName} to stop working if it is the only Gateway connected to this location or if other connected Gateways are currently unavailable. This action cannot be undone.", "modal_delete_location_title": "Delete location", "modal_delete_location_body": "Deleting location {name} will also delete related gateways. This action cannot be undone.", + "modal_delete_acl_alias_title": "Delete alias", + "modal_delete_acl_alias_body": "Are you sure you want to delete this alias? This action can't be undone.", + "modal_delete_acl_destination_title": "Delete destination", + "modal_delete_acl_destination_body": "Are you sure you want to delete this destination? This action can't be undone.", "modal_mfa_enable_email_title": "Email MFA setup", "modal_mfa_enable_email_verification": "Email verification", "modal_mfa_enable_email_content": "To setup your MFA enter the code that was sent to your account email: {email}", diff --git a/web/package.json b/web/package.json index 4042ffa929..3fb71e6c42 100644 --- a/web/package.json +++ b/web/package.json @@ -15,7 +15,7 @@ "dependencies": { "@axa-ch/react-polymorphic-types": "^1.4.1", "@floating-ui/react": "^0.27.19", - "@inlang/paraglide-js": "^2.13.1", + "@inlang/paraglide-js": "^2.13.2", "@react-hook/resize-observer": "^2.0.2", "@shortercode/webzip": "1.1.1-0", "@stablelib/base64": "^2.0.1", @@ -24,7 +24,7 @@ "@tanstack/react-query": "^5.90.21", "@tanstack/react-router": "^1.166.2", "@tanstack/react-table": "^8.21.3", - "@tanstack/react-virtual": "^3.13.19", + "@tanstack/react-virtual": "^3.13.21", "@uidotdev/usehooks": "^2.4.1", "axios": "^1.13.6", "byte-size": "^9.0.1", @@ -54,15 +54,15 @@ "@biomejs/biome": "2.4.5", "@inlang/paraglide-js": "2.13.1", "@tanstack/devtools-vite": "^0.5.3", - "@tanstack/react-devtools": "^0.9.9", + "@tanstack/react-devtools": "^0.9.10", "@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-router-devtools": "^1.166.2", "@tanstack/router-plugin": "^1.166.2", "@types/byte-size": "^8.1.2", "@types/humanize-duration": "^3.27.4", "@types/lodash-es": "^4.17.12", - "@types/node": "^25.3.3", - "@types/qs": "^6.14.0", + "@types/node": "^25.3.5", + "@types/qs": "^6.15.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react-swc": "^4.2.3", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index e4012a0fac..f125aee1c7 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.1 - version: 2.13.1 + specifier: ^2.13.2 + version: 2.13.2 '@react-hook/resize-observer': specifier: ^2.0.2 version: 2.0.2(react@19.2.4) @@ -42,8 +42,8 @@ importers: specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-virtual': - specifier: ^3.13.19 - version: 3.13.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^3.13.21 + version: 3.13.21(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@uidotdev/usehooks': specifier: ^2.4.1 version: 2.4.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -122,10 +122,10 @@ importers: version: 2.4.5 '@tanstack/devtools-vite': specifier: ^0.5.3 - version: 0.5.3(vite@7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0)) + version: 0.5.3(vite@7.3.1(@types/node@25.3.5)(sass@1.97.3)(tsx@4.21.0)) '@tanstack/react-devtools': - specifier: ^0.9.9 - version: 0.9.9(@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) + 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) '@tanstack/react-query-devtools': specifier: ^5.91.3 version: 5.91.3(@tanstack/react-query@5.90.21(react@19.2.4))(react@19.2.4) @@ -134,7 +134,7 @@ importers: version: 1.166.2(@tanstack/react-router@1.166.2(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/router-plugin': specifier: ^1.166.2 - version: 1.166.2(@tanstack/react-router@1.166.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0)) + version: 1.166.2(@tanstack/react-router@1.166.2(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)) '@types/byte-size': specifier: ^8.1.2 version: 8.1.2 @@ -145,11 +145,11 @@ importers: specifier: ^4.17.12 version: 4.17.12 '@types/node': - specifier: ^25.3.3 - version: 25.3.3 + specifier: ^25.3.5 + version: 25.3.5 '@types/qs': - specifier: ^6.14.0 - version: 6.14.0 + specifier: ^6.15.0 + version: 6.15.0 '@types/react': specifier: ^19.2.14 version: 19.2.14 @@ -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.3)(sass@1.97.3)(tsx@4.21.0)) + version: 4.2.3(vite@7.3.1(@types/node@25.3.5)(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.3)(sass@1.97.3)(tsx@4.21.0) + version: 7.3.1(@types/node@25.3.5)(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.3)(sass@1.97.3)(tsx@4.21.0)) + 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)) 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==} @@ -360,8 +356,8 @@ packages: peerDependencies: '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.0.29': - resolution: {integrity: sha512-jx9GjkkP5YHuTmko2eWAvpPnb0mB4mGRr2U7XwVNwevm8nlpobZEVk+GNmiYMk2VuA75v+plfXWyroWKmICZXg==} + '@csstools/css-syntax-patches-for-csstree@1.1.0': + resolution: {integrity: sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==} '@csstools/css-tokenizer@4.0.0': resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} @@ -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.1': - resolution: {integrity: sha512-IsGm5gHmH6hVA9iwzvoRkt+Q9nrzX+6+G1nAmxODk/l+mpm0siPebfkXiu6DODSluFuHg0Q6fjXcDXAaqOen+Q==} + '@inlang/paraglide-js@2.13.2': + resolution: {integrity: sha512-ecxw95pmMbasVj7M/B6pu5wqYHomYQBcu3QzDl1svwAkbnRqRmsdrH4IizzFwqeVWd+uluibMIy1VOGywin94A==} 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==} @@ -1206,8 +1163,8 @@ packages: peerDependencies: vite: ^6.0.0 || ^7.0.0 - '@tanstack/devtools@0.10.10': - resolution: {integrity: sha512-/SSJcyhZtq1+HB9UViz8e0Y7Io4JPIbyJ0Wns5ENwzSHsNOAheANA8QnEQBVXETY/osCcaGAVOyVfGgn5aBJKA==} + '@tanstack/devtools@0.10.11': + resolution: {integrity: sha512-Nk1rHsv6S/5Krzz+uL5jldW9gKb3s6rkkVl1L9oVYHNClKthbrk2hGef4Di6yj449QIOqVExTdDujjQ4roq1dg==} engines: {node: '>=18'} peerDependencies: solid-js: '>=1.9.7' @@ -1229,8 +1186,8 @@ packages: '@tanstack/query-devtools@5.93.0': resolution: {integrity: sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg==} - '@tanstack/react-devtools@0.9.9': - resolution: {integrity: sha512-w5J5n3tPLfGVRSwV5S05Fg1awa7SJssdyNh/3KPDHU72DPUvH9v5mPGRrbTxsoFWvh3djFTtF9tCX1tCnyoOuQ==} + '@tanstack/react-devtools@0.9.10': + resolution: {integrity: sha512-WKFU8SXN7DLM7EyD2aUAhmk7JGNeONWhQozAH2qDCeOjyc3Yzxs4BxeoyKMYyEiX/eCp8ZkMTf/pJX6vm2LGeA==} engines: {node: '>=18'} peerDependencies: '@types/react': '>=16.8' @@ -1290,8 +1247,8 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-virtual@3.13.19': - resolution: {integrity: sha512-KzwmU1IbE0IvCZSm6OXkS+kRdrgW2c2P3Ho3NC+zZXWK6oObv/L+lcV/2VuJ+snVESRlMJ+w/fg4WXI/JzoNGQ==} + '@tanstack/react-virtual@3.13.21': + resolution: {integrity: sha512-SYXFrmrbPgXBvf+HsOsKhFgqSe4M6B29VHOsX9Jih9TlNkNkDWx0hWMiMLUghMEzyUz772ndzdEeCEBx+3GIZw==} peerDependencies: 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 @@ -1346,8 +1303,8 @@ packages: resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} - '@tanstack/virtual-core@3.13.19': - resolution: {integrity: sha512-/BMP7kNhzKOd7wnDeB8NrIRNLwkf5AhCYCvtfZV2GXWbBieFm/el0n6LOAXlTi6ZwHICSNnQcIxRCWHrLzDY+g==} + '@tanstack/virtual-core@3.13.21': + resolution: {integrity: sha512-ww+fmLHyCbPSf7JNbWZP3g7wl6SdNo3ah5Aiw+0e9FDErkVHLKprYUrwTm7dF646FtEkN/KkAKPYezxpmvOjxw==} '@tanstack/virtual-file-routes@1.161.4': resolution: {integrity: sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w==} @@ -1410,11 +1367,11 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@25.3.3': - resolution: {integrity: sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==} + '@types/node@25.3.5': + resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==} - '@types/qs@6.14.0': - resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + '@types/qs@6.15.0': + resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} @@ -1556,8 +1513,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001776: - resolution: {integrity: sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==} + caniuse-lite@1.0.30001777: + resolution: {integrity: sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1638,8 +1595,8 @@ packages: resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==} engines: {node: '>=12'} - css-tree@3.2.0: - resolution: {integrity: sha512-t99A4LolkP0ZX9WUoaHz4YrPT1FKNlV8IDCeCPPpGaWyxegh64tt/BSUqN3u5necrYRon+ddZ6mPMjxIlfpobg==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} cssesc@3.0.0: @@ -3129,7 +3086,7 @@ snapshots: dependencies: '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.0.29': {} + '@csstools/css-syntax-patches-for-csstree@1.1.0': {} '@csstools/css-tokenizer@4.0.0': {} @@ -3350,7 +3307,7 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@inlang/paraglide-js@2.13.1': + '@inlang/paraglide-js@2.13.2': 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.3)(sass@1.97.3)(tsx@4.21.0))': + '@tanstack/devtools-vite@0.5.3(vite@7.3.1(@types/node@25.3.5)(sass@1.97.3)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/generator': 7.29.1 @@ -3752,13 +3709,13 @@ snapshots: chalk: 5.6.2 launch-editor: 2.13.1 picomatch: 4.0.3 - vite: 7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.3.5)(sass@1.97.3)(tsx@4.21.0) transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@tanstack/devtools@0.10.10(csstype@3.2.3)(solid-js@1.9.9)': + '@tanstack/devtools@0.10.11(csstype@3.2.3)(solid-js@1.9.9)': dependencies: '@solid-primitives/event-listener': 2.4.5(solid-js@1.9.9) '@solid-primitives/keyboard': 1.3.5(solid-js@1.9.9) @@ -3788,9 +3745,9 @@ snapshots: '@tanstack/query-devtools@5.93.0': {} - '@tanstack/react-devtools@0.9.9(@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)': + '@tanstack/react-devtools@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)': dependencies: - '@tanstack/devtools': 0.10.10(csstype@3.2.3)(solid-js@1.9.9) + '@tanstack/devtools': 0.10.11(csstype@3.2.3)(solid-js@1.9.9) '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) react: 19.2.4 @@ -3855,9 +3812,9 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@tanstack/react-virtual@3.13.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-virtual@3.13.21(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/virtual-core': 3.13.19 + '@tanstack/virtual-core': 3.13.21 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -3893,7 +3850,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.166.2(@tanstack/react-router@1.166.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0))': + '@tanstack/router-plugin@1.166.2(@tanstack/react-router@1.166.2(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))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -3910,7 +3867,7 @@ snapshots: zod: 3.25.76 optionalDependencies: '@tanstack/react-router': 1.166.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - vite: 7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.3.5)(sass@1.97.3)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -3932,7 +3889,7 @@ snapshots: '@tanstack/table-core@8.21.3': {} - '@tanstack/virtual-core@3.13.19': {} + '@tanstack/virtual-core@3.13.21': {} '@tanstack/virtual-file-routes@1.161.4': {} @@ -3990,11 +3947,11 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@25.3.3': + '@types/node@25.3.5': dependencies: undici-types: 7.18.2 - '@types/qs@6.14.0': {} + '@types/qs@6.15.0': {} '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: @@ -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.3)(sass@1.97.3)(tsx@4.21.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))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 '@swc/core': 1.15.18 - vite: 7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.3.5)(sass@1.97.3)(tsx@4.21.0) transitivePeerDependencies: - '@swc/helpers' @@ -4066,7 +4023,7 @@ snapshots: autoprefixer@10.4.27(postcss@8.5.8): dependencies: browserslist: 4.28.1 - caniuse-lite: 1.0.30001776 + caniuse-lite: 1.0.30001777 fraction.js: 5.3.4 picocolors: 1.1.1 postcss: 8.5.8 @@ -4102,7 +4059,7 @@ snapshots: browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.10.0 - caniuse-lite: 1.0.30001776 + caniuse-lite: 1.0.30001777 electron-to-chromium: 1.5.307 node-releases: 2.0.36 update-browserslist-db: 1.2.3(browserslist@4.28.1) @@ -4129,7 +4086,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001776: {} + caniuse-lite@1.0.30001777: {} ccount@2.0.1: {} @@ -4199,7 +4156,7 @@ snapshots: css-functions-list@3.3.3: {} - css-tree@3.2.0: + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 source-map-js: 1.2.1 @@ -5361,7 +5318,7 @@ snapshots: stylelint-scss@7.0.0(stylelint@17.4.0(typescript@5.9.3)): dependencies: - css-tree: 3.2.0 + css-tree: 3.2.1 is-plain-object: 5.0.0 known-css-properties: 0.37.0 mdn-data: 2.27.1 @@ -5375,7 +5332,7 @@ snapshots: dependencies: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-syntax-patches-for-csstree': 1.0.29 + '@csstools/css-syntax-patches-for-csstree': 1.1.0 '@csstools/css-tokenizer': 4.0.0 '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/selector-resolve-nested': 4.0.0(postcss-selector-parser@7.1.1) @@ -5383,7 +5340,7 @@ snapshots: colord: 2.9.3 cosmiconfig: 9.0.1(typescript@5.9.3) css-functions-list: 3.3.3 - css-tree: 3.2.0 + css-tree: 3.2.1 debug: 4.4.3 fast-glob: 3.3.3 fastest-levenshtein: 1.0.16 @@ -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.3)(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.3.5)(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.3)(sass@1.97.3)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.3.5)(sass@1.97.3)(tsx@4.21.0) optionalDependencies: sharp: 0.34.5 - vite@7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0): + vite@7.3.1(@types/node@25.3.5)(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.3 + '@types/node': 25.3.5 fsevents: 2.3.3 sass: 1.97.3 tsx: 4.21.0 diff --git a/web/src/pages/Acl/components/DeleteAliasDestinationConfirmModal/DeleteAliasDestinationConfirmModal.tsx b/web/src/pages/Acl/components/DeleteAliasDestinationConfirmModal/DeleteAliasDestinationConfirmModal.tsx new file mode 100644 index 0000000000..0fa08fe8d2 --- /dev/null +++ b/web/src/pages/Acl/components/DeleteAliasDestinationConfirmModal/DeleteAliasDestinationConfirmModal.tsx @@ -0,0 +1,104 @@ +import { useMutation } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import { m } from '../../../../paraglide/messages'; +import api from '../../../../shared/api/api'; +import { Controls } from '../../../../shared/components/Controls/Controls'; +import { AppText } from '../../../../shared/defguard-ui/components/AppText/AppText'; +import { Button } from '../../../../shared/defguard-ui/components/Button/Button'; +import { Modal } from '../../../../shared/defguard-ui/components/Modal/Modal'; +import { Snackbar } from '../../../../shared/defguard-ui/providers/snackbar/snackbar'; +import { TextStyle } from '../../../../shared/defguard-ui/types'; +import { + subscribeCloseModal, + subscribeOpenModal, +} from '../../../../shared/hooks/modalControls/modalsSubjects'; +import { ModalName } from '../../../../shared/hooks/modalControls/modalTypes'; +import type { OpenDeleteAliasDestinationConfirmModal } from '../../../../shared/hooks/modalControls/types'; + +const modalNameValue = ModalName.DeleteAliasDestinationConfirm; + +type ModalData = OpenDeleteAliasDestinationConfirmModal; + +export const DeleteAliasDestinationConfirmModal = () => { + const [isOpen, setOpen] = useState(false); + const [modalData, setModalData] = useState(null); + + const { mutateAsync: deleteAlias, isPending: deleteAliasPending } = useMutation({ + mutationFn: api.acl.alias.deleteAlias, + meta: { + invalidate: ['acl', 'alias'], + }, + }); + + const { mutateAsync: deleteDestination, isPending: deleteDestinationPending } = + useMutation({ + mutationFn: api.acl.destination.deleteDestination, + meta: { + invalidate: ['acl', 'destination'], + }, + }); + + useEffect(() => { + const openSub = subscribeOpenModal(modalNameValue, (data) => { + setModalData(data); + setOpen(true); + }); + const closeSub = subscribeCloseModal(modalNameValue, () => setOpen(false)); + return () => { + openSub.unsubscribe(); + closeSub.unsubscribe(); + }; + }, []); + + const handleDelete = async () => { + if (!modalData) return; + try { + if (modalData.target.kind === 'alias') { + await deleteAlias(modalData.target.id); + Snackbar.success(m.acl_alias_delete_success()); + } else { + await deleteDestination(modalData.target.id); + Snackbar.success(m.acl_destination_delete_success()); + } + setOpen(false); + } catch { + if (modalData.target.kind === 'alias') { + Snackbar.error(m.acl_alias_delete_failed()); + } else { + Snackbar.error(m.acl_destination_delete_failed()); + } + } + }; + + const isPending = deleteAliasPending || deleteDestinationPending; + + return ( + setOpen(false)} + afterClose={() => setModalData(null)} + > + {modalData?.description ?? ''} + +
+
+
+
+ ); +}; diff --git a/web/src/pages/Acl/components/DeletionBlockedModal/DeletionBlockedModal.tsx b/web/src/pages/Acl/components/DeletionBlockedModal/DeletionBlockedModal.tsx new file mode 100644 index 0000000000..440c469a48 --- /dev/null +++ b/web/src/pages/Acl/components/DeletionBlockedModal/DeletionBlockedModal.tsx @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react'; +import { m } from '../../../../paraglide/messages'; +import { Controls } from '../../../../shared/components/Controls/Controls'; +import { AppText } from '../../../../shared/defguard-ui/components/AppText/AppText'; +import { Button } from '../../../../shared/defguard-ui/components/Button/Button'; +import { Modal } from '../../../../shared/defguard-ui/components/Modal/Modal'; +import { TextStyle } from '../../../../shared/defguard-ui/types'; +import { + subscribeCloseModal, + subscribeOpenModal, +} from '../../../../shared/hooks/modalControls/modalsSubjects'; +import { ModalName } from '../../../../shared/hooks/modalControls/modalTypes'; +import type { OpenDeleteAliasDestinationBlockedModal } from '../../../../shared/hooks/modalControls/types'; +import './style.scss'; + +const modalNameValue = ModalName.DeleteAliasDestinationBlocked; + +type ModalData = OpenDeleteAliasDestinationBlockedModal; + +export const DeletionBlockedModal = () => { + const [isOpen, setOpen] = useState(false); + const [modalData, setModalData] = useState(null); + + useEffect(() => { + const openSub = subscribeOpenModal(modalNameValue, (data) => { + setModalData(data); + setOpen(true); + }); + const closeSub = subscribeCloseModal(modalNameValue, () => setOpen(false)); + return () => { + openSub.unsubscribe(); + closeSub.unsubscribe(); + }; + }, []); + + return ( + setOpen(false)} + afterClose={() => setModalData(null)} + > +
+ + {modalData?.description ?? ''} + +
    + {(modalData?.rules ?? []).map((rule, index) => ( +
  • {rule}
  • + ))} +
+ +
+
+
+
+
+ ); +}; diff --git a/web/src/pages/Acl/components/DeletionBlockedModal/style.scss b/web/src/pages/Acl/components/DeletionBlockedModal/style.scss new file mode 100644 index 0000000000..1f17af3c8d --- /dev/null +++ b/web/src/pages/Acl/components/DeletionBlockedModal/style.scss @@ -0,0 +1,25 @@ +#deletion-blocked-modal .modal-content { + .content { + display: flex; + flex-direction: column; + } + + .description { + margin: 0 0 var(--spacing-lg) 0; + } + + .rules-list { + display: flex; + flex-flow: column; + row-gap: var(--spacing-sm); + margin: 0; + padding: 0 0 0 var(--spacing-lg); + list-style: disc; + overflow: hidden auto; + + li { + margin: 0; + padding: 0; + } + } +} diff --git a/web/src/pages/AliasesPage/AliasTable.tsx b/web/src/pages/AliasesPage/AliasTable.tsx index 270bf9cc06..d497871048 100644 --- a/web/src/pages/AliasesPage/AliasTable.tsx +++ b/web/src/pages/AliasesPage/AliasTable.tsx @@ -9,16 +9,18 @@ import { import { useMemo } from 'react'; import { m } from '../../paraglide/messages'; import api from '../../shared/api/api'; -import { type AclAlias, AclProtocolName } from '../../shared/api/types'; +import { type AclAlias, AclProtocolName, type AclRule } from '../../shared/api/types'; import { TableValuesListCell } from '../../shared/components/TableValuesListCell/TableValuesListCell'; import { IconButtonMenu } from '../../shared/defguard-ui/components/IconButtonMenu/IconButtonMenu'; import type { MenuItemsGroup } from '../../shared/defguard-ui/components/Menu/types'; import { tableEditColumnSize } from '../../shared/defguard-ui/components/table/consts'; import { TableBody } from '../../shared/defguard-ui/components/table/TableBody/TableBody'; import { TableCell } from '../../shared/defguard-ui/components/table/TableCell/TableCell'; -import { isPresent } from '../../shared/defguard-ui/utils/isPresent'; +import { openModal } from '../../shared/hooks/modalControls/modalsSubjects'; +import { ModalName } from '../../shared/hooks/modalControls/modalTypes'; import { getLicenseInfoQueryOptions } from '../../shared/query'; import { canUseBusinessFeature, licenseActionCheck } from '../../shared/utils/license'; +import { resourceById } from '../../shared/utils/resourceById'; type RowData = AclAlias; @@ -26,27 +28,31 @@ const columnHelper = createColumnHelper(); type Props = { data: RowData[]; + rules: AclRule[]; + disableBlockedModal?: boolean; }; -export const AliasTable = ({ data: rowData }: Props) => { +export const AliasTable = ({ data: rowData, rules, disableBlockedModal }: Props) => { const navigate = useNavigate(); const { data: licenseInfo, isFetching: isLicenseFetching } = useQuery( getLicenseInfoQueryOptions, ); - const { data: rules } = useQuery({ - queryFn: api.acl.rule.getRules, - queryKey: ['acl', 'rule'], - select: (resp) => resp.data, - }); - - const { mutate: deleteAlias } = useMutation({ - mutationFn: api.acl.alias.deleteAlias, - meta: { - invalidate: ['acl'], - }, - }); + const rulesById = useMemo(() => resourceById(rules), [rules]); + const rulesByAliasId = useMemo(() => { + if (!rules) return {} as Record; + const map: Record = {}; + rules.forEach((rule) => { + rule.aliases.forEach((aliasId) => { + if (!map[aliasId]) { + map[aliasId] = []; + } + map[aliasId].push(rule.name); + }); + }); + return map; + }, [rules]); const { mutate: applyAliases } = useMutation({ mutationFn: api.acl.alias.applyAliases, @@ -107,13 +113,9 @@ export const AliasTable = ({ data: rowData }: Props) => { size: 400, enableSorting: false, cell: (info) => { - const value = info.getValue(); - let inRules: string[] = []; - if (isPresent(rules)) { - inRules = rules - .filter((rule) => value.includes(rule.id)) - .map((rule) => rule.name); - } + const row = info.row.original; + const aliasId = row.parent_id ?? row.id; + const inRules = rulesByAliasId[aliasId] ?? []; return ; }, }), @@ -145,10 +147,30 @@ export const AliasTable = ({ data: rowData }: Props) => { text: m.controls_delete(), icon: 'delete', variant: 'danger', + disabled: disableBlockedModal && row.rules.length > 0, onClick: () => { if (licenseInfo === undefined) return; licenseActionCheck(canUseBusinessFeature(licenseInfo), () => { - deleteAlias(row.id); + if (row.rules.length > 0) { + const ruleNames = row.rules.map( + (ruleId) => rulesById?.[ruleId]?.name ?? `Rule ${ruleId}`, + ); + openModal(ModalName.DeleteAliasDestinationBlocked, { + title: 'Deletion blocked', + description: + 'This alias is currently in use by the following rule(s) and cannot be deleted. To proceed, remove it from these rules first:', + rules: ruleNames, + }); + return; + } + openModal(ModalName.DeleteAliasDestinationConfirm, { + title: m.modal_delete_acl_alias_title(), + description: m.modal_delete_acl_alias_body(), + target: { + kind: 'alias', + id: row.id, + }, + }); }); }, }, @@ -179,7 +201,15 @@ export const AliasTable = ({ data: rowData }: Props) => { }, }), ], - [rules, applyAliases, deleteAlias, navigate, isLicenseFetching, licenseInfo], + [ + rulesById, + rulesByAliasId, + applyAliases, + disableBlockedModal, + navigate, + isLicenseFetching, + licenseInfo, + ], ); const table = useReactTable({ diff --git a/web/src/pages/AliasesPage/AliasesPage.tsx b/web/src/pages/AliasesPage/AliasesPage.tsx index 35a2a2440e..b618e307df 100644 --- a/web/src/pages/AliasesPage/AliasesPage.tsx +++ b/web/src/pages/AliasesPage/AliasesPage.tsx @@ -9,6 +9,7 @@ import { Tabs } from '../../shared/defguard-ui/components/Tabs/Tabs'; import type { TabsItem } from '../../shared/defguard-ui/components/Tabs/types'; import { TablePageLayout } from '../../shared/layout/TablePageLayout/TablePageLayout'; import { getAliasesCountQueryOptions } from '../../shared/query'; +import { DeleteAliasDestinationConfirmModal } from '../Acl/components/DeleteAliasDestinationConfirmModal/DeleteAliasDestinationConfirmModal'; import { AliasesDeployedTab } from './tabs/AliasesDeployedTab'; import { AliasesPendingTab } from './tabs/AliasesPendingTab'; @@ -52,6 +53,7 @@ export const AliasesPage = () => { {activeTab === AclDeploymentState.Applied && } {activeTab === AclDeploymentState.Modified && } + ); diff --git a/web/src/pages/AliasesPage/tabs/AliasesDeployedTab.tsx b/web/src/pages/AliasesPage/tabs/AliasesDeployedTab.tsx index bb37dfc794..d0f52c6184 100644 --- a/web/src/pages/AliasesPage/tabs/AliasesDeployedTab.tsx +++ b/web/src/pages/AliasesPage/tabs/AliasesDeployedTab.tsx @@ -11,8 +11,10 @@ import { TableTop } from '../../../shared/defguard-ui/components/table/TableTop/ import { getAliasesQueryOptions, getLicenseInfoQueryOptions, + getRulesQueryOptions, } from '../../../shared/query'; import { canUseBusinessFeature, licenseActionCheck } from '../../../shared/utils/license'; +import { DeletionBlockedModal } from '../../Acl/components/DeletionBlockedModal/DeletionBlockedModal'; import { AliasTable } from '../AliasTable'; export const AliasesDeployedTab = () => { @@ -26,6 +28,7 @@ export const AliasesDeployedTab = () => { const { data: licenseInfo, isFetching: licenseFetching } = useQuery( getLicenseInfoQueryOptions, ); + const { data: rules } = useSuspenseQuery(getRulesQueryOptions); const addButtonProps = useMemo( (): ButtonProps => ({ @@ -78,7 +81,7 @@ export const AliasesDeployedTab = () => { />