diff --git a/.changeset/mighty-oranges-wait.md b/.changeset/mighty-oranges-wait.md new file mode 100644 index 000000000000..888a013d54ae --- /dev/null +++ b/.changeset/mighty-oranges-wait.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/i18n": minor +--- + +Added a "LDAP group validation strategy" setting to LDAP channels and roles sync in order to enable faster syncs diff --git a/apps/meteor/ee/server/lib/ldap/Manager.ts b/apps/meteor/ee/server/lib/ldap/Manager.ts index b99b6b08cbed..eb96784c264f 100644 --- a/apps/meteor/ee/server/lib/ldap/Manager.ts +++ b/apps/meteor/ee/server/lib/ldap/Manager.ts @@ -219,6 +219,7 @@ export class LDAPEEManager extends LDAPManager { const syncUserRolesFieldMap = (settings.get('LDAP_Sync_User_Data_RolesMap') ?? '').trim(); const syncUserRolesFilter = (settings.get('LDAP_Sync_User_Data_Roles_Filter') ?? '').trim(); const syncUserRolesBaseDN = (settings.get('LDAP_Sync_User_Data_Roles_BaseDN') ?? '').trim(); + const searchStrategy = settings.get<'once' | 'each_group'>('LDAP_Sync_User_Data_Roles_GroupMembershipValidationStrategy'); if (!shouldSyncUserRoles || !syncUserRolesFieldMap) { logger.debug('not syncing user roles'); @@ -239,42 +240,46 @@ export class LDAPEEManager extends LDAPManager { return; } - const fieldMap = this.parseJson(syncUserRolesFieldMap); - if (!fieldMap) { + const groupsToRolesMap = this.parseJson(syncUserRolesFieldMap); + if (!groupsToRolesMap) { logger.debug('missing group role mapping'); return; } - const ldapFields = Object.keys(fieldMap); + const ldapGroups = Object.keys(groupsToRolesMap); const roleList: Array = []; - const allowedRoles: Array = []; - - for await (const ldapField of ldapFields) { - if (!fieldMap[ldapField]) { - continue; - } - - const userFields = ensureArray(fieldMap[ldapField]); - - for await (const userField of userFields) { - const [roleIdOrName] = userField.split(/\.(.+)/); - + const roleIdsList: Array = []; + const allowedRoles: Array = this.getDataMappedByLdapGroups(groupsToRolesMap, ldapGroups) + .map((role) => role.split(/\.(.+)/)[0]) + .reduce((allowedRolesIds: string[], roleIdOrName: string) => { const role = roles.find((role) => role._id === roleIdOrName) ?? roles.find((role) => role.name === roleIdOrName); - if (role) { - allowedRoles.push(role._id); + allowedRolesIds.push(role._id); } - - if (await this.isUserInGroup(ldap, syncUserRolesBaseDN, syncUserRolesFilter, { dn, username }, ldapField)) { - if (role) { - roleList.push(role._id); - } - continue; + return allowedRolesIds; + }, []); + + if (searchStrategy === 'once') { + const ldapUserGroups = await this.getLdapGroupsByUsername(ldap, username, dn, syncUserRolesBaseDN, syncUserRolesFilter); + roleList.push(...this.getDataMappedByLdapGroups(groupsToRolesMap, ldapUserGroups)); + } else if (searchStrategy === 'each_group') { + for await (const ldapGroup of ldapGroups) { + if (await this.isUserInGroup(ldap, syncUserRolesBaseDN, syncUserRolesFilter, { dn, username }, ldapGroup)) { + roleList.push(...ensureArray(groupsToRolesMap[ldapGroup])); } } } - await syncUserRoles(user._id, roleList, { + for await (const nonValidatedRole of roleList) { + const [roleIdOrName] = nonValidatedRole.split(/\.(.+)/); + + const role = roles.find((role) => role._id === roleIdOrName) ?? roles.find((role) => role.name === roleIdOrName); + if (role) { + roleIdsList.push(role._id); + } + } + + await syncUserRoles(user._id, roleIdsList, { allowedRoles, skipRemovingRoles: !syncUserRolesAutoRemove, }); @@ -305,14 +310,15 @@ export class LDAPEEManager extends LDAPManager { const syncUserChannelsFieldMap = (settings.get('LDAP_Sync_User_Data_ChannelsMap') ?? '').trim(); const syncUserChannelsFilter = (settings.get('LDAP_Sync_User_Data_Channels_Filter') ?? '').trim(); const syncUserChannelsBaseDN = (settings.get('LDAP_Sync_User_Data_Channels_BaseDN') ?? '').trim(); + const searchStrategy = settings.get<'once' | 'each_group'>('LDAP_Sync_User_Data_Channels_GroupMembershipValidationStrategy'); if (!syncUserChannels || !syncUserChannelsFieldMap) { logger.debug('not syncing groups to channels'); return; } - const fieldMap = this.parseJson(syncUserChannelsFieldMap); - if (!fieldMap) { + const groupsToRoomsMap = this.parseJson(syncUserChannelsFieldMap); + if (!groupsToRoomsMap) { logger.debug('missing group channel mapping'); return; } @@ -323,56 +329,61 @@ export class LDAPEEManager extends LDAPManager { } logger.debug('syncing user channels'); - const ldapFields = Object.keys(fieldMap); + const ldapGroups = Object.keys(groupsToRoomsMap); + const ldapUserGroups: string[] = []; const channelsToAdd = new Set(); - const channelsToRemove = new Set(); - - for await (const ldapField of ldapFields) { - if (!fieldMap[ldapField]) { - continue; + const userChannelsNames: string[] = []; + + if (searchStrategy === 'once') { + ldapUserGroups.push(...(await this.getLdapGroupsByUsername(ldap, username, dn, syncUserChannelsBaseDN, syncUserChannelsFilter))); + userChannelsNames.push(...this.getDataMappedByLdapGroups(groupsToRoomsMap, ldapUserGroups)); + } else if (searchStrategy === 'each_group') { + for await (const ldapGroup of ldapGroups) { + if (await this.isUserInGroup(ldap, syncUserChannelsBaseDN, syncUserChannelsFilter, { dn, username }, ldapGroup)) { + userChannelsNames.push(...ensureArray(groupsToRoomsMap[ldapGroup])); + ldapUserGroups.push(ldapGroup); + } } + } - const isUserInGroup = await this.isUserInGroup(ldap, syncUserChannelsBaseDN, syncUserChannelsFilter, { dn, username }, ldapField); - - const channels: Array = [].concat(fieldMap[ldapField]); - for await (const channel of channels) { - try { - const name = await getValidRoomName(channel.trim(), undefined, { allowDuplicates: true }); - const room = (await Rooms.findOneByNonValidatedName(name)) || (await this.createRoomForSync(channel)); - if (!room) { - return; - } + for await (const userChannelName of userChannelsNames) { + try { + const name = await getValidRoomName(userChannelName.trim(), undefined, { allowDuplicates: true }); + const room = (await Rooms.findOneByNonValidatedName(name)) || (await this.createRoomForSync(userChannelName)); + if (!room) { + return; + } - if (isUserInGroup) { - if (room.teamMain) { - logger.error(`Can't add user to channel ${channel} because it is a team.`); - } else { - channelsToAdd.add(room._id); - } - } else if (syncUserChannelsRemove && !room.teamMain) { - channelsToRemove.add(room._id); - } - } catch (e) { - logger.debug(`Failed to sync user room, user = ${username}, channel = ${channel}`); - logger.error(e); + if (room.teamMain) { + logger.error(`Can't add user to channel ${userChannelName} because it is a team.`); + } else { + channelsToAdd.add(room._id); + await addUserToRoom(room._id, user); + logger.debug(`Synced user channel ${room._id} from LDAP for ${username}`); } + } catch (e) { + logger.debug(`Failed to sync user room, user = ${username}, channel = ${userChannelName}`); + logger.error(e); } } - for await (const rid of channelsToAdd) { - await addUserToRoom(rid, user); - logger.debug(`Synced user channel ${rid} from LDAP for ${username}`); - } + if (syncUserChannelsRemove) { + const notInUserGroups = ldapGroups.filter((ldapGroup) => !ldapUserGroups.includes(ldapGroup)); + const notMappedRooms = this.getDataMappedByLdapGroups(groupsToRoomsMap, notInUserGroups); - for await (const rid of channelsToRemove) { - if (channelsToAdd.has(rid)) { - return; - } + const roomsToRemove = new Set(notMappedRooms); + for await (const roomName of roomsToRemove) { + const name = await getValidRoomName(roomName.trim(), undefined, { allowDuplicates: true }); + const room = await Rooms.findOneByNonValidatedName(name); + if (!room || room.teamMain || channelsToAdd.has(room._id)) { + return; + } - const subscription = await SubscriptionsRaw.findOneByRoomIdAndUserId(rid, user._id); - if (subscription) { - await removeUserFromRoom(rid, user); - logger.debug(`Removed user ${username} from channel ${rid}`); + const subscription = await SubscriptionsRaw.findOneByRoomIdAndUserId(room._id, user._id); + if (subscription) { + await removeUserFromRoom(room._id, user); + logger.debug(`Removed user ${username} from channel ${room._id}`); + } } } } @@ -389,7 +400,10 @@ export class LDAPEEManager extends LDAPManager { return; } - const ldapUserTeams = await this.getLdapTeamsByUsername(ldap, user.username, dn); + const baseDN = (settings.get('LDAP_Teams_BaseDN') ?? '').trim() || ldap.options.baseDN; + const filter = settings.get('LDAP_Query_To_Get_User_Teams'); + const groupAttributeName = settings.get('LDAP_Teams_Name_Field'); + const ldapUserTeams = await this.getLdapGroupsByUsername(ldap, user.username, dn, baseDN, filter, groupAttributeName); const mapJson = settings.get('LDAP_Groups_To_Rocket_Chat_Teams'); if (!mapJson) { return; @@ -399,7 +413,7 @@ export class LDAPEEManager extends LDAPManager { return; } - const teamNames = this.getRocketChatTeamsByLdapTeams(map, ldapUserTeams); + const teamNames = this.getDataMappedByLdapGroups(map, ldapUserTeams); const allTeamNames = [...new Set(Object.values(map).flat())]; const allTeams = await Team.listByNames(allTeamNames, { projection: { _id: 1, name: 1 } }); @@ -420,39 +434,41 @@ export class LDAPEEManager extends LDAPManager { } } - private static getRocketChatTeamsByLdapTeams(mappedTeams: Record, ldapUserTeams: Array): Array { - const mappedLdapTeams = Object.keys(mappedTeams); - const filteredTeams = ldapUserTeams.filter((ldapTeam) => mappedLdapTeams.includes(ldapTeam)); + private static getDataMappedByLdapGroups(map: Record, ldapGroups: Array): Array { + const mappedLdapGroups = Object.keys(map); + const filteredMappedLdapGroups = ldapGroups.filter((ldapGroup) => mappedLdapGroups.includes(ldapGroup)); - if (filteredTeams.length < ldapUserTeams.length) { - const unmappedLdapTeams = ldapUserTeams.filter((ldapTeam) => !mappedLdapTeams.includes(ldapTeam)); - logger.error(`The following LDAP teams are not mapped in Rocket.Chat: "${unmappedLdapTeams.join(', ')}".`); + if (filteredMappedLdapGroups.length < ldapGroups.length) { + const unmappedLdapGroups = ldapGroups.filter((ldapGroup) => !mappedLdapGroups.includes(ldapGroup)); + logger.error(`The following LDAP groups are not mapped in Rocket.Chat: "${unmappedLdapGroups.join(', ')}".`); } - if (!filteredTeams.length) { + if (!filteredMappedLdapGroups.length) { return []; } - return [...new Set(filteredTeams.map((ldapTeam) => mappedTeams[ldapTeam]).flat())]; + return [...new Set(filteredMappedLdapGroups.map((ldapGroup) => map[ldapGroup]).flat())]; } - private static async getLdapTeamsByUsername(ldap: LDAPConnection, username: string, dn: string): Promise> { - const baseDN = (settings.get('LDAP_Teams_BaseDN') ?? '').trim() || ldap.options.baseDN; - const query = settings.get('LDAP_Query_To_Get_User_Teams'); - if (!query) { + private static async getLdapGroupsByUsername( + ldap: LDAPConnection, + username: string, + userDN: string, + baseDN: string, + filter: string, + groupAttributeName?: string, + ): Promise> { + if (!filter) { return []; } const searchOptions = { - filter: query.replace(/#{username}/g, username).replace(/#{userdn}/g, dn.replace(/\\/g, '\\5c')), + filter: filter.replace(/#{username}/g, username).replace(/#{userdn}/g, userDN.replace(/\\/g, '\\5c')), scope: ldap.options.userSearchScope || 'sub', sizeLimit: ldap.options.searchSizeLimit, }; - const attributeNames = (settings.get('LDAP_Teams_Name_Field') ?? '').split(',').map((attributeName) => attributeName.trim()); - if (!attributeNames.length) { - attributeNames.push('ou'); - } + const attributeNames = groupAttributeName ? groupAttributeName.split(',').map((attributeName) => attributeName.trim()) : ['ou', 'cn']; const ldapUserGroups = await ldap.searchRaw(baseDN, searchOptions); if (!Array.isArray(ldapUserGroups)) { diff --git a/apps/meteor/ee/server/settings/ldap.ts b/apps/meteor/ee/server/settings/ldap.ts index f85f67a40641..d026d913be9d 100644 --- a/apps/meteor/ee/server/settings/ldap.ts +++ b/apps/meteor/ee/server/settings/ldap.ts @@ -157,13 +157,23 @@ export function addSettings(): Promise { invalidValue: false, }); - await this.add('LDAP_Sync_User_Data_Roles_Filter', '(&(cn=#{groupName})(memberUid=#{username}))', { + await this.add('LDAP_Sync_User_Data_Roles_BaseDN', '', { type: 'string', enableQuery: syncRolesQuery, invalidValue: '', }); - await this.add('LDAP_Sync_User_Data_Roles_BaseDN', '', { + await this.add('LDAP_Sync_User_Data_Roles_GroupMembershipValidationStrategy', 'each_group', { + type: 'select', + values: [ + { key: 'each_group', i18nLabel: 'LDAP_Sync_User_Data_GroupMembershipValidationStrategy_EachGroup' }, + { key: 'once', i18nLabel: 'LDAP_Sync_User_Data_GroupMembershipValidationStrategy_Once' }, + ], + enableQuery: syncRolesQuery, + invalidValue: 'each_group', + }); + + await this.add('LDAP_Sync_User_Data_Roles_Filter', '(&(cn=#{groupName})(memberUid=#{username}))', { type: 'string', enableQuery: syncRolesQuery, invalidValue: '', @@ -194,13 +204,23 @@ export function addSettings(): Promise { invalidValue: 'rocket.cat', }); - await this.add('LDAP_Sync_User_Data_Channels_Filter', '(&(cn=#{groupName})(memberUid=#{username}))', { + await this.add('LDAP_Sync_User_Data_Channels_BaseDN', '', { type: 'string', enableQuery: syncChannelsQuery, invalidValue: '', }); - await this.add('LDAP_Sync_User_Data_Channels_BaseDN', '', { + await this.add('LDAP_Sync_User_Data_Channels_GroupMembershipValidationStrategy', 'each_group', { + type: 'select', + values: [ + { key: 'each_group', i18nLabel: 'LDAP_Sync_User_Data_GroupMembershipValidationStrategy_EachGroup' }, + { key: 'once', i18nLabel: 'LDAP_Sync_User_Data_GroupMembershipValidationStrategy_Once' }, + ], + enableQuery: syncChannelsQuery, + invalidValue: 'each_group', + }); + + await this.add('LDAP_Sync_User_Data_Channels_Filter', '(&(cn=#{groupName})(memberUid=#{username}))', { type: 'string', enableQuery: syncChannelsQuery, invalidValue: '', diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 2d67fa8723b7..61b407d516fe 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -3097,15 +3097,21 @@ "LDAP_Sync_User_Data_Channels_Enforce_AutoChannels_Description": "**Attention**: Enabling this will remove any users in a channel that do not have the corresponding LDAP group! Only enable this if you know what you're doing.", "LDAP_Sync_User_Data_Channels_Filter": "User Group Filter", "LDAP_Sync_User_Data_Channels_Filter_Description": "The LDAP search filter used to check if a user is in a group.", + "LDAP_Sync_User_Data_Channels_GroupMembershipValidationStrategy": "Group membership validation strategy", + "LDAP_Sync_User_Data_Channels_GroupMembershipValidationStrategy_Description": "Determine how users' memberships to LDAP groups should be validated. \n - **Apply filter for each group**: apply the LDAP user group filter for each group (key) defined in the LDAP group channel map. This is slower, but can be useful in case you need to use the `#{groupName}` replacement tag to define membership; \n - **Apply filter once to get all memberships**: apply the LDAP user group filter once for each user. A given user will be considered a member of all groups returned by the LDAP search. This is a **faster** option that can be applied in case the `#{groupName}` replacement tag is not used by the filter (e.g. when filtering by the `member` field in groups).", "LDAP_Sync_User_Data_ChannelsMap": "LDAP Group Channel Map", "LDAP_Sync_User_Data_ChannelsMap_Default": "// Enable Auto Sync LDAP Groups to Channels above", "LDAP_Sync_User_Data_ChannelsMap_Description": "Map LDAP groups to Rocket.Chat channels. \n As an example, `{\"employee\":\"general\"}` will add any user in the LDAP group employee, to the general channel.", + "LDAP_Sync_User_Data_GroupMembershipValidationStrategy_EachGroup": "Apply filter for each group", + "LDAP_Sync_User_Data_GroupMembershipValidationStrategy_Once": "Apply filter once to get all memberships", "LDAP_Sync_User_Data_Roles_AutoRemove": "Auto Remove User Roles", "LDAP_Sync_User_Data_Roles_AutoRemove_Description": "**Attention**: Enabling this will automatically remove users from a role if they are not assigned in LDAP! This will only remove roles automatically that are set under the user data group map below.", "LDAP_Sync_User_Data_Roles_BaseDN": "LDAP Group BaseDN", "LDAP_Sync_User_Data_Roles_BaseDN_Description": "The LDAP BaseDN used to lookup users.", "LDAP_Sync_User_Data_Roles_Filter": "User Group Filter", "LDAP_Sync_User_Data_Roles_Filter_Description": "The LDAP search filter used to check if a user is in a group.", + "LDAP_Sync_User_Data_Roles_GroupMembershipValidationStrategy": "Group membership validation strategy", + "LDAP_Sync_User_Data_Roles_GroupMembershipValidationStrategy_Description": "Determine how users' memberships to LDAP groups should be validated. \n - **Apply filter for each group**: apply the LDAP user group filter for each group (key) defined in the LDAP group channel map. This is slower, but can be useful in case you need to use the `#{groupName}` replacement tag to define membership; \n - **Apply filter once to get all memberships**: apply the LDAP user group filter once for each user. A given user will be considered a member of all groups returned by the LDAP search. This is a **faster** option that can be applied in case the `#{groupName}` replacement tag is not used by the filter (e.g. when filtering by the `member` field in groups).", "LDAP_Sync_User_Data_RolesMap": "User Data Group Map", "LDAP_Sync_User_Data_RolesMap_Description": "Map LDAP groups to Rocket.Chat user roles \n As an example, `{\"rocket-admin\":\"admin\", \"tech-support\":\"support\", \"manager\":[\"leader\", \"moderator\"]}` will map the rocket-admin LDAP group to Rocket's \"admin\" role.", "LDAP_Teams_BaseDN": "LDAP Teams BaseDN",