From afe41640bccb42aa60486da8ac9ed6e2a37a80f5 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Thu, 14 Aug 2025 15:12:50 +0200 Subject: [PATCH 01/11] jumpcloud sync 1 --- .../enterprise/db/models/openid_provider.rs | 12 +- .../src/enterprise/directory_sync/google.rs | 10 +- .../enterprise/directory_sync/jumpcloud.rs | 677 +++++++++++++++ .../enterprise/directory_sync/microsoft.rs | 11 +- .../src/enterprise/directory_sync/mod.rs | 820 ++---------------- .../src/enterprise/directory_sync/okta.rs | 10 +- .../enterprise/directory_sync/testprovider.rs | 6 +- .../src/enterprise/directory_sync/tests.rs | 773 +++++++++++++++++ .../enterprise/handlers/openid_providers.rs | 2 + .../20250812112132_add_jumpcloud_key.down.sql | 1 + .../20250812112132_add_jumpcloud_key.up.sql | 1 + web/src/i18n/en/index.ts | 8 +- web/src/i18n/i18n-types.ts | 20 + web/src/i18n/pl/index.ts | 8 +- .../components/DirectorySyncSettings.tsx | 15 + .../components/OpenIdProviderSettings.tsx | 11 +- .../components/OpenIdSettingsForm.tsx | 11 + .../components/SupportedProviders.ts | 2 +- 18 files changed, 1612 insertions(+), 786 deletions(-) create mode 100644 crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs create mode 100644 crates/defguard_core/src/enterprise/directory_sync/tests.rs create mode 100644 migrations/20250812112132_add_jumpcloud_key.down.sql create mode 100644 migrations/20250812112132_add_jumpcloud_key.up.sql diff --git a/crates/defguard_core/src/enterprise/db/models/openid_provider.rs b/crates/defguard_core/src/enterprise/db/models/openid_provider.rs index e8f3f2e9d1..c84cc5cf5f 100644 --- a/crates/defguard_core/src/enterprise/db/models/openid_provider.rs +++ b/crates/defguard_core/src/enterprise/db/models/openid_provider.rs @@ -115,6 +115,7 @@ pub struct OpenIdProvider { #[model(ref)] // The groups to sync from the directory, exact match pub directory_sync_group_match: Vec, + pub jumpcloud_api_key: Option, } impl OpenIdProvider { @@ -136,6 +137,7 @@ impl OpenIdProvider { okta_private_jwk: Option, okta_dirsync_client_id: Option, directory_sync_group_match: Vec, + jumpcloud_api_key: Option, ) -> Self { Self { id: NoId, @@ -155,6 +157,7 @@ impl OpenIdProvider { okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match, + jumpcloud_api_key, } } @@ -166,8 +169,8 @@ impl OpenIdProvider { display_name = $5, google_service_account_key = $6, google_service_account_email = $7, admin_email = $8, \ directory_sync_enabled = $9, directory_sync_interval = $10, directory_sync_user_behavior = $11, \ directory_sync_admin_behavior = $12, directory_sync_target = $13, \ - okta_private_jwk = $14, okta_dirsync_client_id = $15, directory_sync_group_match = $16 \ - WHERE id = $17", + okta_private_jwk = $14, okta_dirsync_client_id = $15, directory_sync_group_match = $16, jumpcloud_api_key = $17 \ + WHERE id = $18", self.name, self.base_url, self.client_id, @@ -184,6 +187,7 @@ impl OpenIdProvider { self.okta_private_jwk, self.okta_dirsync_client_id, &self.directory_sync_group_match, + self.jumpcloud_api_key, provider.id, ) .execute(pool) @@ -208,7 +212,7 @@ impl OpenIdProvider { directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", \ directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", \ directory_sync_target \"directory_sync_target: DirectorySyncTarget\", \ - okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match \ + okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match, jumpcloud_api_key \ FROM openidprovider WHERE name = $1", name ) @@ -227,7 +231,7 @@ impl OpenIdProvider { directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", \ directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", \ directory_sync_target \"directory_sync_target: DirectorySyncTarget\", \ - okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match \ + okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match, jumpcloud_api_key \ FROM openidprovider LIMIT 1" ) .fetch_optional(executor) diff --git a/crates/defguard_core/src/enterprise/directory_sync/google.rs b/crates/defguard_core/src/enterprise/directory_sync/google.rs index b7f45601a1..4f7a5139fa 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/google.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/google.rs @@ -105,6 +105,7 @@ impl From for DirectoryUser { Self { email: val.primary_email, active: !val.suspended, + id: None, } } } @@ -425,17 +426,18 @@ impl DirectorySync for GoogleDirectorySync { async fn get_user_groups( &self, - user_id: &str, + user_email: &str, ) -> Result, DirectorySyncError> { - debug!("Getting groups of user {user_id}"); - let response = self.query_user_groups(user_id).await?; - debug!("Got groups response for user {user_id}"); + debug!("Getting groups of user {user_email}"); + let response = self.query_user_groups(user_email).await?; + debug!("Got groups response for user {user_email}"); Ok(response.groups) } async fn get_group_members( &self, group: &DirectoryGroup, + _all_users_helper: Option<&[DirectoryUser]>, ) -> Result, DirectorySyncError> { debug!("Getting group members of group {}", group.name); let response = self.query_group_members(group).await?; diff --git a/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs b/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs new file mode 100644 index 0000000000..3736e17aaf --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs @@ -0,0 +1,677 @@ +use std::collections::HashMap; + +use super::{DirectoryGroup, DirectorySync, DirectorySyncError, DirectoryUser, parse_response}; + +const GROUPS_URL: &str = "https://console.jumpcloud.com/api/v2/usergroups"; +// TODO: systemusers vs users? +const ALL_USERS_URL: &str = "https://console.jumpcloud.com/api/systemusers"; +const USER_GROUPS_URL: &str = "https://console.jumpcloud.com/api/v2/users//memberof"; +const USER_GROUP_MEMBERS_URL: &str = + "https://console.jumpcloud.com/api/v2/usergroups//members"; +const MAX_REQUESTS: usize = 50; +const MAX_RESULTS: &str = "100"; + +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "UPPERCASE")] +enum UserState { + Staged, + Activated, + Suspended, +} + +#[derive(Debug, Deserialize)] +struct User { + email: String, + activated: bool, + account_locked: bool, + id: String, + state: UserState, +} + +impl From for DirectoryUser { + fn from(user: User) -> Self { + DirectoryUser { + email: user.email, + active: user.activated && !user.account_locked && user.state == UserState::Activated, + id: Some(user.id), + } + } +} + +#[derive(Debug, Deserialize)] +struct UsersResponse { + results: Vec, + #[serde(rename = "totalCount")] + total_count: usize, +} + +impl From for Vec { + fn from(response: UsersResponse) -> Self { + response.results.into_iter().map(Into::into).collect() + } +} + +#[derive(Debug, Deserialize)] +struct GroupsResponse { + results: Vec, +} + +impl From for Vec { + fn from(response: GroupsResponse) -> Self { + response.results + } +} + +#[derive(Debug, Deserialize)] +struct LdapGroup { + name: String, +} + +#[derive(Debug, Deserialize)] +struct CompiledAttributes { + ldap_groups: Option>, +} + +#[derive(Debug, Deserialize)] +struct UserGroup { + id: String, + #[serde(rename = "type")] + group_type: String, + compiled_attributes: CompiledAttributes, +} + +impl From for DirectoryGroup { + fn from(group: UserGroup) -> Self { + let name = group + .compiled_attributes + .ldap_groups + .and_then(|groups| groups.into_iter().next()) + .map_or(group.id.clone(), |g| g.name); + DirectoryGroup { id: group.id, name } + } +} + +#[derive(Debug, Deserialize)] +struct GroupMember { + id: String, + #[serde(rename = "type")] + member_type: String, +} + +#[derive(Debug, Deserialize)] +struct GroupMemberThing { + to: GroupMember, +} + +pub(crate) struct JumpCloudDirectorySync { + api_key: String, +} + +impl JumpCloudDirectorySync { + #[must_use] + pub fn new(api_key: String) -> Self { + Self { api_key } + } + + async fn query_group_members( + &self, + group: &DirectoryGroup, + ) -> Result, DirectorySyncError> { + let client = reqwest::Client::new(); + let url = USER_GROUP_MEMBERS_URL.replace("", &group.id); + let mut query = HashMap::from([("limit", MAX_RESULTS.to_string())]); + + let response = client + .get(&url) + .header("x-api-key", &self.api_key) + .query(&query) + .send() + .await?; + let mut all_members_response: Vec = parse_response( + response, + "Failed to query group members from JumpCloud API.", + ) + .await?; + + for i in 1..MAX_REQUESTS { + query.insert( + "skip", + (i * MAX_RESULTS.parse::().unwrap()).to_string(), + ); + let response = client + .get(&url) + .header("x-api-key", &self.api_key) + .query(&query) + .send() + .await?; + + let members_response: Vec = parse_response( + response, + "Failed to query group members from JumpCloud API.", + ) + .await?; + if members_response.is_empty() { + break; + } else { + all_members_response.extend(members_response); + } + } + + debug!( + "Total members fetched for group {}: {}", + group.id, + all_members_response.len() + ); + Ok(all_members_response) + } + + async fn query_groups(&self) -> Result, DirectorySyncError> { + debug!("Starting to query groups from JumpCloud API"); + let client = reqwest::Client::new(); + + let mut query = HashMap::from([("limit", MAX_RESULTS.to_string())]); + debug!("Initial query parameters: {:?}", query); + + debug!("Sending initial request to: {}", GROUPS_URL); + let response = client + .get(GROUPS_URL) + .header("x-api-key", &self.api_key) + .query(&query) + .send() + .await?; + + debug!("Initial response status: {}", response.status()); + let mut all_groups_response: Vec = + parse_response(response, "Failed to query groups from JumpCloud API.").await?; + + debug!("Initial batch fetched {} groups", all_groups_response.len()); + + for i in 1..MAX_REQUESTS { + let skip_value = i * MAX_RESULTS.parse::().unwrap(); + query.insert("skip", skip_value.to_string()); + + debug!( + "Requesting page {} (skip: {}) from JumpCloud API", + i + 1, + skip_value + ); + + let response = client + .get(GROUPS_URL) + .header("x-api-key", &self.api_key) + .query(&query) + .send() + .await?; + + debug!("Page {} response status: {}", i + 1, response.status()); + let groups_response: Vec = + parse_response(response, "Failed to query groups from JumpCloud API.").await?; + + debug!("Page {} returned {} groups", i + 1, groups_response.len()); + + if groups_response.is_empty() { + debug!("No more groups found, stopping pagination"); + break; + } else { + all_groups_response.extend(groups_response); + debug!( + "Total groups accumulated so far: {}", + all_groups_response.len() + ); + } + } + + debug!("Total groups fetched: {}", all_groups_response.len()); + Ok(all_groups_response) + } + + async fn query_all_users(&self) -> Result { + let client = reqwest::Client::new(); + + let mut query = HashMap::from([("limit", MAX_RESULTS.to_string())]); + let response = client + .get(ALL_USERS_URL) + .header("x-api-key", &self.api_key) + .query(&query) + .send() + .await?; + + let mut all_users_response: UsersResponse = + parse_response(response, "Failed to query users from JumpCloud API.").await?; + + for i in 1..MAX_REQUESTS { + query.insert( + "skip", + (i * MAX_RESULTS.parse::().unwrap()).to_string(), + ); + let response = client + .get(ALL_USERS_URL) + .header("x-api-key", &self.api_key) + .query(&query) + .send() + .await?; + + let users_response: UsersResponse = + parse_response(response, "Failed to query users from JumpCloud API.").await?; + + if users_response.results.is_empty() { + break; + } else { + all_users_response.results.extend(users_response.results); + } + } + + Ok(all_users_response) + } + + async fn query_user_groups(&self, user_id: &str) -> Result, DirectorySyncError> { + let client = reqwest::Client::new(); + let url = USER_GROUPS_URL.replace("", user_id); + + let mut query = HashMap::from([("limit", MAX_RESULTS.to_string())]); + let response = client + .get(&url) + .header("x-api-key", &self.api_key) + .query(&query) + .send() + .await?; + + let mut all_groups_response: Vec = + parse_response(response, "Failed to query user groups from JumpCloud API.").await?; + + for i in 1..MAX_REQUESTS { + query.insert( + "skip", + (i * MAX_RESULTS.parse::().unwrap()).to_string(), + ); + let response = client + .get(&url) + .header("x-api-key", &self.api_key) + .query(&query) + .send() + .await?; + + let groups_response: Vec = + parse_response(response, "Failed to query user groups from JumpCloud API.").await?; + if groups_response.is_empty() { + break; + } else { + all_groups_response.extend(groups_response); + } + } + + debug!( + "Total groups fetched for user {}: {}", + user_id, + all_groups_response.len() + ); + Ok(all_groups_response) + } + + async fn query_test_connection(&self) -> Result<(), DirectorySyncError> { + let client = reqwest::Client::new(); + let response = client + .get(ALL_USERS_URL) + .header("x-api-key", &self.api_key) + .send() + .await?; + + let _: UsersResponse = + parse_response(response, "Failed to test connection to JumpCloud API.").await?; + Ok(()) + } + + async fn get_user_by_email( + &self, + email: &str, + ) -> Result, DirectorySyncError> { + let client = reqwest::Client::new(); + + let filter = format!("email:$eq:{email}"); + + debug!("Querying JumpCloud for user with email: {}", email); + debug!("Using filter: {}", filter); + + let response = client + .get(ALL_USERS_URL) + .header("x-api-key", &self.api_key) + .query(&[("filter", &filter)]) + .send() + .await?; + + if response.status().is_success() { + let mut users: UsersResponse = + parse_response(response, "Failed to query user by email.").await?; + + if users.total_count > 1 { + return Err(DirectorySyncError::MultipleUsersFound(format!( + "Multiple users found with email: {email}." + ))); + } + + if let Some(user) = users.results.pop() { + debug!("Found user: {:?}", user); + Ok(Some(user.into())) + } else { + debug!("No user found with email: {}", email); + Ok(None) + } + } else { + Err(DirectorySyncError::RequestError(format!( + "Failed to query user by email: {}. Status: {}. Details: {}", + email, + response.status(), + response + .text() + .await + .unwrap_or_else(|_| "No details".to_string()) + ))) + } + } +} + +impl DirectorySync for JumpCloudDirectorySync { + async fn get_groups(&self) -> Result, DirectorySyncError> { + debug!("Getting all groups"); + let response = self.query_groups().await?; + debug!("Got all groups response"); + Ok(response) + } + + async fn get_user_groups( + &self, + user_email: &str, + ) -> Result, DirectorySyncError> { + debug!("Getting groups of user {user_email}"); + if let Some(user) = self.get_user_by_email(user_email).await? { + if let Some(user_id) = user.id { + let response = self.query_user_groups(&user_id).await?; + debug!("Got groups response for user {user_id}"); + return Ok(response.into_iter().map(Into::into).collect()); + } + } + + debug!("No user found with email {user_email}, returning an error."); + Err(DirectorySyncError::UserNotFound(user_email.to_string())) + } + + async fn get_group_members( + &self, + group: &DirectoryGroup, + all_users_helper: Option<&[DirectoryUser]>, + ) -> Result, DirectorySyncError> { + debug!("Getting group members of group {}", group.name); + + let users: Vec; + + // extract all_users_helper, if its empty, return an error + let all_users = if let Some(users) = all_users_helper { + debug!("Using provided all users helper"); + users + } else { + debug!("No all users helper provided, forcing a query for all users as a fallback."); + users = self.query_all_users().await?.into(); + &users + }; + + let member_response = self + .query_group_members(group) + .await? + .into_iter() + .filter(|m| m.to.member_type == "user") + .collect::>(); + + let mut members = Vec::new(); + for member in member_response { + if let Some(user) = all_users + .iter() + .find(|u| u.id.as_deref() == Some(&member.to.id) && u.active) + { + members.push(user.email.clone()); + } else { + debug!( + "Skipping member with ID {} in group {} as they are not found in all users", + member.to.id, group.name + ); + } + } + debug!( + "Got group members response for group {}. Extracting their email addresses...", + group.name + ); + Ok(members) + } + + async fn prepare(&mut self) -> Result<(), DirectorySyncError> { + debug!("JumpCloud does not require any preparation steps, skipping."); + Ok(()) + } + + async fn get_all_users(&self) -> Result, DirectorySyncError> { + debug!("Getting all users"); + let response = self.query_all_users().await?; + debug!("Got all users response"); + Ok(response.into()) + } + + async fn test_connection(&self) -> Result<(), DirectorySyncError> { + debug!("Testing connection to Google API."); + self.query_test_connection().await?; + info!("Successfully tested connection to Google API, connection is working."); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_user_to_directory_user_conversions() { + // Test active user (activated=true, account_locked=false, state=ACTIVATED) + let active_user = User { + email: "active@example.com".to_string(), + activated: true, + account_locked: false, + id: "user123".to_string(), + state: UserState::Activated, + }; + let active_directory_user: DirectoryUser = active_user.into(); + assert_eq!(active_directory_user.email, "active@example.com"); + assert!(active_directory_user.active); + assert_eq!(active_directory_user.id, Some("user123".to_string())); + + // Test inactive user (activated=false) + let inactive_user = User { + email: "inactive@example.com".to_string(), + activated: false, + account_locked: false, + id: "user456".to_string(), + state: UserState::Activated, + }; + let inactive_directory_user: DirectoryUser = inactive_user.into(); + assert_eq!(inactive_directory_user.email, "inactive@example.com"); + assert!(!inactive_directory_user.active); + assert_eq!(inactive_directory_user.id, Some("user456".to_string())); + + // Test locked user (account_locked=true) + let locked_user = User { + email: "locked@example.com".to_string(), + activated: true, + account_locked: true, + id: "user789".to_string(), + state: UserState::Activated, + }; + let locked_directory_user: DirectoryUser = locked_user.into(); + assert_eq!(locked_directory_user.email, "locked@example.com"); + assert!(!locked_directory_user.active); + assert_eq!(locked_directory_user.id, Some("user789".to_string())); + + // Test suspended user (state=SUSPENDED) + let suspended_user = User { + email: "suspended@example.com".to_string(), + activated: true, + account_locked: false, + id: "user999".to_string(), + state: UserState::Suspended, + }; + let suspended_directory_user: DirectoryUser = suspended_user.into(); + assert_eq!(suspended_directory_user.email, "suspended@example.com"); + assert!(!suspended_directory_user.active); + assert_eq!(suspended_directory_user.id, Some("user999".to_string())); + + // Test staged user (state=STAGED) + let staged_user = User { + email: "staged@example.com".to_string(), + activated: true, + account_locked: false, + id: "user888".to_string(), + state: UserState::Staged, + }; + let staged_directory_user: DirectoryUser = staged_user.into(); + assert_eq!(staged_directory_user.email, "staged@example.com"); + assert!(!staged_directory_user.active); + assert_eq!(staged_directory_user.id, Some("user888".to_string())); + + // Test both inactive and locked user + let both_user = User { + email: "both@example.com".to_string(), + activated: false, + account_locked: true, + id: "user000".to_string(), + state: UserState::Activated, + }; + let both_directory_user: DirectoryUser = both_user.into(); + assert_eq!(both_directory_user.email, "both@example.com"); + assert!(!both_directory_user.active); + assert_eq!(both_directory_user.id, Some("user000".to_string())); + } + + #[test] + fn test_user_group_to_directory_group_conversions() { + // Test group with LDAP groups (uses first LDAP group name) + let group_with_ldap = UserGroup { + id: "group123".to_string(), + group_type: "user_group".to_string(), + compiled_attributes: CompiledAttributes { + ldap_groups: Some(vec![ + LdapGroup { + name: "LDAP Group Name".to_string(), + }, + LdapGroup { + name: "Second LDAP Group".to_string(), + }, + ]), + }, + }; + let directory_group_with_ldap: DirectoryGroup = group_with_ldap.into(); + assert_eq!(directory_group_with_ldap.id, "group123"); + assert_eq!(directory_group_with_ldap.name, "LDAP Group Name"); + + // Test group without LDAP groups (falls back to group ID) + let group_without_ldap = UserGroup { + id: "group456".to_string(), + group_type: "user_group".to_string(), + compiled_attributes: CompiledAttributes { ldap_groups: None }, + }; + let directory_group_without_ldap: DirectoryGroup = group_without_ldap.into(); + assert_eq!(directory_group_without_ldap.id, "group456"); + assert_eq!(directory_group_without_ldap.name, "group456"); + + // Test group with empty LDAP groups (falls back to group ID) + let group_empty_ldap = UserGroup { + id: "group789".to_string(), + group_type: "user_group".to_string(), + compiled_attributes: CompiledAttributes { + ldap_groups: Some(vec![]), + }, + }; + let directory_group_empty_ldap: DirectoryGroup = group_empty_ldap.into(); + assert_eq!(directory_group_empty_ldap.id, "group789"); + assert_eq!(directory_group_empty_ldap.name, "group789"); + } + + #[test] + fn test_response_collection_conversions() { + // Test empty UsersResponse conversion + let empty_users_response = UsersResponse { + results: vec![], + total_count: 0, + }; + let empty_directory_users: Vec = empty_users_response.into(); + assert!(empty_directory_users.is_empty()); + + // Test single user UsersResponse conversion + let single_users_response = UsersResponse { + results: vec![User { + email: "single@example.com".to_string(), + activated: true, + account_locked: false, + id: "single123".to_string(), + state: UserState::Activated, + }], + total_count: 1, + }; + let single_directory_users: Vec = single_users_response.into(); + assert_eq!(single_directory_users.len(), 1); + assert_eq!(single_directory_users[0].email, "single@example.com"); + assert!(single_directory_users[0].active); + assert_eq!(single_directory_users[0].id, Some("single123".to_string())); + + // Test multiple users with mixed states + let multiple_users_response = UsersResponse { + results: vec![ + User { + email: "user1@example.com".to_string(), + activated: true, + account_locked: false, + id: "user1".to_string(), + state: UserState::Activated, + }, + User { + email: "user2@example.com".to_string(), + activated: false, + account_locked: false, + id: "user2".to_string(), + state: UserState::Activated, + }, + User { + email: "user3@example.com".to_string(), + activated: true, + account_locked: true, + id: "user3".to_string(), + state: UserState::Activated, + }, + ], + total_count: 3, + }; + let multiple_directory_users: Vec = multiple_users_response.into(); + assert_eq!(multiple_directory_users.len(), 3); + assert_eq!(multiple_directory_users[0].email, "user1@example.com"); + assert!(multiple_directory_users[0].active); + assert_eq!(multiple_directory_users[1].email, "user2@example.com"); + assert!(!multiple_directory_users[1].active); + assert_eq!(multiple_directory_users[2].email, "user3@example.com"); + assert!(!multiple_directory_users[2].active); + + // Test GroupsResponse conversion + let groups_response = GroupsResponse { + results: vec![ + DirectoryGroup { + id: "group1".to_string(), + name: "Group 1".to_string(), + }, + DirectoryGroup { + id: "group2".to_string(), + name: "Group 2".to_string(), + }, + ], + }; + let directory_groups: Vec = groups_response.into(); + assert_eq!(directory_groups.len(), 2); + assert_eq!(directory_groups[0].id, "group1"); + assert_eq!(directory_groups[0].name, "Group 1"); + assert_eq!(directory_groups[1].id, "group2"); + assert_eq!(directory_groups[1].name, "Group 2"); + } +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/microsoft.rs b/crates/defguard_core/src/enterprise/directory_sync/microsoft.rs index 0f1044bc37..a1399a81e0 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/microsoft.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/microsoft.rs @@ -125,10 +125,10 @@ impl From for Vec { .into_iter() .filter_map(|user| { if let Some(email) = user.mail { - Some(DirectoryUser { email, active: user.account_enabled }) + Some(DirectoryUser { email, active: user.account_enabled, id: None }) } else if let Some(email) = user.other_mails.into_iter().next() { warn!("User {} doesn't have a primary email address set, his first additional email address will be used: {email}", user.display_name); - Some(DirectoryUser { email, active: user.account_enabled }) + Some(DirectoryUser { email, active: user.account_enabled, id: None }) } else { warn!("User {} doesn't have any email address and will be skipped in synchronization.", user.display_name); None @@ -499,10 +499,10 @@ impl DirectorySync for MicrosoftDirectorySync { async fn get_user_groups( &self, - user_id: &str, + user_email: &str, ) -> Result, DirectorySyncError> { - debug!("Querying groups of user: {user_id}"); - let groups = self.query_user_groups(user_id).await?; + debug!("Querying groups of user: {user_email}"); + let groups = self.query_user_groups(user_email).await?; debug!("User groups queried successfully."); Ok(groups.into()) } @@ -510,6 +510,7 @@ impl DirectorySync for MicrosoftDirectorySync { async fn get_group_members( &self, group: &DirectoryGroup, + _all_users_helper: Option<&[DirectoryUser]>, ) -> Result, DirectorySyncError> { debug!("Querying members of group: {}", group.name); let members = self.query_group_members(group).await?; diff --git a/crates/defguard_core/src/enterprise/directory_sync/mod.rs b/crates/defguard_core/src/enterprise/directory_sync/mod.rs index fef3db04c3..69d1830e02 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/mod.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/mod.rs @@ -79,10 +79,13 @@ impl From for DirectorySyncError { } pub mod google; +pub mod jumpcloud; pub mod microsoft; pub mod okta; #[cfg(test)] pub mod testprovider; +#[cfg(test)] +pub mod tests; #[derive(Debug, Serialize, Deserialize)] pub struct DirectoryGroup { @@ -92,6 +95,7 @@ pub struct DirectoryGroup { #[derive(Debug, Serialize, Deserialize)] pub struct DirectoryUser { + pub id: Option, pub email: String, // Users may be disabled/suspended in the directory pub active: bool, @@ -106,13 +110,16 @@ trait DirectorySync { /// Get all groups a user is a member of async fn get_user_groups( &self, - user_id: &str, + user_email: &str, ) -> Result, DirectorySyncError>; - /// Get all members of a group + /// Get all members of a group, returns a list of user emails async fn get_group_members( &self, group: &DirectoryGroup, + // Some providers (JumpCloud) doesn't return emails of group members, just ids. + // In such cases, we can use the list of all users in the directory to map ids to emails. + all_users_helper: Option<&[DirectoryUser]>, ) -> Result, DirectorySyncError>; /// Prepare the directory sync client, e.g. get an access token @@ -155,18 +162,20 @@ macro_rules! dirsync_clients { } } - async fn get_user_groups(&self, user_id: &str) -> Result, DirectorySyncError> { + async fn get_user_groups(&self, user_email: &str) -> Result, DirectorySyncError> { match self { $( - DirectorySyncClient::$variant(client) => client.get_user_groups(user_id).await, + DirectorySyncClient::$variant(client) => client.get_user_groups(user_email).await, )* } } - async fn get_group_members(&self, group: &DirectoryGroup) -> Result, DirectorySyncError> { + async fn get_group_members(&self, group: &DirectoryGroup, + all_users_helper: Option<&[DirectoryUser]>, + ) -> Result, DirectorySyncError> { match self { $( - DirectorySyncClient::$variant(client) => client.get_group_members(group).await, + DirectorySyncClient::$variant(client) => client.get_group_members(group, all_users_helper).await, )* } } @@ -199,10 +208,10 @@ macro_rules! dirsync_clients { } #[cfg(test)] -dirsync_clients!(Google, Microsoft, Okta, TestProvider); +dirsync_clients!(Google, Microsoft, Okta, TestProvider, JumpCloud); #[cfg(not(test))] -dirsync_clients!(Google, Microsoft, Okta); +dirsync_clients!(Google, Microsoft, Okta, JumpCloud); impl DirectorySyncClient { /// Builds the current directory sync client based on the current provider settings (provider name), if possible. @@ -260,6 +269,19 @@ impl DirectorySyncClient { )) } } + "JumpCloud" => { + debug!("JumpCloud directory sync provider selected"); + if let Some(key) = provider_settings.jumpcloud_api_key.as_ref() { + debug!( + "JumpCloud directory has all the configuration needed, proceeding with creating the sync client" + ); + let client = jumpcloud::JumpCloudDirectorySync::new(key.clone()); + debug!("JumpCloud directory sync client created"); + Ok(Self::JumpCloud(client)) + } else { + Err(DirectorySyncError::NotConfigured) + } + } #[cfg(test)] "Test" => Ok(Self::TestProvider(testprovider::TestProviderDirectorySync)), _ => Err(DirectorySyncError::UnsupportedProvider( @@ -431,6 +453,7 @@ async fn sync_all_users_groups( directory_sync: &T, pool: &PgPool, wg_tx: &Sender, + all_users: Option<&[DirectoryUser]>, ) -> Result<(), DirectorySyncError> { info!("Syncing all users' groups with the directory, this may take a while..."); let directory_groups = directory_sync.get_groups().await?; @@ -443,7 +466,7 @@ async fn sync_all_users_groups( "Beggining a construction of user-group mapping which will be applied later to Defguard" ); for group in &directory_groups { - match directory_sync.get_group_members(group).await { + match directory_sync.get_group_members(group, all_users).await { Ok(members) => { debug!( "Group {} has {} members in the directory, adding them to the user-group mapping", @@ -559,10 +582,10 @@ async fn sync_all_users_state( directory_sync: &T, pool: &PgPool, wg_tx: &Sender, + all_users: &[DirectoryUser], ) -> Result<(), DirectorySyncError> { info!("Syncing all users' state with the directory, this may take a while..."); let mut transaction = pool.begin().await?; - let all_users = directory_sync.get_all_users().await?; let settings = OpenIdProvider::get_current(pool) .await? .ok_or(DirectorySyncError::NotConfigured)?; @@ -815,17 +838,38 @@ pub(crate) async fn do_directory_sync( // Same goes for Etags, those could be used to reduce the amount of data transferred. Some way // of preserving them should be implemented. dir_sync.prepare().await?; + + // This is an optimization, both sync_all_users_state and sync_all_users_groups depend on it so we might + // as well get all users once and pass it to both functions. + let mut all_users = None; + if matches!( sync_target, DirectorySyncTarget::All | DirectorySyncTarget::Users ) { - sync_all_users_state(&dir_sync, pool, wireguard_tx).await?; + let users = dir_sync.get_all_users().await?; + sync_all_users_state(&dir_sync, pool, wireguard_tx, &users).await?; + all_users = Some(users); } if matches!( sync_target, DirectorySyncTarget::All | DirectorySyncTarget::Groups ) { - sync_all_users_groups(&dir_sync, pool, wireguard_tx).await?; + // Sometimes we don't even need to query all users, this is an optimization to reduce the amount of data transferred. + let users_to_pass = match dir_sync { + DirectorySyncClient::JumpCloud(_) => { + if all_users.is_none() { + // JumpCloud doesn't return emails of group members, so we need to pass all users + // to the get_user_groups method to map ids to emails. + Some(dir_sync.get_all_users().await?) + } else { + all_users + } + } + _ => None, // No need to pass all users for other providers, for the time being. + }; + sync_all_users_groups(&dir_sync, pool, wireguard_tx, users_to_pass.as_deref()) + .await?; } } Err(err) => { @@ -895,755 +939,3 @@ async fn make_get_request( ))), } } - -#[cfg(test)] -mod test { - use std::str::FromStr; - - use ipnetwork::IpNetwork; - use secrecy::ExposeSecret; - use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; - use tokio::sync::broadcast; - - use super::*; - use crate::{ - SERVER_CONFIG, - config::DefGuardConfig, - db::{ - Device, Session, SessionState, Settings, WireguardNetwork, - models::{ - device::DeviceType, settings::initialize_current_settings, - wireguard::LocationMfaMode, - }, - setup_pool, - }, - enterprise::db::models::openid_provider::DirectorySyncTarget, - }; - - async fn get_test_network(pool: &PgPool) -> WireguardNetwork { - WireguardNetwork::find_by_name(pool, "test") - .await - .unwrap() - .unwrap() - .pop() - .unwrap() - } - - async fn make_test_provider( - pool: &PgPool, - user_behavior: DirectorySyncUserBehavior, - admin_behavior: DirectorySyncUserBehavior, - target: DirectorySyncTarget, - ) -> OpenIdProvider { - Settings::init_defaults(pool).await.unwrap(); - initialize_current_settings(pool).await.unwrap(); - - let current = OpenIdProvider::get_current(pool).await.unwrap(); - - if let Some(provider) = current { - provider.delete(pool).await.unwrap(); - } - - WireguardNetwork::new( - "test".to_string(), - vec![IpNetwork::from_str("10.10.10.1/24").unwrap()], - 1234, - "123.123.123.123".to_string(), - None, - vec![], - 32, - 32, - false, - false, - LocationMfaMode::Disabled, - ) - .unwrap() - .save(pool) - .await - .unwrap(); - - OpenIdProvider::new( - "Test".to_string(), - "base_url".to_string(), - "client_id".to_string(), - "client_secret".to_string(), - Some("display_name".to_string()), - Some("google_service_account_key".to_string()), - Some("google_service_account_email".to_string()), - Some("admin_email".to_string()), - true, - 60, - user_behavior, - admin_behavior, - target, - None, - None, - vec![], - ) - .save(pool) - .await - .unwrap() - } - - async fn make_test_user_and_device(name: &str, pool: &PgPool) -> User { - let user = User::new( - name, - None, - "lastname", - "firstname", - format!("{name}@email.com").as_str(), - None, - ) - .save(pool) - .await - .unwrap(); - - let dev = Device::new( - format!("{name}-device"), - format!("{name}-key"), - user.id, - DeviceType::User, - None, - true, - ) - .save(pool) - .await - .unwrap(); - - let mut transaction = pool.begin().await.unwrap(); - dev.add_to_all_networks(&mut transaction).await.unwrap(); - transaction.commit().await.unwrap(); - - user - } - - async fn get_test_user(pool: &PgPool, name: &str) -> Option> { - User::find_by_username(pool, name).await.unwrap() - } - - async fn make_admin(pool: &PgPool, user: &User) { - let admin_group = Group::find_by_name(pool, "admin").await.unwrap().unwrap(); - user.add_to_group(pool, &admin_group).await.unwrap(); - } - - // Keep both users and admins - #[sqlx::test] - async fn test_users_state_keep_both(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, mut wg_rx) = broadcast::channel::(16); - make_test_provider( - &pool, - DirectorySyncUserBehavior::Keep, - DirectorySyncUserBehavior::Keep, - DirectorySyncTarget::All, - ) - .await; - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - let user1 = make_test_user_and_device("user1", &pool).await; - make_test_user_and_device("user2", &pool).await; - make_test_user_and_device("testuser", &pool).await; - make_admin(&pool, &user1).await; - - assert!(get_test_user(&pool, "user1").await.is_some()); - assert!(get_test_user(&pool, "user2").await.is_some()); - assert!(get_test_user(&pool, "testuser").await.is_some()); - - sync_all_users_state(&client, &pool, &wg_tx).await.unwrap(); - - assert!(get_test_user(&pool, "user1").await.is_some()); - assert!(get_test_user(&pool, "user2").await.is_some()); - assert!(get_test_user(&pool, "testuser").await.is_some()); - - // No events - assert!(wg_rx.try_recv().is_err()); - } - - // Delete users, keep admins - #[sqlx::test] - async fn test_users_state_delete_users(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, mut wg_rx) = broadcast::channel::(16); - make_test_provider( - &pool, - DirectorySyncUserBehavior::Delete, - DirectorySyncUserBehavior::Keep, - DirectorySyncTarget::All, - ) - .await; - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - - let user1 = make_test_user_and_device("user1", &pool).await; - let user2 = make_test_user_and_device("user2", &pool).await; - make_test_user_and_device("testuser", &pool).await; - make_admin(&pool, &user1).await; - - assert!(get_test_user(&pool, "user1").await.is_some()); - assert!(get_test_user(&pool, "user2").await.is_some()); - assert!(get_test_user(&pool, "testuser").await.is_some()); - - sync_all_users_state(&client, &pool, &wg_tx).await.unwrap(); - - assert!(get_test_user(&pool, "user1").await.is_some()); - assert!(get_test_user(&pool, "user2").await.is_none()); - assert!(get_test_user(&pool, "testuser").await.is_some()); - - let event = wg_rx.try_recv(); - if let Ok(GatewayEvent::DeviceDeleted(dev)) = event { - assert_eq!(dev.device.user_id, user2.id); - } else { - panic!("Expected a DeviceDeleted event"); - } - } - #[sqlx::test] - async fn test_users_state_delete_admins(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, mut wg_rx) = broadcast::channel::(16); - User::init_admin_user(&pool, config.default_admin_password.expose_secret()) - .await - .unwrap(); - - let _ = make_test_provider( - &pool, - DirectorySyncUserBehavior::Keep, - DirectorySyncUserBehavior::Delete, - DirectorySyncTarget::All, - ) - .await; - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - - let user1 = make_test_user_and_device("user1", &pool).await; - make_test_user_and_device("user2", &pool).await; - let user3 = make_test_user_and_device("user3", &pool).await; - make_test_user_and_device("testuser", &pool).await; - make_admin(&pool, &user1).await; - make_admin(&pool, &user3).await; - - assert!(get_test_user(&pool, "user1").await.is_some()); - assert!(get_test_user(&pool, "user2").await.is_some()); - assert!(get_test_user(&pool, "testuser").await.is_some()); - sync_all_users_state(&client, &pool, &wg_tx).await.unwrap(); - - assert!( - get_test_user(&pool, "user1").await.is_none() - || get_test_user(&pool, "user3").await.is_none() - ); - assert!( - get_test_user(&pool, "user1").await.is_some() - || get_test_user(&pool, "user3").await.is_some() - ); - assert!(get_test_user(&pool, "user2").await.is_some()); - assert!(get_test_user(&pool, "testuser").await.is_some()); - - // Check that we received a device deleted event for whichever admin was removed - let event = wg_rx.try_recv(); - if let Ok(GatewayEvent::DeviceDeleted(dev)) = event { - assert!(dev.device.user_id == user1.id || dev.device.user_id == user3.id); - } else { - panic!("Expected a DeviceDeleted event"); - } - } - - #[sqlx::test] - async fn test_users_state_delete_both(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, mut wg_rx) = broadcast::channel::(16); - make_test_provider( - &pool, - DirectorySyncUserBehavior::Delete, - DirectorySyncUserBehavior::Delete, - DirectorySyncTarget::All, - ) - .await; - User::init_admin_user(&pool, config.default_admin_password.expose_secret()) - .await - .unwrap(); - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - - let user1 = make_test_user_and_device("user1", &pool).await; - let user2 = make_test_user_and_device("user2", &pool).await; - let user3 = make_test_user_and_device("user3", &pool).await; - make_test_user_and_device("testuser", &pool).await; - make_admin(&pool, &user1).await; - make_admin(&pool, &user3).await; - - assert!(get_test_user(&pool, "user1").await.is_some()); - assert!(get_test_user(&pool, "user2").await.is_some()); - assert!(get_test_user(&pool, "testuser").await.is_some()); - sync_all_users_state(&client, &pool, &wg_tx).await.unwrap(); - - assert!( - get_test_user(&pool, "user1").await.is_none() - || get_test_user(&pool, "user3").await.is_none() - ); - assert!( - get_test_user(&pool, "user1").await.is_some() - || get_test_user(&pool, "user3").await.is_some() - ); - assert!(get_test_user(&pool, "user2").await.is_none()); - assert!(get_test_user(&pool, "testuser").await.is_some()); - - // Check for device deletion events - let event1 = wg_rx.try_recv(); - if let Ok(GatewayEvent::DeviceDeleted(dev)) = event1 { - assert!( - dev.device.user_id == user1.id - || dev.device.user_id == user2.id - || dev.device.user_id == user3.id - ); - } else { - panic!("Expected a DeviceDeleted event"); - } - - let event2 = wg_rx.try_recv(); - if let Ok(GatewayEvent::DeviceDeleted(dev)) = event2 { - assert!( - dev.device.user_id == user1.id - || dev.device.user_id == user2.id - || dev.device.user_id == user3.id - ); - } else { - panic!("Expected a DeviceDeleted event"); - } - } - - #[sqlx::test] - async fn test_users_state_disable_users(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, mut wg_rx) = broadcast::channel::(16); - make_test_provider( - &pool, - DirectorySyncUserBehavior::Disable, - DirectorySyncUserBehavior::Keep, - DirectorySyncTarget::All, - ) - .await; - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - - let user1 = make_test_user_and_device("user1", &pool).await; - make_test_user_and_device("user2", &pool).await; - make_test_user_and_device("testuser", &pool).await; - make_test_user_and_device("testuserdisabled", &pool).await; - make_admin(&pool, &user1).await; - - let user1 = get_test_user(&pool, "user1").await.unwrap(); - let user2 = get_test_user(&pool, "user2").await.unwrap(); - let testuser = get_test_user(&pool, "testuser").await.unwrap(); - let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); - let disabled_user_session = Session::new( - testuserdisabled.id, - SessionState::PasswordVerified, - "127.0.0.1".into(), - None, - ); - disabled_user_session.save(&pool).await.unwrap(); - assert!( - Session::find_by_id(&pool, &disabled_user_session.id) - .await - .unwrap() - .is_some() - ); - - assert!(user1.is_active); - assert!(user2.is_active); - assert!(testuser.is_active); - assert!(testuserdisabled.is_active); - - sync_all_users_state(&client, &pool, &wg_tx).await.unwrap(); - - // Check for device disconnection events - let event1 = wg_rx.try_recv(); - if let Ok(GatewayEvent::DeviceDeleted(dev)) = event1 { - assert!(dev.device.user_id == user2.id || dev.device.user_id == testuserdisabled.id); - } else { - panic!("Expected a DeviceDisconnected event"); - } - - let event2 = wg_rx.try_recv(); - if let Ok(GatewayEvent::DeviceDeleted(dev)) = event2 { - assert!(dev.device.user_id == user2.id || dev.device.user_id == testuserdisabled.id); - } else { - panic!("Expected a DeviceDisconnected event"); - } - - let user1 = get_test_user(&pool, "user1").await.unwrap(); - let user2 = get_test_user(&pool, "user2").await.unwrap(); - let testuser = get_test_user(&pool, "testuser").await.unwrap(); - let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); - - assert!( - Session::find_by_id(&pool, &disabled_user_session.id) - .await - .unwrap() - .is_none() - ); - assert!(user1.is_active); - assert!(!user2.is_active); - assert!(testuser.is_active); - assert!(!testuserdisabled.is_active); - } - #[sqlx::test] - async fn test_users_state_disable_admins(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, mut wg_rx) = broadcast::channel::(16); // Added mut wg_rx - make_test_provider( - &pool, - DirectorySyncUserBehavior::Keep, - DirectorySyncUserBehavior::Disable, - DirectorySyncTarget::All, - ) - .await; - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - - let user1 = make_test_user_and_device("user1", &pool).await; - make_test_user_and_device("user2", &pool).await; - let user3 = make_test_user_and_device("user3", &pool).await; - make_test_user_and_device("testuser", &pool).await; - make_test_user_and_device("testuserdisabled", &pool).await; - make_admin(&pool, &user1).await; - make_admin(&pool, &user3).await; - - let user1 = get_test_user(&pool, "user1").await.unwrap(); - let user2 = get_test_user(&pool, "user2").await.unwrap(); - let testuser = get_test_user(&pool, "testuser").await.unwrap(); - let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); - - assert!(user1.is_active); - assert!(user2.is_active); - assert!(user3.is_active); - assert!(testuser.is_active); - assert!(testuserdisabled.is_active); - - sync_all_users_state(&client, &pool, &wg_tx).await.unwrap(); - - // Check for device disconnection events - let event1 = wg_rx.try_recv(); - if let Ok(GatewayEvent::DeviceDeleted(dev)) = event1 { - assert!( - dev.device.user_id == user1.id - || dev.device.user_id == user3.id - || dev.device.user_id == testuserdisabled.id - ); - } else { - panic!("Expected a DeviceDisconnected event"); - } - - let event2 = wg_rx.try_recv(); - if let Ok(GatewayEvent::DeviceDeleted(dev)) = event2 { - assert!( - dev.device.user_id == user1.id - || dev.device.user_id == user3.id - || dev.device.user_id == testuserdisabled.id - ); - } else { - panic!("Expected a DeviceDisconnected event"); - } - - let user1 = get_test_user(&pool, "user1").await.unwrap(); - let user2 = get_test_user(&pool, "user2").await.unwrap(); - let user3 = get_test_user(&pool, "user3").await.unwrap(); - let testuser = get_test_user(&pool, "testuser").await.unwrap(); - let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); - - assert!(!user1.is_active || !user3.is_active); - assert!(user1.is_active || user3.is_active); - assert!(user2.is_active); - assert!(testuser.is_active); - assert!(!testuserdisabled.is_active); - } - - #[sqlx::test] - async fn test_users_groups(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, _) = broadcast::channel::(16); - make_test_provider( - &pool, - DirectorySyncUserBehavior::Delete, - DirectorySyncUserBehavior::Delete, - DirectorySyncTarget::All, - ) - .await; - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - - make_test_user_and_device("testuser", &pool).await; - make_test_user_and_device("testuser2", &pool).await; - make_test_user_and_device("testuserdisabled", &pool).await; - sync_all_users_groups(&client, &pool, &wg_tx).await.unwrap(); - - let mut groups = Group::all(&pool).await.unwrap(); - - let testuser = get_test_user(&pool, "testuser").await.unwrap(); - let testuser2 = get_test_user(&pool, "testuser2").await.unwrap(); - let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); - - let testuser_groups = testuser.member_of(&pool).await.unwrap(); - let testuser2_groups = testuser2.member_of(&pool).await.unwrap(); - let testuserdisabled_groups = testuserdisabled.member_of(&pool).await.unwrap(); - - assert_eq!(testuser_groups.len(), 3); - assert_eq!(testuser2_groups.len(), 3); - assert_eq!(testuserdisabled_groups.len(), 3); - groups.sort_by(|a, b| a.name.cmp(&b.name)); - - let group_present = - |groups: &Vec>, name: &str| groups.iter().any(|g| g.name == name); - - assert!(group_present(&testuser_groups, "group1")); - assert!(group_present(&testuser_groups, "group2")); - assert!(group_present(&testuser_groups, "group3")); - - assert!(group_present(&testuser2_groups, "group1")); - assert!(group_present(&testuser2_groups, "group2")); - assert!(group_present(&testuser2_groups, "group3")); - - assert!(group_present(&testuserdisabled_groups, "group1")); - assert!(group_present(&testuserdisabled_groups, "group2")); - assert!(group_present(&testuserdisabled_groups, "group3")); - } - - #[sqlx::test] - async fn test_sync_user_groups(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, _) = broadcast::channel::(16); - make_test_provider( - &pool, - DirectorySyncUserBehavior::Delete, - DirectorySyncUserBehavior::Delete, - DirectorySyncTarget::All, - ) - .await; - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - let user = make_test_user_and_device("testuser", &pool).await; - let user_groups = user.member_of(&pool).await.unwrap(); - assert_eq!(user_groups.len(), 0); - sync_user_groups_if_configured(&user, &pool, &wg_tx) - .await - .unwrap(); - let user_groups = user.member_of(&pool).await.unwrap(); - assert_eq!(user_groups.len(), 1); - let group = Group::find_by_name(&pool, "group1").await.unwrap().unwrap(); - assert_eq!(user_groups[0].id, group.id); - } - - #[sqlx::test] - async fn test_sync_target_users(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, _) = broadcast::channel::(16); - make_test_provider( - &pool, - DirectorySyncUserBehavior::Delete, - DirectorySyncUserBehavior::Delete, - DirectorySyncTarget::Users, - ) - .await; - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - let user = make_test_user_and_device("testuser", &pool).await; - let user_groups = user.member_of(&pool).await.unwrap(); - assert_eq!(user_groups.len(), 0); - do_directory_sync(&pool, &wg_tx).await.unwrap(); - let user_groups = user.member_of(&pool).await.unwrap(); - assert_eq!(user_groups.len(), 0); - } - - #[sqlx::test] - async fn test_sync_target_all(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, mut wg_rx) = broadcast::channel::(16); - make_test_provider( - &pool, - DirectorySyncUserBehavior::Delete, - DirectorySyncUserBehavior::Delete, - DirectorySyncTarget::All, - ) - .await; - let network = get_test_network(&pool).await; - let mut transaction = pool.begin().await.unwrap(); - let group = Group::new("group1".to_string()) - .save(&mut *transaction) - .await - .unwrap(); - network - .set_allowed_groups(&mut transaction, vec![group.name]) - .await - .unwrap(); - transaction.commit().await.unwrap(); - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - let user = make_test_user_and_device("testuser", &pool).await; - let user2_pre_sync = make_test_user_and_device("user2", &pool).await; - let user_groups = user.member_of(&pool).await.unwrap(); - assert_eq!(user_groups.len(), 0); - do_directory_sync(&pool, &wg_tx).await.unwrap(); - let user_groups = user.member_of(&pool).await.unwrap(); - assert_eq!(user_groups.len(), 3); - let user2 = get_test_user(&pool, "user2").await; - assert!(user2.is_none()); - let mut transaction = pool.begin().await.unwrap(); - user.sync_allowed_devices(&mut transaction, &wg_tx) - .await - .unwrap(); - transaction.commit().await.unwrap(); - let event = wg_rx.try_recv(); - if let Ok(GatewayEvent::DeviceDeleted(dev)) = event { - assert_eq!(dev.device.user_id, user2_pre_sync.id); - } else { - panic!("Expected a DeviceDeleted event"); - } - let event = wg_rx.try_recv(); - if let Ok(GatewayEvent::DeviceCreated(dev)) = event { - assert_eq!(dev.device.user_id, user.id); - } else { - panic!("Expected a DeviceDeleted event"); - } - } - - #[sqlx::test] - async fn test_sync_target_groups(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, _) = broadcast::channel::(16); - make_test_provider( - &pool, - DirectorySyncUserBehavior::Delete, - DirectorySyncUserBehavior::Delete, - DirectorySyncTarget::Groups, - ) - .await; - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - let user = make_test_user_and_device("testuser", &pool).await; - make_test_user_and_device("user2", &pool).await; - let user_groups = user.member_of(&pool).await.unwrap(); - assert_eq!(user_groups.len(), 0); - do_directory_sync(&pool, &wg_tx).await.unwrap(); - let user_groups = user.member_of(&pool).await.unwrap(); - assert_eq!(user_groups.len(), 3); - let user2 = get_test_user(&pool, "user2").await; - assert!(user2.is_some()); - } - - #[sqlx::test] - async fn test_sync_unassign_last_admin_group(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, _) = broadcast::channel::(16); - make_test_provider( - &pool, - DirectorySyncUserBehavior::Delete, - DirectorySyncUserBehavior::Delete, - DirectorySyncTarget::All, - ) - .await; - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - - // Make one admin and check if he's deleted - let user = make_test_user_and_device("testuser", &pool).await; - let admin_grp = Group::find_by_name(&pool, "admin").await.unwrap().unwrap(); - user.add_to_group(&pool, &admin_grp).await.unwrap(); - let user_groups = user.member_of(&pool).await.unwrap(); - assert_eq!(user_groups.len(), 1); - assert!(user.is_admin(&pool).await.unwrap()); - - do_directory_sync(&pool, &wg_tx).await.unwrap(); - - // He should still be an admin as it's the last one - assert!(user.is_admin(&pool).await.unwrap()); - - // Make another admin and check if one of them is deleted - let user2 = make_test_user_and_device("testuser2", &pool).await; - user2.add_to_group(&pool, &admin_grp).await.unwrap(); - - do_directory_sync(&pool, &wg_tx).await.unwrap(); - - let admins = User::find_admins(&pool).await.unwrap(); - // There should be only one admin left - assert_eq!(admins.len(), 1); - - let defguard_user = make_test_user_and_device("defguard", &pool).await; - make_admin(&pool, &defguard_user).await; - - do_directory_sync(&pool, &wg_tx).await.unwrap(); - } - - #[sqlx::test] - async fn test_sync_delete_last_admin_user(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, _) = broadcast::channel::(16); - make_test_provider( - &pool, - DirectorySyncUserBehavior::Delete, - DirectorySyncUserBehavior::Delete, - DirectorySyncTarget::All, - ) - .await; - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - - // a user that's not in the directory - let defguard_user = make_test_user_and_device("defguard", &pool).await; - make_admin(&pool, &defguard_user).await; - assert!(defguard_user.is_admin(&pool).await.unwrap()); - - do_directory_sync(&pool, &wg_tx).await.unwrap(); - - // The user should still be an admin - assert!(defguard_user.is_admin(&pool).await.unwrap()); - - // remove his admin status - let admin_grp = Group::find_by_name(&pool, "admin").await.unwrap().unwrap(); - defguard_user - .remove_from_group(&pool, &admin_grp) - .await - .unwrap(); - - do_directory_sync(&pool, &wg_tx).await.unwrap(); - let user = User::find_by_username(&pool, "defguard").await.unwrap(); - assert!(user.is_none()); - } -} diff --git a/crates/defguard_core/src/enterprise/directory_sync/okta.rs b/crates/defguard_core/src/enterprise/directory_sync/okta.rs index cc3f2bf33e..bbc168fe16 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/okta.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/okta.rs @@ -95,6 +95,7 @@ impl From for DirectoryUser { Self { email: val.profile.email, active: ACTIVE_STATUS.contains(&val.status.as_str()), + id: None, } } } @@ -412,17 +413,18 @@ impl DirectorySync for OktaDirectorySync { async fn get_user_groups( &self, - user_id: &str, + user_email: &str, ) -> Result, DirectorySyncError> { - debug!("Getting groups of user {user_id}"); - let response = self.query_user_groups(user_id).await?; - debug!("Got groups response for user {user_id}"); + debug!("Getting groups of user {user_email}"); + let response = self.query_user_groups(user_email).await?; + debug!("Got groups response for user {user_email}"); Ok(response.into_iter().map(Into::into).collect()) } async fn get_group_members( &self, group: &DirectoryGroup, + _all_users_helper: Option<&[DirectoryUser]>, ) -> Result, DirectorySyncError> { debug!("Getting group members of group {}", group.name); let response = self.query_group_members(group).await?; diff --git a/crates/defguard_core/src/enterprise/directory_sync/testprovider.rs b/crates/defguard_core/src/enterprise/directory_sync/testprovider.rs index 1083889588..fc74bdebfd 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/testprovider.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/testprovider.rs @@ -23,7 +23,7 @@ impl DirectorySync for TestProviderDirectorySync { async fn get_user_groups( &self, - _user_id: &str, + _user_email: &str, ) -> Result, DirectorySyncError> { Ok(vec![DirectoryGroup { id: "1".into(), @@ -34,6 +34,7 @@ impl DirectorySync for TestProviderDirectorySync { async fn get_group_members( &self, _group: &DirectoryGroup, + _all_users_helper: Option<&[DirectoryUser]>, ) -> Result, DirectorySyncError> { Ok(vec![ "testuser@email.com".into(), @@ -51,14 +52,17 @@ impl DirectorySync for TestProviderDirectorySync { DirectoryUser { email: "testuser@email.com".into(), active: true, + id: Some("testuser-id".into()), }, DirectoryUser { email: "testuserdisabled@email.com".into(), active: false, + id: Some("testuserdisabled-id".into()), }, DirectoryUser { email: "testuser2@email.com".into(), active: true, + id: Some("testuser2-id".into()), }, ]) } diff --git a/crates/defguard_core/src/enterprise/directory_sync/tests.rs b/crates/defguard_core/src/enterprise/directory_sync/tests.rs new file mode 100644 index 0000000000..e1fd0612d9 --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/tests.rs @@ -0,0 +1,773 @@ +#[cfg(test)] +mod test { + use std::str::FromStr; + + use ipnetwork::IpNetwork; + use secrecy::ExposeSecret; + use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; + use tokio::sync::broadcast; + + use super::super::*; + use crate::{ + SERVER_CONFIG, + config::DefGuardConfig, + db::{ + Device, Session, SessionState, Settings, WireguardNetwork, + models::{ + device::DeviceType, settings::initialize_current_settings, + wireguard::LocationMfaMode, + }, + setup_pool, + }, + enterprise::db::models::openid_provider::DirectorySyncTarget, + }; + + async fn get_test_network(pool: &PgPool) -> WireguardNetwork { + WireguardNetwork::find_by_name(pool, "test") + .await + .unwrap() + .unwrap() + .pop() + .unwrap() + } + + async fn make_test_provider( + pool: &PgPool, + user_behavior: DirectorySyncUserBehavior, + admin_behavior: DirectorySyncUserBehavior, + target: DirectorySyncTarget, + ) -> OpenIdProvider { + Settings::init_defaults(pool).await.unwrap(); + initialize_current_settings(pool).await.unwrap(); + + let current = OpenIdProvider::get_current(pool).await.unwrap(); + + if let Some(provider) = current { + provider.delete(pool).await.unwrap(); + } + + WireguardNetwork::new( + "test".to_string(), + vec![IpNetwork::from_str("10.10.10.1/24").unwrap()], + 1234, + "123.123.123.123".to_string(), + None, + vec![], + 32, + 32, + false, + false, + LocationMfaMode::Disabled, + ) + .unwrap() + .save(pool) + .await + .unwrap(); + + OpenIdProvider::new( + "Test".to_string(), + "base_url".to_string(), + "client_id".to_string(), + "client_secret".to_string(), + Some("display_name".to_string()), + Some("google_service_account_key".to_string()), + Some("google_service_account_email".to_string()), + Some("admin_email".to_string()), + true, + 60, + user_behavior, + admin_behavior, + target, + None, + None, + vec![], + None, + ) + .save(pool) + .await + .unwrap() + } + + async fn make_test_user_and_device(name: &str, pool: &PgPool) -> User { + let user = User::new( + name, + None, + "lastname", + "firstname", + format!("{name}@email.com").as_str(), + None, + ) + .save(pool) + .await + .unwrap(); + + let dev = Device::new( + format!("{name}-device"), + format!("{name}-key"), + user.id, + DeviceType::User, + None, + true, + ) + .save(pool) + .await + .unwrap(); + + let mut transaction = pool.begin().await.unwrap(); + dev.add_to_all_networks(&mut transaction).await.unwrap(); + transaction.commit().await.unwrap(); + + user + } + + async fn get_test_user(pool: &PgPool, name: &str) -> Option> { + User::find_by_username(pool, name).await.unwrap() + } + + async fn make_admin(pool: &PgPool, user: &User) { + let admin_group = Group::find_by_name(pool, "admin").await.unwrap().unwrap(); + user.add_to_group(pool, &admin_group).await.unwrap(); + } + + // Keep both users and admins + #[sqlx::test] + async fn test_users_state_keep_both(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, mut wg_rx) = broadcast::channel::(16); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Keep, + DirectorySyncUserBehavior::Keep, + DirectorySyncTarget::All, + ) + .await; + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + let user1 = make_test_user_and_device("user1", &pool).await; + make_test_user_and_device("user2", &pool).await; + make_test_user_and_device("testuser", &pool).await; + make_admin(&pool, &user1).await; + + assert!(get_test_user(&pool, "user1").await.is_some()); + assert!(get_test_user(&pool, "user2").await.is_some()); + assert!(get_test_user(&pool, "testuser").await.is_some()); + + let all_users = client.get_all_users().await.unwrap(); + sync_all_users_state(&client, &pool, &wg_tx, &all_users) + .await + .unwrap(); + + assert!(get_test_user(&pool, "user1").await.is_some()); + assert!(get_test_user(&pool, "user2").await.is_some()); + assert!(get_test_user(&pool, "testuser").await.is_some()); + + // No events + assert!(wg_rx.try_recv().is_err()); + } + + // Delete users, keep admins + #[sqlx::test] + async fn test_users_state_delete_users(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, mut wg_rx) = broadcast::channel::(16); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Keep, + DirectorySyncTarget::All, + ) + .await; + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + let user1 = make_test_user_and_device("user1", &pool).await; + let user2 = make_test_user_and_device("user2", &pool).await; + make_test_user_and_device("testuser", &pool).await; + make_admin(&pool, &user1).await; + + assert!(get_test_user(&pool, "user1").await.is_some()); + assert!(get_test_user(&pool, "user2").await.is_some()); + assert!(get_test_user(&pool, "testuser").await.is_some()); + + let all_users = client.get_all_users().await.unwrap(); + sync_all_users_state(&client, &pool, &wg_tx, &all_users) + .await + .unwrap(); + + assert!(get_test_user(&pool, "user1").await.is_some()); + assert!(get_test_user(&pool, "user2").await.is_none()); + assert!(get_test_user(&pool, "testuser").await.is_some()); + + let event = wg_rx.try_recv(); + if let Ok(GatewayEvent::DeviceDeleted(dev)) = event { + assert_eq!(dev.device.user_id, user2.id); + } else { + panic!("Expected a DeviceDeleted event"); + } + } + #[sqlx::test] + async fn test_users_state_delete_admins(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, mut wg_rx) = broadcast::channel::(16); + User::init_admin_user(&pool, config.default_admin_password.expose_secret()) + .await + .unwrap(); + + let _ = make_test_provider( + &pool, + DirectorySyncUserBehavior::Keep, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, + ) + .await; + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + let user1 = make_test_user_and_device("user1", &pool).await; + make_test_user_and_device("user2", &pool).await; + let user3 = make_test_user_and_device("user3", &pool).await; + make_test_user_and_device("testuser", &pool).await; + make_admin(&pool, &user1).await; + make_admin(&pool, &user3).await; + + assert!(get_test_user(&pool, "user1").await.is_some()); + assert!(get_test_user(&pool, "user2").await.is_some()); + assert!(get_test_user(&pool, "testuser").await.is_some()); + let all_users = client.get_all_users().await.unwrap(); + sync_all_users_state(&client, &pool, &wg_tx, &all_users) + .await + .unwrap(); + + assert!( + get_test_user(&pool, "user1").await.is_none() + || get_test_user(&pool, "user3").await.is_none() + ); + assert!( + get_test_user(&pool, "user1").await.is_some() + || get_test_user(&pool, "user3").await.is_some() + ); + assert!(get_test_user(&pool, "user2").await.is_some()); + assert!(get_test_user(&pool, "testuser").await.is_some()); + + // Check that we received a device deleted event for whichever admin was removed + let event = wg_rx.try_recv(); + if let Ok(GatewayEvent::DeviceDeleted(dev)) = event { + assert!(dev.device.user_id == user1.id || dev.device.user_id == user3.id); + } else { + panic!("Expected a DeviceDeleted event"); + } + } + + #[sqlx::test] + async fn test_users_state_delete_both(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, mut wg_rx) = broadcast::channel::(16); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, + ) + .await; + User::init_admin_user(&pool, config.default_admin_password.expose_secret()) + .await + .unwrap(); + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + let user1 = make_test_user_and_device("user1", &pool).await; + let user2 = make_test_user_and_device("user2", &pool).await; + let user3 = make_test_user_and_device("user3", &pool).await; + make_test_user_and_device("testuser", &pool).await; + make_admin(&pool, &user1).await; + make_admin(&pool, &user3).await; + + assert!(get_test_user(&pool, "user1").await.is_some()); + assert!(get_test_user(&pool, "user2").await.is_some()); + assert!(get_test_user(&pool, "testuser").await.is_some()); + let all_users = client.get_all_users().await.unwrap(); + sync_all_users_state(&client, &pool, &wg_tx, &all_users) + .await + .unwrap(); + + assert!( + get_test_user(&pool, "user1").await.is_none() + || get_test_user(&pool, "user3").await.is_none() + ); + assert!( + get_test_user(&pool, "user1").await.is_some() + || get_test_user(&pool, "user3").await.is_some() + ); + assert!(get_test_user(&pool, "user2").await.is_none()); + assert!(get_test_user(&pool, "testuser").await.is_some()); + + // Check for device deletion events + let event1 = wg_rx.try_recv(); + if let Ok(GatewayEvent::DeviceDeleted(dev)) = event1 { + assert!( + dev.device.user_id == user1.id + || dev.device.user_id == user2.id + || dev.device.user_id == user3.id + ); + } else { + panic!("Expected a DeviceDeleted event"); + } + + let event2 = wg_rx.try_recv(); + if let Ok(GatewayEvent::DeviceDeleted(dev)) = event2 { + assert!( + dev.device.user_id == user1.id + || dev.device.user_id == user2.id + || dev.device.user_id == user3.id + ); + } else { + panic!("Expected a DeviceDeleted event"); + } + } + + #[sqlx::test] + async fn test_users_state_disable_users(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, mut wg_rx) = broadcast::channel::(16); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Disable, + DirectorySyncUserBehavior::Keep, + DirectorySyncTarget::All, + ) + .await; + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + let user1 = make_test_user_and_device("user1", &pool).await; + make_test_user_and_device("user2", &pool).await; + make_test_user_and_device("testuser", &pool).await; + make_test_user_and_device("testuserdisabled", &pool).await; + make_admin(&pool, &user1).await; + + let user1 = get_test_user(&pool, "user1").await.unwrap(); + let user2 = get_test_user(&pool, "user2").await.unwrap(); + let testuser = get_test_user(&pool, "testuser").await.unwrap(); + let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); + let disabled_user_session = Session::new( + testuserdisabled.id, + SessionState::PasswordVerified, + "127.0.0.1".into(), + None, + ); + disabled_user_session.save(&pool).await.unwrap(); + assert!( + Session::find_by_id(&pool, &disabled_user_session.id) + .await + .unwrap() + .is_some() + ); + + assert!(user1.is_active); + assert!(user2.is_active); + assert!(testuser.is_active); + assert!(testuserdisabled.is_active); + + let all_users = client.get_all_users().await.unwrap(); + sync_all_users_state(&client, &pool, &wg_tx, &all_users) + .await + .unwrap(); + + // Check for device disconnection events + let event1 = wg_rx.try_recv(); + if let Ok(GatewayEvent::DeviceDeleted(dev)) = event1 { + assert!(dev.device.user_id == user2.id || dev.device.user_id == testuserdisabled.id); + } else { + panic!("Expected a DeviceDisconnected event"); + } + + let event2 = wg_rx.try_recv(); + if let Ok(GatewayEvent::DeviceDeleted(dev)) = event2 { + assert!(dev.device.user_id == user2.id || dev.device.user_id == testuserdisabled.id); + } else { + panic!("Expected a DeviceDisconnected event"); + } + + let user1 = get_test_user(&pool, "user1").await.unwrap(); + let user2 = get_test_user(&pool, "user2").await.unwrap(); + let testuser = get_test_user(&pool, "testuser").await.unwrap(); + let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); + + assert!( + Session::find_by_id(&pool, &disabled_user_session.id) + .await + .unwrap() + .is_none() + ); + assert!(user1.is_active); + assert!(!user2.is_active); + assert!(testuser.is_active); + assert!(!testuserdisabled.is_active); + } + #[sqlx::test] + async fn test_users_state_disable_admins(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, mut wg_rx) = broadcast::channel::(16); // Added mut wg_rx + make_test_provider( + &pool, + DirectorySyncUserBehavior::Keep, + DirectorySyncUserBehavior::Disable, + DirectorySyncTarget::All, + ) + .await; + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + let user1 = make_test_user_and_device("user1", &pool).await; + make_test_user_and_device("user2", &pool).await; + let user3 = make_test_user_and_device("user3", &pool).await; + make_test_user_and_device("testuser", &pool).await; + make_test_user_and_device("testuserdisabled", &pool).await; + make_admin(&pool, &user1).await; + make_admin(&pool, &user3).await; + + let user1 = get_test_user(&pool, "user1").await.unwrap(); + let user2 = get_test_user(&pool, "user2").await.unwrap(); + let testuser = get_test_user(&pool, "testuser").await.unwrap(); + let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); + + assert!(user1.is_active); + assert!(user2.is_active); + assert!(user3.is_active); + assert!(testuser.is_active); + assert!(testuserdisabled.is_active); + + let all_users = client.get_all_users().await.unwrap(); + sync_all_users_state(&client, &pool, &wg_tx, &all_users) + .await + .unwrap(); + + // Check for device disconnection events + let event1 = wg_rx.try_recv(); + if let Ok(GatewayEvent::DeviceDeleted(dev)) = event1 { + assert!( + dev.device.user_id == user1.id + || dev.device.user_id == user3.id + || dev.device.user_id == testuserdisabled.id + ); + } else { + panic!("Expected a DeviceDisconnected event"); + } + + let event2 = wg_rx.try_recv(); + if let Ok(GatewayEvent::DeviceDeleted(dev)) = event2 { + assert!( + dev.device.user_id == user1.id + || dev.device.user_id == user3.id + || dev.device.user_id == testuserdisabled.id + ); + } else { + panic!("Expected a DeviceDisconnected event"); + } + + let user1 = get_test_user(&pool, "user1").await.unwrap(); + let user2 = get_test_user(&pool, "user2").await.unwrap(); + let user3 = get_test_user(&pool, "user3").await.unwrap(); + let testuser = get_test_user(&pool, "testuser").await.unwrap(); + let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); + + assert!(!user1.is_active || !user3.is_active); + assert!(user1.is_active || user3.is_active); + assert!(user2.is_active); + assert!(testuser.is_active); + assert!(!testuserdisabled.is_active); + } + + #[sqlx::test] + async fn test_users_groups(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, _) = broadcast::channel::(16); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, + ) + .await; + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + make_test_user_and_device("testuser", &pool).await; + make_test_user_and_device("testuser2", &pool).await; + make_test_user_and_device("testuserdisabled", &pool).await; + let all_users = client.get_all_users().await.unwrap(); + sync_all_users_groups(&client, &pool, &wg_tx, Some(&all_users)) + .await + .unwrap(); + + let mut groups = Group::all(&pool).await.unwrap(); + + let testuser = get_test_user(&pool, "testuser").await.unwrap(); + let testuser2 = get_test_user(&pool, "testuser2").await.unwrap(); + let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); + + let testuser_groups = testuser.member_of(&pool).await.unwrap(); + let testuser2_groups = testuser2.member_of(&pool).await.unwrap(); + let testuserdisabled_groups = testuserdisabled.member_of(&pool).await.unwrap(); + + assert_eq!(testuser_groups.len(), 3); + assert_eq!(testuser2_groups.len(), 3); + assert_eq!(testuserdisabled_groups.len(), 3); + groups.sort_by(|a, b| a.name.cmp(&b.name)); + + let group_present = + |groups: &Vec>, name: &str| groups.iter().any(|g| g.name == name); + + assert!(group_present(&testuser_groups, "group1")); + assert!(group_present(&testuser_groups, "group2")); + assert!(group_present(&testuser_groups, "group3")); + + assert!(group_present(&testuser2_groups, "group1")); + assert!(group_present(&testuser2_groups, "group2")); + assert!(group_present(&testuser2_groups, "group3")); + + assert!(group_present(&testuserdisabled_groups, "group1")); + assert!(group_present(&testuserdisabled_groups, "group2")); + assert!(group_present(&testuserdisabled_groups, "group3")); + } + + #[sqlx::test] + async fn test_sync_user_groups(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, _) = broadcast::channel::(16); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, + ) + .await; + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + let user = make_test_user_and_device("testuser", &pool).await; + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 0); + sync_user_groups_if_configured(&user, &pool, &wg_tx) + .await + .unwrap(); + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 1); + let group = Group::find_by_name(&pool, "group1").await.unwrap().unwrap(); + assert_eq!(user_groups[0].id, group.id); + } + + #[sqlx::test] + async fn test_sync_target_users(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, _) = broadcast::channel::(16); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::Users, + ) + .await; + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + let user = make_test_user_and_device("testuser", &pool).await; + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 0); + do_directory_sync(&pool, &wg_tx).await.unwrap(); + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 0); + } + + #[sqlx::test] + async fn test_sync_target_all(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, mut wg_rx) = broadcast::channel::(16); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, + ) + .await; + let network = get_test_network(&pool).await; + let mut transaction = pool.begin().await.unwrap(); + let group = Group::new("group1".to_string()) + .save(&mut *transaction) + .await + .unwrap(); + network + .set_allowed_groups(&mut transaction, vec![group.name]) + .await + .unwrap(); + transaction.commit().await.unwrap(); + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + let user = make_test_user_and_device("testuser", &pool).await; + let user2_pre_sync = make_test_user_and_device("user2", &pool).await; + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 0); + do_directory_sync(&pool, &wg_tx).await.unwrap(); + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 3); + let user2 = get_test_user(&pool, "user2").await; + assert!(user2.is_none()); + let mut transaction = pool.begin().await.unwrap(); + user.sync_allowed_devices(&mut transaction, &wg_tx) + .await + .unwrap(); + transaction.commit().await.unwrap(); + let event = wg_rx.try_recv(); + if let Ok(GatewayEvent::DeviceDeleted(dev)) = event { + assert_eq!(dev.device.user_id, user2_pre_sync.id); + } else { + panic!("Expected a DeviceDeleted event"); + } + let event = wg_rx.try_recv(); + if let Ok(GatewayEvent::DeviceCreated(dev)) = event { + assert_eq!(dev.device.user_id, user.id); + } else { + panic!("Expected a DeviceDeleted event"); + } + } + + #[sqlx::test] + async fn test_sync_target_groups(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, _) = broadcast::channel::(16); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::Groups, + ) + .await; + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + let user = make_test_user_and_device("testuser", &pool).await; + make_test_user_and_device("user2", &pool).await; + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 0); + do_directory_sync(&pool, &wg_tx).await.unwrap(); + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 3); + let user2 = get_test_user(&pool, "user2").await; + assert!(user2.is_some()); + } + + #[sqlx::test] + async fn test_sync_unassign_last_admin_group(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, _) = broadcast::channel::(16); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, + ) + .await; + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + // Make one admin and check if he's deleted + let user = make_test_user_and_device("testuser", &pool).await; + let admin_grp = Group::find_by_name(&pool, "admin").await.unwrap().unwrap(); + user.add_to_group(&pool, &admin_grp).await.unwrap(); + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 1); + assert!(user.is_admin(&pool).await.unwrap()); + + do_directory_sync(&pool, &wg_tx).await.unwrap(); + + // He should still be an admin as it's the last one + assert!(user.is_admin(&pool).await.unwrap()); + + // Make another admin and check if one of them is deleted + let user2 = make_test_user_and_device("testuser2", &pool).await; + user2.add_to_group(&pool, &admin_grp).await.unwrap(); + + do_directory_sync(&pool, &wg_tx).await.unwrap(); + + let admins = User::find_admins(&pool).await.unwrap(); + // There should be only one admin left + assert_eq!(admins.len(), 1); + + let defguard_user = make_test_user_and_device("defguard", &pool).await; + make_admin(&pool, &defguard_user).await; + + do_directory_sync(&pool, &wg_tx).await.unwrap(); + } + + #[sqlx::test] + async fn test_sync_delete_last_admin_user(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, _) = broadcast::channel::(16); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, + ) + .await; + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + // a user that's not in the directory + let defguard_user = make_test_user_and_device("defguard", &pool).await; + make_admin(&pool, &defguard_user).await; + assert!(defguard_user.is_admin(&pool).await.unwrap()); + + do_directory_sync(&pool, &wg_tx).await.unwrap(); + + // The user should still be an admin + assert!(defguard_user.is_admin(&pool).await.unwrap()); + + // remove his admin status + let admin_grp = Group::find_by_name(&pool, "admin").await.unwrap().unwrap(); + defguard_user + .remove_from_group(&pool, &admin_grp) + .await + .unwrap(); + + do_directory_sync(&pool, &wg_tx).await.unwrap(); + let user = User::find_by_username(&pool, "defguard").await.unwrap(); + assert!(user.is_none()); + } +} diff --git a/crates/defguard_core/src/enterprise/handlers/openid_providers.rs b/crates/defguard_core/src/enterprise/handlers/openid_providers.rs index 26e537f3b1..678c1399b6 100644 --- a/crates/defguard_core/src/enterprise/handlers/openid_providers.rs +++ b/crates/defguard_core/src/enterprise/handlers/openid_providers.rs @@ -44,6 +44,7 @@ pub struct AddProviderData { pub okta_dirsync_client_id: Option, pub directory_sync_group_match: Option, pub username_handling: OpenidUsernameHandling, + pub jumpcloud_api_key: Option, } #[derive(Debug, Deserialize, Serialize)] @@ -154,6 +155,7 @@ pub async fn add_openid_provider( okta_private_jwk, provider_data.okta_dirsync_client_id, group_match, + provider_data.jumpcloud_api_key, ) .upsert(&appstate.pool) .await?; diff --git a/migrations/20250812112132_add_jumpcloud_key.down.sql b/migrations/20250812112132_add_jumpcloud_key.down.sql new file mode 100644 index 0000000000..60fd3e6a69 --- /dev/null +++ b/migrations/20250812112132_add_jumpcloud_key.down.sql @@ -0,0 +1 @@ +ALTER TABLE openidprovider DROP COLUMN jumpcloud_api_key; diff --git a/migrations/20250812112132_add_jumpcloud_key.up.sql b/migrations/20250812112132_add_jumpcloud_key.up.sql new file mode 100644 index 0000000000..78c09241c5 --- /dev/null +++ b/migrations/20250812112132_add_jumpcloud_key.up.sql @@ -0,0 +1 @@ +ALTER TABLE openidprovider ADD COLUMN jumpcloud_api_key TEXT DEFAULT NULL; diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index 1d83832719..628c100962 100644 --- a/web/src/i18n/en/index.ts +++ b/web/src/i18n/en/index.ts @@ -1404,6 +1404,11 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do helper: "Client private key for the Okta directory sync application in the JWK format. It won't be shown again here.", }, + jumpcloud_api_key: { + label: 'JumpCloud API Key', + helper: + 'API Key for the JumpCloud directory sync. It will be used to periodically query JumpCloud for user state and group membership changes.', + }, group_match: { label: 'Sync only matching groups', helper: @@ -1986,7 +1991,8 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do helpers: { address: 'Based on this address VPN network address will be defined, eg. 10.10.10.1/24 (and VPN network will be: 10.10.10.0/24). You can optionally specify multiple addresses separated by a comma. The first address is the primary address, and this one will be used for IP address assignment for devices. The other IP addresses are auxiliary and are not managed by Defguard.', - endpoint: 'Public IP address or domain name to which the remote peers/users will connect to. This address will be used in the configuration for the clients, but Defguard Gateways do not bind to this address.', + endpoint: + 'Public IP address or domain name to which the remote peers/users will connect to. This address will be used in the configuration for the clients, but Defguard Gateways do not bind to this address.', gateway: 'Gateway public address, used by VPN users to connect', dns: 'Specify the DNS resolvers to query when the wireguard interface is up.', allowedIps: diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index ca8c7751ba..5eee22079e 100644 --- a/web/src/i18n/i18n-types.ts +++ b/web/src/i18n/i18n-types.ts @@ -3448,6 +3448,16 @@ type RootTranslation = { */ helper: string } + jumpcloud_api_key: { + /** + * J​u​m​p​C​l​o​u​d​ ​A​P​I​ ​K​e​y + */ + label: string + /** + * A​P​I​ ​K​e​y​ ​f​o​r​ ​t​h​e​ ​J​u​m​p​C​l​o​u​d​ ​d​i​r​e​c​t​o​r​y​ ​s​y​n​c​.​ ​I​t​ ​w​i​l​l​ ​b​e​ ​u​s​e​d​ ​t​o​ ​p​e​r​i​o​d​i​c​a​l​l​y​ ​q​u​e​r​y​ ​J​u​m​p​C​l​o​u​d​ ​f​o​r​ ​u​s​e​r​ ​s​t​a​t​e​ ​a​n​d​ ​g​r​o​u​p​ ​m​e​m​b​e​r​s​h​i​p​ ​c​h​a​n​g​e​s​. + */ + helper: string + } group_match: { /** * S​y​n​c​ ​o​n​l​y​ ​m​a​t​c​h​i​n​g​ ​g​r​o​u​p​s @@ -10063,6 +10073,16 @@ export type TranslationFunctions = { */ helper: () => LocalizedString } + jumpcloud_api_key: { + /** + * JumpCloud API Key + */ + label: () => LocalizedString + /** + * API Key for the JumpCloud directory sync. It will be used to periodically query JumpCloud for user state and group membership changes. + */ + helper: () => LocalizedString + } group_match: { /** * Sync only matching groups diff --git a/web/src/i18n/pl/index.ts b/web/src/i18n/pl/index.ts index 2ced36f4b7..66c665ffae 100644 --- a/web/src/i18n/pl/index.ts +++ b/web/src/i18n/pl/index.ts @@ -1222,6 +1222,11 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe helper: 'Klucz prywatny dla aplikacji synchronizacji Okta w formacie JWK. Klucz nie jest wyświetlany ponownie po wgraniu.', }, + jumpcloud_api_key: { + label: 'Klucz API JumpCloud', + helper: + 'Klucz API JumpCloud używany do synchronizacji stanu użytkowników i grup.', + }, group_match: { label: 'Synchronizuj tylko pasujące grupy', helper: @@ -1771,7 +1776,8 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe helpers: { address: 'Na podstawie tego adresu będzie stworzona sieć VPN, np. 10.10.10.1/24 (sieć VPN: 10.10.10.0/24). Opcjonalnie możesz podać wiele adresów, oddzielając je przecinkiem. Pierwszy adres będzie adresem głównym i zostanie użyty do przypisywania adresów IP urządzeniom. Pozostałe adresy są dodatkowe i nie będą zarządzane przez Defguarda.', - endpoint: 'Publiczny adres IP lub domena internetowa, do której będą łączyć się użytkownicy/urządzenia. Ten adres zostanie użyty w konfiguracji klientów, ale Gatewaye Defguard nie wiążą się z tym adresem.', + endpoint: + 'Publiczny adres IP lub domena internetowa, do której będą łączyć się użytkownicy/urządzenia. Ten adres zostanie użyty w konfiguracji klientów, ale Gatewaye Defguard nie wiążą się z tym adresem.', gateway: 'Adres publiczny Gatewaya, używany przez użytkowników VPN do łączenia się.', dns: 'Określ resolwery DNS, które mają odpytywać, gdy interfejs WireGuard jest aktywny.', diff --git a/web/src/pages/settings/components/OpenIdSettings/components/DirectorySyncSettings.tsx b/web/src/pages/settings/components/OpenIdSettings/components/DirectorySyncSettings.tsx index a7377ba4cc..db227330d9 100644 --- a/web/src/pages/settings/components/OpenIdSettings/components/DirectorySyncSettings.tsx +++ b/web/src/pages/settings/components/OpenIdSettings/components/DirectorySyncSettings.tsx @@ -173,6 +173,21 @@ export const DirsyncSettings = ({ isLoading }: { isLoading: boolean }) => { /> ) : null} + {providerName === 'JumpCloud' ? ( + <> + + {parse(localLL.form.labels.jumpcloud_api_key.helper())} + + } + required={dirsyncEnabled} + /> + + ) : null} {providerName === 'Google' ? ( <> label: 'Okta', key: 3, }, + { + value: 'JumpCloud', + label: 'JumpCloud', + key: 4, + }, { value: 'Custom', label: localLL.form.custom(), - key: 4, + key: 5, }, ], [localLL.form], @@ -72,6 +77,8 @@ export const OpenIdProviderSettings = ({ isLoading }: { isLoading: boolean }) => return `https://login.microsoftonline.com//v2.0`; case 'Okta': return ``; + case 'JumpCloud': + return 'https://oauth.id.jumpcloud.com'; default: return null; } @@ -86,6 +93,8 @@ export const OpenIdProviderSettings = ({ isLoading }: { isLoading: boolean }) => return 'Microsoft'; case 'Okta': return 'Okta'; + case 'JumpCloud': + return 'JumpCloud'; default: return null; } diff --git a/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx index 3be2f73a68..b29abcadb1 100644 --- a/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx +++ b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx @@ -108,6 +108,7 @@ export const OpenIdSettingsForm = () => { okta_private_jwk: z.string(), okta_dirsync_client_id: z.string(), directory_sync_group_match: z.string(), + jumpcloud_api_key: z.string(), }) .superRefine((val, ctx) => { if (val.name === '') { @@ -145,6 +146,15 @@ export const OpenIdSettingsForm = () => { }); } } + + if (val.directory_sync_enabled && val.name === 'JumpCloud') { + if (val.jumpcloud_api_key.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: LL.form.error.required(), + }); + } + } }), [LL.form.error], ); @@ -171,6 +181,7 @@ export const OpenIdSettingsForm = () => { okta_dirsync_client_id: '', directory_sync_group_match: '', username_handling: 'RemoveForbidden', + jumpcloud_api_key: '', }; if (openidData) { diff --git a/web/src/pages/settings/components/OpenIdSettings/components/SupportedProviders.ts b/web/src/pages/settings/components/OpenIdSettings/components/SupportedProviders.ts index 8a7b66c57f..9bff03eccf 100644 --- a/web/src/pages/settings/components/OpenIdSettings/components/SupportedProviders.ts +++ b/web/src/pages/settings/components/OpenIdSettings/components/SupportedProviders.ts @@ -1 +1 @@ -export const SUPPORTED_SYNC_PROVIDERS = ['Google', 'Microsoft', 'Okta']; +export const SUPPORTED_SYNC_PROVIDERS = ['Google', 'Microsoft', 'Okta', 'JumpCloud']; From a9562ab9ec0de08d4d12e5c96a06bd404d00d0f0 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Mon, 18 Aug 2025 10:52:37 +0200 Subject: [PATCH 02/11] some frontend fixes --- .../OpenIdSettings/components/DirectorySyncSettings.tsx | 1 + .../OpenIdSettings/components/OpenIdProviderSettings.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/pages/settings/components/OpenIdSettings/components/DirectorySyncSettings.tsx b/web/src/pages/settings/components/OpenIdSettings/components/DirectorySyncSettings.tsx index db227330d9..49a95a1bcf 100644 --- a/web/src/pages/settings/components/OpenIdSettings/components/DirectorySyncSettings.tsx +++ b/web/src/pages/settings/components/OpenIdSettings/components/DirectorySyncSettings.tsx @@ -185,6 +185,7 @@ export const DirsyncSettings = ({ isLoading }: { isLoading: boolean }) => { } required={dirsyncEnabled} + type="password" /> ) : null} diff --git a/web/src/pages/settings/components/OpenIdSettings/components/OpenIdProviderSettings.tsx b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdProviderSettings.tsx index 4fa956236c..5a0b959367 100644 --- a/web/src/pages/settings/components/OpenIdSettings/components/OpenIdProviderSettings.tsx +++ b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdProviderSettings.tsx @@ -138,7 +138,7 @@ export const OpenIdProviderSettings = ({ isLoading }: { isLoading: boolean }) => controller={{ control, name: 'base_url' }} label={localLL.form.labels.base_url.label()} labelExtras={{parse(localLL.form.labels.base_url.helper())}} - disabled={providerName === 'Google' || isLoading} + disabled={providerName === 'Google' || providerName === 'JumpCloud' || isLoading} required /> Date: Mon, 18 Aug 2025 12:23:29 +0200 Subject: [PATCH 03/11] more fixes --- .../src/enterprise/directory_sync/jumpcloud.rs | 8 +++++--- crates/defguard_core/src/enterprise/directory_sync/mod.rs | 5 ++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs b/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs index 3736e17aaf..787e0526f3 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs @@ -69,7 +69,8 @@ struct LdapGroup { #[derive(Debug, Deserialize)] struct CompiledAttributes { - ldap_groups: Option>, + #[serde(rename = "ldapGroups")] + ldap_groups: Vec, } #[derive(Debug, Deserialize)] @@ -77,6 +78,7 @@ struct UserGroup { id: String, #[serde(rename = "type")] group_type: String, + #[serde(rename = "compiledAttributes")] compiled_attributes: CompiledAttributes, } @@ -85,8 +87,8 @@ impl From for DirectoryGroup { let name = group .compiled_attributes .ldap_groups - .and_then(|groups| groups.into_iter().next()) - .map_or(group.id.clone(), |g| g.name); + .first() + .map_or(group.id.clone(), |g| g.name.clone()); DirectoryGroup { id: group.id, name } } } diff --git a/crates/defguard_core/src/enterprise/directory_sync/mod.rs b/crates/defguard_core/src/enterprise/directory_sync/mod.rs index 69d1830e02..39039a6ba0 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/mod.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/mod.rs @@ -578,8 +578,7 @@ fn is_directory_sync_enabled(provider: Option<&OpenIdProvider>) -> bool { ) } -async fn sync_all_users_state( - directory_sync: &T, +async fn sync_all_users_state( pool: &PgPool, wg_tx: &Sender, all_users: &[DirectoryUser], @@ -848,7 +847,7 @@ pub(crate) async fn do_directory_sync( DirectorySyncTarget::All | DirectorySyncTarget::Users ) { let users = dir_sync.get_all_users().await?; - sync_all_users_state(&dir_sync, pool, wireguard_tx, &users).await?; + sync_all_users_state(pool, wireguard_tx, &users).await?; all_users = Some(users); } if matches!( From 4d1bc1860275e79db68d95a775adca7e740f8545 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Mon, 18 Aug 2025 12:48:31 +0200 Subject: [PATCH 04/11] Update jumpcloud.rs --- .../defguard_core/src/enterprise/directory_sync/jumpcloud.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs b/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs index 787e0526f3..0e4565d262 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs @@ -3,7 +3,6 @@ use std::collections::HashMap; use super::{DirectoryGroup, DirectorySync, DirectorySyncError, DirectoryUser, parse_response}; const GROUPS_URL: &str = "https://console.jumpcloud.com/api/v2/usergroups"; -// TODO: systemusers vs users? const ALL_USERS_URL: &str = "https://console.jumpcloud.com/api/systemusers"; const USER_GROUPS_URL: &str = "https://console.jumpcloud.com/api/v2/users//memberof"; const USER_GROUP_MEMBERS_URL: &str = @@ -457,9 +456,9 @@ impl DirectorySync for JumpCloudDirectorySync { } async fn test_connection(&self) -> Result<(), DirectorySyncError> { - debug!("Testing connection to Google API."); + debug!("Testing connection to JumpCloud API."); self.query_test_connection().await?; - info!("Successfully tested connection to Google API, connection is working."); + info!("Successfully tested connection to JumpCloud API, connection is working."); Ok(()) } } From 29f2785f83773e4b439c3c829f9069efa3d6cede Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Mon, 18 Aug 2025 12:57:22 +0200 Subject: [PATCH 05/11] request slowdown --- .../enterprise/directory_sync/jumpcloud.rs | 27 ++++++++++--------- .../src/enterprise/directory_sync/tests.rs | 8 +++--- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs b/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs index 0e4565d262..8e3c99691f 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs @@ -1,6 +1,9 @@ use std::collections::HashMap; +use tokio::time::sleep; + use super::{DirectoryGroup, DirectorySync, DirectorySyncError, DirectoryUser, parse_response}; +use crate::enterprise::directory_sync::REQUEST_PAGINATION_SLOWDOWN; const GROUPS_URL: &str = "https://console.jumpcloud.com/api/v2/usergroups"; const ALL_USERS_URL: &str = "https://console.jumpcloud.com/api/systemusers"; @@ -156,6 +159,8 @@ impl JumpCloudDirectorySync { } else { all_members_response.extend(members_response); } + + sleep(REQUEST_PAGINATION_SLOWDOWN).await; } debug!( @@ -220,6 +225,8 @@ impl JumpCloudDirectorySync { all_groups_response.len() ); } + + sleep(REQUEST_PAGINATION_SLOWDOWN).await; } debug!("Total groups fetched: {}", all_groups_response.len()); @@ -260,6 +267,8 @@ impl JumpCloudDirectorySync { } else { all_users_response.results.extend(users_response.results); } + + sleep(REQUEST_PAGINATION_SLOWDOWN).await; } Ok(all_users_response) @@ -299,6 +308,8 @@ impl JumpCloudDirectorySync { } else { all_groups_response.extend(groups_response); } + + sleep(REQUEST_PAGINATION_SLOWDOWN).await; } debug!( @@ -555,36 +566,26 @@ mod tests { id: "group123".to_string(), group_type: "user_group".to_string(), compiled_attributes: CompiledAttributes { - ldap_groups: Some(vec![ + ldap_groups: vec![ LdapGroup { name: "LDAP Group Name".to_string(), }, LdapGroup { name: "Second LDAP Group".to_string(), }, - ]), + ], }, }; let directory_group_with_ldap: DirectoryGroup = group_with_ldap.into(); assert_eq!(directory_group_with_ldap.id, "group123"); assert_eq!(directory_group_with_ldap.name, "LDAP Group Name"); - // Test group without LDAP groups (falls back to group ID) - let group_without_ldap = UserGroup { - id: "group456".to_string(), - group_type: "user_group".to_string(), - compiled_attributes: CompiledAttributes { ldap_groups: None }, - }; - let directory_group_without_ldap: DirectoryGroup = group_without_ldap.into(); - assert_eq!(directory_group_without_ldap.id, "group456"); - assert_eq!(directory_group_without_ldap.name, "group456"); - // Test group with empty LDAP groups (falls back to group ID) let group_empty_ldap = UserGroup { id: "group789".to_string(), group_type: "user_group".to_string(), compiled_attributes: CompiledAttributes { - ldap_groups: Some(vec![]), + ldap_groups: vec![], }, }; let directory_group_empty_ldap: DirectoryGroup = group_empty_ldap.into(); diff --git a/crates/defguard_core/src/enterprise/directory_sync/tests.rs b/crates/defguard_core/src/enterprise/directory_sync/tests.rs index e1fd0612d9..4a92a05102 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/tests.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/tests.rs @@ -156,7 +156,7 @@ mod test { assert!(get_test_user(&pool, "testuser").await.is_some()); let all_users = client.get_all_users().await.unwrap(); - sync_all_users_state(&client, &pool, &wg_tx, &all_users) + sync_all_users_state(&pool, &wg_tx, &all_users) .await .unwrap(); @@ -196,7 +196,7 @@ mod test { assert!(get_test_user(&pool, "testuser").await.is_some()); let all_users = client.get_all_users().await.unwrap(); - sync_all_users_state(&client, &pool, &wg_tx, &all_users) + sync_all_users_state(&pool, &wg_tx, &all_users) .await .unwrap(); @@ -243,7 +243,7 @@ mod test { assert!(get_test_user(&pool, "user2").await.is_some()); assert!(get_test_user(&pool, "testuser").await.is_some()); let all_users = client.get_all_users().await.unwrap(); - sync_all_users_state(&client, &pool, &wg_tx, &all_users) + sync_all_users_state(&pool, &wg_tx, &all_users) .await .unwrap(); @@ -298,7 +298,7 @@ mod test { assert!(get_test_user(&pool, "user2").await.is_some()); assert!(get_test_user(&pool, "testuser").await.is_some()); let all_users = client.get_all_users().await.unwrap(); - sync_all_users_state(&client, &pool, &wg_tx, &all_users) + sync_all_users_state(&pool, &wg_tx, &all_users) .await .unwrap(); From 9deac362334f4222ed277f616fe4d4b08dd970ce Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Mon, 18 Aug 2025 13:07:06 +0200 Subject: [PATCH 06/11] logs --- .../enterprise/directory_sync/jumpcloud.rs | 88 ++++++++++++++++--- 1 file changed, 75 insertions(+), 13 deletions(-) diff --git a/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs b/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs index 8e3c99691f..15a0ac11ce 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs @@ -114,6 +114,7 @@ pub(crate) struct JumpCloudDirectorySync { impl JumpCloudDirectorySync { #[must_use] pub fn new(api_key: String) -> Self { + debug!("Initializing JumpCloud directory sync with API key length: {}", api_key.len()); Self { api_key } } @@ -121,27 +122,36 @@ impl JumpCloudDirectorySync { &self, group: &DirectoryGroup, ) -> Result, DirectorySyncError> { + debug!("Starting to query members for group: {} (ID: {})", group.name, group.id); let client = reqwest::Client::new(); let url = USER_GROUP_MEMBERS_URL.replace("", &group.id); let mut query = HashMap::from([("limit", MAX_RESULTS.to_string())]); + debug!("Requesting group members from URL: {}", url); + debug!("Initial query parameters: {:?}", query); + let response = client .get(&url) .header("x-api-key", &self.api_key) .query(&query) .send() .await?; + + debug!("Initial response status for group {}: {}", group.id, response.status()); let mut all_members_response: Vec = parse_response( response, "Failed to query group members from JumpCloud API.", ) .await?; + debug!("Initial batch fetched {} members for group {}", all_members_response.len(), group.id); + for i in 1..MAX_REQUESTS { - query.insert( - "skip", - (i * MAX_RESULTS.parse::().unwrap()).to_string(), - ); + let skip_value = i * MAX_RESULTS.parse::().unwrap(); + query.insert("skip", skip_value.to_string()); + + debug!("Requesting page {} (skip: {}) for group {} members", i + 1, skip_value, group.id); + let response = client .get(&url) .header("x-api-key", &self.api_key) @@ -149,15 +159,21 @@ impl JumpCloudDirectorySync { .send() .await?; + debug!("Page {} response status for group {}: {}", i + 1, group.id, response.status()); let members_response: Vec = parse_response( response, "Failed to query group members from JumpCloud API.", ) .await?; + + debug!("Page {} returned {} members for group {}", i + 1, members_response.len(), group.id); + if members_response.is_empty() { + debug!("No more members found for group {}, stopping pagination", group.id); break; } else { all_members_response.extend(members_response); + debug!("Total members accumulated so far for group {}: {}", group.id, all_members_response.len()); } sleep(REQUEST_PAGINATION_SLOWDOWN).await; @@ -234,9 +250,13 @@ impl JumpCloudDirectorySync { } async fn query_all_users(&self) -> Result { + debug!("Starting to query all users from JumpCloud API"); let client = reqwest::Client::new(); let mut query = HashMap::from([("limit", MAX_RESULTS.to_string())]); + debug!("Initial query parameters for users: {:?}", query); + debug!("Sending initial request to: {}", ALL_USERS_URL); + let response = client .get(ALL_USERS_URL) .header("x-api-key", &self.api_key) @@ -244,14 +264,20 @@ impl JumpCloudDirectorySync { .send() .await?; + debug!("Initial users response status: {}", response.status()); let mut all_users_response: UsersResponse = parse_response(response, "Failed to query users from JumpCloud API.").await?; + debug!("Initial batch fetched {} users (total_count: {})", + all_users_response.results.len(), + all_users_response.total_count); + for i in 1..MAX_REQUESTS { - query.insert( - "skip", - (i * MAX_RESULTS.parse::().unwrap()).to_string(), - ); + let skip_value = i * MAX_RESULTS.parse::().unwrap(); + query.insert("skip", skip_value.to_string()); + + debug!("Requesting page {} (skip: {}) for users", i + 1, skip_value); + let response = client .get(ALL_USERS_URL) .header("x-api-key", &self.api_key) @@ -259,26 +285,38 @@ impl JumpCloudDirectorySync { .send() .await?; + debug!("Page {} response status for users: {}", i + 1, response.status()); let users_response: UsersResponse = parse_response(response, "Failed to query users from JumpCloud API.").await?; + debug!("Page {} returned {} users", i + 1, users_response.results.len()); + if users_response.results.is_empty() { + debug!("No more users found, stopping pagination"); break; } else { all_users_response.results.extend(users_response.results); + debug!("Total users accumulated so far: {}", all_users_response.results.len()); } sleep(REQUEST_PAGINATION_SLOWDOWN).await; } + debug!("Total users fetched: {} (final total_count: {})", + all_users_response.results.len(), + all_users_response.total_count); Ok(all_users_response) } async fn query_user_groups(&self, user_id: &str) -> Result, DirectorySyncError> { + debug!("Starting to query groups for user: {}", user_id); let client = reqwest::Client::new(); let url = USER_GROUPS_URL.replace("", user_id); let mut query = HashMap::from([("limit", MAX_RESULTS.to_string())]); + debug!("Requesting user groups from URL: {}", url); + debug!("Initial query parameters for user groups: {:?}", query); + let response = client .get(&url) .header("x-api-key", &self.api_key) @@ -286,14 +324,18 @@ impl JumpCloudDirectorySync { .send() .await?; + debug!("Initial response status for user {} groups: {}", user_id, response.status()); let mut all_groups_response: Vec = parse_response(response, "Failed to query user groups from JumpCloud API.").await?; + debug!("Initial batch fetched {} groups for user {}", all_groups_response.len(), user_id); + for i in 1..MAX_REQUESTS { - query.insert( - "skip", - (i * MAX_RESULTS.parse::().unwrap()).to_string(), - ); + let skip_value = i * MAX_RESULTS.parse::().unwrap(); + query.insert("skip", skip_value.to_string()); + + debug!("Requesting page {} (skip: {}) for user {} groups", i + 1, skip_value, user_id); + let response = client .get(&url) .header("x-api-key", &self.api_key) @@ -301,12 +343,18 @@ impl JumpCloudDirectorySync { .send() .await?; + debug!("Page {} response status for user {} groups: {}", i + 1, user_id, response.status()); let groups_response: Vec = parse_response(response, "Failed to query user groups from JumpCloud API.").await?; + + debug!("Page {} returned {} groups for user {}", i + 1, groups_response.len(), user_id); + if groups_response.is_empty() { + debug!("No more groups found for user {}, stopping pagination", user_id); break; } else { all_groups_response.extend(groups_response); + debug!("Total groups accumulated so far for user {}: {}", user_id, all_groups_response.len()); } sleep(REQUEST_PAGINATION_SLOWDOWN).await; @@ -321,15 +369,20 @@ impl JumpCloudDirectorySync { } async fn query_test_connection(&self) -> Result<(), DirectorySyncError> { + debug!("Testing connection to JumpCloud API"); let client = reqwest::Client::new(); + debug!("Sending test request to: {}", ALL_USERS_URL); + let response = client .get(ALL_USERS_URL) .header("x-api-key", &self.api_key) .send() .await?; + debug!("Test connection response status: {}", response.status()); let _: UsersResponse = parse_response(response, "Failed to test connection to JumpCloud API.").await?; + debug!("Test connection successful - API key is valid and endpoint is accessible"); Ok(()) } @@ -337,12 +390,14 @@ impl JumpCloudDirectorySync { &self, email: &str, ) -> Result, DirectorySyncError> { + debug!("Starting search for user by email: {}", email); let client = reqwest::Client::new(); let filter = format!("email:$eq:{email}"); debug!("Querying JumpCloud for user with email: {}", email); debug!("Using filter: {}", filter); + debug!("Sending request to: {}", ALL_USERS_URL); let response = client .get(ALL_USERS_URL) @@ -351,24 +406,31 @@ impl JumpCloudDirectorySync { .send() .await?; + debug!("User search response status: {}", response.status()); + if response.status().is_success() { let mut users: UsersResponse = parse_response(response, "Failed to query user by email.").await?; + debug!("User search returned {} users (total_count: {})", users.results.len(), users.total_count); + if users.total_count > 1 { + warn!("Multiple users found with email: {} (count: {})", email, users.total_count); return Err(DirectorySyncError::MultipleUsersFound(format!( "Multiple users found with email: {email}." ))); } if let Some(user) = users.results.pop() { - debug!("Found user: {:?}", user); + debug!("Found user: {} (ID: {}, activated: {}, locked: {}, state: {:?})", + user.email, user.id, user.activated, user.account_locked, user.state); Ok(Some(user.into())) } else { debug!("No user found with email: {}", email); Ok(None) } } else { + error!("Failed to query user by email: {}. Status: {}", email, response.status()); Err(DirectorySyncError::RequestError(format!( "Failed to query user by email: {}. Status: {}. Details: {}", email, From 897ce0c891fa42085f8f39cfb314cfe89a8c39f1 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Mon, 18 Aug 2025 13:28:27 +0200 Subject: [PATCH 07/11] fix? --- crates/defguard_core/src/enterprise/directory_sync/tests.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/defguard_core/src/enterprise/directory_sync/tests.rs b/crates/defguard_core/src/enterprise/directory_sync/tests.rs index 4a92a05102..8831003b1b 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/tests.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/tests.rs @@ -59,7 +59,6 @@ mod test { false, LocationMfaMode::Disabled, ) - .unwrap() .save(pool) .await .unwrap(); From 0056e268364236c047e8d061bc1d3feee807b498 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Mon, 18 Aug 2025 13:35:25 +0200 Subject: [PATCH 08/11] fmt --- crates/defguard_core/src/db/mod.rs | 4 +- .../src/db/models/biometric_auth.rs | 17 +- crates/defguard_core/src/db/models/mod.rs | 3 +- crates/defguard_core/src/db/models/user.rs | 1 + .../src/enterprise/db/models/snat.rs | 9 +- .../enterprise/directory_sync/jumpcloud.rs | 197 +++++++++++++----- .../src/enterprise/snat/handlers.rs | 3 +- crates/defguard_core/src/lib.rs | 2 +- .../tests/integration/common/mod.rs | 3 +- crates/defguard_event_logger/src/lib.rs | 14 +- 10 files changed, 176 insertions(+), 77 deletions(-) diff --git a/crates/defguard_core/src/db/mod.rs b/crates/defguard_core/src/db/mod.rs index 5f01f92dd3..1dcc96ec1c 100644 --- a/crates/defguard_core/src/db/mod.rs +++ b/crates/defguard_core/src/db/mod.rs @@ -1,10 +1,10 @@ pub mod models; -use crate::MIGRATOR; - use sqlx::postgres::{PgConnectOptions, PgPool, PgPoolOptions}; use utoipa::ToSchema; +use crate::MIGRATOR; + #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema, Eq, Default, Hash)] pub struct NoId; pub type Id = i64; diff --git a/crates/defguard_core/src/db/models/biometric_auth.rs b/crates/defguard_core/src/db/models/biometric_auth.rs index 8122f515e8..f7fe5ef602 100644 --- a/crates/defguard_core/src/db/models/biometric_auth.rs +++ b/crates/defguard_core/src/db/models/biometric_auth.rs @@ -1,14 +1,13 @@ +use base64::{Engine, engine::general_purpose, prelude::BASE64_STANDARD}; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use model_derive::Model; +use sqlx::{PgExecutor, query_as}; +use thiserror::Error; + use crate::{ db::{Id, NoId}, random::gen_alphanumeric, }; -use base64::engine::general_purpose; -use base64::{Engine, prelude::BASE64_STANDARD}; -use ed25519_dalek::Verifier; -use ed25519_dalek::{Signature, VerifyingKey}; -use model_derive::Model; -use sqlx::{PgExecutor, query_as}; -use thiserror::Error; #[derive(Error, Debug)] pub enum BiometricAuthError { @@ -47,6 +46,7 @@ impl BiometricAuth { pub_key, } } + pub fn validate_pubkey(pub_key: &str) -> Result<(), BiometricAuthError> { let decoded = BASE64_STANDARD.decode(pub_key)?; if decoded.len() != ed25519_dalek::PUBLIC_KEY_LENGTH { @@ -145,11 +145,12 @@ fn verify( #[cfg(test)] mod test { - use super::*; use base64::engine::general_purpose; use ed25519_dalek::Signer; use matches::assert_matches; + use super::*; + #[test] fn test_verify_valid_sig() { let mut csprng = rand_core::OsRng; diff --git a/crates/defguard_core/src/db/models/mod.rs b/crates/defguard_core/src/db/models/mod.rs index bbf459faeb..9f6953d57f 100644 --- a/crates/defguard_core/src/db/models/mod.rs +++ b/crates/defguard_core/src/db/models/mod.rs @@ -29,13 +29,12 @@ use std::collections::HashSet; use sqlx::{Error as SqlxError, PgConnection, PgPool, query_as}; use utoipa::ToSchema; -use crate::db::models::biometric_auth::BiometricAuth; - use self::{ device::UserDevice, user::{MFAMethod, User}, }; use super::{Group, Id}; +use crate::db::models::biometric_auth::BiometricAuth; #[cfg(feature = "openid")] #[derive(Deserialize, Serialize)] diff --git a/crates/defguard_core/src/db/models/user.rs b/crates/defguard_core/src/db/models/user.rs index b29676c259..9daf57cf56 100644 --- a/crates/defguard_core/src/db/models/user.rs +++ b/crates/defguard_core/src/db/models/user.rs @@ -902,6 +902,7 @@ impl User { Ok(Self::find_by_email(&mut *conn, username_or_email).await?) } } + pub(crate) async fn find_many_by_emails<'e, E>( executor: E, emails: &[&str], diff --git a/crates/defguard_core/src/enterprise/db/models/snat.rs b/crates/defguard_core/src/enterprise/db/models/snat.rs index e483cbb42a..127da55bf9 100644 --- a/crates/defguard_core/src/enterprise/db/models/snat.rs +++ b/crates/defguard_core/src/enterprise/db/models/snat.rs @@ -1,14 +1,15 @@ use std::net::IpAddr; -use crate::{ - db::{Id, NoId}, - enterprise::snat::error::UserSnatBindingError, -}; use model_derive::Model; use serde::{Deserialize, Serialize}; use sqlx::{PgExecutor, query_as}; use utoipa::ToSchema; +use crate::{ + db::{Id, NoId}, + enterprise::snat::error::UserSnatBindingError, +}; + #[derive(Clone, Debug, Deserialize, Model, Serialize, ToSchema)] #[table(user_snat_binding)] pub struct UserSnatBinding { diff --git a/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs b/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs index 15a0ac11ce..32aac71cdf 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs @@ -86,11 +86,16 @@ struct UserGroup { impl From for DirectoryGroup { fn from(group: UserGroup) -> Self { - let name = group - .compiled_attributes - .ldap_groups - .first() - .map_or(group.id.clone(), |g| g.name.clone()); + let name = group.compiled_attributes.ldap_groups.first().map_or_else( + || { + debug!( + "Group {} has no LDAP groups, using ID as name fallback", + group.id + ); + group.id.clone() + }, + |g| g.name.clone(), + ); DirectoryGroup { id: group.id, name } } } @@ -114,7 +119,10 @@ pub(crate) struct JumpCloudDirectorySync { impl JumpCloudDirectorySync { #[must_use] pub fn new(api_key: String) -> Self { - debug!("Initializing JumpCloud directory sync with API key length: {}", api_key.len()); + debug!( + "Initializing JumpCloud directory sync with API key length: {}", + api_key.len() + ); Self { api_key } } @@ -122,7 +130,10 @@ impl JumpCloudDirectorySync { &self, group: &DirectoryGroup, ) -> Result, DirectorySyncError> { - debug!("Starting to query members for group: {} (ID: {})", group.name, group.id); + debug!( + "Starting to query members for group: {} (ID: {})", + group.name, group.id + ); let client = reqwest::Client::new(); let url = USER_GROUP_MEMBERS_URL.replace("", &group.id); let mut query = HashMap::from([("limit", MAX_RESULTS.to_string())]); @@ -136,22 +147,35 @@ impl JumpCloudDirectorySync { .query(&query) .send() .await?; - - debug!("Initial response status for group {}: {}", group.id, response.status()); + + debug!( + "Initial response status for group {}: {}", + group.id, + response.status() + ); let mut all_members_response: Vec = parse_response( response, "Failed to query group members from JumpCloud API.", ) .await?; - debug!("Initial batch fetched {} members for group {}", all_members_response.len(), group.id); + debug!( + "Initial batch fetched {} members for group {}", + all_members_response.len(), + group.id + ); for i in 1..MAX_REQUESTS { let skip_value = i * MAX_RESULTS.parse::().unwrap(); query.insert("skip", skip_value.to_string()); - - debug!("Requesting page {} (skip: {}) for group {} members", i + 1, skip_value, group.id); - + + debug!( + "Requesting page {} (skip: {}) for group {} members", + i + 1, + skip_value, + group.id + ); + let response = client .get(&url) .header("x-api-key", &self.api_key) @@ -159,21 +183,38 @@ impl JumpCloudDirectorySync { .send() .await?; - debug!("Page {} response status for group {}: {}", i + 1, group.id, response.status()); + debug!( + "Page {} response status for group {}: {}", + i + 1, + group.id, + response.status() + ); let members_response: Vec = parse_response( response, "Failed to query group members from JumpCloud API.", ) .await?; - - debug!("Page {} returned {} members for group {}", i + 1, members_response.len(), group.id); - + + debug!( + "Page {} returned {} members for group {}", + i + 1, + members_response.len(), + group.id + ); + if members_response.is_empty() { - debug!("No more members found for group {}, stopping pagination", group.id); + debug!( + "No more members found for group {}, stopping pagination", + group.id + ); break; } else { all_members_response.extend(members_response); - debug!("Total members accumulated so far for group {}: {}", group.id, all_members_response.len()); + debug!( + "Total members accumulated so far for group {}: {}", + group.id, + all_members_response.len() + ); } sleep(REQUEST_PAGINATION_SLOWDOWN).await; @@ -256,7 +297,7 @@ impl JumpCloudDirectorySync { let mut query = HashMap::from([("limit", MAX_RESULTS.to_string())]); debug!("Initial query parameters for users: {:?}", query); debug!("Sending initial request to: {}", ALL_USERS_URL); - + let response = client .get(ALL_USERS_URL) .header("x-api-key", &self.api_key) @@ -268,16 +309,18 @@ impl JumpCloudDirectorySync { let mut all_users_response: UsersResponse = parse_response(response, "Failed to query users from JumpCloud API.").await?; - debug!("Initial batch fetched {} users (total_count: {})", - all_users_response.results.len(), - all_users_response.total_count); + debug!( + "Initial batch fetched {} users (total_count: {})", + all_users_response.results.len(), + all_users_response.total_count + ); for i in 1..MAX_REQUESTS { let skip_value = i * MAX_RESULTS.parse::().unwrap(); query.insert("skip", skip_value.to_string()); - + debug!("Requesting page {} (skip: {}) for users", i + 1, skip_value); - + let response = client .get(ALL_USERS_URL) .header("x-api-key", &self.api_key) @@ -285,26 +328,39 @@ impl JumpCloudDirectorySync { .send() .await?; - debug!("Page {} response status for users: {}", i + 1, response.status()); + debug!( + "Page {} response status for users: {}", + i + 1, + response.status() + ); let users_response: UsersResponse = parse_response(response, "Failed to query users from JumpCloud API.").await?; - debug!("Page {} returned {} users", i + 1, users_response.results.len()); + debug!( + "Page {} returned {} users", + i + 1, + users_response.results.len() + ); if users_response.results.is_empty() { debug!("No more users found, stopping pagination"); break; } else { all_users_response.results.extend(users_response.results); - debug!("Total users accumulated so far: {}", all_users_response.results.len()); + debug!( + "Total users accumulated so far: {}", + all_users_response.results.len() + ); } sleep(REQUEST_PAGINATION_SLOWDOWN).await; } - debug!("Total users fetched: {} (final total_count: {})", - all_users_response.results.len(), - all_users_response.total_count); + debug!( + "Total users fetched: {} (final total_count: {})", + all_users_response.results.len(), + all_users_response.total_count + ); Ok(all_users_response) } @@ -316,7 +372,7 @@ impl JumpCloudDirectorySync { let mut query = HashMap::from([("limit", MAX_RESULTS.to_string())]); debug!("Requesting user groups from URL: {}", url); debug!("Initial query parameters for user groups: {:?}", query); - + let response = client .get(&url) .header("x-api-key", &self.api_key) @@ -324,18 +380,31 @@ impl JumpCloudDirectorySync { .send() .await?; - debug!("Initial response status for user {} groups: {}", user_id, response.status()); + debug!( + "Initial response status for user {} groups: {}", + user_id, + response.status() + ); let mut all_groups_response: Vec = parse_response(response, "Failed to query user groups from JumpCloud API.").await?; - debug!("Initial batch fetched {} groups for user {}", all_groups_response.len(), user_id); + debug!( + "Initial batch fetched {} groups for user {}", + all_groups_response.len(), + user_id + ); for i in 1..MAX_REQUESTS { let skip_value = i * MAX_RESULTS.parse::().unwrap(); query.insert("skip", skip_value.to_string()); - - debug!("Requesting page {} (skip: {}) for user {} groups", i + 1, skip_value, user_id); - + + debug!( + "Requesting page {} (skip: {}) for user {} groups", + i + 1, + skip_value, + user_id + ); + let response = client .get(&url) .header("x-api-key", &self.api_key) @@ -343,18 +412,35 @@ impl JumpCloudDirectorySync { .send() .await?; - debug!("Page {} response status for user {} groups: {}", i + 1, user_id, response.status()); + debug!( + "Page {} response status for user {} groups: {}", + i + 1, + user_id, + response.status() + ); let groups_response: Vec = parse_response(response, "Failed to query user groups from JumpCloud API.").await?; - - debug!("Page {} returned {} groups for user {}", i + 1, groups_response.len(), user_id); - + + debug!( + "Page {} returned {} groups for user {}", + i + 1, + groups_response.len(), + user_id + ); + if groups_response.is_empty() { - debug!("No more groups found for user {}, stopping pagination", user_id); + debug!( + "No more groups found for user {}, stopping pagination", + user_id + ); break; } else { all_groups_response.extend(groups_response); - debug!("Total groups accumulated so far for user {}: {}", user_id, all_groups_response.len()); + debug!( + "Total groups accumulated so far for user {}: {}", + user_id, + all_groups_response.len() + ); } sleep(REQUEST_PAGINATION_SLOWDOWN).await; @@ -372,7 +458,7 @@ impl JumpCloudDirectorySync { debug!("Testing connection to JumpCloud API"); let client = reqwest::Client::new(); debug!("Sending test request to: {}", ALL_USERS_URL); - + let response = client .get(ALL_USERS_URL) .header("x-api-key", &self.api_key) @@ -412,25 +498,38 @@ impl JumpCloudDirectorySync { let mut users: UsersResponse = parse_response(response, "Failed to query user by email.").await?; - debug!("User search returned {} users (total_count: {})", users.results.len(), users.total_count); + debug!( + "User search returned {} users (total_count: {})", + users.results.len(), + users.total_count + ); if users.total_count > 1 { - warn!("Multiple users found with email: {} (count: {})", email, users.total_count); + warn!( + "Multiple users found with email: {} (count: {})", + email, users.total_count + ); return Err(DirectorySyncError::MultipleUsersFound(format!( "Multiple users found with email: {email}." ))); } if let Some(user) = users.results.pop() { - debug!("Found user: {} (ID: {}, activated: {}, locked: {}, state: {:?})", - user.email, user.id, user.activated, user.account_locked, user.state); + debug!( + "Found user: {} (ID: {}, activated: {}, locked: {}, state: {:?})", + user.email, user.id, user.activated, user.account_locked, user.state + ); Ok(Some(user.into())) } else { debug!("No user found with email: {}", email); Ok(None) } } else { - error!("Failed to query user by email: {}. Status: {}", email, response.status()); + error!( + "Failed to query user by email: {}. Status: {}", + email, + response.status() + ); Err(DirectorySyncError::RequestError(format!( "Failed to query user by email: {}. Status: {}. Details: {}", email, diff --git a/crates/defguard_core/src/enterprise/snat/handlers.rs b/crates/defguard_core/src/enterprise/snat/handlers.rs index 7a2c360f4a..94b24a6797 100644 --- a/crates/defguard_core/src/enterprise/snat/handlers.rs +++ b/crates/defguard_core/src/enterprise/snat/handlers.rs @@ -1,3 +1,5 @@ +use std::net::IpAddr; + use axum::{ Json, extract::{Path, State}, @@ -5,7 +7,6 @@ use axum::{ use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use serde_json::json; -use std::net::IpAddr; use utoipa::ToSchema; use crate::{ diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index 436c923d66..19bac7540e 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -190,7 +190,6 @@ pub(crate) fn server_config() -> &'static DefGuardConfig { pub(crate) const KEY_LENGTH: usize = 32; mod openapi { - use crate::enterprise::snat::handlers as snat; use db::{ AddDevice, UserDetails, UserInfo, models::device::{ModifyDevice, UserDevice}, @@ -208,6 +207,7 @@ mod openapi { }; use super::*; + use crate::enterprise::snat::handlers as snat; #[derive(OpenApi)] #[openapi( diff --git a/crates/defguard_core/tests/integration/common/mod.rs b/crates/defguard_core/tests/integration/common/mod.rs index e167b3146d..32c71664c1 100644 --- a/crates/defguard_core/tests/integration/common/mod.rs +++ b/crates/defguard_core/tests/integration/common/mod.rs @@ -5,6 +5,7 @@ use std::{ sync::{Arc, Mutex}, }; +pub use defguard_core::db::setup_pool; use defguard_core::{ SERVER_CONFIG, auth::failed_login::FailedLoginMap, @@ -33,8 +34,6 @@ use tokio::{ }, }; -pub use defguard_core::db::setup_pool; - use self::client::TestClient; #[allow(clippy::declare_interior_mutable_const)] diff --git a/crates/defguard_event_logger/src/lib.rs b/crates/defguard_event_logger/src/lib.rs index 8b98bb288e..3d04dce4eb 100644 --- a/crates/defguard_event_logger/src/lib.rs +++ b/crates/defguard_event_logger/src/lib.rs @@ -1,7 +1,4 @@ use bytes::Bytes; -use defguard_core::db::models::activity_log::metadata::{ - GroupMembersModifiedMetadata, UserGroupsModifiedMetadata, -}; use defguard_core::db::{ NoId, models::activity_log::{ @@ -11,11 +8,12 @@ use defguard_core::db::{ ApiTokenRenamedMetadata, AuthenticationKeyMetadata, AuthenticationKeyRenamedMetadata, ClientConfigurationTokenMetadata, DeviceMetadata, DeviceModifiedMetadata, EnrollmentDeviceAddedMetadata, EnrollmentTokenMetadata, GroupAssignedMetadata, - GroupMetadata, GroupModifiedMetadata, GroupsBulkAssignedMetadata, LoginFailedMetadata, - MfaLoginFailedMetadata, MfaLoginMetadata, MfaSecurityKeyMetadata, - NetworkDeviceMetadata, NetworkDeviceModifiedMetadata, OpenIdAppMetadata, - OpenIdAppModifiedMetadata, OpenIdAppStateChangedMetadata, OpenIdProviderMetadata, - PasswordChangedByAdminMetadata, PasswordResetMetadata, SettingsUpdateMetadata, + GroupMembersModifiedMetadata, GroupMetadata, GroupModifiedMetadata, + GroupsBulkAssignedMetadata, LoginFailedMetadata, MfaLoginFailedMetadata, + MfaLoginMetadata, MfaSecurityKeyMetadata, NetworkDeviceMetadata, + NetworkDeviceModifiedMetadata, OpenIdAppMetadata, OpenIdAppModifiedMetadata, + OpenIdAppStateChangedMetadata, OpenIdProviderMetadata, PasswordChangedByAdminMetadata, + PasswordResetMetadata, SettingsUpdateMetadata, UserGroupsModifiedMetadata, UserMetadata, UserMfaDisabledMetadata, UserModifiedMetadata, UserSnatBindingMetadata, UserSnatBindingModifiedMetadata, VpnClientMetadata, VpnClientMfaFailedMetadata, VpnClientMfaMetadata, VpnLocationMetadata, VpnLocationModifiedMetadata, From 7c9202db658002e11cc794dd063fb57766681037 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Mon, 18 Aug 2025 13:49:06 +0200 Subject: [PATCH 09/11] sqlx --- ...38efa9acb62c4bd9b646846740333d8ae3d154d1d77.json} | 12 +++++++++--- ...90784fc40392f423c16cd326716994fcb1f45c84eee.json} | 12 +++++++++--- ...aa57d9477cbad81d77c2db2b67dca90de198721b483.json} | 7 ++++--- ...c546b014818139cdfac6ed6c15603f6e6806c63ac6f.json} | 12 +++++++++--- ...fc7a4d3d29e22e58f33e902c06bbf6163ee48ae802a.json} | 12 +++++++++--- ...6e53e781bab2a0e2020e33653ef34ab1ea7df67a0cb.json} | 5 +++-- ...5dd5978c56cc1e6b0c6fbf1907cce4bbe0a1bde88ff.json} | 7 ++++--- .../src/enterprise/directory_sync/jumpcloud.rs | 4 ---- .../src/enterprise/directory_sync/tests.rs | 4 ++-- .../defguard_core/tests/integration/openid_login.rs | 1 + crates/defguard_core/tests/integration/wireguard.rs | 2 ++ 11 files changed, 52 insertions(+), 26 deletions(-) rename .sqlx/{query-770fcf951f69a40e2e9833486425dc105a0411bd634a080391e41f431f966c17.json => query-06bbd4a7662ea9ec62a0138efa9acb62c4bd9b646846740333d8ae3d154d1d77.json} (92%) rename .sqlx/{query-5e304fafd2e6b526042c2f43e038f6464ef320242782b486f7e17c7742eec1f0.json => query-07ac05be4850e0154414090784fc40392f423c16cd326716994fcb1f45c84eee.json} (92%) rename .sqlx/{query-0d16965b4248d7297b92c0d14ded508dbd1407c8963b3fb240ad24b84fdf5fab.json => query-187b82f0cc866ff2f1049aa57d9477cbad81d77c2db2b67dca90de198721b483.json} (87%) rename .sqlx/{query-d2c2173b83c2948b01c2571c5a929a3c89e0725d2d2d7a1aa6739f1870a4fd05.json => query-6c3bbaa998dbb9d0b3771c546b014818139cdfac6ed6c15603f6e6806c63ac6f.json} (92%) rename .sqlx/{query-b84b09a440fab66250603e50a3080fc67194a7de7cf7241d938b25f068525411.json => query-9f98a138560451105b104fc7a4d3d29e22e58f33e902c06bbf6163ee48ae802a.json} (92%) rename .sqlx/{query-9564c6bf55964238003a93c4047ea956656c9ef58f46ede7bc8225900ade4579.json => query-d4d76206a3eeb48f4c3e06e53e781bab2a0e2020e33653ef34ab1ea7df67a0cb.json} (91%) rename .sqlx/{query-406d99b05beaa7cbd8554aadd7a16ceb4e139f131e33a032dd4b719a937de935.json => query-dce467a600d7b0e51d1b75dd5978c56cc1e6b0c6fbf1907cce4bbe0a1bde88ff.json} (85%) diff --git a/.sqlx/query-770fcf951f69a40e2e9833486425dc105a0411bd634a080391e41f431f966c17.json b/.sqlx/query-06bbd4a7662ea9ec62a0138efa9acb62c4bd9b646846740333d8ae3d154d1d77.json similarity index 92% rename from .sqlx/query-770fcf951f69a40e2e9833486425dc105a0411bd634a080391e41f431f966c17.json rename to .sqlx/query-06bbd4a7662ea9ec62a0138efa9acb62c4bd9b646846740333d8ae3d154d1d77.json index 418350329a..dec553bccb 100644 --- a/.sqlx/query-770fcf951f69a40e2e9833486425dc105a0411bd634a080391e41f431f966c17.json +++ b/.sqlx/query-06bbd4a7662ea9ec62a0138efa9acb62c4bd9b646846740333d8ae3d154d1d77.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, name, base_url, client_id, client_secret, display_name, google_service_account_key, google_service_account_email, admin_email, directory_sync_enabled,\n directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", directory_sync_target \"directory_sync_target: DirectorySyncTarget\", okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match FROM openidprovider WHERE name = $1", + "query": "SELECT id, name, base_url, client_id, client_secret, display_name, google_service_account_key, google_service_account_email, admin_email, directory_sync_enabled,\n directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", directory_sync_target \"directory_sync_target: DirectorySyncTarget\", okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match, jumpcloud_api_key FROM openidprovider WHERE name = $1", "describe": { "columns": [ { @@ -120,6 +120,11 @@ "ordinal": 16, "name": "directory_sync_group_match", "type_info": "TextArray" + }, + { + "ordinal": 17, + "name": "jumpcloud_api_key", + "type_info": "Text" } ], "parameters": { @@ -144,8 +149,9 @@ false, true, true, - false + false, + true ] }, - "hash": "770fcf951f69a40e2e9833486425dc105a0411bd634a080391e41f431f966c17" + "hash": "06bbd4a7662ea9ec62a0138efa9acb62c4bd9b646846740333d8ae3d154d1d77" } diff --git a/.sqlx/query-5e304fafd2e6b526042c2f43e038f6464ef320242782b486f7e17c7742eec1f0.json b/.sqlx/query-07ac05be4850e0154414090784fc40392f423c16cd326716994fcb1f45c84eee.json similarity index 92% rename from .sqlx/query-5e304fafd2e6b526042c2f43e038f6464ef320242782b486f7e17c7742eec1f0.json rename to .sqlx/query-07ac05be4850e0154414090784fc40392f423c16cd326716994fcb1f45c84eee.json index 73ad3046b5..5a629b4c9f 100644 --- a/.sqlx/query-5e304fafd2e6b526042c2f43e038f6464ef320242782b486f7e17c7742eec1f0.json +++ b/.sqlx/query-07ac05be4850e0154414090784fc40392f423c16cd326716994fcb1f45c84eee.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"name\",\"base_url\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\" \"directory_sync_user_behavior: _\",\"directory_sync_admin_behavior\" \"directory_sync_admin_behavior: _\",\"directory_sync_target\" \"directory_sync_target: _\",\"okta_private_jwk\",\"okta_dirsync_client_id\",\"directory_sync_group_match\" \"directory_sync_group_match: _\" FROM \"openidprovider\"", + "query": "SELECT id, \"name\",\"base_url\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\" \"directory_sync_user_behavior: _\",\"directory_sync_admin_behavior\" \"directory_sync_admin_behavior: _\",\"directory_sync_target\" \"directory_sync_target: _\",\"okta_private_jwk\",\"okta_dirsync_client_id\",\"directory_sync_group_match\" \"directory_sync_group_match: _\",\"jumpcloud_api_key\" FROM \"openidprovider\"", "describe": { "columns": [ { @@ -120,6 +120,11 @@ "ordinal": 16, "name": "directory_sync_group_match: _", "type_info": "TextArray" + }, + { + "ordinal": 17, + "name": "jumpcloud_api_key", + "type_info": "Text" } ], "parameters": { @@ -142,8 +147,9 @@ false, true, true, - false + false, + true ] }, - "hash": "5e304fafd2e6b526042c2f43e038f6464ef320242782b486f7e17c7742eec1f0" + "hash": "07ac05be4850e0154414090784fc40392f423c16cd326716994fcb1f45c84eee" } diff --git a/.sqlx/query-0d16965b4248d7297b92c0d14ded508dbd1407c8963b3fb240ad24b84fdf5fab.json b/.sqlx/query-187b82f0cc866ff2f1049aa57d9477cbad81d77c2db2b67dca90de198721b483.json similarity index 87% rename from .sqlx/query-0d16965b4248d7297b92c0d14ded508dbd1407c8963b3fb240ad24b84fdf5fab.json rename to .sqlx/query-187b82f0cc866ff2f1049aa57d9477cbad81d77c2db2b67dca90de198721b483.json index c182462846..3576fdb99b 100644 --- a/.sqlx/query-0d16965b4248d7297b92c0d14ded508dbd1407c8963b3fb240ad24b84fdf5fab.json +++ b/.sqlx/query-187b82f0cc866ff2f1049aa57d9477cbad81d77c2db2b67dca90de198721b483.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE \"openidprovider\" SET \"name\" = $2,\"base_url\" = $3,\"client_id\" = $4,\"client_secret\" = $5,\"display_name\" = $6,\"google_service_account_key\" = $7,\"google_service_account_email\" = $8,\"admin_email\" = $9,\"directory_sync_enabled\" = $10,\"directory_sync_interval\" = $11,\"directory_sync_user_behavior\" = $12,\"directory_sync_admin_behavior\" = $13,\"directory_sync_target\" = $14,\"okta_private_jwk\" = $15,\"okta_dirsync_client_id\" = $16,\"directory_sync_group_match\" = $17 WHERE id = $1", + "query": "UPDATE \"openidprovider\" SET \"name\" = $2,\"base_url\" = $3,\"client_id\" = $4,\"client_secret\" = $5,\"display_name\" = $6,\"google_service_account_key\" = $7,\"google_service_account_email\" = $8,\"admin_email\" = $9,\"directory_sync_enabled\" = $10,\"directory_sync_interval\" = $11,\"directory_sync_user_behavior\" = $12,\"directory_sync_admin_behavior\" = $13,\"directory_sync_target\" = $14,\"okta_private_jwk\" = $15,\"okta_dirsync_client_id\" = $16,\"directory_sync_group_match\" = $17,\"jumpcloud_api_key\" = $18 WHERE id = $1", "describe": { "columns": [], "parameters": { @@ -54,10 +54,11 @@ }, "Text", "Text", - "TextArray" + "TextArray", + "Text" ] }, "nullable": [] }, - "hash": "0d16965b4248d7297b92c0d14ded508dbd1407c8963b3fb240ad24b84fdf5fab" + "hash": "187b82f0cc866ff2f1049aa57d9477cbad81d77c2db2b67dca90de198721b483" } diff --git a/.sqlx/query-d2c2173b83c2948b01c2571c5a929a3c89e0725d2d2d7a1aa6739f1870a4fd05.json b/.sqlx/query-6c3bbaa998dbb9d0b3771c546b014818139cdfac6ed6c15603f6e6806c63ac6f.json similarity index 92% rename from .sqlx/query-d2c2173b83c2948b01c2571c5a929a3c89e0725d2d2d7a1aa6739f1870a4fd05.json rename to .sqlx/query-6c3bbaa998dbb9d0b3771c546b014818139cdfac6ed6c15603f6e6806c63ac6f.json index 5866d742d6..8b48d798c8 100644 --- a/.sqlx/query-d2c2173b83c2948b01c2571c5a929a3c89e0725d2d2d7a1aa6739f1870a4fd05.json +++ b/.sqlx/query-6c3bbaa998dbb9d0b3771c546b014818139cdfac6ed6c15603f6e6806c63ac6f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, name, base_url, client_id, client_secret, display_name, google_service_account_key, google_service_account_email, admin_email, directory_sync_enabled, directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", directory_sync_target \"directory_sync_target: DirectorySyncTarget\", okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match FROM openidprovider LIMIT 1", + "query": "SELECT id, name, base_url, client_id, client_secret, display_name, google_service_account_key, google_service_account_email, admin_email, directory_sync_enabled, directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", directory_sync_target \"directory_sync_target: DirectorySyncTarget\", okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match, jumpcloud_api_key FROM openidprovider LIMIT 1", "describe": { "columns": [ { @@ -120,6 +120,11 @@ "ordinal": 16, "name": "directory_sync_group_match", "type_info": "TextArray" + }, + { + "ordinal": 17, + "name": "jumpcloud_api_key", + "type_info": "Text" } ], "parameters": { @@ -142,8 +147,9 @@ false, true, true, - false + false, + true ] }, - "hash": "d2c2173b83c2948b01c2571c5a929a3c89e0725d2d2d7a1aa6739f1870a4fd05" + "hash": "6c3bbaa998dbb9d0b3771c546b014818139cdfac6ed6c15603f6e6806c63ac6f" } diff --git a/.sqlx/query-b84b09a440fab66250603e50a3080fc67194a7de7cf7241d938b25f068525411.json b/.sqlx/query-9f98a138560451105b104fc7a4d3d29e22e58f33e902c06bbf6163ee48ae802a.json similarity index 92% rename from .sqlx/query-b84b09a440fab66250603e50a3080fc67194a7de7cf7241d938b25f068525411.json rename to .sqlx/query-9f98a138560451105b104fc7a4d3d29e22e58f33e902c06bbf6163ee48ae802a.json index 5015312198..f847621f43 100644 --- a/.sqlx/query-b84b09a440fab66250603e50a3080fc67194a7de7cf7241d938b25f068525411.json +++ b/.sqlx/query-9f98a138560451105b104fc7a4d3d29e22e58f33e902c06bbf6163ee48ae802a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"name\",\"base_url\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\" \"directory_sync_user_behavior: _\",\"directory_sync_admin_behavior\" \"directory_sync_admin_behavior: _\",\"directory_sync_target\" \"directory_sync_target: _\",\"okta_private_jwk\",\"okta_dirsync_client_id\",\"directory_sync_group_match\" \"directory_sync_group_match: _\" FROM \"openidprovider\" WHERE id = $1", + "query": "SELECT id, \"name\",\"base_url\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\" \"directory_sync_user_behavior: _\",\"directory_sync_admin_behavior\" \"directory_sync_admin_behavior: _\",\"directory_sync_target\" \"directory_sync_target: _\",\"okta_private_jwk\",\"okta_dirsync_client_id\",\"directory_sync_group_match\" \"directory_sync_group_match: _\",\"jumpcloud_api_key\" FROM \"openidprovider\" WHERE id = $1", "describe": { "columns": [ { @@ -120,6 +120,11 @@ "ordinal": 16, "name": "directory_sync_group_match: _", "type_info": "TextArray" + }, + { + "ordinal": 17, + "name": "jumpcloud_api_key", + "type_info": "Text" } ], "parameters": { @@ -144,8 +149,9 @@ false, true, true, - false + false, + true ] }, - "hash": "b84b09a440fab66250603e50a3080fc67194a7de7cf7241d938b25f068525411" + "hash": "9f98a138560451105b104fc7a4d3d29e22e58f33e902c06bbf6163ee48ae802a" } diff --git a/.sqlx/query-9564c6bf55964238003a93c4047ea956656c9ef58f46ede7bc8225900ade4579.json b/.sqlx/query-d4d76206a3eeb48f4c3e06e53e781bab2a0e2020e33653ef34ab1ea7df67a0cb.json similarity index 91% rename from .sqlx/query-9564c6bf55964238003a93c4047ea956656c9ef58f46ede7bc8225900ade4579.json rename to .sqlx/query-d4d76206a3eeb48f4c3e06e53e781bab2a0e2020e33653ef34ab1ea7df67a0cb.json index 8b25a6c3fb..e28198163d 100644 --- a/.sqlx/query-9564c6bf55964238003a93c4047ea956656c9ef58f46ede7bc8225900ade4579.json +++ b/.sqlx/query-d4d76206a3eeb48f4c3e06e53e781bab2a0e2020e33653ef34ab1ea7df67a0cb.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE openidprovider SET name = $1, base_url = $2, client_id = $3, client_secret = $4, display_name = $5, google_service_account_key = $6, google_service_account_email = $7, admin_email = $8, directory_sync_enabled = $9, directory_sync_interval = $10, directory_sync_user_behavior = $11, directory_sync_admin_behavior = $12, directory_sync_target = $13, okta_private_jwk = $14, okta_dirsync_client_id = $15, directory_sync_group_match = $16 WHERE id = $17", + "query": "UPDATE openidprovider SET name = $1, base_url = $2, client_id = $3, client_secret = $4, display_name = $5, google_service_account_key = $6, google_service_account_email = $7, admin_email = $8, directory_sync_enabled = $9, directory_sync_interval = $10, directory_sync_user_behavior = $11, directory_sync_admin_behavior = $12, directory_sync_target = $13, okta_private_jwk = $14, okta_dirsync_client_id = $15, directory_sync_group_match = $16, jumpcloud_api_key = $17 WHERE id = $18", "describe": { "columns": [], "parameters": { @@ -54,10 +54,11 @@ "Text", "Text", "TextArray", + "Text", "Int8" ] }, "nullable": [] }, - "hash": "9564c6bf55964238003a93c4047ea956656c9ef58f46ede7bc8225900ade4579" + "hash": "d4d76206a3eeb48f4c3e06e53e781bab2a0e2020e33653ef34ab1ea7df67a0cb" } diff --git a/.sqlx/query-406d99b05beaa7cbd8554aadd7a16ceb4e139f131e33a032dd4b719a937de935.json b/.sqlx/query-dce467a600d7b0e51d1b75dd5978c56cc1e6b0c6fbf1907cce4bbe0a1bde88ff.json similarity index 85% rename from .sqlx/query-406d99b05beaa7cbd8554aadd7a16ceb4e139f131e33a032dd4b719a937de935.json rename to .sqlx/query-dce467a600d7b0e51d1b75dd5978c56cc1e6b0c6fbf1907cce4bbe0a1bde88ff.json index c192b0f873..8902fca66f 100644 --- a/.sqlx/query-406d99b05beaa7cbd8554aadd7a16ceb4e139f131e33a032dd4b719a937de935.json +++ b/.sqlx/query-dce467a600d7b0e51d1b75dd5978c56cc1e6b0c6fbf1907cce4bbe0a1bde88ff.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO \"openidprovider\" (\"name\",\"base_url\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\",\"directory_sync_admin_behavior\",\"directory_sync_target\",\"okta_private_jwk\",\"okta_dirsync_client_id\",\"directory_sync_group_match\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) RETURNING id", + "query": "INSERT INTO \"openidprovider\" (\"name\",\"base_url\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\",\"directory_sync_admin_behavior\",\"directory_sync_target\",\"okta_private_jwk\",\"okta_dirsync_client_id\",\"directory_sync_group_match\",\"jumpcloud_api_key\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) RETURNING id", "describe": { "columns": [ { @@ -59,12 +59,13 @@ }, "Text", "Text", - "TextArray" + "TextArray", + "Text" ] }, "nullable": [ false ] }, - "hash": "406d99b05beaa7cbd8554aadd7a16ceb4e139f131e33a032dd4b719a937de935" + "hash": "dce467a600d7b0e51d1b75dd5978c56cc1e6b0c6fbf1907cce4bbe0a1bde88ff" } diff --git a/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs b/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs index 32aac71cdf..c397e30f3a 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs @@ -78,8 +78,6 @@ struct CompiledAttributes { #[derive(Debug, Deserialize)] struct UserGroup { id: String, - #[serde(rename = "type")] - group_type: String, #[serde(rename = "compiledAttributes")] compiled_attributes: CompiledAttributes, } @@ -725,7 +723,6 @@ mod tests { // Test group with LDAP groups (uses first LDAP group name) let group_with_ldap = UserGroup { id: "group123".to_string(), - group_type: "user_group".to_string(), compiled_attributes: CompiledAttributes { ldap_groups: vec![ LdapGroup { @@ -744,7 +741,6 @@ mod tests { // Test group with empty LDAP groups (falls back to group ID) let group_empty_ldap = UserGroup { id: "group789".to_string(), - group_type: "user_group".to_string(), compiled_attributes: CompiledAttributes { ldap_groups: vec![], }, diff --git a/crates/defguard_core/src/enterprise/directory_sync/tests.rs b/crates/defguard_core/src/enterprise/directory_sync/tests.rs index 8831003b1b..54df5c6a2c 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/tests.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/tests.rs @@ -383,7 +383,7 @@ mod test { assert!(testuserdisabled.is_active); let all_users = client.get_all_users().await.unwrap(); - sync_all_users_state(&client, &pool, &wg_tx, &all_users) + sync_all_users_state(&pool, &wg_tx, &all_users) .await .unwrap(); @@ -455,7 +455,7 @@ mod test { assert!(testuserdisabled.is_active); let all_users = client.get_all_users().await.unwrap(); - sync_all_users_state(&client, &pool, &wg_tx, &all_users) + sync_all_users_state(&pool, &wg_tx, &all_users) .await .unwrap(); diff --git a/crates/defguard_core/tests/integration/openid_login.rs b/crates/defguard_core/tests/integration/openid_login.rs index d088318d24..2a11304f2e 100644 --- a/crates/defguard_core/tests/integration/openid_login.rs +++ b/crates/defguard_core/tests/integration/openid_login.rs @@ -56,6 +56,7 @@ async fn test_openid_providers(_: PgPoolOptions, options: PgConnectOptions) { okta_private_jwk: None, directory_sync_group_match: None, username_handling: OpenidUsernameHandling::PruneEmailDomain, + jumpcloud_api_key: None, }; let response = client diff --git a/crates/defguard_core/tests/integration/wireguard.rs b/crates/defguard_core/tests/integration/wireguard.rs index f3d1385e64..ab6b41ec1d 100644 --- a/crates/defguard_core/tests/integration/wireguard.rs +++ b/crates/defguard_core/tests/integration/wireguard.rs @@ -189,6 +189,7 @@ async fn test_location_mfa_mode_validation_create(_: PgPoolOptions, options: PgC okta_private_jwk: None, directory_sync_group_match: None, username_handling: OpenidUsernameHandling::PruneEmailDomain, + jumpcloud_api_key: None, }; let response = client @@ -282,6 +283,7 @@ async fn test_location_mfa_mode_validation_modify(_: PgPoolOptions, options: PgC okta_private_jwk: None, directory_sync_group_match: None, username_handling: OpenidUsernameHandling::PruneEmailDomain, + jumpcloud_api_key: None, }; let response = client From 79f788312cb561cbc496b381f7a1e7d8cbd3a703 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Tue, 19 Aug 2025 12:37:58 +0200 Subject: [PATCH 10/11] cleanup --- Cargo.toml | 6 +- .../enterprise/directory_sync/jumpcloud.rs | 97 ++++++++----------- 2 files changed, 48 insertions(+), 55 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index dc2f5fb586..cccc178a7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,7 +88,11 @@ tokio = { version = "1", features = [ ] } tokio-stream = "0.1" tokio-util = "0.7" -tonic = { version = "0.14", features = ["gzip", "tls-native-roots"] } +tonic = { version = "0.14", features = [ + "gzip", + "tls-native-roots", + "tls-ring", +] } tonic-health = "0.14" totp-lite = { version = "2.0" } tower-http = { version = "0.6", features = ["fs", "trace"] } diff --git a/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs b/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs index c397e30f3a..0ab76b71e8 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs @@ -11,7 +11,8 @@ const USER_GROUPS_URL: &str = "https://console.jumpcloud.com/api/v2/users/", &group.id); let mut query = HashMap::from([("limit", MAX_RESULTS.to_string())]); - debug!("Requesting group members from URL: {}", url); - debug!("Initial query parameters: {:?}", query); + debug!("Requesting group members from URL: {url}"); + debug!("Initial query parameters: {query:?}"); let response = client .get(&url) - .header("x-api-key", &self.api_key) + .header(API_KEY_HEADER, &self.api_key) .query(&query) .send() .await?; @@ -164,19 +165,18 @@ impl JumpCloudDirectorySync { ); for i in 1..MAX_REQUESTS { - let skip_value = i * MAX_RESULTS.parse::().unwrap(); + let skip_value = i * MAX_RESULTS; query.insert("skip", skip_value.to_string()); debug!( - "Requesting page {} (skip: {}) for group {} members", + "Requesting page {} (skip: {skip_value}) for group {} members", i + 1, - skip_value, group.id ); let response = client .get(&url) - .header("x-api-key", &self.api_key) + .header(API_KEY_HEADER, &self.api_key) .query(&query) .send() .await?; @@ -231,12 +231,12 @@ impl JumpCloudDirectorySync { let client = reqwest::Client::new(); let mut query = HashMap::from([("limit", MAX_RESULTS.to_string())]); - debug!("Initial query parameters: {:?}", query); + debug!("Initial query parameters: {query:?}"); - debug!("Sending initial request to: {}", GROUPS_URL); + debug!("Sending initial request to: {GROUPS_URL}"); let response = client .get(GROUPS_URL) - .header("x-api-key", &self.api_key) + .header(API_KEY_HEADER, &self.api_key) .query(&query) .send() .await?; @@ -248,18 +248,17 @@ impl JumpCloudDirectorySync { debug!("Initial batch fetched {} groups", all_groups_response.len()); for i in 1..MAX_REQUESTS { - let skip_value = i * MAX_RESULTS.parse::().unwrap(); + let skip_value = i * MAX_RESULTS; query.insert("skip", skip_value.to_string()); debug!( - "Requesting page {} (skip: {}) from JumpCloud API", - i + 1, - skip_value + "Requesting page {} (skip: {skip_value}) from JumpCloud API", + i + 1 ); let response = client .get(GROUPS_URL) - .header("x-api-key", &self.api_key) + .header(API_KEY_HEADER, &self.api_key) .query(&query) .send() .await?; @@ -293,12 +292,12 @@ impl JumpCloudDirectorySync { let client = reqwest::Client::new(); let mut query = HashMap::from([("limit", MAX_RESULTS.to_string())]); - debug!("Initial query parameters for users: {:?}", query); - debug!("Sending initial request to: {}", ALL_USERS_URL); + debug!("Initial query parameters for users: {query:?}"); + debug!("Sending initial request to: {ALL_USERS_URL}"); let response = client .get(ALL_USERS_URL) - .header("x-api-key", &self.api_key) + .header(API_KEY_HEADER, &self.api_key) .query(&query) .send() .await?; @@ -314,14 +313,14 @@ impl JumpCloudDirectorySync { ); for i in 1..MAX_REQUESTS { - let skip_value = i * MAX_RESULTS.parse::().unwrap(); + let skip_value = i * MAX_RESULTS; query.insert("skip", skip_value.to_string()); - debug!("Requesting page {} (skip: {}) for users", i + 1, skip_value); + debug!("Requesting page {} (skip: {skip_value}) for users", i + 1); let response = client .get(ALL_USERS_URL) - .header("x-api-key", &self.api_key) + .header(API_KEY_HEADER, &self.api_key) .query(&query) .send() .await?; @@ -363,80 +362,71 @@ impl JumpCloudDirectorySync { } async fn query_user_groups(&self, user_id: &str) -> Result, DirectorySyncError> { - debug!("Starting to query groups for user: {}", user_id); + debug!("Starting to query groups for user: {user_id}"); let client = reqwest::Client::new(); let url = USER_GROUPS_URL.replace("", user_id); let mut query = HashMap::from([("limit", MAX_RESULTS.to_string())]); - debug!("Requesting user groups from URL: {}", url); - debug!("Initial query parameters for user groups: {:?}", query); + debug!("Requesting user groups from URL: {url}"); + debug!("Initial query parameters for user groups: {query:?}"); let response = client .get(&url) - .header("x-api-key", &self.api_key) + .header(API_KEY_HEADER, &self.api_key) .query(&query) .send() .await?; debug!( - "Initial response status for user {} groups: {}", - user_id, + "Initial response status for user {user_id} groups: {}", response.status() ); let mut all_groups_response: Vec = parse_response(response, "Failed to query user groups from JumpCloud API.").await?; debug!( - "Initial batch fetched {} groups for user {}", - all_groups_response.len(), - user_id + "Initial batch fetched {} groups for user {user_id}", + all_groups_response.len() ); for i in 1..MAX_REQUESTS { - let skip_value = i * MAX_RESULTS.parse::().unwrap(); + let skip_value = i * MAX_RESULTS; query.insert("skip", skip_value.to_string()); debug!( - "Requesting page {} (skip: {}) for user {} groups", + "Requesting page {} (skip: {}) for user {user_id} groups", i + 1, skip_value, - user_id ); let response = client .get(&url) - .header("x-api-key", &self.api_key) + .header(API_KEY_HEADER, &self.api_key) .query(&query) .send() .await?; debug!( - "Page {} response status for user {} groups: {}", + "Page {} response status for user {user_id} groups: {}", i + 1, - user_id, response.status() ); let groups_response: Vec = parse_response(response, "Failed to query user groups from JumpCloud API.").await?; debug!( - "Page {} returned {} groups for user {}", + "Page {} returned {} groups for user {user_id}", i + 1, groups_response.len(), - user_id ); if groups_response.is_empty() { - debug!( - "No more groups found for user {}, stopping pagination", - user_id - ); + debug!("No more groups found for user {user_id}, stopping pagination"); break; } else { all_groups_response.extend(groups_response); debug!( - "Total groups accumulated so far for user {}: {}", - user_id, + "Total groups accumulated so far for user {user_id}: {}", all_groups_response.len() ); } @@ -445,8 +435,7 @@ impl JumpCloudDirectorySync { } debug!( - "Total groups fetched for user {}: {}", - user_id, + "Total groups fetched for user {user_id}: {}", all_groups_response.len() ); Ok(all_groups_response) @@ -455,11 +444,11 @@ impl JumpCloudDirectorySync { async fn query_test_connection(&self) -> Result<(), DirectorySyncError> { debug!("Testing connection to JumpCloud API"); let client = reqwest::Client::new(); - debug!("Sending test request to: {}", ALL_USERS_URL); + debug!("Sending test request to: {ALL_USERS_URL}"); let response = client .get(ALL_USERS_URL) - .header("x-api-key", &self.api_key) + .header(API_KEY_HEADER, &self.api_key) .send() .await?; @@ -474,18 +463,18 @@ impl JumpCloudDirectorySync { &self, email: &str, ) -> Result, DirectorySyncError> { - debug!("Starting search for user by email: {}", email); + debug!("Starting search for user by email: {email}"); let client = reqwest::Client::new(); let filter = format!("email:$eq:{email}"); - debug!("Querying JumpCloud for user with email: {}", email); - debug!("Using filter: {}", filter); - debug!("Sending request to: {}", ALL_USERS_URL); + debug!("Querying JumpCloud for user with email: {email}"); + debug!("Using filter: {filter}"); + debug!("Sending request to: {ALL_USERS_URL}"); let response = client .get(ALL_USERS_URL) - .header("x-api-key", &self.api_key) + .header(API_KEY_HEADER, &self.api_key) .query(&[("filter", &filter)]) .send() .await?; From e9c305d2736831586576ff3c23d0dd505134d796 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Tue, 19 Aug 2025 12:39:56 +0200 Subject: [PATCH 11/11] Update jumpcloud.rs --- .../src/enterprise/directory_sync/jumpcloud.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs b/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs index 0ab76b71e8..0039b89af8 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs @@ -2,8 +2,10 @@ use std::collections::HashMap; use tokio::time::sleep; -use super::{DirectoryGroup, DirectorySync, DirectorySyncError, DirectoryUser, parse_response}; -use crate::enterprise::directory_sync::REQUEST_PAGINATION_SLOWDOWN; +use super::{ + DirectoryGroup, DirectorySync, DirectorySyncError, DirectoryUser, REQUEST_PAGINATION_SLOWDOWN, + parse_response, +}; const GROUPS_URL: &str = "https://console.jumpcloud.com/api/v2/usergroups"; const ALL_USERS_URL: &str = "https://console.jumpcloud.com/api/systemusers";