diff --git a/.sqlx/query-8941a709b6223b3ef010a1c41098738c87ab132a63c1736382bca59e882c47e4.json b/.sqlx/query-97b1f59b98de39bdd76a0960447f900acda7960bd61c7bb3f2650a3b289526af.json similarity index 59% rename from .sqlx/query-8941a709b6223b3ef010a1c41098738c87ab132a63c1736382bca59e882c47e4.json rename to .sqlx/query-97b1f59b98de39bdd76a0960447f900acda7960bd61c7bb3f2650a3b289526af.json index b75191b17e..3f24c2dba7 100644 --- a/.sqlx/query-8941a709b6223b3ef010a1c41098738c87ab132a63c1736382bca59e882c47e4.json +++ b/.sqlx/query-97b1f59b98de39bdd76a0960447f900acda7960bd61c7bb3f2650a3b289526af.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT (SELECT count(*) FROM \"user\") \"users!\", (SELECT count(*) FROM device WHERE device_type = 'user') \"user_devices!\", (SELECT count(*) FROM device WHERE device_type = 'network') \"network_devices!\",\n (SELECT count(*) FROM wireguard_network) \"wireguard_networks!\"\n ", + "query": "SELECT (SELECT count(*) FROM \"user\" WHERE is_active) \"users!\", (SELECT count(*) FROM device WHERE device_type = 'user') \"user_devices!\", (SELECT count(*) FROM device WHERE device_type = 'network') \"network_devices!\",\n (SELECT count(*) FROM wireguard_network) \"wireguard_networks!\"\n ", "describe": { "columns": [ { @@ -34,5 +34,5 @@ null ] }, - "hash": "8941a709b6223b3ef010a1c41098738c87ab132a63c1736382bca59e882c47e4" + "hash": "97b1f59b98de39bdd76a0960447f900acda7960bd61c7bb3f2650a3b289526af" } diff --git a/crates/defguard_core/src/enterprise/limits.rs b/crates/defguard_core/src/enterprise/limits.rs index 046bb22234..72fc2761b9 100644 --- a/crates/defguard_core/src/enterprise/limits.rs +++ b/crates/defguard_core/src/enterprise/limits.rs @@ -22,7 +22,7 @@ pub async fn update_counts<'e, E: sqlx::PgExecutor<'e>>(executor: E) -> sqlx::Re debug!("Updating device, user, and wireguard network counts."); let result = query!( "SELECT \ - (SELECT count(*) FROM \"user\") \"users!\", \ + (SELECT count(*) FROM \"user\" WHERE is_active) \"users!\", \ (SELECT count(*) FROM device WHERE device_type = 'user') \"user_devices!\", \ (SELECT count(*) FROM device WHERE device_type = 'network') \"network_devices!\", (SELECT count(*) FROM wireguard_network) \"wireguard_networks!\" diff --git a/crates/defguard_core/src/handlers/user.rs b/crates/defguard_core/src/handlers/user.rs index 974e16cf0e..0e19485ea9 100644 --- a/crates/defguard_core/src/handlers/user.rs +++ b/crates/defguard_core/src/handlers/user.rs @@ -973,6 +973,20 @@ pub(crate) async fn modify_user( return Ok(ApiResponse::with_status(StatusCode::BAD_REQUEST)); } + // check if re-enabling a disabled user will go over license limits + if !user.is_active && user_info.is_active { + let user_count = get_counts().user(); + + if get_cached_license() + .as_ref() + .and_then(|l| l.limits.as_ref()) + .is_some_and(|l| user_count >= l.users) + { + error!("Enabling user {username} blocked! License limit reached."); + return Ok(WebError::Forbidden("License limit reached").into()); + } + } + // update VPN gateway config if user status or groups have changed group_diff = user_info .handle_user_groups(&mut transaction, &mut user) @@ -1003,6 +1017,9 @@ pub(crate) async fn modify_user( user.save(&mut *transaction).await?; transaction.commit().await?; + if status_changing { + update_counts(&appstate.pool).await?; + } let user_info = UserInfo::from_user( &appstate.pool, user.clone(), @@ -1770,6 +1787,24 @@ pub(crate) async fn bulk_enable_users( )); } + // check if enabling the requested users will go over license limits + let to_enable_count = users.iter().filter(|user| !user.is_active).count() as u32; + if to_enable_count > 0 { + let user_count = get_counts().user(); + + if get_cached_license() + .as_ref() + .and_then(|l| l.limits.as_ref()) + .is_some_and(|l| user_count + to_enable_count > l.users) + { + error!( + "User {} bulk-enabling users blocked! License limit reached.", + session.user.username + ); + return Ok(WebError::Forbidden("License limit reached").into()); + } + } + let mut events = Vec::with_capacity(users.len()); let mut transaction = appstate.pool.begin().await?; for user in users { @@ -1784,6 +1819,9 @@ pub(crate) async fn bulk_enable_users( events.push((before, user_to_enable)); } transaction.commit().await?; + if to_enable_count > 0 { + update_counts(&appstate.pool).await?; + } for (_, user) in &mut events { Box::pin(ldap_update_user_state( diff --git a/crates/defguard_core/src/user_management.rs b/crates/defguard_core/src/user_management.rs index 48308f4f83..89b3724872 100644 --- a/crates/defguard_core/src/user_management.rs +++ b/crates/defguard_core/src/user_management.rs @@ -88,6 +88,7 @@ pub async fn disable_user( ) -> Result<(), UserManagementError> { user.is_active = false; user.save(&mut *conn).await?; + update_counts(&mut *conn).await?; user.logout_all_sessions(&mut *conn).await?; sync_allowed_user_devices(user, conn, gateway_tx).await?; Ok(()) diff --git a/crates/defguard_core/tests/integration/api/user.rs b/crates/defguard_core/tests/integration/api/user.rs index 7de417fc96..f70f433e8e 100644 --- a/crates/defguard_core/tests/integration/api/user.rs +++ b/crates/defguard_core/tests/integration/api/user.rs @@ -1046,6 +1046,222 @@ async fn test_add_user_blocked_when_user_count_exceeds_license_limit( client.assert_event_queue_is_empty(); } +#[sqlx::test] +async fn test_disabled_users_not_counted_towards_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; + + // baseline is admin + hpotter, both active + let hpotter = get_db_user(&pool, "hpotter").await; + let response = client + .post("/api/v1/user/bulk-disable") + .json(&serde_json::json!({ "users": [hpotter.id] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + let license = get_cached_license().clone(); + set_cached_license(Some(License::new( + "test_customer".to_owned(), + false, + None, + Some(LicenseLimits { + users: 2, + devices: 100, + locations: 100, + network_devices: Some(100), + }), + None, + LicenseTier::Business, + SupportType::Basic, + vec![], + ))); + + // only admin is active, so there is still room under the limit of 2 + 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::CREATED); + + set_cached_license(license); +} + +#[sqlx::test] +async fn test_modify_user_enable_blocked_when_it_would_exceed_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; + + 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::CREATED); + let added_dumbledore = get_db_user(&pool, "adumbledore").await; + + // disable the new user so there is something to re-enable; active count drops back to 2 + let response = client + .post("/api/v1/user/bulk-disable") + .json(&serde_json::json!({ "users": [added_dumbledore.id] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let disabled_dumbledore = get_db_user(&pool, "adumbledore").await; + assert!(!disabled_dumbledore.is_active); + + let license = get_cached_license().clone(); + set_cached_license(Some(License::new( + "test_customer".to_owned(), + false, + None, + Some(LicenseLimits { + users: 2, + devices: 100, + locations: 100, + network_devices: Some(100), + }), + None, + LicenseTier::Business, + SupportType::Basic, + vec![], + ))); + + // active count is already at the limit of 2 (admin, hpotter), so re-enabling must be blocked + let mut user_details = fetch_user_details(&client, "adumbledore").await; + user_details.user.is_active = true; + let response = client + .put("/api/v1/user/adumbledore") + .json(&user_details.user) + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let still_disabled_dumbledore = get_db_user(&pool, "adumbledore").await; + assert!(!still_disabled_dumbledore.is_active); + + set_cached_license(license); + client.verify_api_events(&[ + ApiEventType::UserAdded { + user: added_dumbledore.clone(), + }, + ApiEventType::UserModified { + before: added_dumbledore, + after: disabled_dumbledore, + }, + ]); +} + +#[sqlx::test] +async fn test_bulk_enable_users_blocked_when_it_would_exceed_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; + + for (username, email) in [ + ("adumbledore", "a.dumbledore@hogwart.edu.uk"), + ("mmcgonagall", "m.mcgonagall@hogwart.edu.uk"), + ] { + let new_user = AddUserData { + username: username.into(), + last_name: format!("{username}-last"), + first_name: format!("{username}-first"), + email: email.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::CREATED); + } + + let added_dumbledore = get_db_user(&pool, "adumbledore").await; + let added_mcgonagall = get_db_user(&pool, "mmcgonagall").await; + + // disable both new users; active count drops back to 2 (admin, hpotter) + let response = client + .post("/api/v1/user/bulk-disable") + .json(&serde_json::json!({ "users": [added_dumbledore.id, added_mcgonagall.id] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let disabled_dumbledore = get_db_user(&pool, "adumbledore").await; + let disabled_mcgonagall = get_db_user(&pool, "mmcgonagall").await; + + let license = get_cached_license().clone(); + set_cached_license(Some(License::new( + "test_customer".to_owned(), + false, + None, + Some(LicenseLimits { + users: 3, + devices: 100, + locations: 100, + network_devices: Some(100), + }), + None, + LicenseTier::Business, + SupportType::Basic, + vec![], + ))); + + // active count is 2 (admin, hpotter); re-enabling both would bring it to 4, over the limit of 3 + let response = client + .post("/api/v1/user/bulk-enable") + .json(&serde_json::json!({ "users": [disabled_dumbledore.id, disabled_mcgonagall.id] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let still_disabled_dumbledore = get_db_user(&pool, "adumbledore").await; + let still_disabled_mcgonagall = get_db_user(&pool, "mmcgonagall").await; + assert!(!still_disabled_dumbledore.is_active); + assert!(!still_disabled_mcgonagall.is_active); + + set_cached_license(license); + client.verify_api_events(&[ + ApiEventType::UserAdded { + user: added_dumbledore.clone(), + }, + ApiEventType::UserAdded { + user: added_mcgonagall.clone(), + }, + ApiEventType::UserModified { + before: added_dumbledore, + after: disabled_dumbledore, + }, + ApiEventType::UserModified { + before: added_mcgonagall, + after: disabled_mcgonagall, + }, + ]); +} + #[sqlx::test] async fn test_check_username(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await;