diff --git a/apps/meteor/.mocharc.js b/apps/meteor/.mocharc.js index f8118d74f7c..3a7c511d8a2 100644 --- a/apps/meteor/.mocharc.js +++ b/apps/meteor/.mocharc.js @@ -24,6 +24,7 @@ module.exports = { ...base, // see https://github.com/mochajs/mocha/issues/3916 exit: true, spec: [ + 'ee/server/lib/ldap/*.spec.ts', 'ee/tests/**/*.tests.ts', 'ee/tests/**/*.spec.ts', 'tests/unit/app/**/*.spec.ts', diff --git a/apps/meteor/app/importer/server/classes/ImportDataConverter.ts b/apps/meteor/app/importer/server/classes/ImportDataConverter.ts index 74eec85b942..3d10e43ff09 100644 --- a/apps/meteor/app/importer/server/classes/ImportDataConverter.ts +++ b/apps/meteor/app/importer/server/classes/ImportDataConverter.ts @@ -217,7 +217,10 @@ export class ImportDataConverter { continue; } - updateData.$set[keyPath] = source[key]; + updateData.$set = { + ...updateData.$set, + ...{ [keyPath]: source[key] }, + }; } }; @@ -237,16 +240,16 @@ export class ImportDataConverter { } // #ToDo: #TODO: Move this to the model class - const updateData: Record = { - $set: { + const updateData: Record = Object.assign(Object.create(null), { + $set: Object.assign(Object.create(null), { ...(userData.roles && { roles: userData.roles }), ...(userData.type && { type: userData.type }), ...(userData.statusText && { statusText: userData.statusText }), ...(userData.bio && { bio: userData.bio }), ...(userData.services?.ldap && { ldap: true }), ...(userData.avatarUrl && { _pendingAvatarUrl: userData.avatarUrl }), - }, - }; + }), + }); this.addCustomFields(updateData, userData); this.addUserServices(updateData, userData); diff --git a/apps/meteor/app/utils/client/index.ts b/apps/meteor/app/utils/client/index.ts index 26bc7cd5d5b..8029a0d4826 100644 --- a/apps/meteor/app/utils/client/index.ts +++ b/apps/meteor/app/utils/client/index.ts @@ -9,6 +9,5 @@ export { getUserNotificationPreference } from '../lib/getUserNotificationPrefere export { getAvatarColor } from '../lib/getAvatarColor'; export { getURL } from '../lib/getURL'; export { placeholders } from '../lib/placeholders'; -export { templateVarHandler } from '../lib/templateVarHandler'; export { APIClient } from './lib/RestApiClient'; export { secondsToHHMMSS } from '../../../lib/utils/secondsToHHMMSS'; diff --git a/apps/meteor/app/utils/lib/templateVarHandler.js b/apps/meteor/app/utils/lib/templateVarHandler.js index a04c8391e15..309cdc49bb2 100644 --- a/apps/meteor/app/utils/lib/templateVarHandler.js +++ b/apps/meteor/app/utils/lib/templateVarHandler.js @@ -1,11 +1,6 @@ -import { Meteor } from 'meteor/meteor'; +import { Logger } from '../../../server/lib/logger/Logger'; -let logger; - -if (Meteor.isServer) { - const { Logger } = require('../../../server/lib/logger/Logger'); - logger = new Logger('TemplateVarHandler'); -} +const logger = new Logger('TemplateVarHandler'); export const templateVarHandler = function (variable, object) { const templateRegex = /#{([\w\-]+)}/gi; diff --git a/apps/meteor/ee/server/lib/ldap/Manager.ts b/apps/meteor/ee/server/lib/ldap/Manager.ts index 1be6c3bb78d..02c7766e592 100644 --- a/apps/meteor/ee/server/lib/ldap/Manager.ts +++ b/apps/meteor/ee/server/lib/ldap/Manager.ts @@ -1,4 +1,3 @@ -import _ from 'underscore'; import type ldapjs from 'ldapjs'; import type { ILDAPEntry, IUser, IRoom, ICreatedRoom, IRole, IImportUser } from '@rocket.chat/core-typings'; import { Users as UsersRaw, Roles, Subscriptions as SubscriptionsRaw } from '@rocket.chat/models'; @@ -10,11 +9,11 @@ import { LDAPDataConverter } from '../../../../server/lib/ldap/DataConverter'; import { LDAPConnection } from '../../../../server/lib/ldap/Connection'; import { LDAPManager } from '../../../../server/lib/ldap/Manager'; import { logger, searchLogger, mapLogger } from '../../../../server/lib/ldap/Logger'; -import { templateVarHandler } from '../../../../app/utils/lib/templateVarHandler'; import { addUserToRoom, removeUserFromRoom, createRoom } from '../../../../app/lib/server/functions'; import { syncUserRoles } from '../syncUserRoles'; import { Team } from '../../../../server/sdk'; import { ensureArray } from '../../../../lib/utils/arrayUtils'; +import { copyCustomFieldsLDAP } from './copyCustomFieldsLDAP'; export class LDAPEEManager extends LDAPManager { public static async sync(): Promise { @@ -506,76 +505,16 @@ export class LDAPEEManager extends LDAPManager { } public static copyCustomFields(ldapUser: ILDAPEntry, userData: IImportUser): void { - if (!settings.get('LDAP_Sync_Custom_Fields')) { - return; - } - - const customFieldsSettings = settings.get('Accounts_CustomFields'); - const customFieldsMap = settings.get('LDAP_CustomFieldMap'); - - if (!customFieldsMap || !customFieldsSettings) { - if (customFieldsMap) { - logger.debug('Skipping LDAP custom fields because there are no custom fields configured.'); - } - return; - } - - let map: Record; - try { - map = JSON.parse(customFieldsMap) as Record; - } catch (error) { - logger.error('Failed to parse LDAP Custom Fields mapping'); - logger.error(error); - return; - } - - let customFields: Record; - try { - customFields = JSON.parse(customFieldsSettings) as Record; - } catch (error) { - logger.error('Failed to parse Custom Fields'); - logger.error(error); - return; - } - - _.map(map, (userField, ldapField) => { - if (!this.getCustomField(customFields, userField)) { - logger.debug(`User attribute does not exist: ${userField}`); - return; - } - - if (!userData.customFields) { - userData.customFields = {}; - } - - const value = templateVarHandler(ldapField, ldapUser); - - if (value) { - let ref: Record = userData.customFields; - const attributeNames = userField.split('.'); - let previousKey: string | undefined; - - for (const key of attributeNames) { - if (previousKey) { - if (ref[previousKey] === undefined) { - ref[previousKey] = {}; - } else if (typeof ref[previousKey] !== 'object') { - logger.error(`Failed to assign custom field: ${userField}`); - return; - } - - ref = ref[previousKey]; - } - - previousKey = key; - } - - if (previousKey) { - ref[previousKey] = value; - logger.debug(`user.customFields.${userField} changed to: ${value}`); - } - } - }); + return copyCustomFieldsLDAP( + { + ldapUser, + userData, + customFieldsSettings: settings.get('Accounts_CustomFields'), + customFieldsMap: settings.get('LDAP_CustomFieldMap'), + syncCustomFields: settings.get('LDAP_Sync_Custom_Fields'), + }, + logger, + ); } private static async importNewUsers(ldap: LDAPConnection, converter: LDAPDataConverter): Promise { @@ -660,12 +599,4 @@ export class LDAPEEManager extends LDAPManager { } } } - - private static getCustomField(customFields: Record, property: string): any { - try { - return _.reduce(property.split('.'), (acc, el) => acc[el], customFields); - } catch { - // ignore errors - } - } } diff --git a/apps/meteor/ee/server/lib/ldap/copyCustomFieldsLDAP.spec.ts b/apps/meteor/ee/server/lib/ldap/copyCustomFieldsLDAP.spec.ts new file mode 100644 index 00000000000..fade0e19e89 --- /dev/null +++ b/apps/meteor/ee/server/lib/ldap/copyCustomFieldsLDAP.spec.ts @@ -0,0 +1,209 @@ +import type { IImportUser, ILDAPEntry } from '@rocket.chat/core-typings'; +import { expect, spy } from 'chai'; + +import { copyCustomFieldsLDAP } from './copyCustomFieldsLDAP'; +import type { Logger } from '../../../../app/logger/server'; + +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' }); + }); +}); diff --git a/apps/meteor/ee/server/lib/ldap/copyCustomFieldsLDAP.ts b/apps/meteor/ee/server/lib/ldap/copyCustomFieldsLDAP.ts new file mode 100644 index 00000000000..d3ab09d7b24 --- /dev/null +++ b/apps/meteor/ee/server/lib/ldap/copyCustomFieldsLDAP.ts @@ -0,0 +1,71 @@ +import type { IImportUser, ILDAPEntry } from '@rocket.chat/core-typings'; + +import type { Logger } from '../../../../app/logger/server'; +import { templateVarHandler } from '../../../../app/utils/lib/templateVarHandler'; +import { getNestedProp } from './getNestedProp'; +import { replacesNestedValues } from './replacesNestedValues'; + +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 = (() => { + try { + return JSON.parse(customFieldsMap); + } catch (err) { + logger.error({ msg: 'Error parsing LDAP custom fields map.', err }); + } + })(); + + if (!map) { + return; + } + + let customFields: Record; + try { + customFields = JSON.parse(customFieldsSettings) as Record; + } catch (err) { + logger.error({ msg: 'Failed to parse Custom Fields', err }); + return; + } + + Object.entries(map).forEach(([ldapField, userField]) => { + if (!getNestedProp(customFields, userField)) { + logger.debug(`User attribute does not exist: ${userField}`); + return; + } + + const value = templateVarHandler(ldapField, ldapUser); + + if (!value) { + return; + } + + userData.customFields = replacesNestedValues({ ...userData.customFields }, userField, value); + }); +}; diff --git a/apps/meteor/ee/server/lib/ldap/getNestedProp.spec.ts b/apps/meteor/ee/server/lib/ldap/getNestedProp.spec.ts new file mode 100644 index 00000000000..b751f444193 --- /dev/null +++ b/apps/meteor/ee/server/lib/ldap/getNestedProp.spec.ts @@ -0,0 +1,23 @@ +import { expect } from 'chai'; + +import { getNestedProp } from './getNestedProp'; + +describe('LDAP getNestedProp', () => { + it('should find shallow values', () => { + const customFields = { + foo: 'bar', + }; + + expect(getNestedProp(customFields, 'foo')).to.equal('bar'); + }); + + it('should find deep values', () => { + const customFields = { + foo: { + bar: 'baz', + }, + }; + + expect(getNestedProp(customFields, 'foo.bar')).to.equal('baz'); + }); +}); diff --git a/apps/meteor/ee/server/lib/ldap/getNestedProp.ts b/apps/meteor/ee/server/lib/ldap/getNestedProp.ts new file mode 100644 index 00000000000..9836683801d --- /dev/null +++ b/apps/meteor/ee/server/lib/ldap/getNestedProp.ts @@ -0,0 +1,7 @@ +export const getNestedProp = (customFields: Record, property: string): unknown => { + try { + return property.split('.').reduce((acc, el) => acc[el], customFields); + } catch { + // ignore errors + } +}; diff --git a/apps/meteor/ee/server/lib/ldap/replacesNestedValues.spec.ts b/apps/meteor/ee/server/lib/ldap/replacesNestedValues.spec.ts new file mode 100644 index 00000000000..a6ead7a087d --- /dev/null +++ b/apps/meteor/ee/server/lib/ldap/replacesNestedValues.spec.ts @@ -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(); + }); +}); diff --git a/apps/meteor/ee/server/lib/ldap/replacesNestedValues.ts b/apps/meteor/ee/server/lib/ldap/replacesNestedValues.ts new file mode 100644 index 00000000000..c8af4be0cd7 --- /dev/null +++ b/apps/meteor/ee/server/lib/ldap/replacesNestedValues.ts @@ -0,0 +1,26 @@ +export const replacesNestedValues = (obj: Record, key: string, value: unknown): Record => { + 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, keys.join('.'), value), + }), + }; +};