From 2c190740d0ff166a4cefe8e833b0b2682a41fab1 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Thu, 17 Apr 2025 02:11:35 -0300 Subject: [PATCH] feat: ldap custom variables / string manipulation (#35717) --- .changeset/beige-days-push.md | 7 ++ apps/meteor/.mocharc.js | 2 + apps/meteor/server/lib/ldap/Connection.ts | 15 ++- apps/meteor/server/lib/ldap/Manager.ts | 47 ++------ .../lib/ldap/getLdapDynamicValue.spec.ts | 65 +++++++++++ .../server/lib/ldap/getLdapDynamicValue.ts | 31 +++++ .../server/lib/ldap/getLdapString.spec.ts | 47 ++++++++ apps/meteor/server/lib/ldap/getLdapString.ts | 5 + .../server/lib/ldap/ldapKeyExists.spec.ts | 94 ++++++++++++++++ apps/meteor/server/lib/ldap/ldapKeyExists.ts | 6 + .../lib/ldap/operations/executeOperation.ts | 31 +++++ .../lib/ldap/operations/fallback.spec.ts | 61 ++++++++++ .../server/lib/ldap/operations/fallback.ts | 24 ++++ .../server/lib/ldap/operations/match.spec.ts | 106 ++++++++++++++++++ .../server/lib/ldap/operations/match.ts | 28 +++++ .../lib/ldap/operations/replace.spec.ts | 105 +++++++++++++++++ .../server/lib/ldap/operations/replace.ts | 23 ++++ .../server/lib/ldap/operations/split.spec.ts | 48 ++++++++ .../server/lib/ldap/operations/split.ts | 30 +++++ .../lib/ldap/operations/substring.spec.ts | 48 ++++++++ .../server/lib/ldap/operations/substring.ts | 13 +++ .../server/lib/ldap/processLdapVariables.ts | 38 +++++++ apps/meteor/server/settings/ldap.ts | 13 +++ .../core-typings/src/ldap/ILDAPOptions.ts | 2 + packages/i18n/src/locales/en.i18n.json | 2 + 25 files changed, 851 insertions(+), 40 deletions(-) create mode 100644 .changeset/beige-days-push.md create mode 100644 apps/meteor/server/lib/ldap/getLdapDynamicValue.spec.ts create mode 100644 apps/meteor/server/lib/ldap/getLdapDynamicValue.ts create mode 100644 apps/meteor/server/lib/ldap/getLdapString.spec.ts create mode 100644 apps/meteor/server/lib/ldap/getLdapString.ts create mode 100644 apps/meteor/server/lib/ldap/ldapKeyExists.spec.ts create mode 100644 apps/meteor/server/lib/ldap/ldapKeyExists.ts create mode 100644 apps/meteor/server/lib/ldap/operations/executeOperation.ts create mode 100644 apps/meteor/server/lib/ldap/operations/fallback.spec.ts create mode 100644 apps/meteor/server/lib/ldap/operations/fallback.ts create mode 100644 apps/meteor/server/lib/ldap/operations/match.spec.ts create mode 100644 apps/meteor/server/lib/ldap/operations/match.ts create mode 100644 apps/meteor/server/lib/ldap/operations/replace.spec.ts create mode 100644 apps/meteor/server/lib/ldap/operations/replace.ts create mode 100644 apps/meteor/server/lib/ldap/operations/split.spec.ts create mode 100644 apps/meteor/server/lib/ldap/operations/split.ts create mode 100644 apps/meteor/server/lib/ldap/operations/substring.spec.ts create mode 100644 apps/meteor/server/lib/ldap/operations/substring.ts create mode 100644 apps/meteor/server/lib/ldap/processLdapVariables.ts diff --git a/.changeset/beige-days-push.md b/.changeset/beige-days-push.md new file mode 100644 index 00000000000..bcf10b1759a --- /dev/null +++ b/.changeset/beige-days-push.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/core-typings': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Adds new settings to allow configuring custom variables with string manipulation functions on the LDAP data mapper diff --git a/apps/meteor/.mocharc.js b/apps/meteor/.mocharc.js index 076f6c56b66..4a47aa3a52d 100644 --- a/apps/meteor/.mocharc.js +++ b/apps/meteor/.mocharc.js @@ -25,6 +25,8 @@ module.exports = { exit: true, spec: [ 'lib/callbacks.spec.ts', + 'server/lib/ldap/*.spec.ts', + 'server/lib/ldap/**/*.spec.ts', 'ee/server/lib/ldap/*.spec.ts', 'ee/tests/**/*.tests.ts', 'ee/tests/**/*.spec.ts', diff --git a/apps/meteor/server/lib/ldap/Connection.ts b/apps/meteor/server/lib/ldap/Connection.ts index a6e3e69f75f..44166b445a7 100644 --- a/apps/meteor/server/lib/ldap/Connection.ts +++ b/apps/meteor/server/lib/ldap/Connection.ts @@ -6,10 +6,12 @@ import type { ILDAPCallback, ILDAPPageCallback, } from '@rocket.chat/core-typings'; +import { wrapExceptions } from '@rocket.chat/tools'; import ldapjs from 'ldapjs'; import { logger, connLogger, searchLogger, authLogger, bindLogger, mapLogger } from './Logger'; import { getLDAPConditionalSetting } from './getLDAPConditionalSetting'; +import { processLdapVariables, type LDAPVariableMap } from './processLdapVariables'; import { settings } from '../../../app/settings/server'; import { ensureArray } from '../../../lib/utils/arrayUtils'; @@ -50,6 +52,8 @@ export class LDAPConnection { private usingAuthentication: boolean; + private _variableMap: LDAPVariableMap; + constructor() { this.ldapjs = ldapjs; @@ -83,9 +87,18 @@ export class LDAPConnection { authentication: settings.get('LDAP_Authentication') ?? false, authenticationUserDN: settings.get('LDAP_Authentication_UserDN') ?? '', authenticationPassword: settings.get('LDAP_Authentication_Password') ?? '', + useVariables: settings.get('LDAP_DataSync_UseVariables') ?? false, + variableMap: settings.get('LDAP_DataSync_VariableMap') ?? '{}', attributesToQuery: this.parseAttributeList(settings.get('LDAP_User_Search_AttributesToQuery')), }; + this._variableMap = + (this.options.useVariables && + wrapExceptions(() => JSON.parse(this.options.variableMap)).suppress(() => { + mapLogger.error({ msg: 'Failed to parse LDAP Variable Map', map: this.options.variableMap }); + })) || + {}; + if (!this.options.host) { logger.warn('LDAP Host is not configured.'); } @@ -322,7 +335,7 @@ export class LDAPConnection { mapLogger.debug({ msg: 'Extracted Attribute', key, type: dataType, value: values[key] }); }); - return values; + return processLdapVariables(values, this._variableMap); } public async doCustomSearch(baseDN: string, searchOptions: ldapjs.SearchOptions, entryCallback: ILDAPEntryCallback): Promise { diff --git a/apps/meteor/server/lib/ldap/Manager.ts b/apps/meteor/server/lib/ldap/Manager.ts index 668b2f9242c..9fe1163ade0 100644 --- a/apps/meteor/server/lib/ldap/Manager.ts +++ b/apps/meteor/server/lib/ldap/Manager.ts @@ -12,6 +12,9 @@ import { LDAPConnection } from './Connection'; import { logger, authLogger, connLogger } from './Logger'; import { LDAPUserConverter } from './UserConverter'; import { getLDAPConditionalSetting } from './getLDAPConditionalSetting'; +import { getLdapDynamicValue } from './getLdapDynamicValue'; +import { getLdapString } from './getLdapString'; +import { ldapKeyExists } from './ldapKeyExists'; import type { UserConverterOptions } from '../../../app/importer/server/classes/converters/UserConverter'; import { setUserAvatar } from '../../../app/lib/server/functions/setUserAvatar'; import { settings } from '../../../app/settings/server'; @@ -416,43 +419,9 @@ export class LDAPManager { connLogger.debug(ldapUser); } - private static ldapKeyExists(ldapUser: ILDAPEntry, key: string): boolean { - return !_.isEmpty(ldapUser[key.trim()]); - } - - private static getLdapString(ldapUser: ILDAPEntry, key: string): string { - return ldapUser[key.trim()]; - } - - private static getLdapDynamicValue(ldapUser: ILDAPEntry, attributeSetting: string | undefined): string | undefined { - if (!attributeSetting) { - return; - } - - // If the attribute setting is a template, then convert the variables in it - if (attributeSetting.includes('#{')) { - return attributeSetting.replace(/#{(.+?)}/g, (_match, field) => { - const key = field.trim(); - - if (this.ldapKeyExists(ldapUser, key)) { - return this.getLdapString(ldapUser, key); - } - - return ''; - }); - } - - // If it's not a template, then treat the setting as a CSV list of possible attribute names and return the first valid one. - const attributeList: string[] = attributeSetting.replace(/\s/g, '').split(','); - const key = attributeList.find((field) => this.ldapKeyExists(ldapUser, field)); - if (key) { - return this.getLdapString(ldapUser, key); - } - } - private static getLdapName(ldapUser: ILDAPEntry): string | undefined { const nameAttributes = getLDAPConditionalSetting('LDAP_Name_Field'); - return this.getLdapDynamicValue(ldapUser, nameAttributes); + return getLdapDynamicValue(ldapUser, nameAttributes); } private static getLdapExtension(ldapUser: ILDAPEntry): string | undefined { @@ -461,14 +430,14 @@ export class LDAPManager { return; } - return this.getLdapString(ldapUser, extensionAttribute); + return getLdapString(ldapUser, extensionAttribute); } private static getLdapEmails(ldapUser: ILDAPEntry, username?: string): string[] { const emailAttributes = getLDAPConditionalSetting('LDAP_Email_Field'); if (emailAttributes) { const attributeList: string[] = emailAttributes.replace(/\s/g, '').split(','); - const key = attributeList.find((field) => this.ldapKeyExists(ldapUser, field)); + const key = attributeList.find((field) => ldapKeyExists(ldapUser, field)); const emails: string[] = [].concat(key ? ldapUser[key.trim()] : []); const filteredEmails = emails.filter((email) => email.includes('@')); @@ -512,7 +481,7 @@ export class LDAPManager { protected static getLdapUsername(ldapUser: ILDAPEntry): string | undefined { const usernameField = getLDAPConditionalSetting('LDAP_Username_Field') as string; - return this.getLdapDynamicValue(ldapUser, usernameField); + return getLdapDynamicValue(ldapUser, usernameField); } protected static getFederationHomeServer(ldapUser: ILDAPEntry): string | undefined { @@ -521,7 +490,7 @@ export class LDAPManager { } const homeServerField = settings.get('LDAP_FederationHomeServer_Field'); - const homeServer = this.getLdapDynamicValue(ldapUser, homeServerField); + const homeServer = getLdapDynamicValue(ldapUser, homeServerField); if (!homeServer) { return; diff --git a/apps/meteor/server/lib/ldap/getLdapDynamicValue.spec.ts b/apps/meteor/server/lib/ldap/getLdapDynamicValue.spec.ts new file mode 100644 index 00000000000..90b25c9f988 --- /dev/null +++ b/apps/meteor/server/lib/ldap/getLdapDynamicValue.spec.ts @@ -0,0 +1,65 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import 'mocha'; + +import { getLdapDynamicValue } from './getLdapDynamicValue'; + +describe('getLdapDynamicValue', () => { + const ldapUser: ILDAPEntry = { + _raw: {}, + displayName: 'John Doe', + email: 'john.doe@example.com', + uid: 'johndoe', + emptyField: '', + }; + + it('should return undefined if attributeSetting is undefined', () => { + const result = getLdapDynamicValue(ldapUser, undefined); + expect(result).to.be.undefined; + }); + + it('should return the correct value from a single valid attribute', () => { + const result = getLdapDynamicValue(ldapUser, 'displayName'); + expect(result).to.equal('John Doe'); + }); + + it('should return the correct value from a template attribute', () => { + const result = getLdapDynamicValue(ldapUser, 'Hello, #{displayName}!'); + expect(result).to.equal('Hello, John Doe!'); + }); + + it('should replace missing keys with an empty string in a template', () => { + const result = getLdapDynamicValue(ldapUser, 'Hello, #{nonExistentField}!'); + expect(result).to.equal('Hello, !'); + }); + + it('should return the first valid key from a CSV list of attributes', () => { + const result = getLdapDynamicValue(ldapUser, 'nonExistentField,email,uid'); + expect(result).to.equal('john.doe@example.com'); + }); + + it('should return undefined if none of the keys in CSV list exist', () => { + const result = getLdapDynamicValue(ldapUser, 'nonExistentField,anotherNonExistentField'); + expect(result).to.be.undefined; + }); + + it('should handle attribute keys with surrounding whitespace correctly', () => { + const result = getLdapDynamicValue(ldapUser, ' email '); + expect(result).to.equal('john.doe@example.com'); + }); + + it('should correctly resolve multiple variables in a template', () => { + const result = getLdapDynamicValue(ldapUser, 'User: #{displayName}, Email: #{email}, UID: #{uid}'); + expect(result).to.equal('User: John Doe, Email: john.doe@example.com, UID: johndoe'); + }); + + it('should return undefined if the attribute has an empty value', () => { + const result = getLdapDynamicValue(ldapUser, 'emptyField'); + expect(result).to.be.undefined; + }); + + it('should return an empty string if using only a template attribute that has an empty value', () => { + const result = getLdapDynamicValue(ldapUser, '#{emptyField}'); + expect(result).to.be.equal(''); + }); +}); diff --git a/apps/meteor/server/lib/ldap/getLdapDynamicValue.ts b/apps/meteor/server/lib/ldap/getLdapDynamicValue.ts new file mode 100644 index 00000000000..a0f4c2eeac1 --- /dev/null +++ b/apps/meteor/server/lib/ldap/getLdapDynamicValue.ts @@ -0,0 +1,31 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; + +import { getLdapString } from './getLdapString'; +import { ldapKeyExists } from './ldapKeyExists'; + +export function getLdapDynamicValue(ldapUser: ILDAPEntry, attributeSetting: string | undefined): string | undefined { + if (!attributeSetting) { + return; + } + + // If the attribute setting is a template, then convert the variables in it + if (attributeSetting.includes('#{')) { + return attributeSetting.replace(/#{(.+?)}/g, (_match, field) => { + const key = field.trim(); + + if (ldapKeyExists(ldapUser, key)) { + // We've already validated so it won't ever return undefined, but add a fallback to ensure it doesn't break if something gets changed + return getLdapString(ldapUser, key) || ''; + } + + return ''; + }); + } + + // If it's not a template, then treat the setting as a CSV list of possible attribute names and return the first valid one. + const attributeList: string[] = attributeSetting.replace(/\s/g, '').split(','); + const key = attributeList.find((field) => ldapKeyExists(ldapUser, field)); + if (key) { + return getLdapString(ldapUser, key); + } +} diff --git a/apps/meteor/server/lib/ldap/getLdapString.spec.ts b/apps/meteor/server/lib/ldap/getLdapString.spec.ts new file mode 100644 index 00000000000..9f684a7e8c0 --- /dev/null +++ b/apps/meteor/server/lib/ldap/getLdapString.spec.ts @@ -0,0 +1,47 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import 'mocha'; + +import { getLdapString } from './getLdapString'; + +const ldapUser: ILDAPEntry = { + _raw: {}, + username: 'john_doe', + email: 'john.doe@example.com', + phoneNumber: '123-456-7890', + memberOf: 'group1,group2', +}; + +describe('getLdapString', () => { + it('should return the correct value for a given key', () => { + expect(getLdapString(ldapUser, 'username')).to.equal('john_doe'); + expect(getLdapString(ldapUser, 'email')).to.equal('john.doe@example.com'); + expect(getLdapString(ldapUser, 'phoneNumber')).to.equal('123-456-7890'); + expect(getLdapString(ldapUser, 'memberOf')).to.equal('group1,group2'); + }); + + it('should trim the key and return the correct value', () => { + expect(getLdapString(ldapUser, ' username ')).to.equal('john_doe'); + expect(getLdapString(ldapUser, ' email ')).to.equal('john.doe@example.com'); + }); + + it('should return undefined for non-existing keys', () => { + expect(getLdapString(ldapUser, 'nonExistingKey')).to.be.undefined; + expect(getLdapString(ldapUser, 'foo')).to.be.undefined; + }); + + it('should handle empty keys and return an empty string', () => { + expect(getLdapString(ldapUser, '')).to.be.undefined; + expect(getLdapString(ldapUser, ' ')).to.be.undefined; + }); + + it('should handle keys with only whitespace', () => { + expect(getLdapString(ldapUser, ' ')).to.be.undefined; + expect(getLdapString(ldapUser, ' ')).to.be.undefined; + }); + + it('should handle case-sensitive keys accurately', () => { + expect(getLdapString(ldapUser, 'Username')).to.be.undefined; + expect(getLdapString(ldapUser, 'EMAIL')).to.be.undefined; + }); +}); diff --git a/apps/meteor/server/lib/ldap/getLdapString.ts b/apps/meteor/server/lib/ldap/getLdapString.ts new file mode 100644 index 00000000000..caad9d22141 --- /dev/null +++ b/apps/meteor/server/lib/ldap/getLdapString.ts @@ -0,0 +1,5 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; + +export function getLdapString(ldapUser: ILDAPEntry, key: string): string | undefined { + return ldapUser[key.trim()]; +} diff --git a/apps/meteor/server/lib/ldap/ldapKeyExists.spec.ts b/apps/meteor/server/lib/ldap/ldapKeyExists.spec.ts new file mode 100644 index 00000000000..933c661e0b8 --- /dev/null +++ b/apps/meteor/server/lib/ldap/ldapKeyExists.spec.ts @@ -0,0 +1,94 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { ldapKeyExists } from './ldapKeyExists'; + +describe('ldapKeyExists', () => { + it('should return true when key exists and is not empty', () => { + const ldapUser: ILDAPEntry = { + _raw: {}, + cn: 'John Doe', + mail: 'john.doe@example.com', + }; + + expect(ldapKeyExists(ldapUser, 'cn')).to.be.true; + expect(ldapKeyExists(ldapUser, 'mail')).to.be.true; + }); + + it('should return false when key exists but is empty', () => { + const ldapUser: ILDAPEntry = { + _raw: {}, + cn: '', + mail: '', + }; + + expect(ldapKeyExists(ldapUser, 'cn')).to.be.false; + expect(ldapKeyExists(ldapUser, 'mail')).to.be.false; + }); + + it('should return false when key does not exist', () => { + const ldapUser: ILDAPEntry = { + _raw: {}, + cn: 'John Doe', + }; + expect(ldapKeyExists(ldapUser, 'mail')).to.be.false; + }); + + it('should trim the key before checking', () => { + const ldapUser: ILDAPEntry = { + _raw: {}, + cn: 'John Doe', + }; + expect(ldapKeyExists(ldapUser, ' cn ')).to.be.true; + expect(ldapKeyExists(ldapUser, ' mail ')).to.be.false; + }); + + it('should return false for empty keys', () => { + const ldapUser: ILDAPEntry = { + _raw: {}, + cn: 'John Doe', + }; + expect(ldapKeyExists(ldapUser, '')).to.be.false; + expect(ldapKeyExists(ldapUser, ' ')).to.be.false; + }); + + it('should handle keys with different casing', () => { + const ldapUser: ILDAPEntry = { + _raw: {}, + CN: 'John Doe', + }; + + expect(ldapKeyExists(ldapUser, 'CN')).to.be.true; + expect(ldapKeyExists(ldapUser, 'cn')).to.be.false; + }); + + // #TODO: We only work with strings so this doesn't matter, but why are numbers and booleans being considered "empty"? + it('should treat primitive non-string values as empty', () => { + const ldapUser: ILDAPEntry = { + _raw: {}, + numberValue: 123, + booleanValue: true, + anotherBooleanValue: false, + }; + + expect(ldapKeyExists(ldapUser, 'numberValue')).to.be.false; + expect(ldapKeyExists(ldapUser, 'booleanValue')).to.be.false; + expect(ldapKeyExists(ldapUser, 'anotherBooleanValue')).to.be.false; + }); + + it('should treat non-string values as empty', () => { + const ldapUser: ILDAPEntry = { + _raw: {}, + nullValue: null, + undefinedValue: undefined, + objectValue: {}, + arrayValue: [], + }; + + expect(ldapKeyExists(ldapUser, 'nullValue')).to.be.false; + expect(ldapKeyExists(ldapUser, 'undefinedValue')).to.be.false; + expect(ldapKeyExists(ldapUser, 'objectValue')).to.be.false; + expect(ldapKeyExists(ldapUser, 'arrayValue')).to.be.false; + }); +}); diff --git a/apps/meteor/server/lib/ldap/ldapKeyExists.ts b/apps/meteor/server/lib/ldap/ldapKeyExists.ts new file mode 100644 index 00000000000..1b68b71bdd8 --- /dev/null +++ b/apps/meteor/server/lib/ldap/ldapKeyExists.ts @@ -0,0 +1,6 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; +import _ from 'underscore'; + +export function ldapKeyExists(ldapUser: ILDAPEntry, key: string): boolean { + return !_.isEmpty(ldapUser[key.trim()]); +} diff --git a/apps/meteor/server/lib/ldap/operations/executeOperation.ts b/apps/meteor/server/lib/ldap/operations/executeOperation.ts new file mode 100644 index 00000000000..e3eb2132359 --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/executeOperation.ts @@ -0,0 +1,31 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; + +import { executeFallback, type LDAPVariableFallback } from './fallback'; +import { executeMatch, type LDAPVariableMatch } from './match'; +import { executeReplace, type LDAPVariableReplace } from './replace'; +import { executeSplit, type LDAPVariableSplit } from './split'; +import { executeSubstring, type LDAPVariableSubString } from './substring'; + +export type LDAPVariableOperation = + | LDAPVariableReplace + | LDAPVariableMatch + | LDAPVariableSubString + | LDAPVariableFallback + | LDAPVariableSplit; + +export function executeOperation(ldapUser: ILDAPEntry, input: string, operation?: LDAPVariableOperation): string | undefined { + switch (operation?.operation) { + case 'replace': + return executeReplace(input, operation); + case 'match': + return executeMatch(input, operation); + case 'substring': + return executeSubstring(input, operation); + case 'fallback': + return executeFallback(ldapUser, input, operation); + case 'split': + return executeSplit(input, operation); + } + + return input; +} diff --git a/apps/meteor/server/lib/ldap/operations/fallback.spec.ts b/apps/meteor/server/lib/ldap/operations/fallback.spec.ts new file mode 100644 index 00000000000..8cbdc58912e --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/fallback.spec.ts @@ -0,0 +1,61 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import 'mocha'; + +import type { LDAPVariableFallback } from './fallback'; +import { executeFallback } from './fallback'; + +describe('executeFallback function', () => { + const mockUser: ILDAPEntry = { + _raw: {}, + defaultFallback: 'defaultFallbackValue', + }; + + it('should return the input value when it is valid and no minLength is provided', () => { + const input = 'validInput'; + const operation: LDAPVariableFallback = { operation: 'fallback', fallback: 'defaultFallback' }; + const result = executeFallback(mockUser, input, operation); + expect(result).to.equal(input); + }); + + it('should return the input value when it is valid and meets minLength requirement', () => { + const input = 'validInput'; + const operation: LDAPVariableFallback = { operation: 'fallback', fallback: 'defaultFallback', minLength: 5 }; + const result = executeFallback(mockUser, input, operation); + expect(result).to.equal(input); + }); + + it('should return fallback when input is invalid', () => { + const input = ''; + const operation: LDAPVariableFallback = { operation: 'fallback', fallback: 'defaultFallback' }; + const result = executeFallback(mockUser, input, operation); + expect(result).to.equal('defaultFallbackValue'); + }); + + it('should return fallback when input is too short', () => { + const input = 'short'; + const operation: LDAPVariableFallback = { operation: 'fallback', fallback: 'defaultFallback', minLength: 10 }; + const result = executeFallback(mockUser, input, operation); + expect(result).to.equal('defaultFallbackValue'); + }); + + it('should return fallback when input is undefined', () => { + const operation: LDAPVariableFallback = { operation: 'fallback', fallback: 'defaultFallback' }; + const result = executeFallback(mockUser, undefined as any, operation); + expect(result).to.equal('defaultFallbackValue'); + }); + + it('should return fallback when input is an empty string and minLength is zero', () => { + const input = ''; + const operation: LDAPVariableFallback = { operation: 'fallback', fallback: 'defaultFallback', minLength: 0 }; + const result = executeFallback(mockUser, input, operation); + expect(result).to.equal('defaultFallbackValue'); + }); + + it('should return fallback when input is an empty string and minLength is undefined', () => { + const input = ''; + const operation: LDAPVariableFallback = { operation: 'fallback', fallback: 'defaultFallback', minLength: undefined }; + const result = executeFallback(mockUser, input, operation); + expect(result).to.equal('defaultFallbackValue'); + }); +}); diff --git a/apps/meteor/server/lib/ldap/operations/fallback.ts b/apps/meteor/server/lib/ldap/operations/fallback.ts new file mode 100644 index 00000000000..ecd8c199fbd --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/fallback.ts @@ -0,0 +1,24 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; + +import { getLdapDynamicValue } from '../getLdapDynamicValue'; + +export type LDAPVariableFallback = { + operation: 'fallback'; + fallback: string; + + minLength?: number; +}; + +export function executeFallback(ldapUser: ILDAPEntry, input: string, operation: LDAPVariableFallback): string | undefined { + let valid = Boolean(input); + + if (valid && typeof operation.minLength === 'number') { + valid = input.length >= operation.minLength; + } + + if (valid) { + return input; + } + + return getLdapDynamicValue(ldapUser, operation.fallback); +} diff --git a/apps/meteor/server/lib/ldap/operations/match.spec.ts b/apps/meteor/server/lib/ldap/operations/match.spec.ts new file mode 100644 index 00000000000..ad1deb1148d --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/match.spec.ts @@ -0,0 +1,106 @@ +import { expect } from 'chai'; +import 'mocha'; + +import type { LDAPVariableMatch } from './match'; +import { executeMatch } from './match'; + +describe('executeMatch function', () => { + describe('Validation', () => { + it('throws an error when pattern is missing', () => { + const operation: LDAPVariableMatch = { + operation: 'match', + pattern: '', + regex: true, + }; + expect(() => executeMatch('input', operation)).to.throw('Invalid MATCH operation.'); + }); + + it('throws an error when neither valueIfTrue nor indexToUse is provided', () => { + const operation: LDAPVariableMatch = { + operation: 'match', + pattern: 'pattern', + }; + expect(() => executeMatch('input', operation)).to.throw('Invalid MATCH operation.'); + }); + }); + + describe('Non-Regex Matching', () => { + it('returns valueIfTrue when input matches pattern', () => { + const operation: LDAPVariableMatch = { + operation: 'match', + pattern: 'hello', + valueIfTrue: 'matched', + valueIfFalse: 'not matched', + }; + expect(executeMatch('hello', operation)).to.be.equal('matched'); + }); + + it('returns valueIfFalse when input does not match pattern', () => { + const operation: LDAPVariableMatch = { + operation: 'match', + pattern: 'hello', + valueIfTrue: 'matched', + valueIfFalse: 'not matched', + }; + expect(executeMatch('world', operation)).to.be.equal('not matched'); + }); + }); + + describe('Regex Matching', () => { + it('returns valueIfTrue when input matches regex pattern', () => { + const operation: LDAPVariableMatch = { + operation: 'match', + pattern: '^hello$', + regex: true, + valueIfTrue: 'matched', + valueIfFalse: 'not matched', + }; + expect(executeMatch('hello', operation)).to.be.equal('matched'); + }); + + it('returns valueIfFalse when input does not match regex pattern', () => { + const operation: LDAPVariableMatch = { + operation: 'match', + pattern: '^hello$', + regex: true, + valueIfTrue: 'matched', + valueIfFalse: 'not matched', + }; + expect(executeMatch('world', operation)).to.be.equal('not matched'); + }); + + it('uses flags when provided', () => { + const operation: LDAPVariableMatch = { + operation: 'match', + pattern: '^HELLO$', + regex: true, + flags: 'i', + valueIfTrue: 'matched', + valueIfFalse: 'not matched', + }; + expect(executeMatch('hello', operation)).to.be.equal('matched'); + }); + }); + + describe('IndexToUse', () => { + it('returns the matched group at indexToUse', () => { + const operation: LDAPVariableMatch = { + operation: 'match', + pattern: '(hello) (world)', + regex: true, + indexToUse: 1, + }; + expect(executeMatch('hello world', operation)).to.be.equal('hello'); + }); + + it('returns undefined when indexToUse is out of range', () => { + const operation: LDAPVariableMatch = { + operation: 'match', + pattern: '(hello)', + regex: true, + indexToUse: 2, + }; + expect(executeMatch('hello', operation)).to.be.undefined; + }); + }); +}); diff --git a/apps/meteor/server/lib/ldap/operations/match.ts b/apps/meteor/server/lib/ldap/operations/match.ts new file mode 100644 index 00000000000..422b13446d3 --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/match.ts @@ -0,0 +1,28 @@ +export type LDAPVariableMatch = { + operation: 'match'; + pattern: string; + regex?: boolean; + flags?: string; + indexToUse?: number; + valueIfTrue?: string; + valueIfFalse?: string; +}; + +export function executeMatch(input: string, operation: LDAPVariableMatch): string | undefined { + if (!operation.pattern || (typeof operation.valueIfTrue !== 'string' && typeof operation.indexToUse !== 'number')) { + throw new Error('Invalid MATCH operation.'); + } + + const pattern = operation.regex ? new RegExp(operation.pattern, operation.flags) : operation.pattern; + + const result = input.match(pattern); + if (!result) { + return operation.valueIfFalse; + } + + if (typeof operation.indexToUse === 'number' && result.length > operation.indexToUse) { + return result[operation.indexToUse]; + } + + return operation.valueIfTrue; +} diff --git a/apps/meteor/server/lib/ldap/operations/replace.spec.ts b/apps/meteor/server/lib/ldap/operations/replace.spec.ts new file mode 100644 index 00000000000..b03bbf67962 --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/replace.spec.ts @@ -0,0 +1,105 @@ +import { expect } from 'chai'; +import 'mocha'; + +import type { LDAPVariableReplace } from './replace'; +import { executeReplace } from './replace'; + +describe('executeReplace', () => { + describe('Validation', () => { + it('throws an error when pattern is missing', () => { + const operation: LDAPVariableReplace = { + operation: 'replace', + pattern: '', + replacement: 'new-value', + }; + expect(() => executeReplace('input', operation)).to.throw('Invalid REPLACE operation.'); + }); + + it('throws an error when replacement is not a string', () => { + const operation: LDAPVariableReplace = { + operation: 'replace', + pattern: 'old-value', + replacement: 123 as any, + }; + expect(() => executeReplace('input', operation)).to.throw('Invalid REPLACE operation.'); + }); + }); + + describe('String Replacement', () => { + it('replaces the first occurrence of the pattern', () => { + const operation: LDAPVariableReplace = { + operation: 'replace', + pattern: 'old', + replacement: 'new', + }; + expect(executeReplace('old-value old-value', operation)).to.be.equal('new-value old-value'); + }); + + it('replaces all occurrences of the pattern when `all` is true', () => { + const operation: LDAPVariableReplace = { + operation: 'replace', + pattern: 'old', + replacement: 'new', + all: true, + }; + expect(executeReplace('old-value old-value', operation)).to.be.equal('new-value new-value'); + }); + }); + + describe('Regex Replacement', () => { + it('replaces the first occurrence of the pattern', () => { + const operation: LDAPVariableReplace = { + operation: 'replace', + pattern: 'old', + replacement: 'new', + regex: true, + }; + expect(executeReplace('old-value old-value', operation)).to.be.equal('new-value old-value'); + }); + + it('replaces all occurrences of the pattern when `regex` and `all` are true', () => { + const operation: LDAPVariableReplace = { + operation: 'replace', + pattern: 'old', + replacement: 'new', + regex: true, + all: true, + }; + expect(executeReplace('old-value old-value', operation)).to.be.equal('new-value new-value'); + }); + + it('uses the provided flags', () => { + const operation: LDAPVariableReplace = { + operation: 'replace', + pattern: 'OLD', + replacement: 'new', + regex: true, + flags: 'i', + }; + expect(executeReplace('OLD-value old-value', operation)).to.be.equal('new-value old-value'); + }); + + it('adds the `g` flag when `all` is true', () => { + const operation: LDAPVariableReplace = { + operation: 'replace', + pattern: 'old', + replacement: 'new', + regex: true, + all: true, + }; + expect(executeReplace('old-value old-value', operation)).to.be.equal('new-value new-value'); + }); + + it('does not duplicate the `g` flag when already present', () => { + const operation: LDAPVariableReplace = { + operation: 'replace', + pattern: 'old', + replacement: 'new', + regex: true, + all: true, + flags: 'g', + }; + expect(executeReplace('old-value old-value', operation)).to.be.equal('new-value new-value'); + }); + }); +}); diff --git a/apps/meteor/server/lib/ldap/operations/replace.ts b/apps/meteor/server/lib/ldap/operations/replace.ts new file mode 100644 index 00000000000..49460a8dd17 --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/replace.ts @@ -0,0 +1,23 @@ +export type LDAPVariableReplace = { + operation: 'replace'; + pattern: string; + regex?: boolean; + flags?: string; + all?: boolean; + replacement: string; +}; + +export function executeReplace(input: string, operation: LDAPVariableReplace): string { + if (!operation.pattern || typeof operation.replacement !== 'string') { + throw new Error('Invalid REPLACE operation.'); + } + + const flags = operation.regex && operation.all ? `${operation.flags || ''}${operation.flags?.includes('g') ? '' : 'g'}` : operation.flags; + const pattern = operation.regex ? new RegExp(operation.pattern, flags) : operation.pattern; + + if (operation.all) { + return input.replaceAll(pattern, operation.replacement); + } + + return input.replace(pattern, operation.replacement); +} diff --git a/apps/meteor/server/lib/ldap/operations/split.spec.ts b/apps/meteor/server/lib/ldap/operations/split.spec.ts new file mode 100644 index 00000000000..fa447a38752 --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/split.spec.ts @@ -0,0 +1,48 @@ +import { expect } from 'chai'; +import 'mocha'; + +import type { LDAPVariableSplit } from './split'; +import { executeSplit } from './split'; + +describe('executeSplit function', () => { + it('should throw an error if the pattern is empty', () => { + const operation: LDAPVariableSplit = { operation: 'split', pattern: '' }; + expect(() => executeSplit('input', operation)).to.throw('Invalid SPLIT operation.'); + }); + + it('should split the input string by the pattern and return the first element by default', () => { + const operation: LDAPVariableSplit = { operation: 'split', pattern: ',' }; + const result = executeSplit('hello,world', operation); + expect(result).to.be.equal('hello'); + }); + + it('should split the input string by the pattern and return the element at the specified index', () => { + const operation: LDAPVariableSplit = { operation: 'split', pattern: ',', indexToUse: 1 }; + const result = executeSplit('hello,world', operation); + expect(result).to.be.equal('world'); + }); + + it('should return undefined if the index is out of range', () => { + const operation: LDAPVariableSplit = { operation: 'split', pattern: ',', indexToUse: 2 }; + const result = executeSplit('hello,world', operation); + expect(result).to.be.undefined; + }); + + it('should return undefined if the input string does not contain the pattern', () => { + const operation: LDAPVariableSplit = { operation: 'split', pattern: ',' }; + const result = executeSplit('helloworld', operation); + expect(result).to.be.equal('helloworld'); + }); + + it('should return the first element if the input string is empty', () => { + const operation: LDAPVariableSplit = { operation: 'split', pattern: ',' }; + const result = executeSplit('', operation); + expect(result).to.be.equal(''); + }); + + it('should return undefined if the input string is undefined', () => { + const operation: LDAPVariableSplit = { operation: 'split', pattern: ',' }; + const result = executeSplit(undefined as any, operation); + expect(result).to.be.undefined; + }); +}); diff --git a/apps/meteor/server/lib/ldap/operations/split.ts b/apps/meteor/server/lib/ldap/operations/split.ts new file mode 100644 index 00000000000..e6c68e78657 --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/split.ts @@ -0,0 +1,30 @@ +export type LDAPVariableSplit = { + operation: 'split'; + pattern: string; + indexToUse?: number; +}; + +export function executeSplit(input: string, operation: LDAPVariableSplit): string | undefined { + if (!operation.pattern) { + throw new Error('Invalid SPLIT operation.'); + } + + if (!input) { + return input; + } + + const result = input.split(operation.pattern); + if (!result) { + return; + } + + if (typeof operation.indexToUse === 'number') { + if (result.length > operation.indexToUse) { + return result[operation.indexToUse]; + } + + return; + } + + return result.shift(); +} diff --git a/apps/meteor/server/lib/ldap/operations/substring.spec.ts b/apps/meteor/server/lib/ldap/operations/substring.spec.ts new file mode 100644 index 00000000000..18e55b6eebe --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/substring.spec.ts @@ -0,0 +1,48 @@ +import { expect } from 'chai'; +import 'mocha'; + +import type { LDAPVariableSubString } from './substring'; +import { executeSubstring } from './substring'; + +describe('executeSubstring function', () => { + it('should throw an error if the start is missing', () => { + const operation: LDAPVariableSubString = { operation: 'substring' } as unknown as LDAPVariableSubString; + expect(() => executeSubstring('input', operation)).to.throw('Invalid SUBSTRING operation.'); + }); + + it('should throw an error if the start is invalid', () => { + const operation: LDAPVariableSubString = { operation: 'substring', start: 0, end: null } as unknown as LDAPVariableSubString; + expect(() => executeSubstring('input', operation)).to.throw('Invalid SUBSTRING operation.'); + }); + + it('should get the substring of the input, using the start param', () => { + const result = executeSubstring('hello world', { operation: 'substring', start: 6 }); + expect(result).to.be.equal('world'); + }); + + it('should get the whole string when the start is zero', () => { + const result = executeSubstring('hello world', { operation: 'substring', start: 0 }); + expect(result).to.be.equal('hello world'); + }); + + it('should get the substring of the input, using the start and end param', () => { + const result = executeSubstring('hello world', { operation: 'substring', start: 6, end: 8 }); + expect(result).to.be.equal('wo'); + }); + + it('should work backwards if end is smaller than start', () => { + const result = executeSubstring('hello world', { operation: 'substring', start: 5, end: 0 }); + expect(result).to.be.equal('hello'); + }); + + it('should get an empty string if start and end are the same', () => { + expect(executeSubstring('hello world', { operation: 'substring', start: 0, end: 0 })).to.be.equal(''); + expect(executeSubstring('hello world', { operation: 'substring', start: 5, end: 5 })).to.be.equal(''); + }); + + it('should treat negative values as zero', () => { + expect(executeSubstring('hello world', { operation: 'substring', start: -4, end: 5 })).to.be.equal('hello'); + expect(executeSubstring('hello world', { operation: 'substring', start: 5, end: -4 })).to.be.equal('hello'); + expect(executeSubstring('hello world', { operation: 'substring', start: -5, end: -4 })).to.be.equal(''); + }); +}); diff --git a/apps/meteor/server/lib/ldap/operations/substring.ts b/apps/meteor/server/lib/ldap/operations/substring.ts new file mode 100644 index 00000000000..3af2287c312 --- /dev/null +++ b/apps/meteor/server/lib/ldap/operations/substring.ts @@ -0,0 +1,13 @@ +export type LDAPVariableSubString = { + operation: 'substring'; + start: number; + end?: number; +}; + +export function executeSubstring(input: string, operation: LDAPVariableSubString): string | undefined { + if (typeof operation.start !== 'number' || (operation.end !== undefined && typeof operation.end !== 'number')) { + throw new Error('Invalid SUBSTRING operation.'); + } + + return input.substring(operation.start, operation.end); +} diff --git a/apps/meteor/server/lib/ldap/processLdapVariables.ts b/apps/meteor/server/lib/ldap/processLdapVariables.ts new file mode 100644 index 00000000000..4587bc6c7f9 --- /dev/null +++ b/apps/meteor/server/lib/ldap/processLdapVariables.ts @@ -0,0 +1,38 @@ +import type { ILDAPEntry } from '@rocket.chat/core-typings'; + +import { mapLogger } from './Logger'; +import { getLdapDynamicValue } from './getLdapDynamicValue'; +import { executeOperation, type LDAPVariableOperation } from './operations/executeOperation'; + +export type LDAPVariableConfiguration = { + input: string; + output?: LDAPVariableOperation; +}; +export type LDAPVariableMap = Record; + +export function processLdapVariables(entry: ILDAPEntry, variableMap: LDAPVariableMap): ILDAPEntry { + if (!variableMap || !Object.keys(variableMap).length) { + mapLogger.debug('No LDAP variables to process.'); + return entry; + } + + for (const variableName in variableMap) { + if (!variableMap.hasOwnProperty(variableName)) { + continue; + } + + const variableData = variableMap[variableName]; + if (!variableData?.input) { + continue; + } + + const input = getLdapDynamicValue(entry, variableData.input) || ''; + const output = executeOperation(entry, input, variableData.output) || ''; + + mapLogger.debug({ msg: 'Processed LDAP variable.', variableName, input, output }); + + entry[variableName] = output; + } + + return entry; +} diff --git a/apps/meteor/server/settings/ldap.ts b/apps/meteor/server/settings/ldap.ts index c492c94b695..646bcf31ec2 100644 --- a/apps/meteor/server/settings/ldap.ts +++ b/apps/meteor/server/settings/ldap.ts @@ -219,6 +219,19 @@ export const createLdapSettings = () => type: 'string', enableQuery, }); + + await this.add('LDAP_DataSync_UseVariables', false, { + type: 'boolean', + enableQuery, + invalidValue: false, + }); + + await this.add('LDAP_DataSync_VariableMap', '{}', { + type: 'code', + multiline: true, + enableQuery: [enableQuery, { _id: 'LDAP_DataSync_UseVariables', value: true }], + invalidValue: '{}', + }); }); await this.section('LDAP_DataSync_Avatar', async function () { diff --git a/packages/core-typings/src/ldap/ILDAPOptions.ts b/packages/core-typings/src/ldap/ILDAPOptions.ts index e7721dcd041..ad673f595ef 100644 --- a/packages/core-typings/src/ldap/ILDAPOptions.ts +++ b/packages/core-typings/src/ldap/ILDAPOptions.ts @@ -28,4 +28,6 @@ export interface ILDAPConnectionOptions { authenticationUserDN: string; authenticationPassword: string; attributesToQuery: Array; + useVariables: boolean; + variableMap: string; } diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 2c8b1302819..4e5cc01e43f 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -3332,6 +3332,8 @@ "LDAP_UserSearch_GroupFilter": "Group Filter", "LDAP_DataSync": "Data Sync", "LDAP_DataSync_DataMap": "Mapping", + "LDAP_DataSync_UseVariables": "Use Variables", + "LDAP_DataSync_VariableMap": "Variables Configuration", "Members_List": "Members List", "mention-all": "Mention All", "LDAP_DataSync_Avatar": "Avatar",