snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
110
apps/meteor/ee/server/lib/ldap/Manager.spec.ts
Normal file
110
apps/meteor/ee/server/lib/ldap/Manager.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { expect } from 'chai';
|
||||
import { beforeEach, describe, it } from 'mocha';
|
||||
import proxyquire from 'proxyquire';
|
||||
import sinon from 'sinon';
|
||||
|
||||
const settingsStub = { get: sinon.stub() };
|
||||
const roomsStub = { findOneByNonValidatedName: sinon.stub() };
|
||||
const subscriptionsStub = { findOneByRoomIdAndUserId: sinon.stub() };
|
||||
const addUserToRoomStub = sinon.stub();
|
||||
const removeUserFromRoomStub = sinon.stub();
|
||||
const loggerStub = { debug: sinon.stub(), error: sinon.stub(), info: sinon.stub(), warn: sinon.stub() };
|
||||
|
||||
const { LDAPEEManager } = proxyquire.noCallThru().load('./Manager', {
|
||||
'@rocket.chat/core-services': { Abac: {}, Team: {} },
|
||||
'@rocket.chat/license': { License: { hasModule: () => false } },
|
||||
'@rocket.chat/models': { Users: {}, Roles: {}, Subscriptions: subscriptionsStub, Rooms: roomsStub },
|
||||
'../../../../server/lib/import/definitions/IConversionCallbacks': {},
|
||||
'../../../../server/settings': { settings: settingsStub },
|
||||
'../../../../server/lib/utils/lib/getValidRoomName': { getValidRoomName: (name: string) => Promise.resolve(name) },
|
||||
'../../../../lib/utils/arrayUtils': { ensureArray: (value: unknown) => (Array.isArray(value) ? value : [value]) },
|
||||
'../../../../server/lib/ldap/Connection': { LDAPConnection: class {} },
|
||||
'../../../../server/lib/ldap/Logger': { logger: loggerStub, searchLogger: loggerStub, mapLogger: loggerStub },
|
||||
'../../../../server/lib/ldap/Manager': { LDAPManager: class {} },
|
||||
'../../../../server/lib/ldap/UserConverter': { LDAPUserConverter: class {} },
|
||||
'../../../../server/lib/rooms/addUserToRoom': { addUserToRoom: addUserToRoomStub },
|
||||
'../../../../server/lib/rooms/createRoom': { createRoom: sinon.stub() },
|
||||
'../../../../server/lib/rooms/removeUserFromRoom': { removeUserFromRoom: removeUserFromRoomStub },
|
||||
'../../../../server/lib/users/setUserActiveStatus': { setUserActiveStatus: sinon.stub() },
|
||||
'../syncUserRoles': { syncUserRoles: sinon.stub() },
|
||||
'./copyCustomFieldsLDAP': { copyCustomFieldsLDAP: sinon.stub() },
|
||||
});
|
||||
|
||||
const user = { _id: 'userId', username: 'john' };
|
||||
|
||||
const channelsMap = {
|
||||
groupA: 'channel-a',
|
||||
deadGroup: 'missing-channel',
|
||||
groupB: 'channel-b',
|
||||
};
|
||||
|
||||
const setupSettings = (map: Record<string, string>) => {
|
||||
settingsStub.get.reset();
|
||||
settingsStub.get.withArgs('LDAP_Sync_User_Data_Channels').returns(true);
|
||||
settingsStub.get.withArgs('LDAP_Sync_User_Data_Channels_Enforce_AutoChannels').returns(true);
|
||||
settingsStub.get.withArgs('LDAP_Sync_User_Data_ChannelsMap').returns(JSON.stringify(map));
|
||||
settingsStub.get.withArgs('LDAP_Sync_User_Data_Channels_Filter').returns('');
|
||||
settingsStub.get.withArgs('LDAP_Sync_User_Data_Channels_BaseDN').returns('');
|
||||
settingsStub.get.withArgs('LDAP_Sync_User_Data_Channels_GroupMembershipValidationStrategy').returns('once');
|
||||
settingsStub.get.withArgs('ABAC_Enabled').returns(false);
|
||||
};
|
||||
|
||||
const syncUserChannels = (ldapUserGroups: string[]) => {
|
||||
const groupsStub = sinon.stub(LDAPEEManager as any, 'getLdapGroupsByUsername').resolves(ldapUserGroups);
|
||||
return (LDAPEEManager as any).syncUserChannels({}, user, 'dn').finally(() => groupsStub.restore());
|
||||
};
|
||||
|
||||
describe('LDAPEEManager syncUserChannels', () => {
|
||||
beforeEach(() => {
|
||||
roomsStub.findOneByNonValidatedName.reset();
|
||||
subscriptionsStub.findOneByRoomIdAndUserId.reset();
|
||||
addUserToRoomStub.reset();
|
||||
removeUserFromRoomStub.reset();
|
||||
setupSettings(channelsMap);
|
||||
|
||||
roomsStub.findOneByNonValidatedName.withArgs('channel-a').resolves({ _id: 'ridA' });
|
||||
roomsStub.findOneByNonValidatedName.withArgs('channel-b').resolves({ _id: 'ridB' });
|
||||
roomsStub.findOneByNonValidatedName.withArgs('missing-channel').resolves(null);
|
||||
subscriptionsStub.findOneByRoomIdAndUserId.resolves({ _id: 'subId' });
|
||||
});
|
||||
|
||||
it('should remove the user from remaining channels when an earlier mapped channel cannot be resolved', async () => {
|
||||
await syncUserChannels(['groupA']);
|
||||
|
||||
expect(addUserToRoomStub.calledWith('ridA', user)).to.be.true;
|
||||
expect(removeUserFromRoomStub.calledWith('ridB', user)).to.be.true;
|
||||
});
|
||||
|
||||
it('should skip Team channels on removal without aborting the rest of the pass', async () => {
|
||||
roomsStub.findOneByNonValidatedName.withArgs('missing-channel').resolves({ _id: 'ridTeam', teamMain: true });
|
||||
|
||||
await syncUserChannels(['groupA']);
|
||||
|
||||
expect(removeUserFromRoomStub.calledWith('ridTeam', user)).to.be.false;
|
||||
expect(removeUserFromRoomStub.calledWith('ridB', user)).to.be.true;
|
||||
});
|
||||
|
||||
it('should keep a channel that was added in the same sync when another group maps it for removal, and still remove the rest', async () => {
|
||||
setupSettings({ groupA: 'channel-a', groupB: 'channel-a', groupC: 'channel-c' });
|
||||
roomsStub.findOneByNonValidatedName.withArgs('channel-c').resolves({ _id: 'ridC' });
|
||||
|
||||
await syncUserChannels(['groupA']);
|
||||
|
||||
expect(addUserToRoomStub.calledWith('ridA', user)).to.be.true;
|
||||
expect(removeUserFromRoomStub.calledWith('ridA', user)).to.be.false;
|
||||
expect(removeUserFromRoomStub.calledWith('ridC', user)).to.be.true;
|
||||
});
|
||||
|
||||
it('should keep adding and removing channels when an unresolved channel fails to be created on the add pass', async () => {
|
||||
sinon.stub(LDAPEEManager as any, 'createRoomForSync').resolves(undefined);
|
||||
|
||||
try {
|
||||
await syncUserChannels(['deadGroup', 'groupA']);
|
||||
} finally {
|
||||
((LDAPEEManager as any).createRoomForSync as sinon.SinonStub).restore();
|
||||
}
|
||||
|
||||
expect(addUserToRoomStub.calledWith('ridA', user)).to.be.true;
|
||||
expect(removeUserFromRoomStub.calledWith('ridB', user)).to.be.true;
|
||||
});
|
||||
});
|
||||
825
apps/meteor/ee/server/lib/ldap/Manager.ts
Normal file
825
apps/meteor/ee/server/lib/ldap/Manager.ts
Normal file
@@ -0,0 +1,825 @@
|
||||
import { Abac, Team } from '@rocket.chat/core-services';
|
||||
import type { ILDAPEntry, IUser, IRoom, IRole, IImportUser, IImportRecord } from '@rocket.chat/core-typings';
|
||||
import { License } from '@rocket.chat/license';
|
||||
import { Users, Roles, Subscriptions as SubscriptionsRaw, Rooms } from '@rocket.chat/models';
|
||||
import type ldapjs from 'ldapjs';
|
||||
import type { FindCursor } from 'mongodb';
|
||||
|
||||
import { copyCustomFieldsLDAP } from './copyCustomFieldsLDAP';
|
||||
import { ensureArray } from '../../../../lib/utils/arrayUtils';
|
||||
import type {
|
||||
ImporterAfterImportCallback,
|
||||
ImporterBeforeImportCallback,
|
||||
} from '../../../../server/lib/import/definitions/IConversionCallbacks';
|
||||
import { LDAPConnection } from '../../../../server/lib/ldap/Connection';
|
||||
import { logger, searchLogger, mapLogger } from '../../../../server/lib/ldap/Logger';
|
||||
import { LDAPManager } from '../../../../server/lib/ldap/Manager';
|
||||
import { LDAPUserConverter } from '../../../../server/lib/ldap/UserConverter';
|
||||
import { addUserToRoom } from '../../../../server/lib/rooms/addUserToRoom';
|
||||
import { createRoom } from '../../../../server/lib/rooms/createRoom';
|
||||
import { removeUserFromRoom } from '../../../../server/lib/rooms/removeUserFromRoom';
|
||||
import { setUserActiveStatus } from '../../../../server/lib/users/setUserActiveStatus';
|
||||
import { getValidRoomName } from '../../../../server/lib/utils/lib/getValidRoomName';
|
||||
import { settings } from '../../../../server/settings';
|
||||
import { syncUserRoles } from '../syncUserRoles';
|
||||
|
||||
export class LDAPEEManager extends LDAPManager {
|
||||
public static async sync(): Promise<void> {
|
||||
if (settings.get('LDAP_Enable') !== true || settings.get('LDAP_Background_Sync') !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const createNewUsers = settings.get<boolean>('LDAP_Background_Sync_Import_New_Users') ?? true;
|
||||
const updateExistingUsers = settings.get<boolean>('LDAP_Background_Sync_Keep_Existant_Users_Updated') ?? true;
|
||||
let disableMissingUsers = updateExistingUsers && (settings.get<boolean>('LDAP_Background_Sync_Disable_Missing_Users') ?? false);
|
||||
const mergeExistingUsers = settings.get<boolean>('LDAP_Background_Sync_Merge_Existent_Users') ?? false;
|
||||
|
||||
const options = this.getConverterOptions();
|
||||
options.skipExistingUsers = !updateExistingUsers;
|
||||
options.skipNewUsers = !createNewUsers;
|
||||
|
||||
const ldap = new LDAPConnection();
|
||||
const converter = new LDAPUserConverter(options);
|
||||
const touchedUsers = new Set<IUser['_id']>();
|
||||
|
||||
try {
|
||||
await ldap.connect();
|
||||
|
||||
if (createNewUsers || mergeExistingUsers) {
|
||||
await this.importNewUsers(ldap, converter);
|
||||
} else if (updateExistingUsers) {
|
||||
await this.updateExistingUsers(ldap, converter, disableMissingUsers);
|
||||
// Missing users will have been disabled automatically by the update operation, so no need to do a separate query for them
|
||||
disableMissingUsers = false;
|
||||
}
|
||||
|
||||
const membersOfGroupFilter = await ldap.searchMembersOfGroupFilter();
|
||||
|
||||
await converter.convertData({
|
||||
beforeImportFn: (async ({ options }: IImportRecord): Promise<boolean> => {
|
||||
if (!ldap.options.groupFilterEnabled || !ldap.options.groupFilterGroupMemberFormat) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const memberFormat = ldap.options.groupFilterGroupMemberFormat
|
||||
?.replace(/#{username}/g, options?.username || '#{username}')
|
||||
.replace(/#{userdn}/g, options?.dn || '#{userdn}');
|
||||
|
||||
return membersOfGroupFilter.includes(memberFormat);
|
||||
}) as ImporterBeforeImportCallback,
|
||||
afterImportFn: (async ({ data }, isNewRecord: boolean): Promise<void> => {
|
||||
if (data._id) {
|
||||
touchedUsers.add(data._id);
|
||||
}
|
||||
await this.advancedSync(ldap, data as IImportUser, converter, isNewRecord);
|
||||
}) as ImporterAfterImportCallback,
|
||||
});
|
||||
|
||||
if (disableMissingUsers) {
|
||||
await this.disableMissingUsers([...touchedUsers]);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err });
|
||||
}
|
||||
|
||||
ldap.disconnect();
|
||||
}
|
||||
|
||||
public static async syncAvatars(): Promise<void> {
|
||||
if (settings.get('LDAP_Enable') !== true || settings.get('LDAP_Background_Sync_Avatars') !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ldap = new LDAPConnection();
|
||||
await ldap.connect();
|
||||
|
||||
try {
|
||||
await this.updateUserAvatars(ldap);
|
||||
} finally {
|
||||
ldap.disconnect();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err });
|
||||
}
|
||||
}
|
||||
|
||||
public static async syncAbacAttributes(): Promise<void> {
|
||||
if (
|
||||
!settings.get('LDAP_Enable') ||
|
||||
!settings.get('LDAP_Background_Sync_ABAC_Attributes') ||
|
||||
!License.hasModule('abac') ||
|
||||
!settings.get('ABAC_Enabled') ||
|
||||
settings.get('ABAC_PDP_Type') === 'virtru'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ldap = new LDAPConnection();
|
||||
await ldap.connect();
|
||||
|
||||
try {
|
||||
await this.updateUserAbacAttributes(ldap);
|
||||
} finally {
|
||||
ldap.disconnect();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err });
|
||||
}
|
||||
}
|
||||
|
||||
public static async syncUsersAbacAttributes(users: FindCursor<IUser>): Promise<void> {
|
||||
if (
|
||||
!settings.get('LDAP_Enable') ||
|
||||
!License.hasModule('abac') ||
|
||||
!settings.get('ABAC_Enabled') ||
|
||||
settings.get('ABAC_PDP_Type') === 'virtru'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ldap = new LDAPConnection();
|
||||
await ldap.connect();
|
||||
|
||||
try {
|
||||
logger.debug({ msg: 'Starting ABAC attributes sync for LDAP users' });
|
||||
for await (const user of users) {
|
||||
await this.syncUserAbacAttribute(ldap, user);
|
||||
}
|
||||
} finally {
|
||||
ldap.disconnect();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err });
|
||||
}
|
||||
}
|
||||
|
||||
public static validateLDAPTeamsMappingChanges(json: string): void {
|
||||
if (!json) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mustBeAnArrayOfStrings = (array: Array<string>): boolean =>
|
||||
Boolean(Array.isArray(array) && array.length && array.every((item) => typeof item === 'string'));
|
||||
const mappedTeams = this.parseJson(json);
|
||||
if (!mappedTeams) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mappedRocketChatTeams = Object.values(mappedTeams);
|
||||
const validStructureMapping = mappedRocketChatTeams.every(mustBeAnArrayOfStrings);
|
||||
if (!validStructureMapping) {
|
||||
throw new Error(
|
||||
'Please verify your mapping for LDAP X RocketChat Teams. The structure is invalid, the structure should be an object like: {key: LdapTeam, value: [An array of rocket.chat teams]}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static validateLDAPABACAttributeMap(json: string): void {
|
||||
if (!json) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mappedAttributes = this.parseJson(json);
|
||||
|
||||
// attributes are { key: value } with key being the ldap attribute and value being the abac attribute in rocketchat
|
||||
// both strings
|
||||
// There's no need for the attribute to exist in rocketchat, we just add whatever the admin wants to map
|
||||
|
||||
if (!mappedAttributes || Object.keys(mappedAttributes).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validStructureMapping = Object.entries(mappedAttributes).every(
|
||||
([key, value]) => typeof key === 'string' && typeof value === 'string',
|
||||
);
|
||||
|
||||
if (!validStructureMapping) {
|
||||
throw new Error(
|
||||
'Please verify your mapping for LDAP X RocketChat ABAC Attributes. The structure is invalid, the structure should be an object like: {key: LdapAttribute, value: RocketChatAbacAttribute}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static async syncAvatarAndAbacAttributes(): Promise<void> {
|
||||
const syncAvatars = settings.get('LDAP_Background_Sync_Avatars');
|
||||
const syncAbac = settings.get('LDAP_Background_Sync_ABAC_Attributes') && License.hasModule('abac') && settings.get('ABAC_Enabled');
|
||||
const abacMapping = syncAbac && this.parseJson(settings.get('LDAP_ABAC_AttributeMap'));
|
||||
|
||||
if (!syncAvatars && !syncAbac) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ldap = new LDAPConnection();
|
||||
await ldap.connect();
|
||||
|
||||
try {
|
||||
const users = Users.findLDAPUsers();
|
||||
for await (const user of users) {
|
||||
const ldapUser = await this.findLDAPUser(ldap, user);
|
||||
if (!ldapUser) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (syncAvatars) {
|
||||
await LDAPManager.syncUserAvatar(user, ldapUser);
|
||||
}
|
||||
|
||||
if (syncAbac && abacMapping) {
|
||||
await Abac.addSubjectAttributes(user, ldapUser, abacMapping, undefined);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
ldap.disconnect();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err });
|
||||
}
|
||||
}
|
||||
|
||||
public static async syncLogout(): Promise<void> {
|
||||
if (settings.get('LDAP_Enable') !== true || settings.get('LDAP_Sync_AutoLogout_Enabled') !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ldap = new LDAPConnection();
|
||||
await ldap.connect();
|
||||
|
||||
try {
|
||||
await this.logoutDeactivatedUsers(ldap);
|
||||
} finally {
|
||||
ldap.disconnect();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err });
|
||||
}
|
||||
}
|
||||
|
||||
public static async advancedSyncForUser(ldap: LDAPConnection, user: IUser, isNewRecord: boolean, dn: string): Promise<void> {
|
||||
try {
|
||||
await this.syncUserRoles(ldap, user, dn);
|
||||
await this.syncUserChannels(ldap, user, dn);
|
||||
await this.syncUserTeams(ldap, user, dn, isNewRecord);
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Advanced Sync failed for user', dn, err });
|
||||
}
|
||||
}
|
||||
|
||||
private static async advancedSync(
|
||||
ldap: LDAPConnection,
|
||||
importUser: IImportUser,
|
||||
converter: LDAPUserConverter,
|
||||
isNewRecord: boolean,
|
||||
): Promise<void> {
|
||||
const user = await converter.findExistingUser(importUser);
|
||||
if (!user?.username) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dn = importUser.importIds[0];
|
||||
return this.advancedSyncForUser(ldap, user, isNewRecord, dn);
|
||||
}
|
||||
|
||||
private static async isUserInGroup(
|
||||
ldap: LDAPConnection,
|
||||
baseDN: string,
|
||||
filter: string,
|
||||
{ dn, username }: { dn: string; username: string },
|
||||
groupName: string,
|
||||
): Promise<boolean> {
|
||||
if (!filter || !baseDN) {
|
||||
logger.error('Please setup LDAP Group Filter and LDAP Group BaseDN in LDAP Settings.');
|
||||
return false;
|
||||
}
|
||||
const searchOptions: ldapjs.SearchOptions = {
|
||||
filter: filter
|
||||
.replace(/#{username}/g, username)
|
||||
.replace(/#{groupName}/g, groupName)
|
||||
.replace(/#{userdn}/g, dn.replace(/\\/g, '\\5c')),
|
||||
scope: 'sub',
|
||||
};
|
||||
|
||||
const result = await ldap.searchRaw(baseDN, searchOptions);
|
||||
|
||||
if (!Array.isArray(result) || result.length === 0) {
|
||||
logger.debug({ msg: 'User is not in group', username, groupName });
|
||||
} else {
|
||||
logger.debug({ msg: 'User is in group', username, groupName });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static parseJson(json: string): Record<string, any> | undefined {
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Unexpected error', err });
|
||||
}
|
||||
}
|
||||
|
||||
private static async syncUserRoles(ldap: LDAPConnection, user: IUser, dn: string): Promise<void> {
|
||||
const { username } = user;
|
||||
if (!username) {
|
||||
logger.debug('User has no username');
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldSyncUserRoles = settings.get<boolean>('LDAP_Sync_User_Data_Roles') ?? false;
|
||||
const syncUserRolesAutoRemove = settings.get<boolean>('LDAP_Sync_User_Data_Roles_AutoRemove') ?? false;
|
||||
const syncUserRolesFieldMap = (settings.get<string>('LDAP_Sync_User_Data_RolesMap') ?? '').trim();
|
||||
const syncUserRolesFilter = (settings.get<string>('LDAP_Sync_User_Data_Roles_Filter') ?? '').trim();
|
||||
const syncUserRolesBaseDN = (settings.get<string>('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');
|
||||
return;
|
||||
}
|
||||
|
||||
const roles = await Roles.find(
|
||||
{},
|
||||
{
|
||||
projection: {
|
||||
_id: 1,
|
||||
name: 1,
|
||||
},
|
||||
},
|
||||
).toArray();
|
||||
|
||||
if (!roles) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupsToRolesMap = this.parseJson(syncUserRolesFieldMap);
|
||||
if (!groupsToRolesMap) {
|
||||
logger.debug('missing group role mapping');
|
||||
return;
|
||||
}
|
||||
|
||||
const ldapGroups = Object.keys(groupsToRolesMap);
|
||||
const roleList: Array<IRole['_id']> = [];
|
||||
const roleIdsList: Array<IRole['_id']> = [];
|
||||
const allowedRoles: Array<IRole['_id']> = 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) {
|
||||
allowedRolesIds.push(role._id);
|
||||
}
|
||||
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<string>(groupsToRolesMap[ldapGroup]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
private static async createRoomForSync(channel: string): Promise<IRoom | undefined> {
|
||||
logger.debug({ msg: "Channel doesn't exist, creating it", channel });
|
||||
|
||||
const roomOwner = settings.get<string>('LDAP_Sync_User_Data_Channels_Admin') || '';
|
||||
|
||||
const user = await Users.findOneByUsernameIgnoringCase(roomOwner);
|
||||
if (!user) {
|
||||
logger.error({ msg: 'Unable to find user to own channel', roomOwner, channel });
|
||||
return;
|
||||
}
|
||||
|
||||
const room = await createRoom('c', channel, user, [], false, false, {
|
||||
customFields: { ldap: true },
|
||||
});
|
||||
if (!room?.rid) {
|
||||
logger.error({ msg: 'Unable to auto-create channel during LDAP sync', channel });
|
||||
return;
|
||||
}
|
||||
|
||||
room._id = room.rid;
|
||||
return room;
|
||||
}
|
||||
|
||||
private static async syncUserChannels(ldap: LDAPConnection, user: IUser, dn: string): Promise<void> {
|
||||
const syncUserChannels = settings.get<boolean>('LDAP_Sync_User_Data_Channels') ?? false;
|
||||
const syncUserChannelsRemove = settings.get<boolean>('LDAP_Sync_User_Data_Channels_Enforce_AutoChannels') ?? false;
|
||||
const syncUserChannelsFieldMap = (settings.get<string>('LDAP_Sync_User_Data_ChannelsMap') ?? '').trim();
|
||||
const syncUserChannelsFilter = (settings.get<string>('LDAP_Sync_User_Data_Channels_Filter') ?? '').trim();
|
||||
const syncUserChannelsBaseDN = (settings.get<string>('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 groupsToRoomsMap = this.parseJson(syncUserChannelsFieldMap);
|
||||
if (!groupsToRoomsMap) {
|
||||
logger.debug('missing group channel mapping');
|
||||
return;
|
||||
}
|
||||
|
||||
const { username } = user;
|
||||
if (!username) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('syncing user channels');
|
||||
const ldapGroups = Object.keys(groupsToRoomsMap);
|
||||
const ldapUserGroups: string[] = [];
|
||||
const channelsToAdd = new Set<string>();
|
||||
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<string>(groupsToRoomsMap[ldapGroup]));
|
||||
ldapUserGroups.push(ldapGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
logger.debug({ msg: 'Unable to resolve mapped channel for sync', userChannelName, username });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (settings.get('ABAC_Enabled') && room?.abacAttributes?.length) {
|
||||
logger.error({ msg: 'Cannot add user to channel. Channel is ABAC managed', userChannelName });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (room.teamMain) {
|
||||
logger.error({ msg: "Can't add user to channel because it is a team", userChannelName });
|
||||
} else {
|
||||
channelsToAdd.add(room._id);
|
||||
await addUserToRoom(room._id, user);
|
||||
logger.debug({ msg: 'Synced user channel from LDAP', roomId: room._id, username });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug({ msg: 'Failed to sync user room', username, userChannelName });
|
||||
logger.error({ err });
|
||||
}
|
||||
}
|
||||
|
||||
if (syncUserChannelsRemove) {
|
||||
const notInUserGroups = ldapGroups.filter((ldapGroup) => !ldapUserGroups.includes(ldapGroup));
|
||||
const notMappedRooms = this.getDataMappedByLdapGroups(groupsToRoomsMap, notInUserGroups);
|
||||
|
||||
const roomsToRemove = new Set<string>(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)) {
|
||||
logger.debug({ msg: 'Skipping channel on removal sync', roomName, username });
|
||||
continue;
|
||||
}
|
||||
|
||||
const subscription = await SubscriptionsRaw.findOneByRoomIdAndUserId(room._id, user._id);
|
||||
if (subscription) {
|
||||
await removeUserFromRoom(room._id, user);
|
||||
logger.debug({ msg: 'Removed user from channel', username, roomId: room._id });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async syncUserTeams(ldap: LDAPConnection, user: IUser, dn: string, isNewRecord: boolean): Promise<void> {
|
||||
if (!user.username) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mapTeams =
|
||||
settings.get<boolean>('LDAP_Enable_LDAP_Groups_To_RC_Teams') &&
|
||||
(isNewRecord || settings.get<boolean>('LDAP_Validate_Teams_For_Each_Login'));
|
||||
if (!mapTeams) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseDN = (settings.get<string>('LDAP_Teams_BaseDN') ?? '').trim() || ldap.options.baseDN;
|
||||
const filter = settings.get<string>('LDAP_Query_To_Get_User_Teams');
|
||||
const groupAttributeName = settings.get<string>('LDAP_Teams_Name_Field');
|
||||
const ldapUserTeams = await this.getLdapGroupsByUsername(ldap, user.username, dn, baseDN, filter, groupAttributeName);
|
||||
const mapJson = settings.get<string>('LDAP_Groups_To_Rocket_Chat_Teams');
|
||||
if (!mapJson) {
|
||||
return;
|
||||
}
|
||||
const map = this.parseJson(mapJson) as Record<string, string>;
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 } });
|
||||
|
||||
const inTeamIds = allTeams.filter(({ name }) => teamNames.includes(name)).map(({ _id }) => _id);
|
||||
const notInTeamIds = allTeams.filter(({ name }) => !teamNames.includes(name)).map(({ _id }) => _id);
|
||||
|
||||
const currentTeams = await Team.listTeamsBySubscriberUserId(user._id, {
|
||||
projection: { teamId: 1 },
|
||||
});
|
||||
const currentTeamIds = currentTeams?.map(({ teamId }) => teamId);
|
||||
const teamsToRemove = currentTeamIds?.filter((teamId) => notInTeamIds.includes(teamId));
|
||||
let teamsToAdd = inTeamIds.filter((teamId) => !currentTeamIds?.includes(teamId));
|
||||
|
||||
if (settings.get('ABAC_Enabled')) {
|
||||
const roomsWithAbacAttributes = await Rooms.findPrivateRoomsByIdsWithAbacAttributes(
|
||||
allTeams.filter((t) => teamsToAdd.includes(t._id)).map((t) => t.roomId),
|
||||
{ projection: { teamId: 1 } },
|
||||
)
|
||||
.map((r) => r.teamId)
|
||||
.toArray();
|
||||
|
||||
logger.debug({ msg: 'Some teams will be ignored from sync because they are abac managed', roomsWithAbacAttributes });
|
||||
|
||||
teamsToAdd = teamsToAdd.filter((teamId) => !roomsWithAbacAttributes.includes(teamId));
|
||||
if (!teamsToAdd.length) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await Team.insertMemberOnTeams(user._id, teamsToAdd);
|
||||
if (teamsToRemove) {
|
||||
await Team.removeMemberFromTeams(user._id, teamsToRemove);
|
||||
}
|
||||
}
|
||||
|
||||
private static getDataMappedByLdapGroups(map: Record<string, string>, ldapGroups: Array<string>): Array<string> {
|
||||
const mappedLdapGroups = Object.keys(map);
|
||||
const filteredMappedLdapGroups = ldapGroups.filter((ldapGroup) => mappedLdapGroups.includes(ldapGroup));
|
||||
|
||||
if (filteredMappedLdapGroups.length < ldapGroups.length) {
|
||||
const unmappedLdapGroups = ldapGroups.filter((ldapGroup) => !mappedLdapGroups.includes(ldapGroup));
|
||||
logger.error({ msg: 'The following LDAP groups are not mapped in Rocket.Chat', unmappedLdapGroups });
|
||||
}
|
||||
|
||||
if (!filteredMappedLdapGroups.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...new Set(filteredMappedLdapGroups.map((ldapGroup) => map[ldapGroup]).flat())];
|
||||
}
|
||||
|
||||
private static async getLdapGroupsByUsername(
|
||||
ldap: LDAPConnection,
|
||||
username: string,
|
||||
userDN: string,
|
||||
baseDN: string,
|
||||
filter: string,
|
||||
groupAttributeName?: string,
|
||||
): Promise<Array<string>> {
|
||||
if (!filter) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const searchOptions = {
|
||||
filter: filter.replace(/#{username}/g, username).replace(/#{userdn}/g, userDN.replace(/\\/g, '\\5c')),
|
||||
scope: ldap.options.userSearchScope || 'sub',
|
||||
sizeLimit: ldap.options.searchSizeLimit,
|
||||
};
|
||||
|
||||
const attributeNames = groupAttributeName ? groupAttributeName.split(',').map((attributeName) => attributeName.trim()) : ['ou', 'cn'];
|
||||
|
||||
const ldapUserGroups = await ldap.searchRaw(baseDN, searchOptions);
|
||||
if (!Array.isArray(ldapUserGroups)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ldapUserGroups
|
||||
.map((entry) => {
|
||||
if (!entry?.raw) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const attributeName of attributeNames) {
|
||||
if (entry.raw[attributeName]) {
|
||||
return ldap.extractLdapAttribute(entry.raw[attributeName]) as string;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.flat();
|
||||
}
|
||||
|
||||
private static isUserDeactivated(ldapUser: ILDAPEntry): boolean {
|
||||
// EDirectory: Account manually disabled by an admin
|
||||
if (ldapUser.loginDisabled) {
|
||||
mapLogger.debug('User account was manually disabled by an admin (attribute loginDisabled)');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Oracle: Account must not be allowed to authenticate
|
||||
if (ldapUser.orclIsEnabled && ldapUser.orclIsEnabled !== 'ENABLED') {
|
||||
mapLogger.debug('User must not be allowed to authenticate (attribute orclIsEnabled)');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Active Directory - Account disabled by an Admin
|
||||
if (ldapUser.userAccountControl && (ldapUser.userAccountControl & 2) === 2) {
|
||||
mapLogger.debug('User account disabled by an admin (attribute userAccountControl)');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static copyActiveState(ldapUser: ILDAPEntry, userData: IImportUser): void {
|
||||
if (!ldapUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syncUserState = settings.get('LDAP_Sync_User_Active_State');
|
||||
if (syncUserState === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = this.isUserDeactivated(ldapUser);
|
||||
if (deleted === userData.deleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncUserState === 'disable' && !deleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncUserState === 'enable' && deleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
userData.deleted = deleted;
|
||||
logger.info({ msg: 'Switching user status', name: userData.name, username: userData.username, active: !deleted });
|
||||
}
|
||||
|
||||
public static copyCustomFields(ldapUser: ILDAPEntry, userData: IImportUser): void {
|
||||
return copyCustomFieldsLDAP(
|
||||
{
|
||||
ldapUser,
|
||||
userData,
|
||||
customFieldsSettings: settings.get<string>('Accounts_CustomFields'),
|
||||
customFieldsMap: settings.get<string>('LDAP_CustomFieldMap'),
|
||||
syncCustomFields: settings.get<boolean>('LDAP_Sync_Custom_Fields'),
|
||||
},
|
||||
logger,
|
||||
);
|
||||
}
|
||||
|
||||
private static async importNewUsers(ldap: LDAPConnection, converter: LDAPUserConverter): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let count = 0;
|
||||
|
||||
ldap
|
||||
.searchAllUsers<IImportUser>({
|
||||
entryCallback: (entry: ldapjs.SearchEntry): IImportUser | undefined => {
|
||||
const data = ldap.extractLdapEntryData(entry);
|
||||
count++;
|
||||
|
||||
const userData = this.mapUserData(data);
|
||||
converter.addObjectToMemory(userData, { dn: data.dn, username: this.getLdapUsername(data) });
|
||||
return userData;
|
||||
},
|
||||
endCallback: (err: any): void => {
|
||||
if (err) {
|
||||
logger.error({ err });
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info({ msg: 'LDAP finished loading users. Users added to importer', count });
|
||||
resolve();
|
||||
},
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
private static async updateExistingUsers(ldap: LDAPConnection, converter: LDAPUserConverter, disableMissingUsers = false): Promise<void> {
|
||||
const users = await Users.findLDAPUsers().toArray();
|
||||
for await (const user of users) {
|
||||
const ldapUser = await this.findLDAPUser(ldap, user);
|
||||
|
||||
if (ldapUser) {
|
||||
const userData = this.mapUserData(ldapUser, user.username);
|
||||
converter.addObjectToMemory(userData, { dn: ldapUser.dn, username: this.getLdapUsername(ldapUser) });
|
||||
} else if (disableMissingUsers && user.active) {
|
||||
await setUserActiveStatus(user._id, false, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async disableMissingUsers(foundUsers: IUser['_id'][]): Promise<void> {
|
||||
const userIds = (await Users.findActiveLDAPUsersExceptIds(foundUsers, { projection: { _id: 1 } }).toArray()).map(({ _id }) => _id);
|
||||
|
||||
await Promise.allSettled(userIds.map((id) => setUserActiveStatus(id, false, true)));
|
||||
}
|
||||
|
||||
private static async updateUserAvatars(ldap: LDAPConnection): Promise<void> {
|
||||
const users = await Users.findLDAPUsers().toArray();
|
||||
for await (const user of users) {
|
||||
const ldapUser = await this.findLDAPUser(ldap, user);
|
||||
if (!ldapUser) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await LDAPManager.syncUserAvatar(user, ldapUser);
|
||||
}
|
||||
}
|
||||
|
||||
private static async updateUserAbacAttributes(ldap: LDAPConnection): Promise<void> {
|
||||
const mapping = this.parseJson(settings.get('LDAP_ABAC_AttributeMap'));
|
||||
if (!mapping) {
|
||||
logger.error('LDAP to ABAC attribute mapping is not valid JSON');
|
||||
return;
|
||||
}
|
||||
|
||||
for await (const user of Users.findLDAPUsers()) {
|
||||
const ldapUser = await this.findLDAPUser(ldap, user);
|
||||
if (!ldapUser) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await Abac.addSubjectAttributes(user, ldapUser, mapping, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private static async syncUserAbacAttribute(ldap: LDAPConnection, user: IUser): Promise<void> {
|
||||
const mapping = this.parseJson(settings.get('LDAP_ABAC_AttributeMap'));
|
||||
if (!mapping) {
|
||||
logger.error('LDAP to ABAC attribute mapping is not valid JSON');
|
||||
return;
|
||||
}
|
||||
|
||||
const ldapUser = await this.findLDAPUser(ldap, user);
|
||||
if (!ldapUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Abac.addSubjectAttributes(user, ldapUser, mapping, undefined);
|
||||
}
|
||||
|
||||
private static async findLDAPUser(ldap: LDAPConnection, user: IUser): Promise<ILDAPEntry | undefined> {
|
||||
if (user.services?.ldap?.id) {
|
||||
return ldap.findOneById(user.services.ldap.id, user.services.ldap.idAttribute);
|
||||
}
|
||||
|
||||
if (user.username) {
|
||||
return ldap.findOneByUsername(user.username);
|
||||
}
|
||||
|
||||
searchLogger.debug({
|
||||
msg: 'existing LDAP user not found during Sync',
|
||||
ldapId: user.services?.ldap?.id,
|
||||
ldapAttribute: user.services?.ldap?.idAttribute,
|
||||
username: user.username,
|
||||
});
|
||||
}
|
||||
|
||||
private static async logoutDeactivatedUsers(ldap: LDAPConnection): Promise<void> {
|
||||
const users = await Users.findConnectedLDAPUsers().toArray();
|
||||
|
||||
for await (const user of users) {
|
||||
const ldapUser = await this.findLDAPUser(ldap, user);
|
||||
if (!ldapUser) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.isUserDeactivated(ldapUser)) {
|
||||
await Users.unsetLoginTokens(user._id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
209
apps/meteor/ee/server/lib/ldap/copyCustomFieldsLDAP.spec.ts
Normal file
209
apps/meteor/ee/server/lib/ldap/copyCustomFieldsLDAP.spec.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import type { IImportUser, ILDAPEntry } from '@rocket.chat/core-typings';
|
||||
import type { Logger } from '@rocket.chat/logger';
|
||||
import { expect, spy } from 'chai';
|
||||
|
||||
import { copyCustomFieldsLDAP } from './copyCustomFieldsLDAP';
|
||||
|
||||
describe('LDAP copyCustomFieldsLDAP', () => {
|
||||
it('should copy custom fields from ldapUser to rcUser', () => {
|
||||
const ldapUser = {
|
||||
mail: 'test@test.com',
|
||||
givenName: 'Test',
|
||||
} as unknown as ILDAPEntry;
|
||||
|
||||
const userData = {
|
||||
name: 'Test',
|
||||
username: 'test',
|
||||
} as unknown as IImportUser;
|
||||
|
||||
copyCustomFieldsLDAP(
|
||||
{
|
||||
ldapUser,
|
||||
userData,
|
||||
syncCustomFields: true,
|
||||
customFieldsSettings: JSON.stringify({
|
||||
mappedGivenName: { type: 'text', required: false },
|
||||
}),
|
||||
customFieldsMap: JSON.stringify({
|
||||
givenName: 'mappedGivenName',
|
||||
}),
|
||||
},
|
||||
{
|
||||
debug: () => undefined,
|
||||
error: () => undefined,
|
||||
} as unknown as Logger,
|
||||
);
|
||||
|
||||
expect(userData).to.have.property('customFields');
|
||||
expect(userData.customFields).to.be.eql({ mappedGivenName: 'Test' });
|
||||
});
|
||||
|
||||
it('should copy custom fields from ldapUser to rcUser already having other custom fields', () => {
|
||||
const ldapUser = {
|
||||
mail: 'test@test.com',
|
||||
givenName: 'Test',
|
||||
} as unknown as ILDAPEntry;
|
||||
|
||||
const userData = {
|
||||
name: 'Test',
|
||||
username: 'test',
|
||||
customFields: {
|
||||
custom: 'Test',
|
||||
},
|
||||
} as unknown as IImportUser;
|
||||
|
||||
copyCustomFieldsLDAP(
|
||||
{
|
||||
ldapUser,
|
||||
userData,
|
||||
syncCustomFields: true,
|
||||
customFieldsSettings: JSON.stringify({
|
||||
mappedGivenName: { type: 'text', required: false },
|
||||
}),
|
||||
customFieldsMap: JSON.stringify({
|
||||
givenName: 'mappedGivenName',
|
||||
}),
|
||||
},
|
||||
{
|
||||
debug: () => undefined,
|
||||
error: () => undefined,
|
||||
} as unknown as Logger,
|
||||
);
|
||||
|
||||
expect(userData).to.have.property('customFields');
|
||||
expect(userData.customFields).to.be.eql({ custom: 'Test', mappedGivenName: 'Test' });
|
||||
});
|
||||
|
||||
it('should not copy custom fields from ldapUser to rcUser if syncCustomFields is false', () => {
|
||||
const ldapUser = {
|
||||
mail: 'test@test.com',
|
||||
givenName: 'Test',
|
||||
} as unknown as ILDAPEntry;
|
||||
|
||||
const userData = {
|
||||
name: 'Test',
|
||||
username: 'test',
|
||||
} as unknown as IImportUser;
|
||||
|
||||
copyCustomFieldsLDAP(
|
||||
{
|
||||
ldapUser,
|
||||
userData,
|
||||
syncCustomFields: false,
|
||||
customFieldsSettings: JSON.stringify({
|
||||
mappedGivenName: { type: 'text', required: false },
|
||||
}),
|
||||
customFieldsMap: JSON.stringify({
|
||||
givenName: 'mappedGivenName',
|
||||
}),
|
||||
},
|
||||
{
|
||||
debug: () => undefined,
|
||||
error: () => undefined,
|
||||
} as unknown as Logger,
|
||||
);
|
||||
|
||||
expect(userData).to.not.have.property('customFields');
|
||||
});
|
||||
|
||||
it('should call logger.error if customFieldsSettings is not a valid JSON', () => {
|
||||
const debug = spy();
|
||||
const error = spy();
|
||||
const ldapUser = {
|
||||
mail: 'test@test.com',
|
||||
givenName: 'Test',
|
||||
} as unknown as ILDAPEntry;
|
||||
|
||||
const userData = {
|
||||
name: 'Test',
|
||||
username: 'test',
|
||||
} as unknown as IImportUser;
|
||||
|
||||
copyCustomFieldsLDAP(
|
||||
{
|
||||
ldapUser,
|
||||
userData,
|
||||
syncCustomFields: true,
|
||||
customFieldsSettings: `${JSON.stringify({
|
||||
mappedGivenName: { type: 'text', required: false },
|
||||
})}}`,
|
||||
customFieldsMap: JSON.stringify({
|
||||
givenName: 'mappedGivenName',
|
||||
}),
|
||||
},
|
||||
{
|
||||
debug,
|
||||
error,
|
||||
} as unknown as Logger,
|
||||
);
|
||||
expect(error).to.have.been.called.exactly(1);
|
||||
});
|
||||
it('should call logger.error if customFieldsMap is not a valid JSON', () => {
|
||||
const debug = spy();
|
||||
const error = spy();
|
||||
const ldapUser = {
|
||||
mail: 'test@test.com',
|
||||
givenName: 'Test',
|
||||
} as unknown as ILDAPEntry;
|
||||
|
||||
const userData = {
|
||||
name: 'Test',
|
||||
username: 'test',
|
||||
} as unknown as IImportUser;
|
||||
|
||||
copyCustomFieldsLDAP(
|
||||
{
|
||||
ldapUser,
|
||||
userData,
|
||||
syncCustomFields: true,
|
||||
customFieldsSettings: JSON.stringify({
|
||||
mappedGivenName: { type: 'text', required: false },
|
||||
}),
|
||||
customFieldsMap: `${JSON.stringify({
|
||||
givenName: 'mappedGivenName',
|
||||
})}}`,
|
||||
},
|
||||
{
|
||||
debug,
|
||||
error,
|
||||
} as unknown as Logger,
|
||||
);
|
||||
expect(error).to.have.been.called.exactly(1);
|
||||
});
|
||||
|
||||
it('should call logger.debug if some custom fields are not mapped but still mapping the other fields', () => {
|
||||
const debug = spy();
|
||||
const error = spy();
|
||||
const ldapUser = {
|
||||
mail: 'test@test.com',
|
||||
givenName: 'Test',
|
||||
} as unknown as ILDAPEntry;
|
||||
|
||||
const userData = {
|
||||
name: 'Test',
|
||||
username: 'test',
|
||||
} as unknown as IImportUser;
|
||||
|
||||
copyCustomFieldsLDAP(
|
||||
{
|
||||
ldapUser,
|
||||
userData,
|
||||
syncCustomFields: true,
|
||||
customFieldsSettings: JSON.stringify({
|
||||
mappedGivenName: { type: 'text', required: false },
|
||||
}),
|
||||
customFieldsMap: JSON.stringify({
|
||||
givenName: 'mappedGivenName',
|
||||
test: 'test',
|
||||
}),
|
||||
},
|
||||
{
|
||||
debug,
|
||||
error,
|
||||
} as unknown as Logger,
|
||||
);
|
||||
expect(debug).to.have.been.called.exactly(1);
|
||||
expect(userData).to.have.property('customFields');
|
||||
expect(userData.customFields).to.be.eql({ mappedGivenName: 'Test' });
|
||||
});
|
||||
});
|
||||
71
apps/meteor/ee/server/lib/ldap/copyCustomFieldsLDAP.ts
Normal file
71
apps/meteor/ee/server/lib/ldap/copyCustomFieldsLDAP.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { IImportUser, ILDAPEntry } from '@rocket.chat/core-typings';
|
||||
import type { Logger } from '@rocket.chat/logger';
|
||||
|
||||
import { replacesNestedValues } from './replacesNestedValues';
|
||||
import { getNestedProp } from '../../../../server/lib/getNestedProp';
|
||||
import { templateVarHandler } from '../../../../server/lib/utils/lib/templateVarHandler';
|
||||
|
||||
export const copyCustomFieldsLDAP = (
|
||||
{
|
||||
ldapUser,
|
||||
userData,
|
||||
customFieldsSettings,
|
||||
customFieldsMap,
|
||||
syncCustomFields,
|
||||
}: {
|
||||
ldapUser: ILDAPEntry;
|
||||
userData: IImportUser;
|
||||
syncCustomFields: boolean;
|
||||
customFieldsSettings: string;
|
||||
customFieldsMap: string;
|
||||
},
|
||||
logger: Logger,
|
||||
): void => {
|
||||
if (!syncCustomFields) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!customFieldsMap || !customFieldsSettings) {
|
||||
if (customFieldsMap) {
|
||||
logger.debug('Skipping LDAP custom fields because there are no custom map fields configured.');
|
||||
return;
|
||||
}
|
||||
logger.debug('Skipping LDAP custom fields because there are no custom fields configured.');
|
||||
return;
|
||||
}
|
||||
|
||||
const map: Record<string, string> = (() => {
|
||||
try {
|
||||
return JSON.parse(customFieldsMap);
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error parsing LDAP custom fields map.', err });
|
||||
}
|
||||
})();
|
||||
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
let customFields: Record<string, any>;
|
||||
try {
|
||||
customFields = JSON.parse(customFieldsSettings) as Record<string, any>;
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Failed to parse Custom Fields', err });
|
||||
return;
|
||||
}
|
||||
|
||||
Object.entries(map).forEach(([ldapField, userField]) => {
|
||||
if (!getNestedProp(customFields, userField)) {
|
||||
logger.debug({ msg: 'User attribute does not exist', userField });
|
||||
return;
|
||||
}
|
||||
|
||||
const value = templateVarHandler(ldapField, ldapUser);
|
||||
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
userData.customFields = replacesNestedValues({ ...userData.customFields }, userField, value);
|
||||
});
|
||||
};
|
||||
87
apps/meteor/ee/server/lib/ldap/replacesNestedValues.spec.ts
Normal file
87
apps/meteor/ee/server/lib/ldap/replacesNestedValues.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { expect } from 'chai';
|
||||
|
||||
import { replacesNestedValues } from './replacesNestedValues';
|
||||
|
||||
describe('LDAP replacesNestedValues', () => {
|
||||
it('should replace shallow values', () => {
|
||||
const result = replacesNestedValues(
|
||||
{
|
||||
a: 1,
|
||||
},
|
||||
'a',
|
||||
2,
|
||||
);
|
||||
expect(result).to.eql({
|
||||
a: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should replace undefined values', () => {
|
||||
const result = replacesNestedValues({}, 'a', 2);
|
||||
expect(result).to.eql({
|
||||
a: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should replace nested values', () => {
|
||||
const result = replacesNestedValues(
|
||||
{
|
||||
a: {
|
||||
b: 1,
|
||||
},
|
||||
},
|
||||
'a.b',
|
||||
2,
|
||||
);
|
||||
expect(result).to.eql({
|
||||
a: {
|
||||
b: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should replace undefined nested values', () => {
|
||||
const result = replacesNestedValues(
|
||||
{
|
||||
a: {},
|
||||
},
|
||||
'a.b',
|
||||
2,
|
||||
);
|
||||
expect(result).to.eql({
|
||||
a: {
|
||||
b: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail if the value being replaced is not an object', () => {
|
||||
expect(() =>
|
||||
replacesNestedValues(
|
||||
{
|
||||
a: [],
|
||||
},
|
||||
'a.b',
|
||||
2,
|
||||
),
|
||||
).to.throw();
|
||||
expect(() =>
|
||||
replacesNestedValues(
|
||||
{
|
||||
a: 1,
|
||||
},
|
||||
'a.b',
|
||||
2,
|
||||
),
|
||||
).to.throw();
|
||||
|
||||
expect(() =>
|
||||
replacesNestedValues(
|
||||
{
|
||||
a: { b: [] },
|
||||
},
|
||||
'a.b',
|
||||
2,
|
||||
),
|
||||
).to.throw();
|
||||
});
|
||||
});
|
||||
26
apps/meteor/ee/server/lib/ldap/replacesNestedValues.ts
Normal file
26
apps/meteor/ee/server/lib/ldap/replacesNestedValues.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export const replacesNestedValues = (obj: Record<string, unknown>, key: string, value: unknown): Record<string, unknown> => {
|
||||
const keys = key.split('.');
|
||||
const lastKey = keys.shift();
|
||||
|
||||
if (!lastKey) {
|
||||
throw new Error(`Failed to assign custom field: ${key}`);
|
||||
}
|
||||
|
||||
if (keys.length && obj[lastKey] !== undefined && (typeof obj[lastKey] !== 'object' || Array.isArray(obj[lastKey]))) {
|
||||
throw new Error(`Failed to assign custom field: ${key}`);
|
||||
}
|
||||
|
||||
if (keys.length === 0 && typeof obj[lastKey] === 'object') {
|
||||
throw new Error(`Failed to assign custom field: ${key}`);
|
||||
}
|
||||
|
||||
return {
|
||||
...obj,
|
||||
...(keys.length === 0 && {
|
||||
[lastKey]: value,
|
||||
}),
|
||||
...(keys.length > 0 && {
|
||||
[lastKey]: replacesNestedValues(obj[lastKey] as Record<string, unknown>, keys.join('.'), value),
|
||||
}),
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user