From b5d26cd6ee9d6b2559f52cc4f99660b19c1e3e84 Mon Sep 17 00:00:00 2001 From: Kuba <78603704+jakub-tldr@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:22:36 +0200 Subject: [PATCH 1/4] DG26-4: Extending the number of locations (#2843) --- crates/defguard_core/src/handlers/user.rs | 2 +- .../defguard_core/src/handlers/wireguard.rs | 2 +- .../tests/integration/api/user.rs | 48 ++++++++++++++ .../tests/integration/api/wireguard.rs | 62 ++++++++++++++++++- 4 files changed, 110 insertions(+), 4 deletions(-) diff --git a/crates/defguard_core/src/handlers/user.rs b/crates/defguard_core/src/handlers/user.rs index bad75554c..b198bee8e 100644 --- a/crates/defguard_core/src/handlers/user.rs +++ b/crates/defguard_core/src/handlers/user.rs @@ -332,7 +332,7 @@ pub(crate) async fn add_user( if get_cached_license() .as_ref() .and_then(|l| l.limits.as_ref()) - .is_some_and(|l| l.users == user_count) + .is_some_and(|l| user_count >= l.users) { error!("Adding user {username} blocked! License limit reached."); return Ok(WebError::Forbidden("License limit reached").into()); diff --git a/crates/defguard_core/src/handlers/wireguard.rs b/crates/defguard_core/src/handlers/wireguard.rs index 843d0a2fa..d7b03b424 100644 --- a/crates/defguard_core/src/handlers/wireguard.rs +++ b/crates/defguard_core/src/handlers/wireguard.rs @@ -204,7 +204,7 @@ pub(crate) async fn create_network( if get_cached_license() .as_ref() .and_then(|l| l.limits.as_ref()) - .is_some_and(|l| l.locations == location_count) + .is_some_and(|l| location_count >= l.locations) { error!("Adding location {network_name} blocked! License limit reached."); return Ok(WebError::Forbidden("License limit reached").into()); diff --git a/crates/defguard_core/tests/integration/api/user.rs b/crates/defguard_core/tests/integration/api/user.rs index 881a69287..f7ecc9c88 100644 --- a/crates/defguard_core/tests/integration/api/user.rs +++ b/crates/defguard_core/tests/integration/api/user.rs @@ -16,7 +16,12 @@ use defguard_common::{ types::user_info::UserInfo, }; use defguard_core::{ + enterprise::{ + license::{License, LicenseTier, SupportType, get_cached_license, set_cached_license}, + limits::update_counts, + }, events::ApiEventType, + grpc::proto::enterprise::license::LicenseLimits, handlers::{ AddUserData, Auth, PasswordChange, PasswordChangeSelf, Username, openid_clients::NewOpenIDClient, @@ -612,6 +617,49 @@ async fn test_crud_user(_: PgPoolOptions, options: PgConnectOptions) { ]); } +#[sqlx::test] +async fn test_add_user_blocked_when_user_count_exceeds_license_limit( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + + let (mut client, pool) = make_client_with_db(pool).await; + + client.login_user("admin", "pass123").await; + update_counts(&pool).await.unwrap(); + + let license = get_cached_license().clone(); + set_cached_license(Some(License::new( + "test_customer".to_string(), + false, + None, + Some(LicenseLimits { + users: 1, + devices: 100, + locations: 100, + network_devices: Some(100), + }), + None, + LicenseTier::Business, + SupportType::Basic, + ))); + + let new_user = AddUserData { + username: "adumbledore".into(), + last_name: "Dumbledore".into(), + first_name: "Albus".into(), + email: "a.dumbledore@hogwart.edu.uk".into(), + phone: Some("1234".into()), + password: Some("Password1234543$!".into()), + }; + let response = client.post("/api/v1/user").json(&new_user).send().await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + set_cached_license(license); + client.assert_event_queue_is_empty(); +} + #[sqlx::test] async fn test_check_username(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; diff --git a/crates/defguard_core/tests/integration/api/wireguard.rs b/crates/defguard_core/tests/integration/api/wireguard.rs index b4126015f..ce1d191ef 100644 --- a/crates/defguard_core/tests/integration/api/wireguard.rs +++ b/crates/defguard_core/tests/integration/api/wireguard.rs @@ -19,9 +19,10 @@ use defguard_core::{ DirectorySyncTarget, DirectorySyncUserBehavior, OpenIdProviderKind, }, handlers::openid_providers::AddProviderData, - license::{get_cached_license, set_cached_license}, + license::{License, LicenseTier, SupportType, get_cached_license, set_cached_license}, + limits::update_counts, }, - grpc::GatewayEvent, + grpc::{GatewayEvent, proto::enterprise::license::LicenseLimits}, handlers::{Auth, GroupInfo, wireguard::WireguardNetworkData}, }; use ipnetwork::IpNetwork; @@ -137,6 +138,63 @@ async fn test_network(_: PgPoolOptions, options: PgConnectOptions) { assert_matches!(event, GatewayEvent::NetworkDeleted(..)); } +#[sqlx::test] +async fn test_create_network_blocked_when_location_count_exceeds_license_limit( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + + let (mut client, client_state) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + make_network(&client, "network1").await; + make_network(&client, "network2").await; + update_counts(&client_state.pool).await.unwrap(); + + let license = get_cached_license().clone(); + set_cached_license(Some(License::new( + "test_customer".to_string(), + false, + None, + Some(LicenseLimits { + users: 100, + devices: 100, + locations: 1, + network_devices: Some(100), + }), + None, + LicenseTier::Business, + SupportType::Basic, + ))); + + let response = client + .post("/api/v1/network") + .json(&json!({ + "name": "network3", + "address": "10.1.1.1/24", + "port": 55555, + "endpoint": "192.168.4.14", + "allowed_ips": "10.1.1.0/24", + "dns": "1.1.1.1", + "mtu": 1420, + "fwmark": 0, + "allowed_groups": ["admin"], + "allow_all_groups": false, + "keepalive_interval": 25, + "peer_disconnect_threshold": 300, + "acl_enabled": false, + "acl_default_allow": false, + "location_mfa_mode": "disabled", + "service_location_mode": "disabled" + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + set_cached_license(license); +} + #[sqlx::test] async fn test_location_mfa_mode_validation_create(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; From 9f13b596a8c4b1ce637606850f971d45734a97db Mon Sep 17 00:00:00 2001 From: Kuba <78603704+jakub-tldr@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:29:10 +0200 Subject: [PATCH 2/4] block creating api tokens for inactive users --- .../src/enterprise/handlers/api_tokens.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/defguard_core/src/enterprise/handlers/api_tokens.rs b/crates/defguard_core/src/enterprise/handlers/api_tokens.rs index 81c1348c6..acdfbea51 100644 --- a/crates/defguard_core/src/enterprise/handlers/api_tokens.rs +++ b/crates/defguard_core/src/enterprise/handlers/api_tokens.rs @@ -49,6 +49,16 @@ pub async fn add_api_token( )); } + if !user.is_active { + error!( + "User {} attempted to create API token for inactive user {username}", + session.user.username + ); + return Err(WebError::Forbidden( + "Cannot create API token for inactive user", + )); + } + // TODO: check if the name is already used // generate token string From 4f0059bba6ee8b758406438cd40db21d7b7a8766 Mon Sep 17 00:00:00 2001 From: Kuba <78603704+jakub-tldr@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:29:24 +0200 Subject: [PATCH 3/4] add test --- .../tests/integration/api/api_tokens.rs | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/defguard_core/tests/integration/api/api_tokens.rs b/crates/defguard_core/tests/integration/api/api_tokens.rs index 8d4f8590c..a1796a7f6 100644 --- a/crates/defguard_core/tests/integration/api/api_tokens.rs +++ b/crates/defguard_core/tests/integration/api/api_tokens.rs @@ -363,11 +363,33 @@ async fn dg25_3_test_token_invalidation(_: PgPoolOptions, options: PgConnectOpti let user_details = fetch_user_details(&client, "hpotter").await; assert!(!user_details.user.is_active); + // cannot create a new token for an inactive user + let response = client + .post("/api/v1/user/hpotter/api_token") + .json(&AddApiTokenData { + name: "inactive user token".into(), + }) + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + // re-enable the user + let mut user_details = fetch_user_details(&client, "hpotter").await; + user_details.user.is_active = true; + let response = client + .put("/api/v1/user/hpotter") + .json(&user_details.user) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let user_details = fetch_user_details(&client, "hpotter").await; + assert!(user_details.user.is_active); + // log out let response = client.post("/api/v1/auth/logout").send().await; assert_eq!(response.status(), StatusCode::OK); - // cannot use token for authentication anymore + // cannot use token for authentication anymore after reactivation let response = client .get("/api/v1/me") .header( From 0fa8d126d5a3710c69c83fd76dc30344e5b53d79 Mon Sep 17 00:00:00 2001 From: Kuba <78603704+jakub-tldr@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:15:03 +0200 Subject: [PATCH 4/4] block adding api token for disabled user --- web/messages/en/modal.json | 1 + .../ProfileApiTokensTab.tsx | 25 +++++++++++-------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/web/messages/en/modal.json b/web/messages/en/modal.json index 2e128fd48..9f6f6a674 100644 --- a/web/messages/en/modal.json +++ b/web/messages/en/modal.json @@ -116,6 +116,7 @@ "modal_add_api_title": "Add API token", "modal_add_api_token_copy_copy_label": "API Token", "modal_add_api_token_copy_warning": "Please copy and save the API token below now. You won't be able to see it again.", + "modal_add_api_token_disabled_user_error": "Can't create API token for disabled user", "modal_rename_api_title": "Rename API token", "modal_add_user_title": "Add new user", "modal_add_new_device_title": "Add new device", diff --git a/web/src/pages/user-profile/UserProfilePage/tabs/ProfileApiTokensTab/ProfileApiTokensTab.tsx b/web/src/pages/user-profile/UserProfilePage/tabs/ProfileApiTokensTab/ProfileApiTokensTab.tsx index 67aae645d..cb7cb71d1 100644 --- a/web/src/pages/user-profile/UserProfilePage/tabs/ProfileApiTokensTab/ProfileApiTokensTab.tsx +++ b/web/src/pages/user-profile/UserProfilePage/tabs/ProfileApiTokensTab/ProfileApiTokensTab.tsx @@ -3,6 +3,7 @@ import { LayoutGrid } from '../../../../../shared/components/LayoutGrid/LayoutGr import { Button } from '../../../../../shared/defguard-ui/components/Button/Button'; import { EmptyStateFlexible } from '../../../../../shared/defguard-ui/components/EmptyStateFlexible/EmptyStateFlexible'; import { SizedBox } from '../../../../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { Snackbar } from '../../../../../shared/defguard-ui/providers/snackbar/snackbar'; import { ThemeSpacing } from '../../../../../shared/defguard-ui/types'; import { openModal } from '../../../../../shared/hooks/modalControls/modalsSubjects'; import { ModalName } from '../../../../../shared/hooks/modalControls/modalTypes'; @@ -44,8 +45,20 @@ export const ProfileApiTokensTab = ({ availability, isLoading }: Props) => { const AvailableProfileApiTokensTab = () => { const username = useUserProfile((s) => s.user.username); + const isUserActive = useUserProfile((s) => s.user.is_active); const apiTokens = useUserProfile((s) => s.apiTokens); + const handleAddApiToken = () => { + if (!isUserActive) { + Snackbar.error(m.modal_add_api_token_disabled_user_error()); + return; + } + + openModal(ModalName.AddApiToken, { + username, + }); + }; + return ( <> {apiTokens.length === 0 && ( @@ -57,11 +70,7 @@ const AvailableProfileApiTokensTab = () => { iconLeft: 'add-token', testId: 'add-token', text: m.profile_api_tokens_add(), - onClick: () => { - openModal(ModalName.AddApiToken, { - username, - }); - }, + onClick: handleAddApiToken, }} /> )} @@ -72,11 +81,7 @@ const AvailableProfileApiTokensTab = () => {