From d0109ea460fbc217981af4d5e35b428914e000fd Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Thu, 23 Jul 2026 11:24:52 +0200 Subject: [PATCH 01/15] migration and resolver for group client traffic policy --- ...1781eebf0ffb1687bc97d0f42e0a97f420d63.json | 51 ++++++++++++ ...359a8568238ea6b2bdc2f48933369609f00ac.json | 14 ++++ ...cbcc6a6226737625c37347d4d907703552d78.json | 39 +++++++++ .../db/models/enterprise_settings.rs | 82 +++++++++++++++++++ .../db/models/group_client_traffic_policy.rs | 66 +++++++++++++++ .../src/enterprise/db/models/mod.rs | 1 + ....1.0]_group_client_traffic_policy.down.sql | 1 + ...[2.1.0]_group_client_traffic_policy.up.sql | 4 + 8 files changed, 258 insertions(+) create mode 100644 .sqlx/query-a7fe7af3323984a260dbc3fab701781eebf0ffb1687bc97d0f42e0a97f420d63.json create mode 100644 .sqlx/query-bb5d85fbd4f8cfa89b452e7cb1d359a8568238ea6b2bdc2f48933369609f00ac.json create mode 100644 .sqlx/query-c355c9c90181e0da78602ec73c4cbcc6a6226737625c37347d4d907703552d78.json create mode 100644 crates/defguard_core/src/enterprise/db/models/group_client_traffic_policy.rs create mode 100644 migrations/20260723000000_[2.1.0]_group_client_traffic_policy.down.sql create mode 100644 migrations/20260723000000_[2.1.0]_group_client_traffic_policy.up.sql diff --git a/.sqlx/query-a7fe7af3323984a260dbc3fab701781eebf0ffb1687bc97d0f42e0a97f420d63.json b/.sqlx/query-a7fe7af3323984a260dbc3fab701781eebf0ffb1687bc97d0f42e0a97f420d63.json new file mode 100644 index 0000000000..3d90726fee --- /dev/null +++ b/.sqlx/query-a7fe7af3323984a260dbc3fab701781eebf0ffb1687bc97d0f42e0a97f420d63.json @@ -0,0 +1,51 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO group_client_traffic_policy (group_id, client_traffic_policy) VALUES ($1, $2) ON CONFLICT (group_id) DO UPDATE SET client_traffic_policy = EXCLUDED.client_traffic_policy RETURNING group_id, client_traffic_policy \"client_traffic_policy: ClientTrafficPolicy\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "group_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "client_traffic_policy: ClientTrafficPolicy", + "type_info": { + "Custom": { + "name": "client_traffic_policy", + "kind": { + "Enum": [ + "none", + "disable_all_traffic", + "force_all_traffic" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Int8", + { + "Custom": { + "name": "client_traffic_policy", + "kind": { + "Enum": [ + "none", + "disable_all_traffic", + "force_all_traffic" + ] + } + } + } + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "a7fe7af3323984a260dbc3fab701781eebf0ffb1687bc97d0f42e0a97f420d63" +} diff --git a/.sqlx/query-bb5d85fbd4f8cfa89b452e7cb1d359a8568238ea6b2bdc2f48933369609f00ac.json b/.sqlx/query-bb5d85fbd4f8cfa89b452e7cb1d359a8568238ea6b2bdc2f48933369609f00ac.json new file mode 100644 index 0000000000..4eb0915b44 --- /dev/null +++ b/.sqlx/query-bb5d85fbd4f8cfa89b452e7cb1d359a8568238ea6b2bdc2f48933369609f00ac.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM group_client_traffic_policy WHERE group_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "bb5d85fbd4f8cfa89b452e7cb1d359a8568238ea6b2bdc2f48933369609f00ac" +} diff --git a/.sqlx/query-c355c9c90181e0da78602ec73c4cbcc6a6226737625c37347d4d907703552d78.json b/.sqlx/query-c355c9c90181e0da78602ec73c4cbcc6a6226737625c37347d4d907703552d78.json new file mode 100644 index 0000000000..1bef7bdca8 --- /dev/null +++ b/.sqlx/query-c355c9c90181e0da78602ec73c4cbcc6a6226737625c37347d4d907703552d78.json @@ -0,0 +1,39 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT gctp.group_id, gctp.client_traffic_policy \"client_traffic_policy: ClientTrafficPolicy\" FROM group_client_traffic_policy gctp JOIN group_user gu ON gu.group_id = gctp.group_id WHERE gu.user_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "group_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "client_traffic_policy: ClientTrafficPolicy", + "type_info": { + "Custom": { + "name": "client_traffic_policy", + "kind": { + "Enum": [ + "none", + "disable_all_traffic", + "force_all_traffic" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "c355c9c90181e0da78602ec73c4cbcc6a6226737625c37347d4d907703552d78" +} diff --git a/crates/defguard_core/src/enterprise/db/models/enterprise_settings.rs b/crates/defguard_core/src/enterprise/db/models/enterprise_settings.rs index e251f12908..669198a6c4 100644 --- a/crates/defguard_core/src/enterprise/db/models/enterprise_settings.rs +++ b/crates/defguard_core/src/enterprise/db/models/enterprise_settings.rs @@ -107,3 +107,85 @@ pub enum ClientTrafficPolicy { /// Clients are forced to route all traffic through the VPN. ForceAllTraffic, } + +/// Resolves group policies over the instance-level policy. +/// +/// A configured group policy takes precedence over the instance policy. When a user belongs to +/// multiple groups, disabling all traffic takes precedence over forcing all traffic. An explicit +/// `None` group policy takes precedence over the instance policy when no restrictive group policy +/// is present. +#[must_use] +pub fn resolve_client_traffic_policy( + instance_policy: ClientTrafficPolicy, + group_policies: impl IntoIterator, +) -> ClientTrafficPolicy { + let mut has_force_all_traffic = false; + let mut has_none = false; + + for policy in group_policies { + match policy { + ClientTrafficPolicy::DisableAllTraffic => { + return ClientTrafficPolicy::DisableAllTraffic; + } + ClientTrafficPolicy::ForceAllTraffic => has_force_all_traffic = true, + ClientTrafficPolicy::None => has_none = true, + } + } + + if has_force_all_traffic { + ClientTrafficPolicy::ForceAllTraffic + } else if has_none { + ClientTrafficPolicy::None + } else { + instance_policy + } +} + +#[cfg(test)] +mod tests { + use super::{ClientTrafficPolicy, resolve_client_traffic_policy}; + + #[test] + fn instance_policy_is_used_without_group_overrides() { + assert_eq!( + resolve_client_traffic_policy(ClientTrafficPolicy::ForceAllTraffic, []), + ClientTrafficPolicy::ForceAllTraffic + ); + } + + #[test] + fn group_policy_overrides_instance_policy() { + assert_eq!( + resolve_client_traffic_policy( + ClientTrafficPolicy::DisableAllTraffic, + [ClientTrafficPolicy::ForceAllTraffic] + ), + ClientTrafficPolicy::ForceAllTraffic + ); + } + + #[test] + fn disable_all_traffic_wins_conflicting_group_policies() { + assert_eq!( + resolve_client_traffic_policy( + ClientTrafficPolicy::ForceAllTraffic, + [ + ClientTrafficPolicy::ForceAllTraffic, + ClientTrafficPolicy::DisableAllTraffic, + ] + ), + ClientTrafficPolicy::DisableAllTraffic + ); + } + + #[test] + fn explicit_none_group_policy_overrides_instance_policy() { + assert_eq!( + resolve_client_traffic_policy( + ClientTrafficPolicy::ForceAllTraffic, + [ClientTrafficPolicy::None] + ), + ClientTrafficPolicy::None + ); + } +} diff --git a/crates/defguard_core/src/enterprise/db/models/group_client_traffic_policy.rs b/crates/defguard_core/src/enterprise/db/models/group_client_traffic_policy.rs new file mode 100644 index 0000000000..6535958fab --- /dev/null +++ b/crates/defguard_core/src/enterprise/db/models/group_client_traffic_policy.rs @@ -0,0 +1,66 @@ +use defguard_common::db::Id; +use sqlx::{FromRow, PgExecutor, query, query_as}; + +use super::enterprise_settings::ClientTrafficPolicy; + +#[derive(Clone, Debug, FromRow, PartialEq)] +pub struct GroupClientTrafficPolicy { + pub group_id: Id, + pub client_traffic_policy: ClientTrafficPolicy, +} + +impl GroupClientTrafficPolicy { + pub async fn find_by_user_id<'e, E>(executor: E, user_id: Id) -> sqlx::Result> + where + E: PgExecutor<'e>, + { + query_as!( + Self, + "SELECT gctp.group_id, \ + gctp.client_traffic_policy \"client_traffic_policy: ClientTrafficPolicy\" \ + FROM group_client_traffic_policy gctp \ + JOIN group_user gu ON gu.group_id = gctp.group_id \ + WHERE gu.user_id = $1", + user_id + ) + .fetch_all(executor) + .await + } + + pub async fn upsert<'e, E>( + executor: E, + group_id: Id, + policy: ClientTrafficPolicy, + ) -> sqlx::Result + where + E: PgExecutor<'e>, + { + query_as!( + Self, + "INSERT INTO group_client_traffic_policy \ + (group_id, client_traffic_policy) \ + VALUES ($1, $2) \ + ON CONFLICT (group_id) DO UPDATE SET \ + client_traffic_policy = EXCLUDED.client_traffic_policy \ + RETURNING group_id, \ + client_traffic_policy \"client_traffic_policy: ClientTrafficPolicy\"", + group_id, + policy as ClientTrafficPolicy + ) + .fetch_one(executor) + .await + } + + pub async fn delete<'e, E>(executor: E, group_id: Id) -> sqlx::Result<()> + where + E: PgExecutor<'e>, + { + query!( + "DELETE FROM group_client_traffic_policy WHERE group_id = $1", + group_id + ) + .execute(executor) + .await?; + Ok(()) + } +} diff --git a/crates/defguard_core/src/enterprise/db/models/mod.rs b/crates/defguard_core/src/enterprise/db/models/mod.rs index e835e1f65a..e327617405 100644 --- a/crates/defguard_core/src/enterprise/db/models/mod.rs +++ b/crates/defguard_core/src/enterprise/db/models/mod.rs @@ -3,5 +3,6 @@ pub mod activity_log_stream; pub mod api_tokens; pub mod device_posture; pub mod enterprise_settings; +pub mod group_client_traffic_policy; pub mod openid_provider; pub mod snat; diff --git a/migrations/20260723000000_[2.1.0]_group_client_traffic_policy.down.sql b/migrations/20260723000000_[2.1.0]_group_client_traffic_policy.down.sql new file mode 100644 index 0000000000..f5219e6850 --- /dev/null +++ b/migrations/20260723000000_[2.1.0]_group_client_traffic_policy.down.sql @@ -0,0 +1 @@ +DROP TABLE group_client_traffic_policy; diff --git a/migrations/20260723000000_[2.1.0]_group_client_traffic_policy.up.sql b/migrations/20260723000000_[2.1.0]_group_client_traffic_policy.up.sql new file mode 100644 index 0000000000..9a648e6b70 --- /dev/null +++ b/migrations/20260723000000_[2.1.0]_group_client_traffic_policy.up.sql @@ -0,0 +1,4 @@ +CREATE TABLE group_client_traffic_policy ( + group_id bigint PRIMARY KEY REFERENCES "group"(id) ON DELETE CASCADE, + client_traffic_policy client_traffic_policy NOT NULL +); From b9ccef6dc2a0f8cd51f60a5d1019d55f60396bd2 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 24 Jul 2026 09:01:23 +0200 Subject: [PATCH 02/15] InstanceInfo::build performs traffic policy evaluation --- crates/defguard_core/src/grpc/mod.rs | 41 ++++++++++++++----- crates/defguard_core/src/grpc/utils.rs | 25 ++++------- .../src/servers/enrollment.rs | 32 ++++++--------- 3 files changed, 50 insertions(+), 48 deletions(-) diff --git a/crates/defguard_core/src/grpc/mod.rs b/crates/defguard_core/src/grpc/mod.rs index 283897785f..f42548babe 100644 --- a/crates/defguard_core/src/grpc/mod.rs +++ b/crates/defguard_core/src/grpc/mod.rs @@ -10,7 +10,7 @@ use defguard_common::{ config::server_config, db::{ Id, - models::{Settings, WireguardNetwork, wireguard::ServiceLocationMode}, + models::{Settings, User, WireguardNetwork, wireguard::ServiceLocationMode}, }, types::UrlParseError, }; @@ -24,7 +24,10 @@ use crate::{ enterprise::{ LicenseFeature, db::models::{ - enterprise_settings::{ClientTrafficPolicy, EnterpriseSettings}, + enterprise_settings::{ + ClientTrafficPolicy, EnterpriseSettings, resolve_client_traffic_policy, + }, + group_client_traffic_policy::GroupClientTrafficPolicy, openid_provider::OpenIdProvider, }, has_enterprise_access, is_business_license_active, @@ -163,13 +166,31 @@ pub struct InstanceInfo { openid_display_name: Option, } +#[derive(Debug, thiserror::Error)] +pub enum InstanceInfoBuildError { + #[error("failed to load enterprise settings: {0}")] + Database(#[from] sqlx::Error), + #[error("failed to parse instance URL: {0}")] + UrlParse(#[from] UrlParseError), +} + impl InstanceInfo { - pub fn new>( - settings: Settings, - username: S, - enterprise_settings: &EnterpriseSettings, + pub async fn build( + pool: &PgPool, + settings: &Settings, + user: &User, openid_provider: Option>, - ) -> Result { + ) -> Result { + let enterprise_settings = EnterpriseSettings::get(pool).await?; + let client_traffic_policy = if is_business_license_active() { + let group_policies = GroupClientTrafficPolicy::find_by_user_id(pool, user.id) + .await? + .into_iter() + .map(|policy| policy.client_traffic_policy); + resolve_client_traffic_policy(enterprise_settings.client_traffic_policy, group_policies) + } else { + enterprise_settings.client_traffic_policy + }; let openid_display_name = openid_provider .as_ref() .map(|provider| provider.display_name.clone()) @@ -178,11 +199,11 @@ impl InstanceInfo { let proxy_url = settings.proxy_public_url()?; Ok(Self { id: settings.uuid, - name: settings.instance_name, + name: settings.instance_name.clone(), url, proxy_url, - username: username.into(), - client_traffic_policy: enterprise_settings.client_traffic_policy, + username: user.username.clone(), + client_traffic_policy, enterprise_enabled: is_business_license_active(), openid_display_name, }) diff --git a/crates/defguard_core/src/grpc/utils.rs b/crates/defguard_core/src/grpc/utils.rs index 7f1fd478e0..5b8d040180 100644 --- a/crates/defguard_core/src/grpc/utils.rs +++ b/crates/defguard_core/src/grpc/utils.rs @@ -24,9 +24,7 @@ use tonic::Status; use super::InstanceInfo; use crate::{ device_access::build_device_config, - enterprise::db::models::{ - enterprise_settings::EnterpriseSettings, openid_provider::OpenIdProvider, - }, + enterprise::db::models::openid_provider::OpenIdProvider, grpc::{client_version::ClientFeature, should_prevent_service_location_usage}, }; @@ -48,11 +46,6 @@ pub async fn build_device_config_response( Status::internal(format!("unexpected error: {err}")) })?; - let enterprise_settings = EnterpriseSettings::get(pool).await.map_err(|err| { - error!("Failed to get enterprise settings: {err}"); - Status::internal(format!("unexpected error: {err}")) - })?; - let mut configs = Vec::new(); let user = User::find_by_id(pool, device.user_id) .await @@ -231,16 +224,12 @@ pub async fn build_device_config_response( user.username, user.id, device.name, device.id ); - let instance_info = InstanceInfo::new( - settings, - &user.username, - &enterprise_settings, - openid_provider, - ) - .map_err(|err| { - error!("Failed to build instance info: {err}"); - Status::internal(format!("unexpected error: {err}")) - })?; + let instance_info = InstanceInfo::build(pool, &settings, &user, openid_provider) + .await + .map_err(|err| { + error!("Failed to build instance info: {err}"); + Status::internal(format!("unexpected error: {err}")) + })?; Ok(DeviceConfigResponse { device: Some(device.into()), diff --git a/crates/defguard_proxy_manager/src/servers/enrollment.rs b/crates/defguard_proxy_manager/src/servers/enrollment.rs index a0f9e872b8..3515fa003c 100644 --- a/crates/defguard_proxy_manager/src/servers/enrollment.rs +++ b/crates/defguard_proxy_manager/src/servers/enrollment.rs @@ -217,16 +217,12 @@ impl EnrollmentServer { Status::internal(format!("unexpected error: {err}")) })?; let smtp_configured = settings.smtp_configured(); - let instance_info = InstanceInfo::new( - settings, - &user.username, - &enterprise_settings, - openid_provider, - ) - .map_err(|err| { - error!("Failed to create instance info: {err}"); - Status::internal("unexpected error") - })?; + let instance_info = InstanceInfo::build(&self.pool, &settings, &user, openid_provider) + .await + .map_err(|err| { + error!("Failed to create instance info: {err}"); + Status::internal("unexpected error") + })?; debug!("Instance info {instance_info:?}"); debug!( @@ -954,16 +950,12 @@ impl EnrollmentServer { Status::internal(format!("unexpected error: {err}")) })?; - let instance_info = InstanceInfo::new( - settings, - &user.username, - &enterprise_settings, - openid_provider, - ) - .map_err(|err| { - error!("Failed to create instance info: {err}"); - Status::internal("unexpected error") - })?; + let instance_info = InstanceInfo::build(&self.pool, &settings, &user, openid_provider) + .await + .map_err(|err| { + error!("Failed to create instance info: {err}"); + Status::internal("unexpected error") + })?; let response = DeviceConfigResponse { device: Some(device.clone().into()), From 117523c6e0305a17ce282e2f0bdeb80ab7084aa7 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 24 Jul 2026 10:19:53 +0200 Subject: [PATCH 03/15] enterprise settings patch API andpoint includes traffic policy group assignments --- ...8b68cb9f0fd6d689f3eb34de23f5859797ca2.json | 37 +++++ ...b1b277daa23b14073edba46e4f9bde969bb91.json | 12 ++ ...7d864722cff2cf1ea01507edf40ba12cdbd69.json | 22 +++ .../src/db/models/activity_log/metadata.rs | 6 +- .../db/models/enterprise_settings.rs | 29 +++- .../db/models/group_client_traffic_policy.rs | 64 +++++++- .../handlers/enterprise_settings.rs | 126 ++++++++++++++-- crates/defguard_core/src/events.rs | 6 +- crates/defguard_core/src/grpc/mod.rs | 2 + .../integration/api/enterprise_settings.rs | 142 +++++++++++++++++- crates/defguard_event_logger/src/tests/mod.rs | 6 +- 11 files changed, 420 insertions(+), 32 deletions(-) create mode 100644 .sqlx/query-64e2cd0e9e56065007eeac8cf038b68cb9f0fd6d689f3eb34de23f5859797ca2.json create mode 100644 .sqlx/query-93ddc32a014dc4e232842416fa6b1b277daa23b14073edba46e4f9bde969bb91.json create mode 100644 .sqlx/query-eefacbad5c1a6edcb81c569bc287d864722cff2cf1ea01507edf40ba12cdbd69.json diff --git a/.sqlx/query-64e2cd0e9e56065007eeac8cf038b68cb9f0fd6d689f3eb34de23f5859797ca2.json b/.sqlx/query-64e2cd0e9e56065007eeac8cf038b68cb9f0fd6d689f3eb34de23f5859797ca2.json new file mode 100644 index 0000000000..41428929e1 --- /dev/null +++ b/.sqlx/query-64e2cd0e9e56065007eeac8cf038b68cb9f0fd6d689f3eb34de23f5859797ca2.json @@ -0,0 +1,37 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT group_id, client_traffic_policy \"client_traffic_policy: ClientTrafficPolicy\" FROM group_client_traffic_policy ORDER BY group_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "group_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "client_traffic_policy: ClientTrafficPolicy", + "type_info": { + "Custom": { + "name": "client_traffic_policy", + "kind": { + "Enum": [ + "none", + "disable_all_traffic", + "force_all_traffic" + ] + } + } + } + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false + ] + }, + "hash": "64e2cd0e9e56065007eeac8cf038b68cb9f0fd6d689f3eb34de23f5859797ca2" +} diff --git a/.sqlx/query-93ddc32a014dc4e232842416fa6b1b277daa23b14073edba46e4f9bde969bb91.json b/.sqlx/query-93ddc32a014dc4e232842416fa6b1b277daa23b14073edba46e4f9bde969bb91.json new file mode 100644 index 0000000000..e3fba057ac --- /dev/null +++ b/.sqlx/query-93ddc32a014dc4e232842416fa6b1b277daa23b14073edba46e4f9bde969bb91.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM group_client_traffic_policy", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "93ddc32a014dc4e232842416fa6b1b277daa23b14073edba46e4f9bde969bb91" +} diff --git a/.sqlx/query-eefacbad5c1a6edcb81c569bc287d864722cff2cf1ea01507edf40ba12cdbd69.json b/.sqlx/query-eefacbad5c1a6edcb81c569bc287d864722cff2cf1ea01507edf40ba12cdbd69.json new file mode 100644 index 0000000000..dcc9537ad3 --- /dev/null +++ b/.sqlx/query-eefacbad5c1a6edcb81c569bc287d864722cff2cf1ea01507edf40ba12cdbd69.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM \"group\" WHERE id = ANY($1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false + ] + }, + "hash": "eefacbad5c1a6edcb81c569bc287d864722cff2cf1ea01507edf40ba12cdbd69" +} diff --git a/crates/defguard_core/src/db/models/activity_log/metadata.rs b/crates/defguard_core/src/db/models/activity_log/metadata.rs index 9a87e2ea9e..c74fe07cb6 100644 --- a/crates/defguard_core/src/db/models/activity_log/metadata.rs +++ b/crates/defguard_core/src/db/models/activity_log/metadata.rs @@ -18,7 +18,7 @@ use crate::{ enterprise::db::models::{ activity_log_stream::{ActivityLogStream, ActivityLogStreamType}, api_tokens::ApiToken, - enterprise_settings::EnterpriseSettings, + enterprise_settings::EnterpriseSettingsInfo, openid_provider::{DirectorySyncTarget, DirectorySyncUserBehavior, OpenIdProvider}, snat::UserSnatBinding, }, @@ -357,8 +357,8 @@ pub struct SettingsUpdateMetadata { #[derive(Serialize)] pub struct EnterpriseSettingsUpdateMetadata { - pub before: EnterpriseSettings, - pub after: EnterpriseSettings, + pub before: EnterpriseSettingsInfo, + pub after: EnterpriseSettingsInfo, } #[derive(Serialize)] diff --git a/crates/defguard_core/src/enterprise/db/models/enterprise_settings.rs b/crates/defguard_core/src/enterprise/db/models/enterprise_settings.rs index 669198a6c4..f27ef08604 100644 --- a/crates/defguard_core/src/enterprise/db/models/enterprise_settings.rs +++ b/crates/defguard_core/src/enterprise/db/models/enterprise_settings.rs @@ -1,4 +1,4 @@ -use defguard_common::db::models::Settings; +use defguard_common::db::{Id, models::Settings}; use sqlx::{PgExecutor, Type, query, query_as}; use struct_patch::Patch; @@ -19,6 +19,33 @@ pub struct EnterpriseSettings { pub display_password_reset: bool, } +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct GroupClientTrafficPolicies { + pub none: Vec, + pub disable_all_traffic: Vec, + pub force_all_traffic: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct EnterpriseSettingsInfo { + #[serde(flatten)] + pub settings: EnterpriseSettings, + pub group_client_traffic_policies: GroupClientTrafficPolicies, +} + +impl EnterpriseSettingsInfo { + #[must_use] + pub fn new( + settings: EnterpriseSettings, + group_client_traffic_policies: GroupClientTrafficPolicies, + ) -> Self { + Self { + settings, + group_client_traffic_policies, + } + } +} + // We want to be conscious of what the defaults are here #[allow(clippy::derivable_impls)] impl Default for EnterpriseSettings { diff --git a/crates/defguard_core/src/enterprise/db/models/group_client_traffic_policy.rs b/crates/defguard_core/src/enterprise/db/models/group_client_traffic_policy.rs index 6535958fab..d8e7bf89cb 100644 --- a/crates/defguard_core/src/enterprise/db/models/group_client_traffic_policy.rs +++ b/crates/defguard_core/src/enterprise/db/models/group_client_traffic_policy.rs @@ -1,15 +1,75 @@ use defguard_common::db::Id; -use sqlx::{FromRow, PgExecutor, query, query_as}; +use sqlx::{FromRow, PgConnection, PgExecutor, query, query_as}; -use super::enterprise_settings::ClientTrafficPolicy; +use super::enterprise_settings::{ClientTrafficPolicy, GroupClientTrafficPolicies}; #[derive(Clone, Debug, FromRow, PartialEq)] +/// A traffic policy assigned to a single group. pub struct GroupClientTrafficPolicy { pub group_id: Id, pub client_traffic_policy: ClientTrafficPolicy, } impl GroupClientTrafficPolicy { + pub async fn all<'e, E>(executor: E) -> sqlx::Result> + where + E: PgExecutor<'e>, + { + query_as!( + Self, + "SELECT group_id, \ + client_traffic_policy \"client_traffic_policy: ClientTrafficPolicy\" \ + FROM group_client_traffic_policy ORDER BY group_id" + ) + .fetch_all(executor) + .await + } + + /// Converts database assignments into the API's policy-grouped representation. + #[must_use] + pub fn grouped(policies: Vec) -> GroupClientTrafficPolicies { + let mut grouped = GroupClientTrafficPolicies::default(); + for policy in policies { + match policy.client_traffic_policy { + ClientTrafficPolicy::None => grouped.none.push(policy.group_id), + ClientTrafficPolicy::DisableAllTraffic => { + grouped.disable_all_traffic.push(policy.group_id); + } + ClientTrafficPolicy::ForceAllTraffic => { + grouped.force_all_traffic.push(policy.group_id); + } + } + } + grouped + } + + /// Replaces all group policy assignments within an existing transaction. + pub async fn replace_all( + transaction: &mut PgConnection, + policies: &GroupClientTrafficPolicies, + ) -> sqlx::Result<()> { + query!("DELETE FROM group_client_traffic_policy") + .execute(&mut *transaction) + .await?; + for (group_ids, policy) in [ + (&policies.none, ClientTrafficPolicy::None), + ( + &policies.disable_all_traffic, + ClientTrafficPolicy::DisableAllTraffic, + ), + ( + &policies.force_all_traffic, + ClientTrafficPolicy::ForceAllTraffic, + ), + ] { + for &group_id in group_ids { + Self::upsert(&mut *transaction, group_id, policy).await?; + } + } + Ok(()) + } + + /// Returns policy assignments for groups belonging to a user. pub async fn find_by_user_id<'e, E>(executor: E, user_id: Id) -> sqlx::Result> where E: PgExecutor<'e>, diff --git a/crates/defguard_core/src/enterprise/handlers/enterprise_settings.rs b/crates/defguard_core/src/enterprise/handlers/enterprise_settings.rs index 0f374dbe96..8479c30be7 100644 --- a/crates/defguard_core/src/enterprise/handlers/enterprise_settings.rs +++ b/crates/defguard_core/src/enterprise/handlers/enterprise_settings.rs @@ -1,16 +1,101 @@ +use std::collections::HashSet; + use axum::{Json, extract::State, http::StatusCode}; -use defguard_common::types::proxy::ProxyControlMessage; +use defguard_common::{db::Id, types::proxy::ProxyControlMessage}; +use sqlx::{PgConnection, PgPool, query_scalar}; use struct_patch::Patch; use super::LicenseInfo; use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, - enterprise::db::models::enterprise_settings::{EnterpriseSettings, EnterpriseSettingsPatch}, + enterprise::{ + db::models::{ + enterprise_settings::{ + ClientTrafficPolicy, EnterpriseSettings, EnterpriseSettingsInfo, + EnterpriseSettingsPatch, GroupClientTrafficPolicies, + }, + group_client_traffic_policy::GroupClientTrafficPolicy, + }, + is_business_license_active, + }, + error::WebError, events::{ApiEvent, ApiEventType, ApiRequestContext}, handlers::{ApiResponse, ApiResult}, }; +#[derive(Deserialize)] +/// Request payload for partially updating enterprise settings and group policies. +pub struct EnterpriseSettingsPatchRequest { + #[serde(flatten)] + pub settings: EnterpriseSettingsPatch, + pub group_client_traffic_policies: Option, +} + +fn policy_assignments(policies: &GroupClientTrafficPolicies) -> Vec<(Id, ClientTrafficPolicy)> { + policies + .none + .iter() + .map(|&id| (id, ClientTrafficPolicy::None)) + .chain( + policies + .disable_all_traffic + .iter() + .map(|&id| (id, ClientTrafficPolicy::DisableAllTraffic)), + ) + .chain( + policies + .force_all_traffic + .iter() + .map(|&id| (id, ClientTrafficPolicy::ForceAllTraffic)), + ) + .collect() +} + +async fn validate_policy_assignments( + transaction: &mut PgConnection, + policies: &GroupClientTrafficPolicies, +) -> Result<(), WebError> { + let assignments = policy_assignments(policies); + let mut group_ids = HashSet::with_capacity(assignments.len()); + for (group_id, _) in &assignments { + if !group_ids.insert(*group_id) { + return Err(WebError::BadRequest( + "A group cannot be assigned to multiple client traffic policies.".into(), + )); + } + } + + let group_ids = group_ids.into_iter().collect::>(); + if group_ids.is_empty() { + return Ok(()); + } + let existing_ids: HashSet = + query_scalar!("SELECT id FROM \"group\" WHERE id = ANY($1)", &group_ids) + .fetch_all(&mut *transaction) + .await? + .into_iter() + .collect(); + if existing_ids.len() != group_ids.len() { + return Err(WebError::BadRequest( + "One or more client traffic policy groups do not exist.".into(), + )); + } + Ok(()) +} + +async fn settings_info( + pool: &PgPool, + settings: EnterpriseSettings, +) -> Result { + let group_policies = if is_business_license_active() { + GroupClientTrafficPolicy::grouped(GroupClientTrafficPolicy::all(pool).await?) + } else { + GroupClientTrafficPolicies::default() + }; + Ok(EnterpriseSettingsInfo::new(settings, group_policies)) +} + pub async fn get_enterprise_settings( session: SessionInfo, State(appstate): State, @@ -24,7 +109,10 @@ pub async fn get_enterprise_settings( "User {} retrieved enterprise settings", session.user.username ); - Ok(ApiResponse::json(settings, StatusCode::OK)) + Ok(ApiResponse::json( + settings_info(&appstate.pool, settings).await?, + StatusCode::OK, + )) } pub async fn patch_enterprise_settings( @@ -32,13 +120,16 @@ pub async fn patch_enterprise_settings( _admin: AdminRole, State(appstate): State, session: SessionInfo, - Json(data): Json, + Json(data): Json, ) -> ApiResult { debug!( "Admin {} patching enterprise settings.", session.user.username, ); - let mut settings = EnterpriseSettings::get(&appstate.pool).await?; + let mut transaction = appstate.pool.begin().await?; + let mut settings = EnterpriseSettings::get(&mut *transaction).await?; + let old_group_policies = + GroupClientTrafficPolicy::grouped(GroupClientTrafficPolicy::all(&mut *transaction).await?); // snapshot for audit event let old_settings = settings.clone(); @@ -46,9 +137,23 @@ pub async fn patch_enterprise_settings( let old_display_password_reset = old_settings.display_password_reset; let old_display_download_step = old_settings.display_download_step; - settings.apply(data); - settings.save(&appstate.pool).await?; - info!("Admin {} patched settings.", session.user.username); + settings.apply(data.settings); + let group_policies = if let Some(group_policies) = data.group_client_traffic_policies { + validate_policy_assignments(&mut transaction, &group_policies).await?; + GroupClientTrafficPolicy::replace_all(&mut transaction, &group_policies).await?; + group_policies + } else { + old_group_policies.clone() + }; + settings.save(&mut *transaction).await?; + transaction.commit().await?; + + let before = EnterpriseSettingsInfo::new(old_settings, old_group_policies); + let after = EnterpriseSettingsInfo::new(settings.clone(), group_policies); + info!( + "Admin {} patched enterprise settings.", + session.user.username + ); appstate.emit_event(ApiEvent { context: ApiRequestContext::new( @@ -57,10 +162,7 @@ pub async fn patch_enterprise_settings( None::, "web".into(), ), - event: Box::new(ApiEventType::EnterpriseSettingsUpdated { - before: old_settings, - after: settings.clone(), - }), + event: Box::new(ApiEventType::EnterpriseSettingsUpdated { before, after }), })?; // Broadcast updated public settings to proxies only if they changed. diff --git a/crates/defguard_core/src/events.rs b/crates/defguard_core/src/events.rs index 4fb8a2855b..5edbe8a916 100644 --- a/crates/defguard_core/src/events.rs +++ b/crates/defguard_core/src/events.rs @@ -17,7 +17,7 @@ use crate::{ activity_log_stream::ActivityLogStream, api_tokens::ApiToken, device_posture::{DevicePosture, DevicePostureSnapshot}, - enterprise_settings::EnterpriseSettings, + enterprise_settings::EnterpriseSettingsInfo, openid_provider::OpenIdProvider, snat::UserSnatBinding, }, @@ -251,8 +251,8 @@ pub enum ApiEventType { }, SettingsDefaultBrandingRestored, EnterpriseSettingsUpdated { - before: EnterpriseSettings, - after: EnterpriseSettings, + before: EnterpriseSettingsInfo, + after: EnterpriseSettingsInfo, }, GroupsBulkAssigned { users: Vec>, diff --git a/crates/defguard_core/src/grpc/mod.rs b/crates/defguard_core/src/grpc/mod.rs index f42548babe..73394af13a 100644 --- a/crates/defguard_core/src/grpc/mod.rs +++ b/crates/defguard_core/src/grpc/mod.rs @@ -167,6 +167,7 @@ pub struct InstanceInfo { } #[derive(Debug, thiserror::Error)] +/// Errors that can occur while building client instance information. pub enum InstanceInfoBuildError { #[error("failed to load enterprise settings: {0}")] Database(#[from] sqlx::Error), @@ -175,6 +176,7 @@ pub enum InstanceInfoBuildError { } impl InstanceInfo { + /// Builds client instance information with the effective user traffic policy. pub async fn build( pool: &PgPool, settings: &Settings, diff --git a/crates/defguard_core/tests/integration/api/enterprise_settings.rs b/crates/defguard_core/tests/integration/api/enterprise_settings.rs index 4704557464..4e14c4a4cf 100644 --- a/crates/defguard_core/tests/integration/api/enterprise_settings.rs +++ b/crates/defguard_core/tests/integration/api/enterprise_settings.rs @@ -1,9 +1,11 @@ use std::time::Duration; -use defguard_common::types::proxy::ProxyControlMessage; +use defguard_common::{db::models::group::Group, types::proxy::ProxyControlMessage}; use defguard_core::{ enterprise::{ - db::models::enterprise_settings::{ClientTrafficPolicy, EnterpriseSettings}, + db::models::enterprise_settings::{ + ClientTrafficPolicy, EnterpriseSettings, EnterpriseSettingsInfo, + }, license::{get_cached_license, set_cached_license}, }, events::ApiEventType, @@ -523,13 +525,13 @@ async fn test_display_flags_round_trip(_: PgPoolOptions, options: PgConnectOptio // Read back and verify the values persisted let response = client.get("/api/v1/settings_enterprise").send().await; assert_eq!(response.status(), StatusCode::OK); - let body: EnterpriseSettings = response.json().await; + let body: EnterpriseSettingsInfo = response.json().await; assert!( - !body.display_download_step, + !body.settings.display_download_step, "display_download_step should be false" ); assert!( - !body.display_password_reset, + !body.settings.display_password_reset, "display_password_reset should be false" ); @@ -551,13 +553,13 @@ async fn test_display_flags_round_trip(_: PgPoolOptions, options: PgConnectOptio // Read back and verify let response = client.get("/api/v1/settings_enterprise").send().await; assert_eq!(response.status(), StatusCode::OK); - let body: EnterpriseSettings = response.json().await; + let body: EnterpriseSettingsInfo = response.json().await; assert!( - body.display_download_step, + body.settings.display_download_step, "display_download_step should be true" ); assert!( - body.display_password_reset, + body.settings.display_password_reset, "display_password_reset should be true" ); } @@ -734,3 +736,127 @@ async fn test_public_settings_broadcast_on_save(_: PgPoolOptions, options: PgCon } } } + +#[sqlx::test] +async fn test_group_client_traffic_policies_are_saved_and_validated( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (client, _) = make_test_client(pool.clone()).await; + let auth = Auth::new("admin", "pass123"); + assert_eq!( + client + .post("/api/v1/auth") + .json(&auth) + .send() + .await + .status(), + StatusCode::OK + ); + exceed_enterprise_limits(&client).await; + + let allow_choice = Group::new("allow-choice").save(&pool).await.unwrap(); + let disable = Group::new("disable").save(&pool).await.unwrap(); + + let response = client + .patch("/api/v1/settings_enterprise") + .json(&json!({ + "client_traffic_policy": "force_all_traffic", + "group_client_traffic_policies": { + "none": [allow_choice.id], + "disable_all_traffic": [disable.id], + "force_all_traffic": [] + } + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + let response = client.get("/api/v1/settings_enterprise").send().await; + assert_eq!(response.status(), StatusCode::OK); + let settings: EnterpriseSettingsInfo = response.json().await; + assert_eq!( + settings.group_client_traffic_policies.none, + vec![allow_choice.id] + ); + assert_eq!( + settings.group_client_traffic_policies.disable_all_traffic, + vec![disable.id] + ); + assert!( + settings + .group_client_traffic_policies + .force_all_traffic + .is_empty() + ); + + let response = client + .patch("/api/v1/settings_enterprise") + .json(&json!({"display_download_step": false})) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + let response = client.get("/api/v1/settings_enterprise").send().await; + assert_eq!(response.status(), StatusCode::OK); + let settings: EnterpriseSettingsInfo = response.json().await; + assert_eq!( + settings.group_client_traffic_policies.none, + vec![allow_choice.id] + ); + assert_eq!( + settings.group_client_traffic_policies.disable_all_traffic, + vec![disable.id] + ); + + let response = client + .patch("/api/v1/settings_enterprise") + .json(&json!({ + "group_client_traffic_policies": { + "none": [allow_choice.id], + "disable_all_traffic": [allow_choice.id], + "force_all_traffic": [] + } + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let response = client.get("/api/v1/settings_enterprise").send().await; + assert_eq!(response.status(), StatusCode::OK); + let settings: EnterpriseSettingsInfo = response.json().await; + assert_eq!( + settings.group_client_traffic_policies.none, + vec![allow_choice.id] + ); + assert_eq!( + settings.group_client_traffic_policies.disable_all_traffic, + vec![disable.id] + ); + + let response = client + .patch("/api/v1/settings_enterprise") + .json(&json!({ + "group_client_traffic_policies": { + "none": [999999], + "disable_all_traffic": [], + "force_all_traffic": [] + } + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let response = client.get("/api/v1/settings_enterprise").send().await; + assert_eq!(response.status(), StatusCode::OK); + let settings: EnterpriseSettingsInfo = response.json().await; + assert_eq!( + settings.group_client_traffic_policies.none, + vec![allow_choice.id] + ); + assert_eq!( + settings.group_client_traffic_policies.disable_all_traffic, + vec![disable.id] + ); +} diff --git a/crates/defguard_event_logger/src/tests/mod.rs b/crates/defguard_event_logger/src/tests/mod.rs index 9807d8babd..8fa5caec82 100644 --- a/crates/defguard_event_logger/src/tests/mod.rs +++ b/crates/defguard_event_logger/src/tests/mod.rs @@ -23,7 +23,7 @@ use defguard_core::{ activity_log_stream::{ActivityLogStream, ActivityLogStreamType}, api_tokens::ApiToken, device_posture::{DevicePosture, DevicePostureSnapshot}, - enterprise_settings::EnterpriseSettings, + enterprise_settings::EnterpriseSettingsInfo, openid_provider::{ DirectorySyncTarget, DirectorySyncUserBehavior, OpenIdProvider, OpenIdProviderKind, }, @@ -852,8 +852,8 @@ fn api_event_cases() -> Vec { EventTestCase { name: "EnterpriseSettingsUpdated", message: api_message(ApiEventType::EnterpriseSettingsUpdated { - before: EnterpriseSettings::default(), - after: EnterpriseSettings::default(), + before: EnterpriseSettingsInfo::default(), + after: EnterpriseSettingsInfo::default(), }), event_type: EventType::EnterpriseSettingsUpdated, module: ActivityLogModule::Defguard, From ad760395372d0483e9c7445d1fca14ed9fae9dde Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 24 Jul 2026 12:17:50 +0200 Subject: [PATCH 04/15] group-based policies form --- web/messages/en/settings.json | 9 +++ .../SettingsClientPage/SettingsClientPage.tsx | 72 ++++++++++++++++++- .../settings/SettingsClientPage/style.scss | 28 ++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) diff --git a/web/messages/en/settings.json b/web/messages/en/settings.json index 4e1cf227b5..6354ee26e6 100644 --- a/web/messages/en/settings.json +++ b/web/messages/en/settings.json @@ -326,6 +326,15 @@ "settings_client_traffic_policy_disable_all_content": "When this option is enabled, users will not be able to route all traffic through the VPN.", "settings_client_traffic_policy_force_all_title": "Force all traffic", "settings_client_traffic_policy_force_all_content": "When this option is enabled, the users will always route all traffic through the VPN.", + "settings_client_traffic_policy_group_title": "Group-based policies", + "settings_client_traffic_policy_group_description": "Define the groups that should use their own traffic rules instead of the global traffic policy. Any groups not included below will use the global traffic policy.", + "settings_client_traffic_policy_group_none_title": "No limitations", + "settings_client_traffic_policy_group_none_content": "When this option is enabled, users in groups will be able to select all routing options.", + "settings_client_traffic_policy_group_disable_all_title": "Disable all traffic", + "settings_client_traffic_policy_group_disable_all_content": "When this option is enabled, users in groups will not be able to route all traffic through the VPN.", + "settings_client_traffic_policy_group_force_all_title": "Force all traffic", + "settings_client_traffic_policy_group_force_all_content": "When this option is enabled, users in groups will always route all traffic through the VPN.", + "settings_client_traffic_policy_select_groups": "Select groups", "settings_gateway_notifications_title": "Gateway notifications", "settings_gateway_notifications_subtitle": "Here you can manage email notifications.", "settings_notifications_gateway_card_content": "Configure admin email notifications for gateway disconnect and reconnect events, and set the inactivity threshold that triggers disconnect notifications.", diff --git a/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx b/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx index 1af2e693db..7afdbd2ba5 100644 --- a/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx +++ b/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx @@ -12,6 +12,8 @@ import { SettingsCard } from '../../../shared/components/SettingsCard/SettingsCa import { SettingsHeader } from '../../../shared/components/SettingsHeader/SettingsHeader'; import { SettingsLayout } from '../../../shared/components/SettingsLayout/SettingsLayout'; import { Divider } from '../../../shared/defguard-ui/components/Divider/Divider'; +import { Fold } from '../../../shared/defguard-ui/components/Fold/Fold'; +import { InteractiveBlock } from '../../../shared/defguard-ui/components/InteractiveBlock/InteractiveBlock'; import { MarkedSection } from '../../../shared/defguard-ui/components/MarkedSection/MarkedSection'; import { ThemeSpacing } from '../../../shared/defguard-ui/types'; import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; @@ -20,7 +22,7 @@ import { getLicenseInfoQueryOptions, } from '../../../shared/query'; import './style.scss'; -import { Suspense, useMemo } from 'react'; +import { Suspense, useMemo, useState } from 'react'; import Skeleton from 'react-loading-skeleton'; import z from 'zod'; import { m } from '../../../paraglide/messages'; @@ -74,11 +76,39 @@ const formSchema = z.object({ type FormFields = z.infer; +type GroupPolicyRowProps = { + content: string; + enabled: boolean; + onToggle: () => void; + title: string; +}; + +const GroupPolicyRow = ({ content, enabled, onToggle, title }: GroupPolicyRowProps) => ( + + + + + +); + const Content = () => { const { data: licenseInfo } = useSuspenseQuery(getLicenseInfoQueryOptions); const { data: settings } = useSuspenseQuery(getEnterpriseSettingsQueryOptions); const noLicense = !isPresent(licenseInfo); + const [groupPolicyState, setGroupPolicyState] = useState({ + allowChoice: false, + disableAllTraffic: false, + forceAllTraffic: false, + }); const { mutateAsync: patchSettings } = useMutation({ mutationFn: api.settings.patchEnterpriseSettings, @@ -204,6 +234,46 @@ const Content = () => { /> )} + +
+

{m.settings_client_traffic_policy_group_title()}

+

+ {m.settings_client_traffic_policy_group_description()} +

+ + setGroupPolicyState((state) => ({ + ...state, + allowChoice: !state.allowChoice, + })) + } + title={m.settings_client_traffic_policy_group_none_title()} + /> + + setGroupPolicyState((state) => ({ + ...state, + disableAllTraffic: !state.disableAllTraffic, + })) + } + title={m.settings_client_traffic_policy_group_disable_all_title()} + /> + + setGroupPolicyState((state) => ({ + ...state, + forceAllTraffic: !state.forceAllTraffic, + })) + } + title={m.settings_client_traffic_policy_group_force_all_title()} + /> +
({ diff --git a/web/src/pages/settings/SettingsClientPage/style.scss b/web/src/pages/settings/SettingsClientPage/style.scss index df76f2d176..f093dcf514 100644 --- a/web/src/pages/settings/SettingsClientPage/style.scss +++ b/web/src/pages/settings/SettingsClientPage/style.scss @@ -7,5 +7,33 @@ h3 { font: var(--t-body-primary-600); } + + h4 { + font: var(--t-body-primary-600); + } + + .group-policy-section { + display: flex; + flex-flow: column; + row-gap: var(--spacing-md); + } + + .group-policy-description { + color: var(--fg-muted); + font: var(--t-body-sm-400); + } + + .select-multiple-edit { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: var(--spacing-sm); + padding: 0; + border: 0; + background-color: transparent; + color: var(--fg-action); + font: var(--t-body-sm-500); + cursor: pointer; + } } } From 2fa26cd2a82582356f348828e76c826d1bda5f79 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 24 Jul 2026 13:43:56 +0200 Subject: [PATCH 05/15] remove the toggles --- web/messages/en/settings.json | 10 ++-- .../SettingsClientPage/SettingsClientPage.tsx | 54 ++++--------------- .../settings/SettingsClientPage/style.scss | 15 ++++++ 3 files changed, 29 insertions(+), 50 deletions(-) diff --git a/web/messages/en/settings.json b/web/messages/en/settings.json index 6354ee26e6..189b2d6940 100644 --- a/web/messages/en/settings.json +++ b/web/messages/en/settings.json @@ -328,13 +328,13 @@ "settings_client_traffic_policy_force_all_content": "When this option is enabled, the users will always route all traffic through the VPN.", "settings_client_traffic_policy_group_title": "Group-based policies", "settings_client_traffic_policy_group_description": "Define the groups that should use their own traffic rules instead of the global traffic policy. Any groups not included below will use the global traffic policy.", - "settings_client_traffic_policy_group_none_title": "No limitations", - "settings_client_traffic_policy_group_none_content": "When this option is enabled, users in groups will be able to select all routing options.", + "settings_client_traffic_policy_group_none_title": "Allow choice", + "settings_client_traffic_policy_group_none_content": "Members of selected groups will be able to choose whether to route all traffic through the VPN.", "settings_client_traffic_policy_group_disable_all_title": "Disable all traffic", - "settings_client_traffic_policy_group_disable_all_content": "When this option is enabled, users in groups will not be able to route all traffic through the VPN.", + "settings_client_traffic_policy_group_disable_all_content": "Members of selected groups will not be able to route all traffic through the VPN.", "settings_client_traffic_policy_group_force_all_title": "Force all traffic", - "settings_client_traffic_policy_group_force_all_content": "When this option is enabled, users in groups will always route all traffic through the VPN.", - "settings_client_traffic_policy_select_groups": "Select groups", + "settings_client_traffic_policy_group_force_all_content": "Members of selected groups will always route all traffic through the VPN.", + "settings_client_traffic_policy_edit_groups": "Edit groups", "settings_gateway_notifications_title": "Gateway notifications", "settings_gateway_notifications_subtitle": "Here you can manage email notifications.", "settings_notifications_gateway_card_content": "Configure admin email notifications for gateway disconnect and reconnect events, and set the inactivity threshold that triggers disconnect notifications.", diff --git a/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx b/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx index 7afdbd2ba5..a1929c71a8 100644 --- a/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx +++ b/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx @@ -12,8 +12,6 @@ import { SettingsCard } from '../../../shared/components/SettingsCard/SettingsCa import { SettingsHeader } from '../../../shared/components/SettingsHeader/SettingsHeader'; import { SettingsLayout } from '../../../shared/components/SettingsLayout/SettingsLayout'; import { Divider } from '../../../shared/defguard-ui/components/Divider/Divider'; -import { Fold } from '../../../shared/defguard-ui/components/Fold/Fold'; -import { InteractiveBlock } from '../../../shared/defguard-ui/components/InteractiveBlock/InteractiveBlock'; import { MarkedSection } from '../../../shared/defguard-ui/components/MarkedSection/MarkedSection'; import { ThemeSpacing } from '../../../shared/defguard-ui/types'; import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; @@ -22,7 +20,7 @@ import { getLicenseInfoQueryOptions, } from '../../../shared/query'; import './style.scss'; -import { Suspense, useMemo, useState } from 'react'; +import { Suspense, useMemo } from 'react'; import Skeleton from 'react-loading-skeleton'; import z from 'zod'; import { m } from '../../../paraglide/messages'; @@ -78,25 +76,17 @@ type FormFields = z.infer; type GroupPolicyRowProps = { content: string; - enabled: boolean; - onToggle: () => void; title: string; }; -const GroupPolicyRow = ({ content, enabled, onToggle, title }: GroupPolicyRowProps) => ( - - - - - +const GroupPolicyRow = ({ content, title }: GroupPolicyRowProps) => ( +
+

{title}

+

{content}

+ +
); const Content = () => { @@ -104,11 +94,6 @@ const Content = () => { const { data: settings } = useSuspenseQuery(getEnterpriseSettingsQueryOptions); const noLicense = !isPresent(licenseInfo); - const [groupPolicyState, setGroupPolicyState] = useState({ - allowChoice: false, - disableAllTraffic: false, - forceAllTraffic: false, - }); const { mutateAsync: patchSettings } = useMutation({ mutationFn: api.settings.patchEnterpriseSettings, @@ -242,35 +227,14 @@ const Content = () => {

- setGroupPolicyState((state) => ({ - ...state, - allowChoice: !state.allowChoice, - })) - } title={m.settings_client_traffic_policy_group_none_title()} /> - setGroupPolicyState((state) => ({ - ...state, - disableAllTraffic: !state.disableAllTraffic, - })) - } title={m.settings_client_traffic_policy_group_disable_all_title()} /> - setGroupPolicyState((state) => ({ - ...state, - forceAllTraffic: !state.forceAllTraffic, - })) - } title={m.settings_client_traffic_policy_group_force_all_title()} /> diff --git a/web/src/pages/settings/SettingsClientPage/style.scss b/web/src/pages/settings/SettingsClientPage/style.scss index f093dcf514..613306984a 100644 --- a/web/src/pages/settings/SettingsClientPage/style.scss +++ b/web/src/pages/settings/SettingsClientPage/style.scss @@ -23,6 +23,21 @@ font: var(--t-body-sm-400); } + .group-policy-row { + display: flex; + flex-flow: column; + row-gap: var(--spacing-sm); + } + + .group-policy-title { + font: var(--t-body-primary-500); + } + + .group-policy-content { + color: var(--fg-neutral); + font: var(--t-body-sm-400); + } + .select-multiple-edit { align-self: flex-start; display: inline-flex; From ed656a246ac89b777aecece2414305b43d4315f1 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 24 Jul 2026 13:52:58 +0200 Subject: [PATCH 06/15] add icons --- .../SettingsClientPage/SettingsClientPage.tsx | 60 +++++++++++-------- .../settings/SettingsClientPage/style.scss | 17 +++--- 2 files changed, 41 insertions(+), 36 deletions(-) diff --git a/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx b/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx index a1929c71a8..5ee25e595a 100644 --- a/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx +++ b/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx @@ -12,8 +12,9 @@ import { SettingsCard } from '../../../shared/components/SettingsCard/SettingsCa import { SettingsHeader } from '../../../shared/components/SettingsHeader/SettingsHeader'; import { SettingsLayout } from '../../../shared/components/SettingsLayout/SettingsLayout'; import { Divider } from '../../../shared/defguard-ui/components/Divider/Divider'; +import { Icon, type IconKindValue } from '../../../shared/defguard-ui/components/Icon'; import { MarkedSection } from '../../../shared/defguard-ui/components/MarkedSection/MarkedSection'; -import { ThemeSpacing } from '../../../shared/defguard-ui/types'; +import { ThemeSpacing, ThemeVariable } from '../../../shared/defguard-ui/types'; import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; import { getEnterpriseSettingsQueryOptions, @@ -77,15 +78,19 @@ type FormFields = z.infer; type GroupPolicyRowProps = { content: string; title: string; + icon: IconKindValue; }; -const GroupPolicyRow = ({ content, title }: GroupPolicyRowProps) => ( +const GroupPolicyRow = ({ content, title, icon }: GroupPolicyRowProps) => (
-

{title}

-

{content}

- + +
+

{title}

+

{content}

+ +
); @@ -219,25 +224,28 @@ const Content = () => { /> )} - -
-

{m.settings_client_traffic_policy_group_title()}

-

- {m.settings_client_traffic_policy_group_description()} -

- - - -
+ + + +

{m.settings_client_traffic_policy_group_title()}

+

+ {m.settings_client_traffic_policy_group_description()} +

+ + +
({ diff --git a/web/src/pages/settings/SettingsClientPage/style.scss b/web/src/pages/settings/SettingsClientPage/style.scss index 613306984a..3135ec7058 100644 --- a/web/src/pages/settings/SettingsClientPage/style.scss +++ b/web/src/pages/settings/SettingsClientPage/style.scss @@ -8,16 +8,6 @@ font: var(--t-body-primary-600); } - h4 { - font: var(--t-body-primary-600); - } - - .group-policy-section { - display: flex; - flex-flow: column; - row-gap: var(--spacing-md); - } - .group-policy-description { color: var(--fg-muted); font: var(--t-body-sm-400); @@ -25,6 +15,13 @@ .group-policy-row { display: flex; + align-items: flex-start; + column-gap: var(--spacing-md); + } + + .group-policy-row-content { + display: flex; + flex: 1; flex-flow: column; row-gap: var(--spacing-sm); } From 4908a750f8398dc02126bb7baab214e1ebc681be Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 24 Jul 2026 14:15:01 +0200 Subject: [PATCH 07/15] groups selection modal --- .../SettingsClientPage/SettingsClientPage.tsx | 115 +++++++++++++++--- web/src/shared/api/types.ts | 7 ++ 2 files changed, 103 insertions(+), 19 deletions(-) diff --git a/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx b/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx index 5ee25e595a..76ff7ba6b7 100644 --- a/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx +++ b/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx @@ -8,6 +8,8 @@ import { } from '../../../shared/components/ContextualHelp'; import { DescriptionBlock } from '../../../shared/components/DescriptionBlock/DescriptionBlock'; import { Page } from '../../../shared/components/Page/Page'; +import type { SelectionOption } from '../../../shared/components/SelectionSection/type'; +import { SelectMultiple } from '../../../shared/components/SelectMultiple/SelectMultiple'; import { SettingsCard } from '../../../shared/components/SettingsCard/SettingsCard'; import { SettingsHeader } from '../../../shared/components/SettingsHeader/SettingsHeader'; import { SettingsLayout } from '../../../shared/components/SettingsLayout/SettingsLayout'; @@ -18,6 +20,7 @@ import { ThemeSpacing, ThemeVariable } from '../../../shared/defguard-ui/types'; import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; import { getEnterpriseSettingsQueryOptions, + getGroupsInfoQueryOptions, getLicenseInfoQueryOptions, } from '../../../shared/query'; import './style.scss'; @@ -71,25 +74,58 @@ const formSchema = z.object({ admin_device_management: z.boolean(), only_client_activation: z.boolean(), client_traffic_policy: z.enum(ClientTrafficPolicy), + group_client_traffic_policies: z.object({ + none: z.array(z.number()), + disable_all_traffic: z.array(z.number()), + force_all_traffic: z.array(z.number()), + }), }); type FormFields = z.infer; +const emptyGroupClientTrafficPolicies = { + none: [], + disable_all_traffic: [], + force_all_traffic: [], +}; + type GroupPolicyRowProps = { content: string; title: string; icon: IconKindValue; + options: SelectionOption[]; + selected: number[]; + onSelectionChange: (value: number[]) => void; }; -const GroupPolicyRow = ({ content, title, icon }: GroupPolicyRowProps) => ( +const getSelectedGroupsCounterText = (count: number) => { + if (count === 1) return m.location_access_selected_group_count_one({ count }); + return m.location_access_selected_group_count_other({ count }); +}; + +const GroupPolicyRow = ({ + content, + title, + icon, + onSelectionChange, + options, + selected, +}: GroupPolicyRowProps) => (

{title}

{content}

- + {}} + options={options} + selected={new Set(selected)} + toggleValue={false} + />
); @@ -97,6 +133,7 @@ const GroupPolicyRow = ({ content, title, icon }: GroupPolicyRowProps) => ( const Content = () => { const { data: licenseInfo } = useSuspenseQuery(getLicenseInfoQueryOptions); const { data: settings } = useSuspenseQuery(getEnterpriseSettingsQueryOptions); + const { data: groups } = useSuspenseQuery(getGroupsInfoQueryOptions); const noLicense = !isPresent(licenseInfo); @@ -118,13 +155,21 @@ const Content = () => { admin_device_management: settings.admin_device_management, only_client_activation: settings.only_client_activation, client_traffic_policy: settings.client_traffic_policy, + group_client_traffic_policies: + settings.group_client_traffic_policies ?? emptyGroupClientTrafficPolicies, }; }, [ settings.admin_device_management, settings.client_traffic_policy, + settings.group_client_traffic_policies, settings.only_client_activation, ]); + const groupOptions = groups.map>((group) => ({ + id: group.id, + label: group.name, + })); + const form = useAppForm({ defaultValues, validationLogic: formChangeLogic, @@ -231,21 +276,53 @@ const Content = () => {

{m.settings_client_traffic_policy_group_description()}

- - - + state.values.group_client_traffic_policies} + > + {(policies) => ( + <> + + form.setFieldValue('group_client_traffic_policies', { + ...policies, + none, + }) + } + options={groupOptions} + selected={policies.none} + /> + + form.setFieldValue('group_client_traffic_policies', { + ...policies, + disable_all_traffic, + }) + } + options={groupOptions} + selected={policies.disable_all_traffic} + /> + + form.setFieldValue('group_client_traffic_policies', { + ...policies, + force_all_traffic, + }) + } + options={groupOptions} + selected={policies.force_all_traffic} + /> + + )} + ({ diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index cd83e8fefe..f6905dd9cb 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -944,12 +944,19 @@ export const ClientTrafficPolicy = { export type ClientTrafficPolicyValue = (typeof ClientTrafficPolicy)[keyof typeof ClientTrafficPolicy]; +export interface GroupClientTrafficPolicies { + none: number[]; + disable_all_traffic: number[]; + force_all_traffic: number[]; +} + export interface SettingsEnterprise { admin_device_management: boolean; client_traffic_policy: ClientTrafficPolicyValue; only_client_activation: boolean; display_download_step: boolean; display_password_reset: boolean; + group_client_traffic_policies: GroupClientTrafficPolicies; } export type ApiDevicePostureOsRule = From e223b5267caa74382beaef6cd521776665c48481 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 24 Jul 2026 14:17:50 +0200 Subject: [PATCH 08/15] don't allow assignment of the same group to multiple policies --- .../SettingsClientPage/SettingsClientPage.tsx | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx b/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx index 76ff7ba6b7..2452c54660 100644 --- a/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx +++ b/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx @@ -1,6 +1,9 @@ import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; -import { ClientTrafficPolicy } from '../../../shared/api/types'; +import { + ClientTrafficPolicy, + type GroupClientTrafficPolicies, +} from '../../../shared/api/types'; import { Breadcrumbs } from '../../../shared/components/Breadcrumbs/Breadcrumbs'; import { ContextualHelpKey, @@ -83,6 +86,8 @@ const formSchema = z.object({ type FormFields = z.infer; +type GroupPolicy = keyof GroupClientTrafficPolicies; + const emptyGroupClientTrafficPolicies = { none: [], disable_all_traffic: [], @@ -103,6 +108,20 @@ const getSelectedGroupsCounterText = (count: number) => { return m.location_access_selected_group_count_other({ count }); }; +const getAvailableGroupOptions = ( + options: SelectionOption[], + policy: GroupPolicy, + policies: GroupClientTrafficPolicies, +) => { + const assignedToOtherPolicy = new Set( + Object.entries(policies) + .filter(([key]) => key !== policy) + .flatMap(([, groupIds]) => groupIds), + ); + + return options.filter((option) => !assignedToOtherPolicy.has(option.id)); +}; + const GroupPolicyRow = ({ content, title, @@ -291,7 +310,7 @@ const Content = () => { none, }) } - options={groupOptions} + options={getAvailableGroupOptions(groupOptions, 'none', policies)} selected={policies.none} /> { disable_all_traffic, }) } - options={groupOptions} + options={getAvailableGroupOptions( + groupOptions, + 'disable_all_traffic', + policies, + )} selected={policies.disable_all_traffic} /> { force_all_traffic, }) } - options={groupOptions} + options={getAvailableGroupOptions( + groupOptions, + 'force_all_traffic', + policies, + )} selected={policies.force_all_traffic} /> From 71f37af1dbaf64ea06aeb6d36cfc2825598d28b9 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 27 Jul 2026 09:45:27 +0200 Subject: [PATCH 09/15] add traffic policy column to groups table --- web/messages/en/groups.json | 4 ++ web/src/pages/GroupsPage/GroupsPage.tsx | 16 +++++++- .../components/GroupsTable/GroupsTable.tsx | 38 +++++++++++++++++-- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/web/messages/en/groups.json b/web/messages/en/groups.json index b89bf6757d..4c638fa95e 100644 --- a/web/messages/en/groups.json +++ b/web/messages/en/groups.json @@ -7,6 +7,10 @@ "groups_col_name": "Group name", "groups_col_users_count": "Added users", "groups_col_type": "Type", + "groups_col_traffic_policy": "Traffic policy", + "groups_traffic_policy_none": "No limitations", + "groups_traffic_policy_disable_all": "Disable all traffic", + "groups_traffic_policy_force_all": "Force all traffic", "groups_col_locations": "Used in locations", "groups_type_admin": "Admin", "groups_type_user": "User" diff --git a/web/src/pages/GroupsPage/GroupsPage.tsx b/web/src/pages/GroupsPage/GroupsPage.tsx index fc63ac999f..8f7f7a942d 100644 --- a/web/src/pages/GroupsPage/GroupsPage.tsx +++ b/web/src/pages/GroupsPage/GroupsPage.tsx @@ -5,6 +5,7 @@ import { SizedBox } from '../../shared/defguard-ui/components/SizedBox/SizedBox' import { ThemeSpacing } from '../../shared/defguard-ui/types'; import { TablePageLayout } from '../../shared/layout/TablePageLayout/TablePageLayout'; import { + getEnterpriseSettingsQueryOptions, getGroupsInfoQueryOptions, getLocationsQueryOptions, getUsersOverviewQueryOptions, @@ -14,14 +15,27 @@ import { CEGroupModal } from './modals/CEGroupModal/CEGroupModal'; export const GroupsPage = () => { const { data: groups } = useSuspenseQuery(getGroupsInfoQueryOptions); + const { data: enterpriseSettings } = useSuspenseQuery( + getEnterpriseSettingsQueryOptions, + ); const { data: locations } = useSuspenseQuery(getLocationsQueryOptions); const { data: users } = useSuspenseQuery(getUsersOverviewQueryOptions); + const groupClientTrafficPolicies = enterpriseSettings.group_client_traffic_policies ?? { + none: [], + disable_all_traffic: [], + force_all_traffic: [], + }; return ( <> - + diff --git a/web/src/pages/GroupsPage/components/GroupsTable/GroupsTable.tsx b/web/src/pages/GroupsPage/components/GroupsTable/GroupsTable.tsx index d250102443..e0c9859bbd 100644 --- a/web/src/pages/GroupsPage/components/GroupsTable/GroupsTable.tsx +++ b/web/src/pages/GroupsPage/components/GroupsTable/GroupsTable.tsx @@ -7,7 +7,12 @@ import { import { useMemo, useState } from 'react'; import { m } from '../../../../paraglide/messages'; import api from '../../../../shared/api/api'; -import type { GroupInfo, NetworkLocation, User } from '../../../../shared/api/types'; +import type { + GroupClientTrafficPolicies, + GroupInfo, + NetworkLocation, + User, +} from '../../../../shared/api/types'; import { Badge } from '../../../../shared/defguard-ui/components/Badge/Badge'; import { Button } from '../../../../shared/defguard-ui/components/Button/Button'; import type { MenuItemProps } from '../../../../shared/defguard-ui/components/Menu/types'; @@ -23,6 +28,7 @@ import { ModalName } from '../../../../shared/hooks/modalControls/modalTypes'; type Props = { groups: GroupInfo[]; + groupClientTrafficPolicies: GroupClientTrafficPolicies; locations: NetworkLocation[]; users: User[]; }; @@ -31,7 +37,12 @@ type RowData = GroupInfo; const columnHelper = createColumnHelper(); -export const GroupsTable = ({ groups, locations, users }: Props) => { +export const GroupsTable = ({ + groups, + groupClientTrafficPolicies, + locations, + users, +}: Props) => { const [search, setSearch] = useState(''); const reservedNames = useMemo(() => groups.map((g) => g.name), [groups]); @@ -82,6 +93,27 @@ export const GroupsTable = ({ groups, locations, users }: Props) => { ), }), + columnHelper.display({ + id: 'traffic_policy', + minSize: 200, + header: m.groups_col_traffic_policy(), + cell: (info) => { + const groupId = info.row.original.id; + let policy = '-'; + if (groupClientTrafficPolicies.none.includes(groupId)) { + policy = m.groups_traffic_policy_none(); + } else if (groupClientTrafficPolicies.disable_all_traffic.includes(groupId)) { + policy = m.groups_traffic_policy_disable_all(); + } else if (groupClientTrafficPolicies.force_all_traffic.includes(groupId)) { + policy = m.groups_traffic_policy_force_all(); + } + return ( + + {policy} + + ); + }, + }), columnHelper.accessor('vpn_locations', { minSize: 350, header: m.groups_col_locations(), @@ -157,7 +189,7 @@ export const GroupsTable = ({ groups, locations, users }: Props) => { }, }), ], - [locations, reservedNames, users], + [groupClientTrafficPolicies, locations, reservedNames, users], ); const table = useReactTable({ From 6cd6ae72608af64672fba2c8db4c8bbcb1a3b90f Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 27 Jul 2026 10:31:08 +0200 Subject: [PATCH 10/15] handle missing license --- .../handlers/enterprise_settings.rs | 20 ++--- .../integration/api/enterprise_settings.rs | 15 ++++ web/messages/en/settings.json | 2 +- .../SettingsClientPage/SettingsClientPage.tsx | 86 ++++++++++++------- 4 files changed, 79 insertions(+), 44 deletions(-) diff --git a/crates/defguard_core/src/enterprise/handlers/enterprise_settings.rs b/crates/defguard_core/src/enterprise/handlers/enterprise_settings.rs index 8479c30be7..dce7f36e55 100644 --- a/crates/defguard_core/src/enterprise/handlers/enterprise_settings.rs +++ b/crates/defguard_core/src/enterprise/handlers/enterprise_settings.rs @@ -9,15 +9,12 @@ use super::LicenseInfo; use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, - enterprise::{ - db::models::{ - enterprise_settings::{ - ClientTrafficPolicy, EnterpriseSettings, EnterpriseSettingsInfo, - EnterpriseSettingsPatch, GroupClientTrafficPolicies, - }, - group_client_traffic_policy::GroupClientTrafficPolicy, + enterprise::db::models::{ + enterprise_settings::{ + ClientTrafficPolicy, EnterpriseSettings, EnterpriseSettingsInfo, + EnterpriseSettingsPatch, GroupClientTrafficPolicies, }, - is_business_license_active, + group_client_traffic_policy::GroupClientTrafficPolicy, }, error::WebError, events::{ApiEvent, ApiEventType, ApiRequestContext}, @@ -88,11 +85,8 @@ async fn settings_info( pool: &PgPool, settings: EnterpriseSettings, ) -> Result { - let group_policies = if is_business_license_active() { - GroupClientTrafficPolicy::grouped(GroupClientTrafficPolicy::all(pool).await?) - } else { - GroupClientTrafficPolicies::default() - }; + let group_policies = + GroupClientTrafficPolicy::grouped(GroupClientTrafficPolicy::all(pool).await?); Ok(EnterpriseSettingsInfo::new(settings, group_policies)) } diff --git a/crates/defguard_core/tests/integration/api/enterprise_settings.rs b/crates/defguard_core/tests/integration/api/enterprise_settings.rs index 4e14c4a4cf..03fbc21851 100644 --- a/crates/defguard_core/tests/integration/api/enterprise_settings.rs +++ b/crates/defguard_core/tests/integration/api/enterprise_settings.rs @@ -791,6 +791,21 @@ async fn test_group_client_traffic_policies_are_saved_and_validated( .is_empty() ); + let license = get_cached_license().clone(); + set_cached_license(None); + let response = client.get("/api/v1/settings_enterprise").send().await; + assert_eq!(response.status(), StatusCode::OK); + let settings: EnterpriseSettingsInfo = response.json().await; + assert_eq!( + settings.group_client_traffic_policies.none, + vec![allow_choice.id] + ); + assert_eq!( + settings.group_client_traffic_policies.disable_all_traffic, + vec![disable.id] + ); + set_cached_license(license); + let response = client .patch("/api/v1/settings_enterprise") .json(&json!({"display_download_step": false})) diff --git a/web/messages/en/settings.json b/web/messages/en/settings.json index 189b2d6940..8a7cdac1fd 100644 --- a/web/messages/en/settings.json +++ b/web/messages/en/settings.json @@ -320,7 +320,7 @@ "settings_client_section_traffic_policy_title": "Client traffic policy", "settings_client_traffic_policy_description_title": "Client traffic rules", "settings_client_traffic_policy_description": "Specify the conditions that determine how traffic should behave in the application.", - "settings_client_traffic_policy_none_title": "None", + "settings_client_traffic_policy_none_title": "No limitation", "settings_client_traffic_policy_none_content": "When this option is enabled, users will be able to select all routing options.", "settings_client_traffic_policy_disable_all_title": "Disable all traffic", "settings_client_traffic_policy_disable_all_content": "When this option is enabled, users will not be able to route all traffic through the VPN.", diff --git a/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx b/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx index 2452c54660..36c4f63c7a 100644 --- a/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx +++ b/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx @@ -38,9 +38,7 @@ import { Button } from '../../../shared/defguard-ui/components/Button/Button'; import { Snackbar } from '../../../shared/defguard-ui/providers/snackbar/snackbar'; import { useAppForm } from '../../../shared/form'; import { formChangeLogic } from '../../../shared/formLogic'; -import { openModal } from '../../../shared/hooks/modalControls/modalsSubjects'; -import { ModalName } from '../../../shared/hooks/modalControls/modalTypes'; -import { canUseBusinessFeature } from '../../../shared/utils/license'; +import { canUseBusinessFeature, licenseActionCheck } from '../../../shared/utils/license'; const breadcrumbs = [ @@ -95,12 +93,14 @@ const emptyGroupClientTrafficPolicies = { }; type GroupPolicyRowProps = { + canEdit: boolean; content: string; title: string; icon: IconKindValue; options: SelectionOption[]; selected: number[]; onSelectionChange: (value: number[]) => void; + onEditUnavailable: () => void; }; const getSelectedGroupsCounterText = (count: number) => { @@ -126,25 +126,37 @@ const GroupPolicyRow = ({ content, title, icon, + canEdit, onSelectionChange, options, selected, + onEditUnavailable, }: GroupPolicyRowProps) => (

{title}

{content}

- {}} - options={options} - selected={new Set(selected)} - toggleValue={false} - /> + {canEdit ? ( + {}} + options={options} + selected={new Set(selected)} + toggleValue={false} + /> + ) : ( + + )}
); @@ -155,6 +167,10 @@ const Content = () => { const { data: groups } = useSuspenseQuery(getGroupsInfoQueryOptions); const noLicense = !isPresent(licenseInfo); + const canUseTrafficPolicies = canUseBusinessFeature(licenseInfo).result; + const groupClientTrafficPolicies = canUseTrafficPolicies + ? (settings.group_client_traffic_policies ?? emptyGroupClientTrafficPolicies) + : emptyGroupClientTrafficPolicies; const { mutateAsync: patchSettings } = useMutation({ mutationFn: api.settings.patchEnterpriseSettings, @@ -173,15 +189,17 @@ const Content = () => { return { admin_device_management: settings.admin_device_management, only_client_activation: settings.only_client_activation, - client_traffic_policy: settings.client_traffic_policy, - group_client_traffic_policies: - settings.group_client_traffic_policies ?? emptyGroupClientTrafficPolicies, + client_traffic_policy: canUseTrafficPolicies + ? settings.client_traffic_policy + : ClientTrafficPolicy.None, + group_client_traffic_policies: groupClientTrafficPolicies, }; }, [ settings.admin_device_management, settings.client_traffic_policy, - settings.group_client_traffic_policies, settings.only_client_activation, + canUseTrafficPolicies, + groupClientTrafficPolicies, ]); const groupOptions = groups.map>((group) => ({ @@ -197,17 +215,13 @@ const Content = () => { onChange: formSchema, }, onSubmit: async ({ value }) => { - if (!licenseInfo) return; - // only expire error is possible here - const { result } = canUseBusinessFeature(licenseInfo); - if (result) { - await patchSettings(value); - form.reset(value); - } else { - openModal(ModalName.LicenseExpired, { - licenseTier: licenseInfo?.tier, - }); + const licenseCheck = canUseBusinessFeature(licenseInfo); + if (!licenseCheck.result) { + licenseActionCheck(licenseCheck, () => {}); + return; } + await patchSettings(value); + form.reset(value); }, }); @@ -258,7 +272,7 @@ const Content = () => { {(field) => ( { {(field) => ( { {(field) => ( { {(policies) => ( <> { } options={getAvailableGroupOptions(groupOptions, 'none', policies)} selected={policies.none} + onEditUnavailable={() => + licenseActionCheck(canUseBusinessFeature(licenseInfo), () => {}) + } /> { policies, )} selected={policies.disable_all_traffic} + onEditUnavailable={() => + licenseActionCheck(canUseBusinessFeature(licenseInfo), () => {}) + } /> { policies, )} selected={policies.force_all_traffic} + onEditUnavailable={() => + licenseActionCheck(canUseBusinessFeature(licenseInfo), () => {}) + } /> )} From ed456b62dd88797464270795594397223798b037 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 27 Jul 2026 10:54:39 +0200 Subject: [PATCH 11/15] fix "Business plan" badges --- .../settings/SettingsClientPage/SettingsClientPage.tsx | 8 ++++++-- .../SettingsIndexPage/tabs/SettingsGeneralTab.tsx | 7 ++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx b/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx index 36c4f63c7a..7e4dca35ca 100644 --- a/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx +++ b/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx @@ -50,7 +50,7 @@ const breadcrumbs = [ ]; export const SettingsClientPage = () => { - const { data: license, isFetched } = useQuery(getLicenseInfoQueryOptions); + const { data: license } = useQuery(getLicenseInfoQueryOptions); return ( @@ -61,7 +61,11 @@ export const SettingsClientPage = () => { icon="user" title={m.settings_client_title()} subtitle={m.settings_client_subtitle()} - badgeProps={!isPresent(license) && isFetched ? businessBadgeProps : undefined} + badgeProps={ + license !== undefined && !canUseBusinessFeature(license).result + ? businessBadgeProps + : undefined + } /> }> diff --git a/web/src/pages/settings/SettingsIndexPage/tabs/SettingsGeneralTab.tsx b/web/src/pages/settings/SettingsIndexPage/tabs/SettingsGeneralTab.tsx index 2aef6c2f1c..81b0005bba 100644 --- a/web/src/pages/settings/SettingsIndexPage/tabs/SettingsGeneralTab.tsx +++ b/web/src/pages/settings/SettingsIndexPage/tabs/SettingsGeneralTab.tsx @@ -11,6 +11,7 @@ import { SectionSelect } from '../../../../shared/defguard-ui/components/Section import { SizedBox } from '../../../../shared/defguard-ui/components/SizedBox/SizedBox'; import { ThemeSpacing } from '../../../../shared/defguard-ui/types'; import { getLicenseInfoQueryOptions } from '../../../../shared/query'; +import { canUseBusinessFeature } from '../../../../shared/utils/license'; export const SettingsGeneralTab = () => { const navigate = useNavigate(); @@ -34,7 +35,11 @@ export const SettingsGeneralTab = () => { image="behavior" title={m.settings_breadcrumb_client_behavior()} content={m.settings_general_section_client_behavior_content()} - badgeProps={licenseInfo === null ? businessBadgeProps : undefined} + badgeProps={ + licenseInfo !== undefined && !canUseBusinessFeature(licenseInfo).result + ? businessBadgeProps + : undefined + } onClick={() => { navigate({ to: '/settings/client' }); }} From 15c2a802cd4466ed2c0f63cd2995673670a39ffb Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 27 Jul 2026 11:23:05 +0200 Subject: [PATCH 12/15] add integration tests --- crates/defguard_core/src/lib.rs | 2 +- .../tests/proxy_manager/handler/polling.rs | 176 +++++++++++++++--- 2 files changed, 156 insertions(+), 22 deletions(-) diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index f43b2adb9e..2847e9c73e 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -88,12 +88,12 @@ use tracing::Level; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::enterprise::db::models::openid_provider::OpenIdProvider; use crate::{ appstate::AppState, auth::failed_login::FailedLoginMap, db::AppEvent, enterprise::{ + db::models::openid_provider::OpenIdProvider, firewall::try_get_location_firewall_config, handlers::{ acl::{ diff --git a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/polling.rs b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/polling.rs index ca9bc9cd4b..8de15dd370 100644 --- a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/polling.rs +++ b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/polling.rs @@ -1,9 +1,20 @@ -use defguard_core::device_access::join_device_to_all_networks; +use defguard_common::db::models::group::Group; +use defguard_core::{ + device_access::join_device_to_all_networks, + enterprise::db::models::{ + enterprise_settings::ClientTrafficPolicy, + group_client_traffic_policy::GroupClientTrafficPolicy, + }, + grpc::utils::build_device_config_response, +}; use defguard_proto::{ - client_types::InstanceInfoRequest, + client_types, proxy::{CoreRequest, core_request, core_response}, }; -use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; +use sqlx::{ + PgPool, + postgres::{PgConnectOptions, PgPoolOptions}, +}; use super::support::{ assert_error_response, clear_test_license, complete_proxy_handshake, create_device_for_user, @@ -12,6 +23,121 @@ use super::support::{ }; use crate::tests::common::HandlerTestContext; +async fn poll_client_traffic_policy(context: &mut HandlerTestContext, token: &str) -> i32 { + context.mock_proxy().send_request(CoreRequest { + id: 20, + device_info: None, + payload: Some(core_request::Payload::InstanceInfo( + client_types::InstanceInfoRequest { + token: token.to_owned(), + }, + )), + }); + + let response = context.mock_proxy_mut().recv_outbound().await; + match response.payload { + Some(core_response::Payload::InstanceInfo(info)) => info + .device_config + .and_then(|config| config.instance) + .and_then(|instance| instance.client_traffic_policy) + .expect("InstanceInfo should contain a client traffic policy"), + other => panic!( + "expected InstanceInfo response, got: {:?}", + other.as_ref().map(std::mem::discriminant) + ), + } +} + +async fn set_global_client_traffic_policy(pool: &PgPool, policy: &str) { + sqlx::query( + "UPDATE \"enterprisesettings\" SET client_traffic_policy = $1::client_traffic_policy WHERE id = 1", + ) + .bind(policy) + .execute(pool) + .await + .expect("failed to update global client traffic policy"); +} + +#[sqlx::test] +async fn test_client_traffic_policy_is_resolved_for_instance_info( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let mut context = HandlerTestContext::new(options).await; + complete_proxy_handshake(&mut context).await; + set_test_license_business(); + + let _network = create_network(&context.pool).await; + let (user, device) = create_user_with_device(&context.pool).await; + let token = create_polling_token(&context.pool, device.id).await; + let force_group = Group::new("force-policy") + .save(&context.pool) + .await + .unwrap(); + let disable_group = Group::new("disable-policy") + .save(&context.pool) + .await + .unwrap(); + + set_global_client_traffic_policy(&context.pool, "disable_all_traffic").await; + assert_eq!( + poll_client_traffic_policy(&mut context, &token).await, + client_types::ClientTrafficPolicy::DisableAllTraffic as i32 + ); + + user.add_to_group(&context.pool, &force_group) + .await + .unwrap(); + GroupClientTrafficPolicy::upsert( + &context.pool, + force_group.id, + ClientTrafficPolicy::ForceAllTraffic, + ) + .await + .unwrap(); + assert_eq!( + poll_client_traffic_policy(&mut context, &token).await, + client_types::ClientTrafficPolicy::ForceAllTraffic as i32 + ); + + GroupClientTrafficPolicy::upsert(&context.pool, force_group.id, ClientTrafficPolicy::None) + .await + .unwrap(); + assert_eq!( + poll_client_traffic_policy(&mut context, &token).await, + client_types::ClientTrafficPolicy::None as i32 + ); + + user.add_to_group(&context.pool, &disable_group) + .await + .unwrap(); + GroupClientTrafficPolicy::upsert( + &context.pool, + disable_group.id, + ClientTrafficPolicy::DisableAllTraffic, + ) + .await + .unwrap(); + assert_eq!( + poll_client_traffic_policy(&mut context, &token).await, + client_types::ClientTrafficPolicy::DisableAllTraffic as i32 + ); + + clear_test_license(); + let response = build_device_config_response(&context.pool, device, None, None) + .await + .expect("failed to build device config without a license"); + assert_eq!( + response + .instance + .expect("device config should contain instance info") + .client_traffic_policy, + Some(client_types::ClientTrafficPolicy::None as i32) + ); + + context.finish().await.expect_server_finished().await; +} + #[sqlx::test] async fn test_polling_returns_updated_device_config(_: PgPoolOptions, options: PgConnectOptions) { let mut context = HandlerTestContext::new(options).await; @@ -27,9 +153,11 @@ async fn test_polling_returns_updated_device_config(_: PgPoolOptions, options: P context.mock_proxy().send_request(CoreRequest { id: 10, device_info: None, - payload: Some(core_request::Payload::InstanceInfo(InstanceInfoRequest { - token: token_str.clone(), - })), + payload: Some(core_request::Payload::InstanceInfo( + client_types::InstanceInfoRequest { + token: token_str.clone(), + }, + )), }); let response = context.mock_proxy_mut().recv_outbound().await; @@ -64,9 +192,9 @@ async fn test_polling_requires_business_license(_: PgPoolOptions, options: PgCon context.mock_proxy().send_request(CoreRequest { id: 11, device_info: None, - payload: Some(core_request::Payload::InstanceInfo(InstanceInfoRequest { - token: token_str, - })), + payload: Some(core_request::Payload::InstanceInfo( + client_types::InstanceInfoRequest { token: token_str }, + )), }); let response = context.mock_proxy_mut().recv_outbound().await; @@ -90,9 +218,11 @@ async fn test_polling_invalid_token_returns_error(_: PgPoolOptions, options: PgC context.mock_proxy().send_request(CoreRequest { id: 12, device_info: None, - payload: Some(core_request::Payload::InstanceInfo(InstanceInfoRequest { - token: "this-token-does-not-exist-00000000".to_owned(), - })), + payload: Some(core_request::Payload::InstanceInfo( + client_types::InstanceInfoRequest { + token: "this-token-does-not-exist-00000000".to_owned(), + }, + )), }); let response = context.mock_proxy_mut().recv_outbound().await; @@ -127,9 +257,9 @@ async fn test_polling_inactive_user_returns_error(_: PgPoolOptions, options: PgC context.mock_proxy().send_request(CoreRequest { id: 13, device_info: None, - payload: Some(core_request::Payload::InstanceInfo(InstanceInfoRequest { - token: token_str, - })), + payload: Some(core_request::Payload::InstanceInfo( + client_types::InstanceInfoRequest { token: token_str }, + )), }); let response = context.mock_proxy_mut().recv_outbound().await; @@ -159,9 +289,11 @@ async fn test_polling_reflects_network_changes(_: PgPoolOptions, options: PgConn context.mock_proxy().send_request(CoreRequest { id: 14, device_info: None, - payload: Some(core_request::Payload::InstanceInfo(InstanceInfoRequest { - token: token_str.clone(), - })), + payload: Some(core_request::Payload::InstanceInfo( + client_types::InstanceInfoRequest { + token: token_str.clone(), + }, + )), }); let first_response = context.mock_proxy_mut().recv_outbound().await; let first_info = match &first_response.payload { @@ -182,9 +314,11 @@ async fn test_polling_reflects_network_changes(_: PgPoolOptions, options: PgConn context.mock_proxy().send_request(CoreRequest { id: 15, device_info: None, - payload: Some(core_request::Payload::InstanceInfo(InstanceInfoRequest { - token: token_str.clone(), - })), + payload: Some(core_request::Payload::InstanceInfo( + client_types::InstanceInfoRequest { + token: token_str.clone(), + }, + )), }); let second_response = context.mock_proxy_mut().recv_outbound().await; let second_info = match &second_response.payload { From 05af254ba06ba4b412426ffdf1ddf3a770b22f4b Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 27 Jul 2026 11:31:13 +0200 Subject: [PATCH 13/15] fix the icon --- web/src/shared/defguard-ui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/shared/defguard-ui b/web/src/shared/defguard-ui index 493d13646e..693c5ecf22 160000 --- a/web/src/shared/defguard-ui +++ b/web/src/shared/defguard-ui @@ -1 +1 @@ -Subproject commit 493d13646e9817a197a2d88f2b5bb1931b421ed5 +Subproject commit 693c5ecf2252d33ae9d45bbf36574e6108565944 From 6305804aaeecab4bfa17931cce9ed3ddbfc7aea7 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 27 Jul 2026 11:36:30 +0200 Subject: [PATCH 14/15] fix translations --- web/messages/en/settings.json | 7 +++---- .../settings/SettingsClientPage/SettingsClientPage.tsx | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/web/messages/en/settings.json b/web/messages/en/settings.json index 8a7cdac1fd..36bfe04b2d 100644 --- a/web/messages/en/settings.json +++ b/web/messages/en/settings.json @@ -328,12 +328,11 @@ "settings_client_traffic_policy_force_all_content": "When this option is enabled, the users will always route all traffic through the VPN.", "settings_client_traffic_policy_group_title": "Group-based policies", "settings_client_traffic_policy_group_description": "Define the groups that should use their own traffic rules instead of the global traffic policy. Any groups not included below will use the global traffic policy.", - "settings_client_traffic_policy_group_none_title": "Allow choice", - "settings_client_traffic_policy_group_none_content": "Members of selected groups will be able to choose whether to route all traffic through the VPN.", + "settings_client_traffic_policy_group_none_content": "When this option is enabled, users in groups will be able to select all routing options.", "settings_client_traffic_policy_group_disable_all_title": "Disable all traffic", - "settings_client_traffic_policy_group_disable_all_content": "Members of selected groups will not be able to route all traffic through the VPN.", + "settings_client_traffic_policy_group_disable_all_content": "When this option is enabled, users in groups will not be able to route all traffic through the VPN.", "settings_client_traffic_policy_group_force_all_title": "Force all traffic", - "settings_client_traffic_policy_group_force_all_content": "Members of selected groups will always route all traffic through the VPN.", + "settings_client_traffic_policy_group_force_all_content": "When this option is enabled, the users in groups will always route all traffic through the VPN.", "settings_client_traffic_policy_edit_groups": "Edit groups", "settings_gateway_notifications_title": "Gateway notifications", "settings_gateway_notifications_subtitle": "Here you can manage email notifications.", diff --git a/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx b/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx index 7e4dca35ca..989415f24d 100644 --- a/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx +++ b/web/src/pages/settings/SettingsClientPage/SettingsClientPage.tsx @@ -321,7 +321,7 @@ const Content = () => { form.setFieldValue('group_client_traffic_policies', { @@ -339,7 +339,7 @@ const Content = () => { canEdit={canUseTrafficPolicies} content={m.settings_client_traffic_policy_group_disable_all_content()} title={m.settings_client_traffic_policy_group_disable_all_title()} - icon="online" + icon="globe-denied" onSelectionChange={(disable_all_traffic) => form.setFieldValue('group_client_traffic_policies', { ...policies, From 0167361cec8433322f6d780f5cd515696c445312 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 27 Jul 2026 13:03:19 +0200 Subject: [PATCH 15/15] bump defguard-ui dependency --- web/src/shared/defguard-ui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/shared/defguard-ui b/web/src/shared/defguard-ui index 693c5ecf22..ff24135a90 160000 --- a/web/src/shared/defguard-ui +++ b/web/src/shared/defguard-ui @@ -1 +1 @@ -Subproject commit 693c5ecf2252d33ae9d45bbf36574e6108565944 +Subproject commit ff24135a905c473bf32f2acdf8026e2380c36e52