diff --git a/jest.config.js b/jest.config.js index 1b2fb2f48d..6aefc55ee1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,7 +5,7 @@ const tsJestOptions = { tsconfig: { ...compilerOptions, declaration: false, - sourceMap: false, + sourceMap: true, skipLibCheck: true, }, }; diff --git a/packages/cli/package.json b/packages/cli/package.json index ed65afbaca..ad3fd547ed 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -147,6 +147,7 @@ "jsonschema": "^1.4.1", "jsonwebtoken": "^9.0.0", "jwks-rsa": "~1.12.1", + "ldapts": "^4.2.2", "localtunnel": "^2.0.0", "lodash.get": "^4.4.2", "lodash.intersection": "^4.4.0", diff --git a/packages/cli/src/Db.ts b/packages/cli/src/Db.ts index 1c129657e5..d458138d5b 100644 --- a/packages/cli/src/Db.ts +++ b/packages/cli/src/Db.ts @@ -172,6 +172,8 @@ export async function init( collections.Tag = linkRepository(entities.TagEntity); collections.Role = linkRepository(entities.Role); collections.User = linkRepository(entities.User); + collections.AuthIdentity = linkRepository(entities.AuthIdentity); + collections.AuthProviderSyncHistory = linkRepository(entities.AuthProviderSyncHistory); collections.SharedCredentials = linkRepository(entities.SharedCredentials); collections.SharedWorkflow = linkRepository(entities.SharedWorkflow); collections.Settings = linkRepository(entities.Settings); diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 324aa28ec0..efbcd136a6 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -28,6 +28,8 @@ import type { FindOperator, Repository } from 'typeorm'; import type { ChildProcess } from 'child_process'; +import type { AuthIdentity, AuthProviderType } from '@db/entities/AuthIdentity'; +import type { AuthProviderSyncHistory } from '@db/entities/AuthProviderSyncHistory'; import type { InstalledNodes } from '@db/entities/InstalledNodes'; import type { InstalledPackages } from '@db/entities/InstalledPackages'; import type { Role } from '@db/entities/Role'; @@ -64,6 +66,8 @@ export interface ICredentialsOverwrite { } export interface IDatabaseCollections { + AuthIdentity: Repository; + AuthProviderSyncHistory: Repository; Credentials: Repository; Execution: Repository; Workflow: Repository; @@ -318,6 +322,7 @@ export interface IDiagnosticInfo { binaryDataMode: string; n8n_multi_user_allowed: boolean; smtp_set_up: boolean; + ldap_allowed: boolean; } export interface ITelemetryUserDeletionData { @@ -400,7 +405,13 @@ export interface IInternalHooksClass { }): Promise; onUserPasswordResetRequestClick(userPasswordResetData: { user: User }): Promise; onInstanceOwnerSetup(instanceOwnerSetupData: { user_id: string }, user?: User): Promise; - onUserSignup(userSignupData: { user: User }): Promise; + onUserSignup( + user: User, + userSignupData: { + user_type: AuthProviderType; + was_disabled_ldap_user: boolean; + }, + ): Promise; onCommunityPackageInstallFinished(installationData: { user: User; input_string: string; @@ -519,6 +530,10 @@ export interface IN8nUISettings { personalizationSurveyEnabled: boolean; defaultLocale: string; userManagement: IUserManagementSettings; + ldap: { + loginLabel: string; + loginEnabled: boolean; + }; publicApi: IPublicApiSettings; workflowTagsDisabled: boolean; logLevel: 'info' | 'debug' | 'warn' | 'error' | 'verbose' | 'silent'; @@ -541,6 +556,7 @@ export interface IN8nUISettings { }; enterprise: { sharing: boolean; + ldap: boolean; logStreaming: boolean; }; hideUsagePage: boolean; @@ -567,6 +583,9 @@ export interface IUserManagementSettings { showSetupOnFirstLoad?: boolean; smtpSetup: boolean; } +export interface IActiveDirectorySettings { + enabled: boolean; +} export interface IPublicApiSettings { enabled: boolean; latestVersion: number; diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index 32fe4e8dbf..665f4840c8 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -21,6 +21,7 @@ import { IWorkflowExecutionDataProcess, } from '@/Interfaces'; import { Telemetry } from '@/telemetry'; +import type { AuthProviderType } from '@db/entities/AuthIdentity'; import { RoleService } from './role/role.service'; import { eventBus } from './eventbus'; import type { User } from '@db/entities/User'; @@ -65,6 +66,7 @@ export class InternalHooksClass implements IInternalHooksClass { n8n_binary_data_mode: diagnosticInfo.binaryDataMode, n8n_multi_user_allowed: diagnosticInfo.n8n_multi_user_allowed, smtp_set_up: diagnosticInfo.smtp_set_up, + ldap_allowed: diagnosticInfo.ldap_allowed, }; return Promise.all([ @@ -642,16 +644,23 @@ export class InternalHooksClass implements IInternalHooksClass { return this.telemetry.track('Owner finished instance setup', instanceOwnerSetupData); } - async onUserSignup(userSignupData: { user: User }): Promise { + async onUserSignup( + user: User, + userSignupData: { + user_type: AuthProviderType; + was_disabled_ldap_user: boolean; + }, + ): Promise { void Promise.all([ eventBus.sendAuditEvent({ eventName: 'n8n.audit.user.signedup', payload: { - ...userToPayload(userSignupData.user), + ...userToPayload(user), }, }), this.telemetry.track('User signed up', { - user_id: userSignupData.user.id, + user_id: user.id, + ...userSignupData, }), ]); } @@ -848,7 +857,49 @@ export class InternalHooksClass implements IInternalHooksClass { ]); } - /** + async onLdapSyncFinished(data: { + type: string; + succeeded: boolean; + users_synced: number; + error: string; + }): Promise { + return this.telemetry.track('Ldap general sync finished', data); + } + + async onLdapUsersDisabled(data: { + reason: 'ldap_update' | 'ldap_feature_deactivated'; + users: number; + user_ids: string[]; + }): Promise { + return this.telemetry.track('Ldap users disabled', data); + } + + async onUserUpdatedLdapSettings(data: { + user_id: string; + loginIdAttribute: string; + firstNameAttribute: string; + lastNameAttribute: string; + emailAttribute: string; + ldapIdAttribute: string; + searchPageSize: number; + searchTimeout: number; + synchronizationEnabled: boolean; + synchronizationInterval: number; + loginLabel: string; + loginEnabled: boolean; + }): Promise { + return this.telemetry.track('Ldap general sync finished', data); + } + + async onLdapLoginSyncFailed(data: { error: string }): Promise { + return this.telemetry.track('Ldap login sync failed', data); + } + + async userLoginFailedDueToLdapDisabled(data: { user_id: string }): Promise { + return this.telemetry.track('User login failed since ldap disabled', data); + } + + /* * Execution Statistics */ async onFirstProductionWorkflowSuccess(data: { diff --git a/packages/cli/src/Ldap/LdapManager.ee.ts b/packages/cli/src/Ldap/LdapManager.ee.ts new file mode 100644 index 0000000000..d4e4a39511 --- /dev/null +++ b/packages/cli/src/Ldap/LdapManager.ee.ts @@ -0,0 +1,38 @@ +import { LdapService } from './LdapService.ee'; +import { LdapSync } from './LdapSync.ee'; +import type { LdapConfig } from './types'; + +export class LdapManager { + private static ldap: { + service: LdapService; + sync: LdapSync; + }; + + private static initialized: boolean; + + static getInstance(): { + service: LdapService; + sync: LdapSync; + } { + if (!this.initialized) { + throw new Error('LDAP Manager has not been initialized'); + } + return this.ldap; + } + + static init(config: LdapConfig): void { + this.ldap = { + service: new LdapService(), + sync: new LdapSync(), + }; + this.ldap.service.config = config; + this.ldap.sync.config = config; + this.ldap.sync.ldapService = this.ldap.service; + this.initialized = true; + } + + static updateConfig(config: LdapConfig): void { + this.ldap.service.config = config; + this.ldap.sync.config = config; + } +} diff --git a/packages/cli/src/Ldap/LdapService.ee.ts b/packages/cli/src/Ldap/LdapService.ee.ts new file mode 100644 index 0000000000..28384c1cb8 --- /dev/null +++ b/packages/cli/src/Ldap/LdapService.ee.ts @@ -0,0 +1,104 @@ +/* eslint-disable no-underscore-dangle */ +import { Client, Entry as LdapUser, ClientOptions } from 'ldapts'; +import type { LdapConfig } from './types'; +import { formatUrl, getMappingAttributes } from './helpers'; +import { BINARY_AD_ATTRIBUTES } from './constants'; +import { ConnectionOptions } from 'tls'; + +export class LdapService { + private client: Client | undefined; + + private _config: LdapConfig; + + /** + * Set the LDAP configuration and expire the current client + */ + set config(config: LdapConfig) { + this._config = config; + this.client = undefined; + } + + /** + * Get new/existing LDAP client, + * depending on whether the credentials + * were updated or not + */ + private async getClient() { + if (this._config === undefined) { + throw new Error('Service cannot be used without setting the property config'); + } + if (this.client === undefined) { + const url = formatUrl( + this._config.connectionUrl, + this._config.connectionPort, + this._config.connectionSecurity, + ); + const ldapOptions: ClientOptions = { url }; + const tlsOptions: ConnectionOptions = {}; + + if (this._config.connectionSecurity !== 'none') { + Object.assign(tlsOptions, { + rejectUnauthorized: !this._config.allowUnauthorizedCerts, + }); + if (this._config.connectionSecurity === 'tls') { + ldapOptions.tlsOptions = tlsOptions; + } + } + + this.client = new Client(ldapOptions); + if (this._config.connectionSecurity === 'startTls') { + await this.client.startTLS(tlsOptions); + } + } + } + + /** + * Attempt a binding with the admin credentials + */ + private async bindAdmin(): Promise { + await this.getClient(); + if (this.client) { + await this.client.bind(this._config.bindingAdminDn, this._config.bindingAdminPassword); + } + } + + /** + * Search the LDAP server using the administrator binding + * (if any, else a anonymous binding will be attempted) + */ + async searchWithAdminBinding(filter: string): Promise { + await this.bindAdmin(); + if (this.client) { + const { searchEntries } = await this.client.search(this._config.baseDn, { + attributes: getMappingAttributes(this._config), + explicitBufferAttributes: BINARY_AD_ATTRIBUTES, + filter, + timeLimit: this._config.searchTimeout, + paged: { pageSize: this._config.searchPageSize }, + ...(this._config.searchPageSize === 0 && { paged: true }), + }); + + await this.client.unbind(); + return searchEntries; + } + return Promise.resolve([]); + } + + /** + * Attempt binding with the user's credentials + */ + async validUser(dn: string, password: string): Promise { + await this.getClient(); + if (this.client) { + await this.client.bind(dn, password); + await this.client.unbind(); + } + } + + /** + * Attempt binding with the administrator credentials, to test the connection + */ + async testConnection(): Promise { + await this.bindAdmin(); + } +} diff --git a/packages/cli/src/Ldap/LdapSync.ee.ts b/packages/cli/src/Ldap/LdapSync.ee.ts new file mode 100644 index 0000000000..37188ce4be --- /dev/null +++ b/packages/cli/src/Ldap/LdapSync.ee.ts @@ -0,0 +1,216 @@ +import type { Entry as LdapUser } from 'ldapts'; +import { LoggerProxy as Logger } from 'n8n-workflow'; +import { QueryFailedError } from 'typeorm/error/QueryFailedError'; +import { LdapService } from './LdapService.ee'; +import type { LdapConfig } from './types'; +import { + getLdapUserRole, + mapLdapUserToDbUser, + processUsers, + saveLdapSynchronization, + createFilter, + resolveBinaryAttributes, + getLdapIds, +} from './helpers'; +import type { User } from '@db/entities/User'; +import type { Role } from '@db/entities/Role'; +import type { RunningMode, SyncStatus } from '@db/entities/AuthProviderSyncHistory'; +import { InternalHooksManager } from '@/InternalHooksManager'; + +export class LdapSync { + private intervalId: NodeJS.Timeout | undefined = undefined; + + private _config: LdapConfig; + + private _ldapService: LdapService; + + /** + * Updates the LDAP configuration + */ + set config(config: LdapConfig) { + this._config = config; + // If user disabled synchronization in the UI and there a job schedule, + // stop it + if (this.intervalId && !this._config.synchronizationEnabled) { + this.stop(); + // If instance crashed with a job scheduled, once the server starts + // again, reschedule it. + } else if (!this.intervalId && this._config.synchronizationEnabled) { + this.scheduleRun(); + // If job scheduled and the run interval got updated in the UI + // stop the current one and schedule a new one with the new internal + } else if (this.intervalId && this._config.synchronizationEnabled) { + this.stop(); + this.scheduleRun(); + } + } + + /** + * Set the LDAP service instance + */ + set ldapService(service: LdapService) { + this._ldapService = service; + } + + /** + * Schedule a synchronization job based on the interval set in the LDAP config + */ + scheduleRun(): void { + if (!this._config.synchronizationInterval) { + throw new Error('Interval variable has to be defined'); + } + this.intervalId = setInterval(async () => { + await this.run('live'); + }, this._config.synchronizationInterval * 60000); + } + + /** + * Run the synchronization job. + * If the job runs in "live" mode, changes to LDAP users are persisted in the database, + * else the users are not modified + */ + async run(mode: RunningMode): Promise { + Logger.debug(`LDAP - Starting a synchronization run in ${mode} mode`); + + let adUsers: LdapUser[] = []; + + try { + adUsers = await this._ldapService.searchWithAdminBinding( + createFilter(`(${this._config.loginIdAttribute}=*)`, this._config.userFilter), + ); + + Logger.debug('LDAP - Users return by the query', { + users: adUsers, + }); + + resolveBinaryAttributes(adUsers); + } catch (e) { + if (e instanceof Error) { + Logger.error(`LDAP - ${e.message}`); + throw e; + } + } + + const startedAt = new Date(); + + const localAdUsers = await getLdapIds(); + + const role = await getLdapUserRole(); + + const { usersToCreate, usersToUpdate, usersToDisable } = this.getUsersToProcess( + adUsers, + localAdUsers, + role, + ); + + if (usersToDisable.length) { + void InternalHooksManager.getInstance().onLdapUsersDisabled({ + reason: 'ldap_update', + users: usersToDisable.length, + user_ids: usersToDisable, + }); + } + + Logger.debug('LDAP - Users processed', { + created: usersToCreate.length, + updated: usersToUpdate.length, + disabled: usersToDisable.length, + }); + + const endedAt = new Date(); + let status: SyncStatus = 'success'; + let errorMessage = ''; + + try { + if (mode === 'live') { + await processUsers(usersToCreate, usersToUpdate, usersToDisable); + } + } catch (error) { + if (error instanceof QueryFailedError) { + status = 'error'; + errorMessage = `${error.message}`; + } + } + + await saveLdapSynchronization({ + startedAt, + endedAt, + created: usersToCreate.length, + updated: usersToUpdate.length, + disabled: usersToDisable.length, + scanned: adUsers.length, + runMode: mode, + status, + error: errorMessage, + }); + + void InternalHooksManager.getInstance().onLdapSyncFinished({ + type: !this.intervalId ? 'scheduled' : `manual_${mode}`, + succeeded: true, + users_synced: usersToCreate.length + usersToUpdate.length + usersToDisable.length, + error: errorMessage, + }); + + Logger.debug('LDAP - Synchronization finished successfully'); + } + + /** + * Stop the current job scheduled, if any + */ + stop(): void { + clearInterval(this.intervalId); + this.intervalId = undefined; + } + + /** + * Get all the user that will be changed (created, updated, disabled), in the database + */ + private getUsersToProcess( + adUsers: LdapUser[], + localAdUsers: string[], + role: Role, + ): { + usersToCreate: Array<[string, User]>; + usersToUpdate: Array<[string, User]>; + usersToDisable: string[]; + } { + return { + usersToCreate: this.getUsersToCreate(adUsers, localAdUsers, role), + usersToUpdate: this.getUsersToUpdate(adUsers, localAdUsers), + usersToDisable: this.getUsersToDisable(adUsers, localAdUsers), + }; + } + + /** + * Get users in LDAP that are not in the database yet + */ + private getUsersToCreate( + remoteAdUsers: LdapUser[], + localLdapIds: string[], + role: Role, + ): Array<[string, User]> { + return remoteAdUsers + .filter((adUser) => !localLdapIds.includes(adUser[this._config.ldapIdAttribute] as string)) + .map((adUser) => mapLdapUserToDbUser(adUser, this._config, role)); + } + + /** + * Get users in LDAP that are already in the database + */ + private getUsersToUpdate( + remoteAdUsers: LdapUser[], + localLdapIds: string[], + ): Array<[string, User]> { + return remoteAdUsers + .filter((adUser) => localLdapIds.includes(adUser[this._config.ldapIdAttribute] as string)) + .map((adUser) => mapLdapUserToDbUser(adUser, this._config)); + } + + /** + * Get users that are in the database but not in the LDAP server + */ + private getUsersToDisable(remoteAdUsers: LdapUser[], localLdapIds: string[]): string[] { + const remoteAdUserIds = remoteAdUsers.map((adUser) => adUser[this._config.ldapIdAttribute]); + return localLdapIds.filter((user) => !remoteAdUserIds.includes(user)); + } +} diff --git a/packages/cli/src/Ldap/constants.ts b/packages/cli/src/Ldap/constants.ts new file mode 100644 index 0000000000..630f4b6b77 --- /dev/null +++ b/packages/cli/src/Ldap/constants.ts @@ -0,0 +1,133 @@ +import type { LdapConfig } from './types'; + +export const LDAP_FEATURE_NAME = 'features.ldap'; + +export const LDAP_ENABLED = 'enterprise.features.ldap'; + +export const LDAP_LOGIN_LABEL = 'ldap.loginLabel'; + +export const LDAP_LOGIN_ENABLED = 'ldap.loginEnabled'; + +export const BINARY_AD_ATTRIBUTES = ['objectGUID', 'objectSid']; + +export const LDAP_DEFAULT_CONFIGURATION: LdapConfig = { + loginEnabled: false, + loginLabel: '', + connectionUrl: '', + allowUnauthorizedCerts: false, + connectionSecurity: 'none', + connectionPort: 389, + baseDn: '', + bindingAdminDn: '', + bindingAdminPassword: '', + firstNameAttribute: '', + lastNameAttribute: '', + emailAttribute: '', + loginIdAttribute: '', + ldapIdAttribute: '', + userFilter: '', + synchronizationEnabled: false, + synchronizationInterval: 60, + searchPageSize: 0, + searchTimeout: 60, +}; + +export const LDAP_CONFIG_SCHEMA = { + $schema: 'https://json-schema.org/draft/2019-09/schema', + type: 'object', + properties: { + emailAttribute: { + type: 'string', + }, + firstNameAttribute: { + type: 'string', + }, + lastNameAttribute: { + type: 'string', + }, + ldapIdAttribute: { + type: 'string', + }, + loginIdAttribute: { + type: 'string', + }, + bindingAdminDn: { + type: 'string', + }, + bindingAdminPassword: { + type: 'string', + }, + baseDn: { + type: 'string', + }, + connectionUrl: { + type: 'string', + }, + connectionSecurity: { + type: 'string', + }, + connectionPort: { + type: 'number', + }, + allowUnauthorizedCerts: { + type: 'boolean', + }, + userFilter: { + type: 'string', + }, + loginEnabled: { + type: 'boolean', + }, + loginLabel: { + type: 'string', + }, + synchronizationEnabled: { + type: 'boolean', + }, + synchronizationInterval: { + type: 'number', + }, + searchPageSize: { + type: 'number', + }, + searchTimeout: { + type: 'number', + }, + }, + required: [ + 'loginEnabled', + 'loginLabel', + 'connectionUrl', + 'allowUnauthorizedCerts', + 'connectionSecurity', + 'connectionPort', + 'baseDn', + 'bindingAdminDn', + 'bindingAdminPassword', + 'firstNameAttribute', + 'lastNameAttribute', + 'emailAttribute', + 'loginIdAttribute', + 'ldapIdAttribute', + 'userFilter', + 'synchronizationEnabled', + 'synchronizationInterval', + 'searchPageSize', + 'searchTimeout', + ], + additionalProperties: false, +}; + +export const NON_SENSIBLE_LDAP_CONFIG_PROPERTIES: Array = [ + 'loginEnabled', + 'emailAttribute', + 'firstNameAttribute', + 'lastNameAttribute', + 'loginIdAttribute', + 'ldapIdAttribute', + 'synchronizationEnabled', + 'synchronizationInterval', + 'searchPageSize', + 'searchTimeout', + 'loginLabel', +]; diff --git a/packages/cli/src/Ldap/helpers.ts b/packages/cli/src/Ldap/helpers.ts new file mode 100644 index 0000000000..c8a8c3e140 --- /dev/null +++ b/packages/cli/src/Ldap/helpers.ts @@ -0,0 +1,474 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import { AES, enc } from 'crypto-js'; +import type { Entry as LdapUser } from 'ldapts'; +import { Filter } from 'ldapts/filters/Filter'; +import { UserSettings } from 'n8n-core'; +import { validate } from 'jsonschema'; +import * as Db from '@/Db'; +import config from '@/config'; +import type { Role } from '@db/entities/Role'; +import { User } from '@db/entities/User'; +import { AuthIdentity } from '@/databases/entities/AuthIdentity'; +import type { AuthProviderSyncHistory } from '@db/entities/AuthProviderSyncHistory'; +import { isUserManagementEnabled } from '@/UserManagement/UserManagementHelper'; +import { LdapManager } from './LdapManager.ee'; + +import { + BINARY_AD_ATTRIBUTES, + LDAP_CONFIG_SCHEMA, + LDAP_ENABLED, + LDAP_FEATURE_NAME, + LDAP_LOGIN_ENABLED, + LDAP_LOGIN_LABEL, +} from './constants'; +import type { ConnectionSecurity, LdapConfig } from './types'; +import { InternalHooksManager } from '@/InternalHooksManager'; +import { jsonParse, LoggerProxy as Logger } from 'n8n-workflow'; +import { getLicense } from '@/License'; + +/** + * Check whether the LDAP feature is disabled in the instance + */ +export const isLdapEnabled = (): boolean => { + const license = getLicense(); + return isUserManagementEnabled() && (config.getEnv(LDAP_ENABLED) || license.isLdapEnabled()); +}; + +/** + * Check whether the LDAP feature is enabled in the instance + */ +export const isLdapDisabled = (): boolean => !isLdapEnabled(); + +/** + * Set the LDAP login label to the configuration object + */ +export const setLdapLoginLabel = (value: string): void => { + config.set(LDAP_LOGIN_LABEL, value); +}; + +/** + * Set the LDAP login enabled to the configuration object + */ +export const setLdapLoginEnabled = (value: boolean): void => { + config.set(LDAP_LOGIN_ENABLED, value); +}; + +/** + * Retrieve the LDAP login label from the configuration object + */ +export const getLdapLoginLabel = (): string => config.getEnv(LDAP_LOGIN_LABEL); + +/** + * Retrieve the LDAP login enabled from the configuration object + */ +export const isLdapLoginEnabled = (): boolean => config.getEnv(LDAP_LOGIN_ENABLED); + +/** + * Return a random password to be assigned to the LDAP users + */ +export const randomPassword = (): string => { + return Math.random().toString(36).slice(-8); +}; + +/** + * Return the user role to be assigned to LDAP users + */ +export const getLdapUserRole = async (): Promise => { + return Db.collections.Role.findOneByOrFail({ scope: 'global', name: 'member' }); +}; + +/** + * Validate the structure of the LDAP configuration schema + */ +export const validateLdapConfigurationSchema = ( + ldapConfig: LdapConfig, +): { valid: boolean; message: string } => { + const { valid, errors } = validate(ldapConfig, LDAP_CONFIG_SCHEMA, { nestedErrors: true }); + + let message = ''; + if (!valid) { + message = errors.map((error) => `request.body.${error.path[0]} ${error.message}`).join(','); + } + return { valid, message }; +}; + +/** + * Encrypt password using the instance's encryption key + */ +export const encryptPassword = async (password: string): Promise => { + const encryptionKey = await UserSettings.getEncryptionKey(); + return AES.encrypt(password, encryptionKey).toString(); +}; + +/** + * Decrypt password using the instance's encryption key + */ +export const decryptPassword = async (password: string): Promise => { + const encryptionKey = await UserSettings.getEncryptionKey(); + return AES.decrypt(password, encryptionKey).toString(enc.Utf8); +}; + +/** + * Retrieve the LDAP configuration (decrypted) form the database + */ +export const getLdapConfig = async (): Promise => { + const configuration = await Db.collections.Settings.findOneByOrFail({ + key: LDAP_FEATURE_NAME, + }); + const configurationData = jsonParse(configuration.value); + configurationData.bindingAdminPassword = await decryptPassword( + configurationData.bindingAdminPassword, + ); + return configurationData; +}; + +/** + * Take the LDAP configuration and set login enabled and login label to the config object + */ +export const setGlobalLdapConfigVariables = (ldapConfig: LdapConfig): void => { + setLdapLoginEnabled(ldapConfig.loginEnabled); + setLdapLoginLabel(ldapConfig.loginLabel); +}; + +const resolveEntryBinaryAttributes = (entry: LdapUser): LdapUser => { + Object.entries(entry) + .filter(([k]) => BINARY_AD_ATTRIBUTES.includes(k)) + .forEach(([k]) => { + entry[k] = (entry[k] as Buffer).toString('hex'); + }); + return entry; +}; + +export const resolveBinaryAttributes = (entries: LdapUser[]): void => { + entries.forEach((entry) => resolveEntryBinaryAttributes(entry)); +}; + +/** + * Update the LDAP configuration in the database + */ +export const updateLdapConfig = async (ldapConfig: LdapConfig): Promise => { + const { valid, message } = validateLdapConfigurationSchema(ldapConfig); + + if (!valid) { + throw new Error(message); + } + + LdapManager.updateConfig({ ...ldapConfig }); + + ldapConfig.bindingAdminPassword = await encryptPassword(ldapConfig.bindingAdminPassword); + + if (!ldapConfig.loginEnabled) { + ldapConfig.synchronizationEnabled = false; + const ldapUsers = await getLdapUsers(); + if (ldapUsers.length) { + await deleteAllLdapIdentities(); + void InternalHooksManager.getInstance().onLdapUsersDisabled({ + reason: 'ldap_update', + users: ldapUsers.length, + user_ids: ldapUsers.map((user) => user.id), + }); + } + } + + await Db.collections.Settings.update( + { key: LDAP_FEATURE_NAME }, + { value: JSON.stringify(ldapConfig), loadOnStartup: true }, + ); + setGlobalLdapConfigVariables(ldapConfig); +}; + +/** + * Handle the LDAP initialization. + * If it's the first run of this feature, all the default data is created in the database + */ +export const handleLdapInit = async (): Promise => { + if (!isLdapEnabled()) { + const ldapUsers = await getLdapUsers(); + if (ldapUsers.length) { + void InternalHooksManager.getInstance().onLdapUsersDisabled({ + reason: 'ldap_feature_deactivated', + users: ldapUsers.length, + user_ids: ldapUsers.map((user) => user.id), + }); + } + return; + } + + const ldapConfig = await getLdapConfig(); + + setGlobalLdapConfigVariables(ldapConfig); + + // init LDAP manager with the current + // configuration + LdapManager.init(ldapConfig); +}; + +export const createFilter = (filter: string, userFilter: string) => { + let _filter = `(&(|(objectClass=person)(objectClass=user))${filter})`; + if (userFilter) { + _filter = `(&${userFilter}${filter}`; + } + return _filter; +}; + +export const escapeFilter = (filter: string): string => { + //@ts-ignore + return new Filter().escape(filter); /* eslint-disable-line */ +}; + +/** + * Find and authenticate user in the LDAP server. + */ +export const findAndAuthenticateLdapUser = async ( + loginId: string, + password: string, + loginIdAttribute: string, + userFilter: string, +): Promise => { + const ldapService = LdapManager.getInstance().service; + + // Search for the user with the administrator binding using the + // the Login ID attribute and whatever was inputted in the UI's + // email input. + let searchResult: LdapUser[] = []; + + try { + searchResult = await ldapService.searchWithAdminBinding( + createFilter(`(${loginIdAttribute}=${escapeFilter(loginId)})`, userFilter), + ); + } catch (e) { + if (e instanceof Error) { + void InternalHooksManager.getInstance().onLdapLoginSyncFailed({ + error: e.message, + }); + Logger.error('LDAP - Error during search', { message: e.message }); + } + return undefined; + } + + if (!searchResult.length) { + return undefined; + } + + // In the unlikely scenario that more than one user is found ( + // can happen depending on how the LDAP database is structured + // and the LDAP configuration), return the last one found as it + // should be the less important in the hierarchy. + let user = searchResult.pop(); + + if (user === undefined) { + user = { dn: '' }; + } + + try { + // Now with the user distinguished name (unique identifier + // for the user) and the password, attempt to validate the + // user by binding + await ldapService.validUser(user.dn, password); + } catch (e) { + if (e instanceof Error) { + Logger.error('LDAP - Error validating user against LDAP server', { message: e.message }); + } + return undefined; + } + + resolveEntryBinaryAttributes(user); + + return user; +}; + +/** + * Retrieve auth identity by LDAP ID from database + */ +export const getAuthIdentityByLdapId = async ( + idAttributeValue: string, +): Promise => { + return Db.collections.AuthIdentity.findOne({ + relations: ['user', 'user.globalRole'], + where: { + providerId: idAttributeValue, + providerType: 'ldap', + }, + }); +}; + +export const getUserByEmail = async (email: string): Promise => { + return Db.collections.User.findOne({ + where: { email }, + relations: ['globalRole'], + }); +}; + +/** + * Map attributes from the LDAP server to the proper columns in the database + * e.g. mail => email | uid => ldapId + */ +export const mapLdapAttributesToUser = ( + ldapUser: LdapUser, + ldapConfig: LdapConfig, +): [AuthIdentity['providerId'], Pick] => { + return [ + ldapUser[ldapConfig.ldapIdAttribute] as string, + { + email: ldapUser[ldapConfig.emailAttribute] as string, + firstName: ldapUser[ldapConfig.firstNameAttribute] as string, + lastName: ldapUser[ldapConfig.lastNameAttribute] as string, + }, + ]; +}; + +/** + * Retrieve LDAP ID of all LDAP users in the database + */ +export const getLdapIds = async (): Promise => { + const identities = await Db.collections.AuthIdentity.find({ + select: ['providerId'], + where: { + providerType: 'ldap', + }, + }); + return identities.map((i) => i.providerId); +}; + +export const getLdapUsers = async (): Promise => { + const identities = await Db.collections.AuthIdentity.find({ + relations: ['user'], + where: { + providerType: 'ldap', + }, + }); + return identities.map((i) => i.user); +}; + +/** + * Map a LDAP user to database user + */ +export const mapLdapUserToDbUser = ( + ldapUser: LdapUser, + ldapConfig: LdapConfig, + role?: Role, +): [string, User] => { + const user = new User(); + const [ldapId, data] = mapLdapAttributesToUser(ldapUser, ldapConfig); + Object.assign(user, data); + if (role) { + user.globalRole = role; + user.password = randomPassword(); + user.disabled = false; + } else { + user.disabled = true; + } + return [ldapId, user]; +}; + +/** + * Save "toCreateUsers" in the database + * Update "toUpdateUsers" in the database + * Update "ToDisableUsers" in the database + */ +export const processUsers = async ( + toCreateUsers: Array<[string, User]>, + toUpdateUsers: Array<[string, User]>, + toDisableUsers: string[], +): Promise => { + await Db.transaction(async (transactionManager) => { + return Promise.all([ + ...toCreateUsers.map(async ([ldapId, user]) => { + const authIdentity = AuthIdentity.create(await transactionManager.save(user), ldapId); + return transactionManager.save(authIdentity); + }), + ...toUpdateUsers.map(async ([ldapId, user]) => { + const authIdentity = await transactionManager.findOneBy(AuthIdentity, { + providerId: ldapId, + }); + if (authIdentity?.userId) { + await transactionManager.update( + User, + { id: authIdentity.userId }, + { email: user.email, firstName: user.firstName, lastName: user.lastName }, + ); + } + }), + ...toDisableUsers.map(async (ldapId) => { + const authIdentity = await transactionManager.findOneBy(AuthIdentity, { + providerId: ldapId, + }); + if (authIdentity?.userId) { + await transactionManager.update(User, { id: authIdentity?.userId }, { disabled: true }); + await transactionManager.delete(AuthIdentity, { userId: authIdentity?.userId }); + } + }), + ]); + }); +}; + +/** + * Save a LDAP synchronization data to the database + */ +export const saveLdapSynchronization = async ( + data: Omit, +): Promise => { + await Db.collections.AuthProviderSyncHistory.save({ + ...data, + providerType: 'ldap', + }); +}; + +/** + * Retrieve all LDAP synchronizations in the database + */ +export const getLdapSynchronizations = async ( + page: number, + perPage: number, +): Promise => { + const _page = Math.abs(page); + return Db.collections.AuthProviderSyncHistory.find({ + where: { providerType: 'ldap' }, + order: { id: 'DESC' }, + take: perPage, + skip: _page * perPage, + }); +}; + +/** + * Format the LDAP connection URL to conform with LDAP client library + */ +export const formatUrl = (url: string, port: number, security: ConnectionSecurity) => { + const protocol = ['tls'].includes(security) ? 'ldaps' : 'ldap'; + return `${protocol}://${url}:${port}`; +}; + +export const getMappingAttributes = (ldapConfig: LdapConfig): string[] => { + return [ + ldapConfig.emailAttribute, + ldapConfig.ldapIdAttribute, + ldapConfig.firstNameAttribute, + ldapConfig.lastNameAttribute, + ldapConfig.emailAttribute, + ]; +}; + +export const createLdapAuthIdentity = async (user: User, ldapId: string) => { + return Db.collections.AuthIdentity.save(AuthIdentity.create(user, ldapId)); +}; + +export const createLdapUserOnLocalDb = async (role: Role, data: Partial, ldapId: string) => { + const user = await Db.collections.User.save({ + password: randomPassword(), + globalRole: role, + ...data, + }); + await createLdapAuthIdentity(user, ldapId); + return user; +}; + +export const updateLdapUserOnLocalDb = async (identity: AuthIdentity, data: Partial) => { + const userId = identity?.user?.id; + if (userId) { + await Db.collections.User.update({ id: userId }, data); + } +}; + +const deleteAllLdapIdentities = async () => { + return Db.collections.AuthIdentity.delete({ providerType: 'ldap' }); +}; diff --git a/packages/cli/src/Ldap/routes/ldap.controller.ee.ts b/packages/cli/src/Ldap/routes/ldap.controller.ee.ts new file mode 100644 index 0000000000..53e9d60d8f --- /dev/null +++ b/packages/cli/src/Ldap/routes/ldap.controller.ee.ts @@ -0,0 +1,76 @@ +import express from 'express'; +import { LdapManager } from '../LdapManager.ee'; +import { getLdapConfig, getLdapSynchronizations, updateLdapConfig } from '../helpers'; +import type { LdapConfiguration } from '../types'; +import { InternalHooksManager } from '@/InternalHooksManager'; +import pick from 'lodash.pick'; +import { NON_SENSIBLE_LDAP_CONFIG_PROPERTIES } from '../constants'; + +export const ldapController = express.Router(); + +/** + * GET /ldap/config + */ +ldapController.get('/config', async (req: express.Request, res: express.Response) => { + const data = await getLdapConfig(); + return res.status(200).json({ data }); +}); +/** + * POST /ldap/test-connection + */ +ldapController.post('/test-connection', async (req: express.Request, res: express.Response) => { + try { + await LdapManager.getInstance().service.testConnection(); + } catch (error) { + const errorObject = error as { message: string }; + return res.status(400).json({ message: errorObject.message }); + } + return res.status(200).json(); +}); + +/** + * PUT /ldap/config + */ +ldapController.put('/config', async (req: LdapConfiguration.Update, res: express.Response) => { + try { + await updateLdapConfig(req.body); + } catch (e) { + if (e instanceof Error) { + return res.status(400).json({ message: e.message }); + } + } + + const data = await getLdapConfig(); + + void InternalHooksManager.getInstance().onUserUpdatedLdapSettings({ + user_id: req.user.id, + ...pick(data, NON_SENSIBLE_LDAP_CONFIG_PROPERTIES), + }); + + return res.status(200).json({ data }); +}); + +/** + * POST /ldap/sync + */ +ldapController.post('/sync', async (req: LdapConfiguration.Sync, res: express.Response) => { + const runType = req.body.type; + + try { + await LdapManager.getInstance().sync.run(runType); + } catch (e) { + if (e instanceof Error) { + return res.status(400).json({ message: e.message }); + } + } + return res.status(200).json({}); +}); + +/** + * GET /ldap/sync + */ +ldapController.get('/sync', async (req: LdapConfiguration.GetSync, res: express.Response) => { + const { page = '0', perPage = '20' } = req.query; + const data = await getLdapSynchronizations(parseInt(page, 10), parseInt(perPage, 10)); + return res.status(200).json({ data }); +}); diff --git a/packages/cli/src/Ldap/types.ts b/packages/cli/src/Ldap/types.ts new file mode 100644 index 0000000000..46f4ff2285 --- /dev/null +++ b/packages/cli/src/Ldap/types.ts @@ -0,0 +1,32 @@ +import type { RunningMode } from '@db/entities/AuthProviderSyncHistory'; +import { AuthenticatedRequest } from '@/requests'; + +export type ConnectionSecurity = 'none' | 'tls' | 'startTls'; + +export interface LdapConfig { + loginEnabled: boolean; + loginLabel: string; + connectionUrl: string; + allowUnauthorizedCerts: boolean; + connectionSecurity: ConnectionSecurity; + connectionPort: number; + baseDn: string; + bindingAdminDn: string; + bindingAdminPassword: string; + firstNameAttribute: string; + lastNameAttribute: string; + emailAttribute: string; + loginIdAttribute: string; + ldapIdAttribute: string; + userFilter: string; + synchronizationEnabled: boolean; + synchronizationInterval: number; // minutes + searchPageSize: number; + searchTimeout: number; +} + +export declare namespace LdapConfiguration { + type Update = AuthenticatedRequest<{}, {}, LdapConfig, {}>; + type Sync = AuthenticatedRequest<{}, {}, { type: RunningMode }, {}>; + type GetSync = AuthenticatedRequest<{}, {}, {}, { page?: string; perPage?: string }>; +} diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index 3faadcc12a..11cfbcabb8 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -97,6 +97,10 @@ export class License { return this.isFeatureEnabled(LICENSE_FEATURES.LOG_STREAMING); } + isLdapEnabled() { + return this.isFeatureEnabled(LICENSE_FEATURES.LDAP); + } + getCurrentEntitlements() { return this.manager?.getCurrentEntitlements() ?? []; } diff --git a/packages/cli/src/ResponseHelper.ts b/packages/cli/src/ResponseHelper.ts index 3c1899ae89..14a7c8ee69 100644 --- a/packages/cli/src/ResponseHelper.ts +++ b/packages/cli/src/ResponseHelper.ts @@ -69,6 +69,12 @@ export class ConflictError extends ResponseError { } } +export class UnprocessableRequestError extends ResponseError { + constructor(message: string) { + super(message, 422); + } +} + export class InternalServerError extends ResponseError { constructor(message: string, errorCode = 500) { super(message, 500, errorCode); diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 5d47fbeefd..638f41b5d6 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -152,6 +152,8 @@ import { getLicense } from '@/License'; import { licenseController } from './license/license.controller'; import { corsMiddleware } from './middlewares/cors'; import { initEvents } from './events'; +import { ldapController } from './Ldap/routes/ldap.controller.ee'; +import { getLdapLoginLabel, isLdapEnabled, isLdapLoginEnabled } from './Ldap/helpers'; import { AbstractServer } from './AbstractServer'; import { configureMetrics } from './metrics'; @@ -243,6 +245,10 @@ class Server extends AbstractServer { config.getEnv('userManagement.skipInstanceOwnerSetup') === false, smtpSetup: isEmailSetUp(), }, + ldap: { + loginEnabled: false, + loginLabel: '', + }, publicApi: { enabled: !config.getEnv('publicApi.disabled'), latestVersion: 1, @@ -271,6 +277,7 @@ class Server extends AbstractServer { }, enterprise: { sharing: false, + ldap: false, logStreaming: config.getEnv('enterprise.features.logStreaming'), }, hideUsagePage: config.getEnv('hideUsagePage'), @@ -297,8 +304,16 @@ class Server extends AbstractServer { Object.assign(this.frontendSettings.enterprise, { sharing: isSharingEnabled(), logStreaming: isLogStreamingEnabled(), + ldap: isLdapEnabled(), }); + if (isLdapEnabled()) { + Object.assign(this.frontendSettings.ldap, { + loginLabel: getLdapLoginLabel(), + loginEnabled: isLdapLoginEnabled(), + }); + } + if (config.get('nodes.packagesMissing').length > 0) { this.frontendSettings.missingPackages = true; } @@ -602,6 +617,13 @@ class Server extends AbstractServer { // ---------------------------------------- this.app.use(`/${this.restEndpoint}/tags`, tagsController); + // ---------------------------------------- + // LDAP + // ---------------------------------------- + if (isLdapEnabled()) { + this.app.use(`/${this.restEndpoint}/ldap`, ldapController); + } + // Returns parameter values which normally get loaded from an external API or // get generated dynamically this.app.get( @@ -1428,6 +1450,7 @@ export async function start(): Promise { binaryDataMode: binaryDataConfig.mode, n8n_multi_user_allowed: isUserManagementEnabled(), smtp_set_up: config.getEnv('userManagement.emails.mode') === 'smtp', + ldap_allowed: isLdapEnabled(), }; // Set up event handling diff --git a/packages/cli/src/UserManagement/Interfaces.ts b/packages/cli/src/UserManagement/Interfaces.ts index 9973cd8b5e..6fa153247a 100644 --- a/packages/cli/src/UserManagement/Interfaces.ts +++ b/packages/cli/src/UserManagement/Interfaces.ts @@ -1,6 +1,8 @@ -import { Application } from 'express'; +import type { Application } from 'express'; import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import type { IExternalHooksClass, IPersonalizationSurveyAnswers } from '@/Interfaces'; +import type { AuthProviderType } from '@/databases/entities/AuthIdentity'; +import type { Role } from '@/databases/entities/Role'; export interface JwtToken { token: string; @@ -23,6 +25,9 @@ export interface PublicUser { passwordResetToken?: string; createdAt: Date; isPending: boolean; + globalRole?: Role; + signInType: AuthProviderType; + disabled: boolean; inviteAcceptUrl?: string; } diff --git a/packages/cli/src/UserManagement/UserManagementHelper.ts b/packages/cli/src/UserManagement/UserManagementHelper.ts index 2123bcdfea..e2841db9d1 100644 --- a/packages/cli/src/UserManagement/UserManagementHelper.ts +++ b/packages/cli/src/UserManagement/UserManagementHelper.ts @@ -139,18 +139,27 @@ export function sanitizeUser(user: User, withoutKeys?: string[]): PublicUser { resetPasswordTokenExpiration, updatedAt, apiKey, - ...sanitizedUser + authIdentities, + ...rest } = user; if (withoutKeys) { withoutKeys.forEach((key) => { // @ts-ignore - delete sanitizedUser[key]; + delete rest[key]; }); } + const sanitizedUser: PublicUser = { + ...rest, + signInType: 'email', + }; + const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap'); + if (ldapIdentity) { + sanitizedUser.signInType = 'ldap'; + } return sanitizedUser; } -export function addInviteLinktoUser(user: PublicUser, inviterId: string): PublicUser { +export function addInviteLinkToUser(user: PublicUser, inviterId: string): PublicUser { if (user.isPending) { user.inviteAcceptUrl = generateUserInviteUrl(inviterId, user.id); } diff --git a/packages/cli/src/UserManagement/auth/jwt.ts b/packages/cli/src/UserManagement/auth/jwt.ts index 20ffd79db4..ad520917c9 100644 --- a/packages/cli/src/UserManagement/auth/jwt.ts +++ b/packages/cli/src/UserManagement/auth/jwt.ts @@ -8,6 +8,7 @@ import { AUTH_COOKIE_NAME } from '@/constants'; import { JwtPayload, JwtToken } from '../Interfaces'; import { User } from '@db/entities/User'; import config from '@/config'; +import * as ResponseHelper from '@/ResponseHelper'; export function issueJWT(user: User): JwtToken { const { id, email, password } = user; @@ -49,6 +50,12 @@ export async function resolveJwtContent(jwtPayload: JwtPayload): Promise { .digest('hex'); } + // currently only LDAP users during synchronization + // can be set to disabled + if (user?.disabled) { + throw new ResponseHelper.AuthError('Unauthorized'); + } + if (!user || jwtPayload.password !== passwordHash || user.email !== jwtPayload.email) { // When owner hasn't been set up, the default user // won't have email nor password (both equals null) diff --git a/packages/cli/src/UserManagement/email/UserManagementMailer.ts b/packages/cli/src/UserManagement/email/UserManagementMailer.ts index 3455747f9d..3f245b4d07 100644 --- a/packages/cli/src/UserManagement/email/UserManagementMailer.ts +++ b/packages/cli/src/UserManagement/email/UserManagementMailer.ts @@ -72,7 +72,7 @@ export class UserManagementMailer { } async passwordReset(passwordResetData: PasswordResetData): Promise { - const template = await getTemplate('passwordReset'); + const template = await getTemplate('passwordReset', 'passwordReset.html'); const result = await this.mailer?.sendMail({ emailRecipients: passwordResetData.email, subject: 'n8n password reset', diff --git a/packages/cli/src/UserManagement/routes/auth.ts b/packages/cli/src/UserManagement/routes/auth.ts index 388789d1e3..c105aacd33 100644 --- a/packages/cli/src/UserManagement/routes/auth.ts +++ b/packages/cli/src/UserManagement/routes/auth.ts @@ -7,10 +7,11 @@ import * as ResponseHelper from '@/ResponseHelper'; import { AUTH_COOKIE_NAME } from '@/constants'; import { issueCookie, resolveJwt } from '../auth/jwt'; import { N8nApp, PublicUser } from '../Interfaces'; -import { compareHash, sanitizeUser } from '../UserManagementHelper'; +import { sanitizeUser } from '../UserManagementHelper'; import { User } from '@db/entities/User'; import type { LoginRequest } from '@/requests'; import config from '@/config'; +import { handleEmailLogin, handleLdapLogin } from '@/auth'; export function authenticationMethods(this: N8nApp): void { /** @@ -22,6 +23,7 @@ export function authenticationMethods(this: N8nApp): void { `/${this.restEndpoint}/login`, ResponseHelper.send(async (req: LoginRequest, res: Response): Promise => { const { email, password } = req.body; + if (!email) { throw new Error('Email is required to log in'); } @@ -30,23 +32,23 @@ export function authenticationMethods(this: N8nApp): void { throw new Error('Password is required to log in'); } - let user: User | null; - try { - user = await Db.collections.User.findOne({ - where: { email }, - relations: ['globalRole'], - }); - } catch (error) { - throw new Error('Unable to access database.'); + const adUser = await handleLdapLogin(email, password); + + if (adUser) { + await issueCookie(res, adUser); + + return sanitizeUser(adUser); } - if (!user?.password || !(await compareHash(req.body.password, user.password))) { - throw new ResponseHelper.AuthError('Wrong username or password. Do you have caps lock on?'); + const localUser = await handleEmailLogin(email, password); + + if (localUser) { + await issueCookie(res, localUser); + + return sanitizeUser(localUser); } - await issueCookie(res, user); - - return sanitizeUser(user); + throw new ResponseHelper.AuthError('Wrong username or password. Do you have caps lock on?'); }), ); @@ -64,6 +66,11 @@ export function authenticationMethods(this: N8nApp): void { // If logged in, return user try { user = await resolveJwt(cookieContents); + + if (!config.get('userManagement.isInstanceOwnerSetUp')) { + res.cookie(AUTH_COOKIE_NAME, cookieContents); + } + return sanitizeUser(user); } catch (error) { res.clearCookie(AUTH_COOKIE_NAME); diff --git a/packages/cli/src/UserManagement/routes/index.ts b/packages/cli/src/UserManagement/routes/index.ts index 0e584413b7..e33030092f 100644 --- a/packages/cli/src/UserManagement/routes/index.ts +++ b/packages/cli/src/UserManagement/routes/index.ts @@ -72,12 +72,23 @@ export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint return; } // Not owner and user exists. We now protect restricted urls. - const postRestrictedUrls = [`/${this.restEndpoint}/users`, `/${this.restEndpoint}/owner`]; - const getRestrictedUrls: string[] = []; + const postRestrictedUrls = [ + `/${this.restEndpoint}/users`, + `/${this.restEndpoint}/owner`, + `/${this.restEndpoint}/ldap/sync`, + `/${this.restEndpoint}/ldap/test-connection`, + ]; + const getRestrictedUrls = [ + `/${this.restEndpoint}/users`, + `/${this.restEndpoint}/ldap/sync`, + `/${this.restEndpoint}/ldap/config`, + ]; + const putRestrictedUrls = [`/${this.restEndpoint}/ldap/config`]; const trimmedUrl = req.url.endsWith('/') ? req.url.slice(0, -1) : req.url; if ( (req.method === 'POST' && postRestrictedUrls.includes(trimmedUrl)) || (req.method === 'GET' && getRestrictedUrls.includes(trimmedUrl)) || + (req.method === 'PUT' && putRestrictedUrls.includes(trimmedUrl)) || (req.method === 'DELETE' && new RegExp(`/${restEndpoint}/users/[^/]+`, 'gm').test(trimmedUrl)) || (req.method === 'POST' && diff --git a/packages/cli/src/UserManagement/routes/passwordReset.ts b/packages/cli/src/UserManagement/routes/passwordReset.ts index d6d97c71e3..1951e7a367 100644 --- a/packages/cli/src/UserManagement/routes/passwordReset.ts +++ b/packages/cli/src/UserManagement/routes/passwordReset.ts @@ -13,9 +13,10 @@ import { InternalHooksManager } from '@/InternalHooksManager'; import { N8nApp } from '../Interfaces'; import { getInstanceBaseUrl, hashPassword, validatePassword } from '../UserManagementHelper'; import * as UserManagementMailer from '../email'; -import type { PasswordResetRequest } from '../../requests'; +import type { PasswordResetRequest } from '@/requests'; import { issueCookie } from '../auth/jwt'; import config from '@/config'; +import { isLdapEnabled } from '@/Ldap/helpers'; export function passwordResetNamespace(this: N8nApp): void { /** @@ -52,9 +53,16 @@ export function passwordResetNamespace(this: N8nApp): void { } // User should just be able to reset password if one is already present - const user = await Db.collections.User.findOneBy({ email, password: Not(IsNull()) }); + const user = await Db.collections.User.findOne({ + where: { + email, + password: Not(IsNull()), + }, + relations: ['authIdentities'], + }); + const ldapIdentity = user?.authIdentities?.find((i) => i.providerType === 'ldap'); - if (!user?.password) { + if (!user?.password || (ldapIdentity && user.disabled)) { Logger.debug( 'Request to send password reset email failed because no user was found for the provided email', { invalidEmail: email }, @@ -62,6 +70,12 @@ export function passwordResetNamespace(this: N8nApp): void { return; } + if (isLdapEnabled() && ldapIdentity) { + throw new ResponseHelper.UnprocessableRequestError( + 'forgotPassword.ldapUserPasswordResetUnavailable', + ); + } + user.resetPasswordToken = uuid(); const { id, firstName, lastName, resetPasswordToken } = user; @@ -184,10 +198,13 @@ export function passwordResetNamespace(this: N8nApp): void { // Timestamp is saved in seconds const currentTimestamp = Math.floor(Date.now() / 1000); - const user = await Db.collections.User.findOneBy({ - id: userId, - resetPasswordToken, - resetPasswordTokenExpiration: MoreThanOrEqual(currentTimestamp), + const user = await Db.collections.User.findOne({ + where: { + id: userId, + resetPasswordToken, + resetPasswordTokenExpiration: MoreThanOrEqual(currentTimestamp), + }, + relations: ['authIdentities'], }); if (!user) { @@ -216,6 +233,15 @@ export function passwordResetNamespace(this: N8nApp): void { fields_changed: ['password'], }); + // if this user used to be an LDAP users + const ldapIdentity = user?.authIdentities?.find((i) => i.providerType === 'ldap'); + if (ldapIdentity) { + void InternalHooksManager.getInstance().onUserSignup(user, { + user_type: 'email', + was_disabled_ldap_user: true, + }); + } + await this.externalHooks.run('user.password.update', [user.email, password]); }), ); diff --git a/packages/cli/src/UserManagement/routes/users.ts b/packages/cli/src/UserManagement/routes/users.ts index c3e707d629..ffb5da2d61 100644 --- a/packages/cli/src/UserManagement/routes/users.ts +++ b/packages/cli/src/UserManagement/routes/users.ts @@ -14,7 +14,7 @@ import { UserRequest } from '@/requests'; import * as UserManagementMailer from '../email/UserManagementMailer'; import { N8nApp, PublicUser } from '../Interfaces'; import { - addInviteLinktoUser, + addInviteLinkToUser, generateUserInviteUrl, getInstanceBaseUrl, hashPassword, @@ -27,6 +27,7 @@ import { import config from '@/config'; import { issueCookie } from '../auth/jwt'; import { InternalHooksManager } from '@/InternalHooksManager'; +import { AuthIdentity } from '@/databases/entities/AuthIdentity'; import { RoleService } from '@/role/role.service'; export function usersNamespace(this: N8nApp): void { @@ -348,8 +349,9 @@ export function usersNamespace(this: N8nApp): void { await issueCookie(res, updatedUser); - void InternalHooksManager.getInstance().onUserSignup({ - user: updatedUser, + void InternalHooksManager.getInstance().onUserSignup(updatedUser, { + user_type: 'email', + was_disabled_ldap_user: false, }); await this.externalHooks.run('user.profile.update', [invitee.email, sanitizeUser(invitee)]); @@ -362,11 +364,11 @@ export function usersNamespace(this: N8nApp): void { this.app.get( `/${this.restEndpoint}/users`, ResponseHelper.send(async (req: UserRequest.List) => { - const users = await Db.collections.User.find({ relations: ['globalRole'] }); + const users = await Db.collections.User.find({ relations: ['globalRole', 'authIdentities'] }); return users.map( (user): PublicUser => - addInviteLinktoUser(sanitizeUser(user, ['personalizationAnswers']), req.user.id), + addInviteLinkToUser(sanitizeUser(user, ['personalizationAnswers']), req.user.id), ); }), ); @@ -479,6 +481,8 @@ export function usersNamespace(this: N8nApp): void { { user: transferee }, ); + await transactionManager.delete(AuthIdentity, { userId: userToDelete.id }); + // This will remove all shared workflows and credentials not owned await transactionManager.delete(User, { id: userToDelete.id }); }); @@ -517,6 +521,7 @@ export function usersNamespace(this: N8nApp): void { await transactionManager.remove( ownedSharedCredentials.map(({ credentials }) => credentials), ); + await transactionManager.delete(AuthIdentity, { userId: userToDelete.id }); await transactionManager.delete(User, { id: userToDelete.id }); }); diff --git a/packages/cli/src/api/e2e.api.ts b/packages/cli/src/api/e2e.api.ts index 27752cc9ae..f0c3f39357 100644 --- a/packages/cli/src/api/e2e.api.ts +++ b/packages/cli/src/api/e2e.api.ts @@ -19,6 +19,8 @@ if (process.env.E2E_TESTS !== 'true') { } const tablesToTruncate = [ + 'auth_identity', + 'auth_provider_sync_history', 'event_destinations', 'shared_workflow', 'shared_credentials', diff --git a/packages/cli/src/auth/index.ts b/packages/cli/src/auth/index.ts new file mode 100644 index 0000000000..29d853c30e --- /dev/null +++ b/packages/cli/src/auth/index.ts @@ -0,0 +1,2 @@ +export * from './methods/email'; +export * from './methods/ldap'; diff --git a/packages/cli/src/auth/methods/email.ts b/packages/cli/src/auth/methods/email.ts new file mode 100644 index 0000000000..b3d6bf3363 --- /dev/null +++ b/packages/cli/src/auth/methods/email.ts @@ -0,0 +1,32 @@ +import * as Db from '@/Db'; +import type { User } from '@db/entities/User'; +import { compareHash } from '@/UserManagement/UserManagementHelper'; +import { InternalHooksManager } from '@/InternalHooksManager'; +import * as ResponseHelper from '@/ResponseHelper'; + +export const handleEmailLogin = async ( + email: string, + password: string, +): Promise => { + const user = await Db.collections.User.findOne({ + where: { email }, + relations: ['globalRole', 'authIdentities'], + }); + + if (user?.password && (await compareHash(password, user.password))) { + return user; + } + + // At this point if the user has a LDAP ID, means it was previously an LDAP user, + // so suggest to reset the password to gain access to the instance. + const ldapIdentity = user?.authIdentities?.find((i) => i.providerType === 'ldap'); + if (user && ldapIdentity) { + void InternalHooksManager.getInstance().userLoginFailedDueToLdapDisabled({ + user_id: user.id, + }); + + throw new ResponseHelper.AuthError('Reset your password to gain access to the instance.'); + } + + return undefined; +}; diff --git a/packages/cli/src/auth/methods/ldap.ts b/packages/cli/src/auth/methods/ldap.ts new file mode 100644 index 0000000000..fe4e71c458 --- /dev/null +++ b/packages/cli/src/auth/methods/ldap.ts @@ -0,0 +1,69 @@ +import { InternalHooksManager } from '@/InternalHooksManager'; +import { + createLdapUserOnLocalDb, + findAndAuthenticateLdapUser, + getLdapConfig, + getLdapUserRole, + getUserByEmail, + getAuthIdentityByLdapId, + isLdapDisabled, + mapLdapAttributesToUser, + createLdapAuthIdentity, + updateLdapUserOnLocalDb, +} from '@/Ldap/helpers'; +import type { User } from '@db/entities/User'; + +export const handleLdapLogin = async ( + loginId: string, + password: string, +): Promise => { + if (isLdapDisabled()) return undefined; + + const ldapConfig = await getLdapConfig(); + + if (!ldapConfig.loginEnabled) return undefined; + + const { loginIdAttribute, userFilter } = ldapConfig; + + const ldapUser = await findAndAuthenticateLdapUser( + loginId, + password, + loginIdAttribute, + userFilter, + ); + + if (!ldapUser) return undefined; + + const [ldapId, ldapAttributesValues] = mapLdapAttributesToUser(ldapUser, ldapConfig); + + const { email: emailAttributeValue } = ldapAttributesValues; + + if (!ldapId || !emailAttributeValue) return undefined; + + const ldapAuthIdentity = await getAuthIdentityByLdapId(ldapId); + if (!ldapAuthIdentity) { + const emailUser = await getUserByEmail(emailAttributeValue); + + // check if there is an email user with the same email as the authenticated LDAP user trying to log-in + if (emailUser && emailUser.email === emailAttributeValue) { + const identity = await createLdapAuthIdentity(emailUser, ldapId); + await updateLdapUserOnLocalDb(identity, ldapAttributesValues); + } else { + const role = await getLdapUserRole(); + const user = await createLdapUserOnLocalDb(role, ldapAttributesValues, ldapId); + void InternalHooksManager.getInstance().onUserSignup(user, { + user_type: 'ldap', + was_disabled_ldap_user: false, + }); + return user; + } + } else { + if (ldapAuthIdentity.user) { + if (ldapAuthIdentity.user.disabled) return undefined; + await updateLdapUserOnLocalDb(ldapAuthIdentity, ldapAttributesValues); + } + } + + // Retrieve the user again as user's data might have been updated + return (await getAuthIdentityByLdapId(ldapId))?.user; +}; diff --git a/packages/cli/src/commands/ldap/reset.ts b/packages/cli/src/commands/ldap/reset.ts new file mode 100644 index 0000000000..61e420acb0 --- /dev/null +++ b/packages/cli/src/commands/ldap/reset.ts @@ -0,0 +1,27 @@ +import * as Db from '@/Db'; +import { LDAP_FEATURE_NAME } from '@/Ldap/constants'; +import { In } from 'typeorm'; +import { BaseCommand } from '../BaseCommand'; + +export class Reset extends BaseCommand { + static description = '\nResets the database to the default ldap state'; + + async run(): Promise { + const ldapIdentities = await Db.collections.AuthIdentity.find({ + where: { providerType: 'ldap' }, + select: ['userId'], + }); + await Db.collections.AuthProviderSyncHistory.delete({ providerType: 'ldap' }); + await Db.collections.AuthIdentity.delete({ providerType: 'ldap' }); + await Db.collections.User.delete({ id: In(ldapIdentities.map((i) => i.userId)) }); + await Db.collections.Settings.delete({ key: LDAP_FEATURE_NAME }); + + this.logger.info('Successfully reset the database to default ldap state.'); + } + + async catch(error: Error): Promise { + this.logger.error('Error resetting database. See log messages for details.'); + this.logger.error(error.message); + this.exit(1); + } +} diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 5abe056769..a27e3133ec 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -34,6 +34,7 @@ import { WaitTracker } from '@/WaitTracker'; import { getLogger } from '@/Logger'; import { getAllInstalledPackages } from '@/CommunityNodes/packageModel'; +import { handleLdapInit } from '@/Ldap/helpers'; import { initErrorHandling } from '@/ErrorReporting'; import * as CrashJournal from '@/CrashJournal'; import { createPostHogLoadingScript } from '@/telemetry/scripts'; @@ -407,6 +408,8 @@ export class Start extends Command { WaitTracker(); + await handleLdapInit(); + const editorUrl = GenericHelpers.getBaseUrl(); this.log(`\nEditor is now accessible via:\n${editorUrl}`); diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 4ea7765fd5..ec64920097 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -972,6 +972,10 @@ export const schema = { format: Boolean, default: false, }, + ldap: { + format: Boolean, + default: false, + }, logStreaming: { format: Boolean, default: false, diff --git a/packages/cli/src/config/types.d.ts b/packages/cli/src/config/types.d.ts index 04e2a75aa5..f5ce54726d 100644 --- a/packages/cli/src/config/types.d.ts +++ b/packages/cli/src/config/types.d.ts @@ -81,6 +81,8 @@ type ExceptionPaths = { 'nodes.include': string[] | undefined; 'userManagement.isInstanceOwnerSetUp': boolean; 'userManagement.skipInstanceOwnerSetup': boolean; + 'ldap.loginLabel': string; + 'ldap.loginEnabled': boolean; }; // ----------------------------------- diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index d8a0f994c6..9adab3468a 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -67,6 +67,7 @@ export const SETTINGS_LICENSE_CERT_KEY = 'license.cert'; export enum LICENSE_FEATURES { SHARING = 'feat:sharing', + LDAP = 'feat:ldap', LOG_STREAMING = 'feat:logStreaming', } diff --git a/packages/cli/src/databases/entities/AuthIdentity.ts b/packages/cli/src/databases/entities/AuthIdentity.ts new file mode 100644 index 0000000000..cfe8ab270a --- /dev/null +++ b/packages/cli/src/databases/entities/AuthIdentity.ts @@ -0,0 +1,34 @@ +import { Column, Entity, ManyToOne, PrimaryColumn, Unique } from 'typeorm'; +import { AbstractEntity } from './AbstractEntity'; +import { User } from './User'; + +export type AuthProviderType = 'ldap' | 'email'; //| 'saml' | 'google'; + +@Entity() +@Unique(['providerId', 'providerType']) +export class AuthIdentity extends AbstractEntity { + @Column() + userId: string; + + @ManyToOne(() => User, (user) => user.authIdentities) + user: User; + + @PrimaryColumn() + providerId: string; + + @PrimaryColumn() + providerType: AuthProviderType; + + static create( + user: User, + providerId: string, + providerType: AuthProviderType = 'ldap', + ): AuthIdentity { + const identity = new AuthIdentity(); + identity.user = user; + identity.userId = user.id; + identity.providerId = providerId; + identity.providerType = providerType; + return identity; + } +} diff --git a/packages/cli/src/databases/entities/AuthProviderSyncHistory.ts b/packages/cli/src/databases/entities/AuthProviderSyncHistory.ts new file mode 100644 index 0000000000..51ef007777 --- /dev/null +++ b/packages/cli/src/databases/entities/AuthProviderSyncHistory.ts @@ -0,0 +1,42 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { datetimeColumnType } from './AbstractEntity'; +import { AuthProviderType } from './AuthIdentity'; + +export type RunningMode = 'dry' | 'live'; +export type SyncStatus = 'success' | 'error'; + +@Entity() +export class AuthProviderSyncHistory { + @PrimaryGeneratedColumn() + id: number; + + @Column('text') + providerType: AuthProviderType; + + @Column('text') + runMode: RunningMode; + + @Column('text') + status: SyncStatus; + + @Column(datetimeColumnType) + startedAt: Date; + + @Column(datetimeColumnType) + endedAt: Date; + + @Column() + scanned: number; + + @Column() + created: number; + + @Column() + updated: number; + + @Column() + disabled: number; + + @Column() + error: string; +} diff --git a/packages/cli/src/databases/entities/User.ts b/packages/cli/src/databases/entities/User.ts index 1664f1715d..5896d22fa8 100644 --- a/packages/cli/src/databases/entities/User.ts +++ b/packages/cli/src/databases/entities/User.ts @@ -19,6 +19,7 @@ import { NoXss } from '../utils/customValidators'; import { objectRetriever, lowerCaser } from '../utils/transformers'; import { AbstractEntity, jsonColumnType } from './AbstractEntity'; import type { IPersonalizationSurveyAnswers, IUserSettings } from '@/Interfaces'; +import type { AuthIdentity } from './AuthIdentity'; export const MIN_PASSWORD_LENGTH = 8; @@ -80,12 +81,18 @@ export class User extends AbstractEntity implements IUser { @Column() globalRoleId: string; + @OneToMany('AuthIdentity', 'user') + authIdentities: AuthIdentity[]; + @OneToMany('SharedWorkflow', 'user') sharedWorkflows: SharedWorkflow[]; @OneToMany('SharedCredentials', 'user') sharedCredentials: SharedCredentials[]; + @Column({ type: Boolean, default: false }) + disabled: boolean; + @BeforeInsert() @BeforeUpdate() preUpsertHook(): void { diff --git a/packages/cli/src/databases/entities/index.ts b/packages/cli/src/databases/entities/index.ts index 8d537b62ea..17ff382044 100644 --- a/packages/cli/src/databases/entities/index.ts +++ b/packages/cli/src/databases/entities/index.ts @@ -1,32 +1,36 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import { AuthIdentity } from './AuthIdentity'; +import { AuthProviderSyncHistory } from './AuthProviderSyncHistory'; import { CredentialsEntity } from './CredentialsEntity'; +import { EventDestinations } from './MessageEventBusDestinationEntity'; import { ExecutionEntity } from './ExecutionEntity'; -import { WorkflowEntity } from './WorkflowEntity'; -import { WebhookEntity } from './WebhookEntity'; -import { TagEntity } from './TagEntity'; -import { User } from './User'; +import { InstalledNodes } from './InstalledNodes'; +import { InstalledPackages } from './InstalledPackages'; import { Role } from './Role'; import { Settings } from './Settings'; -import { SharedWorkflow } from './SharedWorkflow'; import { SharedCredentials } from './SharedCredentials'; -import { InstalledPackages } from './InstalledPackages'; -import { InstalledNodes } from './InstalledNodes'; +import { SharedWorkflow } from './SharedWorkflow'; +import { TagEntity } from './TagEntity'; +import { User } from './User'; +import { WebhookEntity } from './WebhookEntity'; +import { WorkflowEntity } from './WorkflowEntity'; import { WorkflowStatistics } from './WorkflowStatistics'; -import { EventDestinations } from './MessageEventBusDestinationEntity'; export const entities = { + AuthIdentity, + AuthProviderSyncHistory, CredentialsEntity, + EventDestinations, ExecutionEntity, - WorkflowEntity, - WebhookEntity, - TagEntity, - User, + InstalledNodes, + InstalledPackages, Role, Settings, - SharedWorkflow, SharedCredentials, - InstalledPackages, - InstalledNodes, + SharedWorkflow, + TagEntity, + User, + WebhookEntity, + WorkflowEntity, WorkflowStatistics, - EventDestinations, }; diff --git a/packages/cli/src/databases/migrations/mysqldb/1674509946020-CreateLdapEntities.ts b/packages/cli/src/databases/migrations/mysqldb/1674509946020-CreateLdapEntities.ts new file mode 100644 index 0000000000..d75c582387 --- /dev/null +++ b/packages/cli/src/databases/migrations/mysqldb/1674509946020-CreateLdapEntities.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/Ldap/constants'; +import { getTablePrefix, logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers'; + +export class CreateLdapEntities1674509946020 implements MigrationInterface { + name = 'CreateLdapEntities1674509946020'; + + async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + + const tablePrefix = getTablePrefix(); + + await queryRunner.query( + `ALTER TABLE \`${tablePrefix}user\` ADD COLUMN disabled BOOLEAN NOT NULL DEFAULT false;`, + ); + + await queryRunner.query(` + INSERT INTO ${tablePrefix}settings(\`key\`, value, loadOnStartup) + VALUES ('${LDAP_FEATURE_NAME}', '${JSON.stringify(LDAP_DEFAULT_CONFIGURATION)}', 1); + `); + + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS \`${tablePrefix}auth_identity\` ( + \`userId\` VARCHAR(36) REFERENCES \`${tablePrefix}user\` (id), + \`providerId\` VARCHAR(64) NOT NULL, + \`providerType\` VARCHAR(32) NOT NULL, + \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`updatedAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(\`providerId\`, \`providerType\`) + ) ENGINE='InnoDB';`, + ); + + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS \`${tablePrefix}auth_provider_sync_history\` ( + \`id\` INTEGER NOT NULL AUTO_INCREMENT, + \`providerType\` VARCHAR(32) NOT NULL, + \`runMode\` TEXT NOT NULL, + \`status\` TEXT NOT NULL, + \`startedAt\` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`endedAt\` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`scanned\` INTEGER NOT NULL, + \`created\` INTEGER NOT NULL, + \`updated\` INTEGER NOT NULL, + \`disabled\` INTEGER NOT NULL, + \`error\` TEXT, + PRIMARY KEY (\`id\`) + ) ENGINE='InnoDB';`, + ); + + logMigrationEnd(this.name); + } + + async down(queryRunner: QueryRunner): Promise { + const tablePrefix = getTablePrefix(); + await queryRunner.query(`DROP TABLE \`${tablePrefix}auth_provider_sync_history\``); + await queryRunner.query(`DROP TABLE \`${tablePrefix}auth_identity\``); + + await queryRunner.query( + `DELETE FROM ${tablePrefix}settings WHERE \`key\` = '${LDAP_FEATURE_NAME}'`, + ); + await queryRunner.query(`ALTER TABLE \`${tablePrefix}user\` DROP COLUMN disabled`); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index 299d47dcb0..8892ddcbe7 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -29,6 +29,7 @@ import { AddTriggerCountColumn1669823906994 } from './1669823906994-AddTriggerCo import { RemoveWorkflowDataLoadedFlag1671726148420 } from './1671726148420-RemoveWorkflowDataLoadedFlag'; import { MessageEventBusDestinations1671535397530 } from './1671535397530-MessageEventBusDestinations'; import { DeleteExecutionsWithWorkflows1673268682475 } from './1673268682475-DeleteExecutionsWithWorkflows'; +import { CreateLdapEntities1674509946020 } from './1674509946020-CreateLdapEntities'; export const mysqlMigrations = [ InitialMigration1588157391238, @@ -62,4 +63,5 @@ export const mysqlMigrations = [ RemoveWorkflowDataLoadedFlag1671726148420, MessageEventBusDestinations1671535397530, DeleteExecutionsWithWorkflows1673268682475, + CreateLdapEntities1674509946020, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/1674509946020-CreateLdapEntities.ts b/packages/cli/src/databases/migrations/postgresdb/1674509946020-CreateLdapEntities.ts new file mode 100644 index 0000000000..478bef686e --- /dev/null +++ b/packages/cli/src/databases/migrations/postgresdb/1674509946020-CreateLdapEntities.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/Ldap/constants'; +import { getTablePrefix, logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers'; + +export class CreateLdapEntities1674509946020 implements MigrationInterface { + name = 'CreateLdapEntities1674509946020'; + + async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + + const tablePrefix = getTablePrefix(); + + await queryRunner.query( + `ALTER TABLE "${tablePrefix}user" ADD COLUMN disabled BOOLEAN NOT NULL DEFAULT false;`, + ); + + await queryRunner.query(` + INSERT INTO ${tablePrefix}settings (key, value, "loadOnStartup") + VALUES ('${LDAP_FEATURE_NAME}', '${JSON.stringify(LDAP_DEFAULT_CONFIGURATION)}', true) + `); + + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS "${tablePrefix}auth_identity" ( + "userId" uuid REFERENCES "${tablePrefix}user" (id), + "providerId" VARCHAR(64) NOT NULL, + "providerType" VARCHAR(32) NOT NULL, + "createdAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY("providerId", "providerType") + );`, + ); + + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS "${tablePrefix}auth_provider_sync_history" ( + "id" serial NOT NULL PRIMARY KEY, + "providerType" VARCHAR(32) NOT NULL, + "runMode" TEXT NOT NULL, + "status" TEXT NOT NULL, + "startedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "endedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "scanned" INTEGER NOT NULL, + "created" INTEGER NOT NULL, + "updated" INTEGER NOT NULL, + "disabled" INTEGER NOT NULL, + "error" TEXT + );`, + ); + + logMigrationEnd(this.name); + } + + async down(queryRunner: QueryRunner): Promise { + const tablePrefix = getTablePrefix(); + await queryRunner.query(`DROP TABLE "${tablePrefix}auth_provider_sync_history"`); + await queryRunner.query(`DROP TABLE "${tablePrefix}auth_identity"`); + + await queryRunner.query( + `DELETE FROM ${tablePrefix}settings WHERE key = '${LDAP_FEATURE_NAME}'`, + ); + await queryRunner.query(`ALTER TABLE "${tablePrefix}user" DROP COLUMN disabled`); + } +} diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index e03b72f77e..6138f89d56 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -27,6 +27,7 @@ import { AddTriggerCountColumn1669823906995 } from './1669823906995-AddTriggerCo import { RemoveWorkflowDataLoadedFlag1671726148421 } from './1671726148421-RemoveWorkflowDataLoadedFlag'; import { MessageEventBusDestinations1671535397530 } from './1671535397530-MessageEventBusDestinations'; import { DeleteExecutionsWithWorkflows1673268682475 } from './1673268682475-DeleteExecutionsWithWorkflows'; +import { CreateLdapEntities1674509946020 } from './1674509946020-CreateLdapEntities'; export const postgresMigrations = [ InitialMigration1587669153312, @@ -58,4 +59,5 @@ export const postgresMigrations = [ RemoveWorkflowDataLoadedFlag1671726148421, MessageEventBusDestinations1671535397530, DeleteExecutionsWithWorkflows1673268682475, + CreateLdapEntities1674509946020, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/1659888469333-AddJsonKeyPinData.ts b/packages/cli/src/databases/migrations/sqlite/1659888469333-AddJsonKeyPinData.ts index f2fc0f65e5..11eea4686e 100644 --- a/packages/cli/src/databases/migrations/sqlite/1659888469333-AddJsonKeyPinData.ts +++ b/packages/cli/src/databases/migrations/sqlite/1659888469333-AddJsonKeyPinData.ts @@ -6,7 +6,7 @@ import { escapeQuery, } from '@db/utils/migrationHelpers'; import type { MigrationInterface, QueryRunner } from 'typeorm'; -import { isJsonKeyObject, PinData } from '../../utils/migrations.types'; +import { isJsonKeyObject, PinData } from '@db/utils/migrations.types'; /** * Convert TEXT-type `pinData` column in `workflow_entity` table from diff --git a/packages/cli/src/databases/migrations/sqlite/1674509946020-CreateLdapEntities.ts b/packages/cli/src/databases/migrations/sqlite/1674509946020-CreateLdapEntities.ts new file mode 100644 index 0000000000..ce88522174 --- /dev/null +++ b/packages/cli/src/databases/migrations/sqlite/1674509946020-CreateLdapEntities.ts @@ -0,0 +1,61 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/Ldap/constants'; +import { getTablePrefix, logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers'; +export class CreateLdapEntities1674509946020 implements MigrationInterface { + name = 'CreateLdapEntities1674509946020'; + + async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + + const tablePrefix = getTablePrefix(); + + await queryRunner.query( + `ALTER TABLE ${tablePrefix}user ADD COLUMN disabled BOOLEAN NOT NULL DEFAULT false;`, + ); + + await queryRunner.query(` + INSERT INTO "${tablePrefix}settings" (key, value, loadOnStartup) + VALUES ('${LDAP_FEATURE_NAME}', '${JSON.stringify(LDAP_DEFAULT_CONFIGURATION)}', true) + `); + + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS "${tablePrefix}auth_identity" ( + "userId" VARCHAR(36) REFERENCES "${tablePrefix}user" (id), + "providerId" VARCHAR(64) NOT NULL, + "providerType" VARCHAR(32) NOT NULL, + "createdAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY("providerId", "providerType") + );`, + ); + + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS "${tablePrefix}auth_provider_sync_history" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "providerType" VARCHAR(32) NOT NULL, + "runMode" TEXT NOT NULL, + "status" TEXT NOT NULL, + "startedAt" DATETIME NOT NULL, + "endedAt" DATETIME NOT NULL, + "scanned" INTEGER NOT NULL, + "created" INTEGER NOT NULL, + "updated" INTEGER NOT NULL, + "disabled" INTEGER NOT NULL, + "error" TEXT + );`, + ); + + logMigrationEnd(this.name); + } + + async down(queryRunner: QueryRunner): Promise { + const tablePrefix = getTablePrefix(); + await queryRunner.query(`DROP TABLE "${tablePrefix}auth_provider_sync_history"`); + await queryRunner.query(`DROP TABLE "${tablePrefix}auth_identity"`); + + await queryRunner.query( + `DELETE FROM "${tablePrefix}settings" WHERE key = '${LDAP_FEATURE_NAME}'`, + ); + await queryRunner.query(`ALTER TABLE "${tablePrefix}user" DROP COLUMN disabled`); + } +} diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index 239672e05d..247daf1280 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -17,15 +17,16 @@ import { IntroducePinData1654089251344 } from './1654089251344-IntroducePinData' import { AddNodeIds1658930531669 } from './1658930531669-AddNodeIds'; import { AddJsonKeyPinData1659888469333 } from './1659888469333-AddJsonKeyPinData'; import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole'; -import { WorkflowStatistics1664196174000 } from './1664196174000-WorkflowStatistics'; import { CreateWorkflowsEditorRole1663755770892 } from './1663755770892-CreateWorkflowsUserRole'; import { CreateCredentialUsageTable1665484192211 } from './1665484192211-CreateCredentialUsageTable'; import { RemoveCredentialUsageTable1665754637024 } from './1665754637024-RemoveCredentialUsageTable'; import { AddWorkflowVersionIdColumn1669739707124 } from './1669739707124-AddWorkflowVersionIdColumn'; +import { WorkflowStatistics1664196174000 } from './1664196174000-WorkflowStatistics'; import { AddTriggerCountColumn1669823906993 } from './1669823906993-AddTriggerCountColumn'; import { RemoveWorkflowDataLoadedFlag1671726148419 } from './1671726148419-RemoveWorkflowDataLoadedFlag'; import { MessageEventBusDestinations1671535397530 } from './1671535397530-MessageEventBusDestinations'; import { DeleteExecutionsWithWorkflows1673268682475 } from './1673268682475-DeleteExecutionsWithWorkflows'; +import { CreateLdapEntities1674509946020 } from './1674509946020-CreateLdapEntities'; const sqliteMigrations = [ InitialMigration1588102412422, @@ -51,11 +52,12 @@ const sqliteMigrations = [ CreateCredentialUsageTable1665484192211, RemoveCredentialUsageTable1665754637024, AddWorkflowVersionIdColumn1669739707124, - AddTriggerCountColumn1669823906993, WorkflowStatistics1664196174000, + AddTriggerCountColumn1669823906993, RemoveWorkflowDataLoadedFlag1671726148419, MessageEventBusDestinations1671535397530, DeleteExecutionsWithWorkflows1673268682475, + CreateLdapEntities1674509946020, ]; export { sqliteMigrations }; diff --git a/packages/cli/test/integration/auth.api.test.ts b/packages/cli/test/integration/auth.api.test.ts index 2cacbebe5a..38aef7e9bf 100644 --- a/packages/cli/test/integration/auth.api.test.ts +++ b/packages/cli/test/integration/auth.api.test.ts @@ -31,6 +31,8 @@ beforeAll(async () => { beforeEach(async () => { await testDb.truncate(['User']); + config.set('ldap.disabled', true); + config.set('userManagement.isInstanceOwnerSetUp', true); await Db.collections.Settings.update( diff --git a/packages/cli/test/integration/ldap/ldap.api.test.ts b/packages/cli/test/integration/ldap/ldap.api.test.ts new file mode 100644 index 0000000000..e4397089c2 --- /dev/null +++ b/packages/cli/test/integration/ldap/ldap.api.test.ts @@ -0,0 +1,630 @@ +import express from 'express'; +import type { Entry as LdapUser } from 'ldapts'; +import { jsonParse } from 'n8n-workflow'; +import config from '@/config'; +import * as Db from '@/Db'; +import type { Role } from '@db/entities/Role'; +import type { User } from '@db/entities/User'; +import { LDAP_DEFAULT_CONFIGURATION, LDAP_ENABLED, LDAP_FEATURE_NAME } from '@/Ldap/constants'; +import { LdapManager } from '@/Ldap/LdapManager.ee'; +import { LdapService } from '@/Ldap/LdapService.ee'; +import { encryptPassword, saveLdapSynchronization } from '@/Ldap/helpers'; +import type { LdapConfig } from '@/Ldap/types'; +import { sanitizeUser } from '@/UserManagement/UserManagementHelper'; +import { randomEmail, randomName, uniqueId } from './../shared/random'; +import * as testDb from './../shared/testDb'; +import type { AuthAgent } from '../shared/types'; +import * as utils from '../shared/utils'; + +jest.mock('@/telemetry'); +jest.mock('@/UserManagement/email/NodeMailer'); + +let app: express.Application; +let globalMemberRole: Role; +let globalOwnerRole: Role; +let owner: User; +let authAgent: AuthAgent; + +const defaultLdapConfig = { + ...LDAP_DEFAULT_CONFIGURATION, + loginEnabled: true, + loginLabel: '', + ldapIdAttribute: 'uid', + firstNameAttribute: 'givenName', + lastNameAttribute: 'sn', + emailAttribute: 'mail', + loginIdAttribute: 'mail', + baseDn: 'baseDn', + bindingAdminDn: 'adminDn', + bindingAdminPassword: 'adminPassword', +}; + +beforeAll(async () => { + await testDb.init(); + app = await utils.initTestServer({ endpointGroups: ['auth', 'ldap'], applyAuth: true }); + + const [fetchedGlobalOwnerRole, fetchedGlobalMemberRole] = await testDb.getAllRoles(); + + globalOwnerRole = fetchedGlobalOwnerRole; + globalMemberRole = fetchedGlobalMemberRole; + + authAgent = utils.createAuthAgent(app); + + config.set(LDAP_ENABLED, true); + defaultLdapConfig.bindingAdminPassword = await encryptPassword( + defaultLdapConfig.bindingAdminPassword, + ); + + utils.initConfigFile(); + utils.initTestLogger(); + utils.initTestTelemetry(); + await utils.initLdapManager(); +}); + +beforeEach(async () => { + await testDb.truncate([ + 'AuthIdentity', + 'AuthProviderSyncHistory', + 'SharedCredentials', + 'Credentials', + 'SharedWorkflow', + 'Workflow', + 'User', + ]); + + owner = await testDb.createUser({ globalRole: globalOwnerRole }); + + jest.mock('@/telemetry'); + + config.set('userManagement.disabled', false); + config.set('userManagement.isInstanceOwnerSetUp', true); + config.set('userManagement.emails.mode', ''); + config.set('enterprise.features.ldap', true); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +const createLdapConfig = async (attributes: Partial = {}): Promise => { + const { value: ldapConfig } = await Db.collections.Settings.save({ + key: LDAP_FEATURE_NAME, + value: JSON.stringify({ + ...defaultLdapConfig, + ...attributes, + }), + loadOnStartup: true, + }); + return jsonParse(ldapConfig); +}; + +test('Member role should not be able to access ldap routes', async () => { + const member = await testDb.createUser({ globalRole: globalMemberRole }); + + let response = await authAgent(member).get('/ldap/config'); + expect(response.statusCode).toBe(403); + + response = await authAgent(member).put('/ldap/config'); + expect(response.statusCode).toBe(403); + + response = await authAgent(member).post('/ldap/test-connection'); + expect(response.statusCode).toBe(403); + + response = await authAgent(member).post('/ldap/sync'); + expect(response.statusCode).toBe(403); + + response = await authAgent(member).get('/ldap/sync'); + expect(response.statusCode).toBe(403); +}); + +describe('PUT /ldap/config', () => { + test('route should validate payload', async () => { + const invalidValuePayload = { + ...LDAP_DEFAULT_CONFIGURATION, + loginEnabled: '', // enabled property only allows boolean + loginLabel: '', + }; + + const invalidExtraPropertyPayload = { + ...LDAP_DEFAULT_CONFIGURATION, + example: true, // property not defined in the validation schema + }; + + const missingPropertyPayload = { + loginEnabled: true, + loginLabel: '', + // missing all other properties defined in the schema + }; + + const invalidPayloads = [ + invalidValuePayload, + invalidExtraPropertyPayload, + missingPropertyPayload, + ]; + + for (const invalidPayload of invalidPayloads) { + const response = await authAgent(owner).put('/ldap/config').send(invalidPayload); + expect(response.statusCode).toBe(400); + expect(response.body).toHaveProperty('message'); + } + }); + + test('route should update model', async () => { + const validPayload = { + ...LDAP_DEFAULT_CONFIGURATION, + loginEnabled: true, + loginLabel: '', + }; + + const response = await authAgent(owner).put('/ldap/config').send(validPayload); + + expect(response.statusCode).toBe(200); + expect(response.body.data.loginEnabled).toBe(true); + expect(response.body.data.loginLabel).toBe(''); + }); + + test('should apply "Convert all LDAP users to email users" strategy when LDAP login disabled', async () => { + const ldapConfig = await createLdapConfig(); + LdapManager.updateConfig(ldapConfig); + + const member = await testDb.createLdapUser({ globalRole: globalMemberRole }, uniqueId()); + + const configuration = ldapConfig; + + // disable the login, so the strategy is applied + await authAgent(owner) + .put('/ldap/config') + .send({ ...configuration, loginEnabled: false }); + + const emailUser = await Db.collections.User.findOneByOrFail({ id: member.id }); + const localLdapIdentities = await testDb.getLdapIdentities(); + + expect(emailUser.email).toBe(member.email); + expect(emailUser.lastName).toBe(member.lastName); + expect(emailUser.firstName).toBe(member.firstName); + expect(localLdapIdentities.length).toEqual(0); + }); +}); + +test('GET /ldap/config route should retrieve current configuration', async () => { + const validPayload = { + ...LDAP_DEFAULT_CONFIGURATION, + loginEnabled: true, + loginLabel: '', + }; + + let response = await authAgent(owner).put('/ldap/config').send(validPayload); + expect(response.statusCode).toBe(200); + + response = await authAgent(owner).get('/ldap/config'); + + expect(response.body.data).toMatchObject(validPayload); +}); + +describe('POST /ldap/test-connection', () => { + test('route should success', async () => { + jest + .spyOn(LdapService.prototype, 'testConnection') + .mockImplementation(async () => Promise.resolve()); + + const response = await authAgent(owner).post('/ldap/test-connection'); + expect(response.statusCode).toBe(200); + }); + + test('route should fail', async () => { + const errorMessage = 'Invalid connection'; + + jest + .spyOn(LdapService.prototype, 'testConnection') + .mockImplementation(async () => Promise.reject(new Error(errorMessage))); + + const response = await authAgent(owner).post('/ldap/test-connection'); + expect(response.statusCode).toBe(400); + expect(response.body).toHaveProperty('message'); + expect(response.body.message).toStrictEqual(errorMessage); + }); +}); + +describe('POST /ldap/sync', () => { + beforeEach(async () => { + const ldapConfig = await createLdapConfig({ + ldapIdAttribute: 'uid', + firstNameAttribute: 'givenName', + lastNameAttribute: 'sn', + emailAttribute: 'mail', + }); + LdapManager.updateConfig(ldapConfig); + }); + + describe('dry mode', () => { + const runTest = async (ldapUsers: LdapUser[]) => { + jest + .spyOn(LdapService.prototype, 'searchWithAdminBinding') + .mockImplementation(async () => Promise.resolve(ldapUsers)); + + const response = await authAgent(owner).post('/ldap/sync').send({ type: 'dry' }); + + expect(response.statusCode).toBe(200); + + const synchronization = await Db.collections.AuthProviderSyncHistory.findOneByOrFail({}); + + expect(synchronization.id).toBeDefined(); + expect(synchronization.startedAt).toBeDefined(); + expect(synchronization.endedAt).toBeDefined(); + expect(synchronization.created).toBeDefined(); + expect(synchronization.updated).toBeDefined(); + expect(synchronization.disabled).toBeDefined(); + expect(synchronization.status).toBeDefined(); + expect(synchronization.scanned).toBeDefined(); + expect(synchronization.error).toBeDefined(); + expect(synchronization.runMode).toBeDefined(); + expect(synchronization.runMode).toBe('dry'); + expect(synchronization.scanned).toBe(ldapUsers.length); + return synchronization; + }; + + test('should detect new user but not persist change in model', async () => { + const synchronization = await runTest([ + { + dn: '', + mail: randomEmail(), + sn: randomName(), + givenName: randomName(), + uid: uniqueId(), + }, + ]); + + expect(synchronization.created).toBe(1); + + // Make sure only the instance owner is on the DB + const localDbUsers = await Db.collections.User.find(); + expect(localDbUsers.length).toBe(1); + expect(localDbUsers[0].id).toBe(owner.id); + }); + + test('should detect updated user but not persist change in model', async () => { + const ldapUserEmail = randomEmail(); + const ldapUserId = uniqueId(); + + const member = await testDb.createLdapUser( + { globalRole: globalMemberRole, email: ldapUserEmail }, + ldapUserId, + ); + + const synchronization = await runTest([ + { + dn: '', + mail: ldapUserEmail, + sn: randomName(), + givenName: 'updated', + uid: ldapUserId, + }, + ]); + + expect(synchronization.updated).toBe(1); + + // Make sure the changes in the "LDAP server" were not persisted in the database + const localLdapIdentities = await testDb.getLdapIdentities(); + const localLdapUsers = localLdapIdentities.map(({ user }) => user); + expect(localLdapUsers.length).toBe(1); + expect(localLdapUsers[0].id).toBe(member.id); + expect(localLdapUsers[0].lastName).toBe(member.lastName); + }); + + test('should detect disabled user but not persist change in model', async () => { + const ldapUserEmail = randomEmail(); + const ldapUserId = uniqueId(); + + const member = await testDb.createLdapUser( + { globalRole: globalMemberRole, email: ldapUserEmail }, + ldapUserId, + ); + + const synchronization = await runTest([]); + + expect(synchronization.disabled).toBe(1); + + // Make sure the changes in the "LDAP server" were not persisted in the database + const localLdapIdentities = await testDb.getLdapIdentities(); + const localLdapUsers = localLdapIdentities.map(({ user }) => user); + expect(localLdapUsers.length).toBe(1); + expect(localLdapUsers[0].id).toBe(member.id); + expect(localLdapUsers[0].disabled).toBe(false); + }); + }); + + describe('live mode', () => { + const runTest = async (ldapUsers: LdapUser[]) => { + jest + .spyOn(LdapService.prototype, 'searchWithAdminBinding') + .mockImplementation(async () => Promise.resolve(ldapUsers)); + + const response = await authAgent(owner).post('/ldap/sync').send({ type: 'live' }); + + expect(response.statusCode).toBe(200); + + const synchronization = await Db.collections.AuthProviderSyncHistory.findOneByOrFail({}); + + expect(synchronization.id).toBeDefined(); + expect(synchronization.startedAt).toBeDefined(); + expect(synchronization.endedAt).toBeDefined(); + expect(synchronization.created).toBeDefined(); + expect(synchronization.updated).toBeDefined(); + expect(synchronization.disabled).toBeDefined(); + expect(synchronization.status).toBeDefined(); + expect(synchronization.scanned).toBeDefined(); + expect(synchronization.error).toBeDefined(); + expect(synchronization.runMode).toBeDefined(); + expect(synchronization.runMode).toBe('live'); + expect(synchronization.scanned).toBe(ldapUsers.length); + return synchronization; + }; + + test('should detect new user and persist change in model', async () => { + const ldapUser = { + mail: randomEmail(), + dn: '', + sn: randomName(), + givenName: randomName(), + uid: uniqueId(), + }; + + const synchronization = await runTest([ldapUser]); + expect(synchronization.created).toBe(1); + + // Make sure the changes in the "LDAP server" were persisted in the database + const allUsers = await testDb.getAllUsers(); + expect(allUsers.length).toBe(2); + + const ownerUser = allUsers.find((u) => u.email === owner.email)!; + expect(ownerUser.email).toBe(owner.email); + + const memberUser = allUsers.find((u) => u.email !== owner.email)!; + expect(memberUser.email).toBe(ldapUser.mail); + expect(memberUser.lastName).toBe(ldapUser.sn); + expect(memberUser.firstName).toBe(ldapUser.givenName); + + const authIdentities = await testDb.getLdapIdentities(); + expect(authIdentities.length).toBe(1); + expect(authIdentities[0].providerId).toBe(ldapUser.uid); + expect(authIdentities[0].providerType).toBe('ldap'); + }); + + test('should detect updated user and persist change in model', async () => { + const ldapUser = { + mail: randomEmail(), + dn: '', + sn: 'updated', + givenName: randomName(), + uid: uniqueId(), + }; + + await testDb.createLdapUser( + { + globalRole: globalMemberRole, + email: ldapUser.mail, + firstName: ldapUser.givenName, + lastName: randomName(), + }, + ldapUser.uid, + ); + + const synchronization = await runTest([ldapUser]); + expect(synchronization.updated).toBe(1); + + // Make sure the changes in the "LDAP server" were persisted in the database + const localLdapIdentities = await testDb.getLdapIdentities(); + const localLdapUsers = localLdapIdentities.map(({ user }) => user); + + expect(localLdapUsers.length).toBe(1); + expect(localLdapUsers[0].email).toBe(ldapUser.mail); + expect(localLdapUsers[0].lastName).toBe(ldapUser.sn); + expect(localLdapUsers[0].firstName).toBe(ldapUser.givenName); + expect(localLdapIdentities[0].providerId).toBe(ldapUser.uid); + }); + + test('should detect disabled user and persist change in model', async () => { + const ldapUser = { + mail: randomEmail(), + dn: '', + sn: 'updated', + givenName: randomName(), + uid: uniqueId(), + }; + + await testDb.createLdapUser( + { + globalRole: globalMemberRole, + email: ldapUser.mail, + firstName: ldapUser.givenName, + lastName: ldapUser.sn, + }, + ldapUser.uid, + ); + + const synchronization = await runTest([]); + expect(synchronization.disabled).toBe(1); + + // Make sure the changes in the "LDAP server" were persisted in the database + const allUsers = await testDb.getAllUsers(); + expect(allUsers.length).toBe(2); + + const ownerUser = allUsers.find((u) => u.email === owner.email)!; + expect(ownerUser.email).toBe(owner.email); + + const memberUser = allUsers.find((u) => u.email !== owner.email)!; + expect(memberUser.email).toBe(ldapUser.mail); + expect(memberUser.lastName).toBe(ldapUser.sn); + expect(memberUser.firstName).toBe(ldapUser.givenName); + expect(memberUser.disabled).toBe(true); + + const authIdentities = await testDb.getLdapIdentities(); + expect(authIdentities.length).toBe(0); + }); + + test('should remove user instance access once the user is disabled during synchronization', async () => { + const member = await testDb.createLdapUser({ globalRole: globalMemberRole }, uniqueId()); + + jest + .spyOn(LdapService.prototype, 'searchWithAdminBinding') + .mockImplementation(async () => Promise.resolve([])); + + await authAgent(owner).post('/ldap/sync').send({ type: 'live' }); + + const response = await authAgent(member).get('/login'); + expect(response.body.code).toBe(401); + }); + }); +}); + +test('GET /ldap/sync should return paginated synchronizations', async () => { + for (let i = 0; i < 2; i++) { + await saveLdapSynchronization({ + created: 0, + scanned: 0, + updated: 0, + disabled: 0, + startedAt: new Date(), + endedAt: new Date(), + status: 'success', + error: '', + runMode: 'dry', + }); + } + + let response = await authAgent(owner).get('/ldap/sync?perPage=1&page=0'); + expect(response.body.data.length).toBe(1); + + response = await authAgent(owner).get('/ldap/sync?perPage=1&page=1'); + expect(response.body.data.length).toBe(1); +}); + +describe('POST /login', () => { + const runTest = async (ldapUser: LdapUser) => { + const ldapConfig = await createLdapConfig(); + LdapManager.updateConfig(ldapConfig); + + const authlessAgent = utils.createAgent(app); + + jest + .spyOn(LdapService.prototype, 'searchWithAdminBinding') + .mockImplementation(async () => Promise.resolve([ldapUser])); + + jest + .spyOn(LdapService.prototype, 'validUser') + .mockImplementation(async () => Promise.resolve()); + + const response = await authlessAgent + .post('/login') + .send({ email: ldapUser.mail, password: 'password' }); + + expect(response.statusCode).toBe(200); + expect(response.headers['set-cookie']).toBeDefined(); + expect(response.headers['set-cookie'][0] as string).toContain('n8n-auth='); + + // Make sure the changes in the "LDAP server" were persisted in the database + const localLdapIdentities = await testDb.getLdapIdentities(); + const localLdapUsers = localLdapIdentities.map(({ user }) => user); + + expect(localLdapUsers.length).toBe(1); + expect(localLdapUsers[0].email).toBe(ldapUser.mail); + expect(localLdapUsers[0].lastName).toBe(ldapUser.sn); + expect(localLdapUsers[0].firstName).toBe(ldapUser.givenName); + expect(localLdapIdentities[0].providerId).toBe(ldapUser.uid); + expect(localLdapUsers[0].disabled).toBe(false); + }; + + test('should allow new LDAP user to login and synchronize data', async () => { + const ldapUser = { + mail: randomEmail(), + dn: '', + sn: '', + givenName: randomName(), + uid: uniqueId(), + }; + await runTest(ldapUser); + }); + + test('should allow existing LDAP user to login and synchronize data', async () => { + const ldapUser = { + mail: randomEmail(), + dn: '', + sn: 'updated', + givenName: 'updated', + uid: uniqueId(), + }; + + await testDb.createLdapUser( + { + globalRole: globalMemberRole, + email: ldapUser.mail, + firstName: 'firstname', + lastName: 'lastname', + }, + ldapUser.uid, + ); + + await runTest(ldapUser); + }); + + test('should transform email user into LDAP user when match found', async () => { + const ldapUser = { + mail: randomEmail(), + dn: '', + sn: randomName(), + givenName: randomName(), + uid: uniqueId(), + }; + + await testDb.createUser({ + globalRole: globalMemberRole, + email: ldapUser.mail, + firstName: ldapUser.givenName, + lastName: 'lastname', + }); + + await runTest(ldapUser); + }); +}); + +describe('Instance owner should able to delete LDAP users', () => { + test("don't transfer workflows", async () => { + const ldapConfig = await createLdapConfig(); + LdapManager.updateConfig(ldapConfig); + + const member = await testDb.createLdapUser({ globalRole: globalMemberRole }, uniqueId()); + + await authAgent(owner).post(`/users/${member.id}`); + }); + + test('transfer workflows and credentials', async () => { + const ldapConfig = await createLdapConfig(); + LdapManager.updateConfig(ldapConfig); + + const member = await testDb.createLdapUser({ globalRole: globalMemberRole }, uniqueId()); + + // delete the LDAP member and transfer its workflows/credentials to instance owner + await authAgent(owner).post(`/users/${member.id}?transferId=${owner.id}`); + }); +}); + +test('Sign-type should be returned when listing users', async () => { + const ldapConfig = await createLdapConfig(); + LdapManager.updateConfig(ldapConfig); + + await testDb.createLdapUser( + { + globalRole: globalMemberRole, + }, + uniqueId(), + ); + + const allUsers = await testDb.getAllUsers(); + expect(allUsers.length).toBe(2); + + const ownerUser = allUsers.find((u) => u.email === owner.email)!; + expect(sanitizeUser(ownerUser).signInType).toStrictEqual('email'); + + const memberUser = allUsers.find((u) => u.email !== owner.email)!; + expect(sanitizeUser(memberUser).signInType).toStrictEqual('ldap'); +}); diff --git a/packages/cli/test/integration/shared/augmentation.d.ts b/packages/cli/test/integration/shared/augmentation.d.ts index f685b42c60..ecd3ec8e13 100644 --- a/packages/cli/test/integration/shared/augmentation.d.ts +++ b/packages/cli/test/integration/shared/augmentation.d.ts @@ -9,13 +9,3 @@ declare module 'supertest' { extends superagent.SuperAgent, Record {} } - -/** - * Prevent `repository.delete({})` (non-criteria) from triggering the type error - * `Expression produces a union type that is too complex to represent.ts(2590)` - */ -declare module 'typeorm' { - interface Repository { - delete(criteria: {}): Promise; - } -} diff --git a/packages/cli/test/integration/shared/random.ts b/packages/cli/test/integration/shared/random.ts index 1855e50735..866ef8492e 100644 --- a/packages/cli/test/integration/shared/random.ts +++ b/packages/cli/test/integration/shared/random.ts @@ -1,6 +1,7 @@ import { randomBytes } from 'crypto'; import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '@db/entities/User'; import type { CredentialPayload } from './types'; +import { v4 as uuid } from 'uuid'; /** * Create a random alphanumeric string of random length between two limits, both inclusive. @@ -59,3 +60,5 @@ export const randomCredentialPayload = (): CredentialPayload => ({ nodesAccess: [{ nodeType: randomName() }], data: { accessToken: randomString(6, 16) }, }); + +export const uniqueId = () => uuid(); diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts index bdd2e48327..9f8c16db99 100644 --- a/packages/cli/test/integration/shared/testDb.ts +++ b/packages/cli/test/integration/shared/testDb.ts @@ -10,17 +10,7 @@ import { mysqlMigrations } from '@db/migrations/mysqldb'; import { postgresMigrations } from '@db/migrations/postgresdb'; import { sqliteMigrations } from '@db/migrations/sqlite'; import { hashPassword } from '@/UserManagement/UserManagementHelper'; -import { DB_INITIALIZATION_TIMEOUT, MAPPING_TABLES, MAPPING_TABLES_TO_CLEAR } from './constants'; -import { - randomApiKey, - randomCredentialPayload, - randomEmail, - randomName, - randomString, - randomValidPassword, -} from './random'; -import { categorize, getPostgresSchemaSection } from './utils'; - +import { AuthIdentity } from '@/databases/entities/AuthIdentity'; import { ExecutionEntity } from '@db/entities/ExecutionEntity'; import { InstalledNodes } from '@db/entities/InstalledNodes'; import { InstalledPackages } from '@db/entities/InstalledPackages'; @@ -35,6 +25,10 @@ import type { InstalledPackagePayload, MappingName, } from './types'; +import { DB_INITIALIZATION_TIMEOUT, MAPPING_TABLES, MAPPING_TABLES_TO_CLEAR } from './constants'; +import { randomApiKey, randomEmail, randomName, randomString, randomValidPassword } from './random'; +import { categorize, getPostgresSchemaSection } from './utils'; + import type { DatabaseType, ICredentialsDb } from '@/Interfaces'; export type TestDBType = 'postgres' | 'mysql'; @@ -103,7 +97,7 @@ export async function terminate() { async function truncateMappingTables( dbType: DatabaseType, - collections: Array, + collections: CollectionName[], testDb: Connection, ) { const mappingTables = collections.reduce((acc, collection) => { @@ -115,7 +109,7 @@ async function truncateMappingTables( }, []); if (dbType === 'sqlite') { - const promises = mappingTables.map((tableName) => + const promises = mappingTables.map(async (tableName) => testDb.query( `DELETE FROM ${tableName}; DELETE FROM sqlite_sequence WHERE name=${tableName};`, ), @@ -152,16 +146,16 @@ async function truncateMappingTables( * @param collections Array of entity names whose tables to truncate. * @param testDbName Name of the test DB to truncate tables in. */ -export async function truncate(collections: Array) { +export async function truncate(collections: CollectionName[]) { const dbType = config.getEnv('database.type'); const testDb = Db.getConnection(); if (dbType === 'sqlite') { await testDb.query('PRAGMA foreign_keys=OFF'); - const truncationPromises = collections.map((collection) => { + const truncationPromises = collections.map(async (collection) => { const tableName = toTableName(collection); - Db.collections[collection].clear(); + // Db.collections[collection].clear(); return testDb.query( `DELETE FROM ${tableName}; DELETE FROM sqlite_sequence WHERE name=${tableName};`, ); @@ -200,7 +194,7 @@ export async function truncate(collections: Array) { const hasIdColumn = await testDb .query(`SHOW COLUMNS FROM ${tableName}`) - .then((columns: { Field: string }[]) => columns.find((c) => c.Field === 'id')); + .then((columns: Array<{ Field: string }>) => columns.find((c) => c.Field === 'id')); if (!hasIdColumn) continue; @@ -218,18 +212,20 @@ function toTableName(sourceName: CollectionName | MappingName) { if (isMapping(sourceName)) return MAPPING_TABLES[sourceName]; return { + AuthIdentity: 'auth_identity', + AuthProviderSyncHistory: 'auth_provider_sync_history', Credentials: 'credentials_entity', - Workflow: 'workflow_entity', Execution: 'execution_entity', - Tag: 'tag_entity', - Webhook: 'webhook_entity', + InstalledNodes: 'installed_nodes', + InstalledPackages: 'installed_packages', Role: 'role', - User: 'user', + Settings: 'settings', SharedCredentials: 'shared_credentials', SharedWorkflow: 'shared_workflow', - Settings: 'settings', - InstalledPackages: 'installed_packages', - InstalledNodes: 'installed_nodes', + Tag: 'tag_entity', + User: 'user', + Webhook: 'webhook_entity', + Workflow: 'workflow_entity', WorkflowStatistics: 'workflow_statistics', EventDestinations: 'event_destinations', }[sourceName]; @@ -243,7 +239,7 @@ function toTableName(sourceName: CollectionName | MappingName) { * Save a credential to the test DB, sharing it with a user. */ export async function saveCredential( - credentialPayload: CredentialPayload = randomCredentialPayload(), + credentialPayload: CredentialPayload, { user, role }: { user: User; role: Role }, ) { const newCredential = new CredentialsEntity(); @@ -280,7 +276,7 @@ export async function shareCredentialWithUsers(credential: CredentialsEntity, us } export function affixRoleToSaveCredential(role: Role) { - return (credentialPayload: CredentialPayload, { user }: { user: User }) => + return async (credentialPayload: CredentialPayload, { user }: { user: User }) => saveCredential(credentialPayload, { user, role }); } @@ -293,7 +289,7 @@ export function affixRoleToSaveCredential(role: Role) { */ export async function createUser(attributes: Partial = {}): Promise { const { email, password, firstName, lastName, globalRole, ...rest } = attributes; - const user = { + const user: Partial = { email: email ?? randomEmail(), password: await hashPassword(password ?? randomValidPassword()), firstName: firstName ?? randomName(), @@ -305,11 +301,17 @@ export async function createUser(attributes: Partial = {}): Promise return Db.collections.User.save(user); } +export async function createLdapUser(attributes: Partial, ldapId: string): Promise { + const user = await createUser(attributes); + await Db.collections.AuthIdentity.save(AuthIdentity.create(user, ldapId, 'ldap')); + return user; +} + export async function createOwner() { return createUser({ globalRole: await getGlobalOwnerRole() }); } -export function createUserShell(globalRole: Role): Promise { +export async function createUserShell(globalRole: Role): Promise { if (globalRole.scope !== 'global') { throw new Error(`Invalid role received: ${JSON.stringify(globalRole)}`); } @@ -366,7 +368,7 @@ export async function saveInstalledPackage( return savedInstalledPackage; } -export function saveInstalledNode( +export async function saveInstalledNode( installedNodePayload: InstalledNodePayload, ): Promise { const newInstalledNode = new InstalledNodes(); @@ -376,7 +378,7 @@ export function saveInstalledNode( return Db.collections.InstalledNodes.save(newInstalledNode); } -export function addApiKey(user: User): Promise { +export async function addApiKey(user: User): Promise { user.apiKey = randomApiKey(); return Db.collections.User.save(user); } @@ -385,42 +387,42 @@ export function addApiKey(user: User): Promise { // role fetchers // ---------------------------------- -export function getGlobalOwnerRole() { +export async function getGlobalOwnerRole() { return Db.collections.Role.findOneByOrFail({ name: 'owner', scope: 'global', }); } -export function getGlobalMemberRole() { +export async function getGlobalMemberRole() { return Db.collections.Role.findOneByOrFail({ name: 'member', scope: 'global', }); } -export function getWorkflowOwnerRole() { +export async function getWorkflowOwnerRole() { return Db.collections.Role.findOneByOrFail({ name: 'owner', scope: 'workflow', }); } -export function getWorkflowEditorRole() { +export async function getWorkflowEditorRole() { return Db.collections.Role.findOneByOrFail({ name: 'editor', scope: 'workflow', }); } -export function getCredentialOwnerRole() { +export async function getCredentialOwnerRole() { return Db.collections.Role.findOneByOrFail({ name: 'owner', scope: 'credential', }); } -export function getAllRoles() { +export async function getAllRoles() { return Promise.all([ getGlobalOwnerRole(), getGlobalMemberRole(), @@ -429,6 +431,17 @@ export function getAllRoles() { ]); } +export const getAllUsers = async () => + Db.collections.User.find({ + relations: ['globalRole', 'authIdentities'], + }); + +export const getLdapIdentities = async () => + Db.collections.AuthIdentity.find({ + where: { providerType: 'ldap' }, + relations: ['user'], + }); + // ---------------------------------- // Execution helpers // ---------------------------------- @@ -438,17 +451,14 @@ export async function createManyExecutions( workflow: WorkflowEntity, callback: (workflow: WorkflowEntity) => Promise, ) { - const executionsRequests = [...Array(amount)].map((_) => callback(workflow)); + const executionsRequests = [...Array(amount)].map(async (_) => callback(workflow)); return Promise.all(executionsRequests); } /** * Store a execution in the DB and assign it to a workflow. */ -export async function createExecution( - attributes: Partial = {}, - workflow: WorkflowEntity, -) { +async function createExecution(attributes: Partial, workflow: WorkflowEntity) { const { data, finished, mode, startedAt, stoppedAt, waitTill } = attributes; const execution = await Db.collections.Execution.save({ @@ -468,38 +478,21 @@ export async function createExecution( * Store a successful execution in the DB and assign it to a workflow. */ export async function createSuccessfulExecution(workflow: WorkflowEntity) { - return await createExecution( - { - finished: true, - }, - workflow, - ); + return createExecution({ finished: true }, workflow); } /** * Store an error execution in the DB and assign it to a workflow. */ export async function createErrorExecution(workflow: WorkflowEntity) { - return await createExecution( - { - finished: false, - stoppedAt: new Date(), - }, - workflow, - ); + return createExecution({ finished: false, stoppedAt: new Date() }, workflow); } /** * Store a waiting execution in the DB and assign it to a workflow. */ export async function createWaitingExecution(workflow: WorkflowEntity) { - return await createExecution( - { - finished: false, - waitTill: new Date(), - }, - workflow, - ); + return createExecution({ finished: false, waitTill: new Date() }, workflow); } // ---------------------------------- @@ -509,7 +502,7 @@ export async function createWaitingExecution(workflow: WorkflowEntity) { export async function createTag(attributes: Partial = {}) { const { name } = attributes; - return await Db.collections.Tag.save({ + return Db.collections.Tag.save({ name: name ?? randomName(), ...attributes, }); @@ -524,7 +517,7 @@ export async function createManyWorkflows( attributes: Partial = {}, user?: User, ) { - const workflowRequests = [...Array(amount)].map((_) => createWorkflow(attributes, user)); + const workflowRequests = [...Array(amount)].map(async (_) => createWorkflow(attributes, user)); return Promise.all(workflowRequests); } @@ -653,7 +646,7 @@ const baseOptions = (type: TestDBType) => ({ port: config.getEnv(`database.${type}db.port`), username: config.getEnv(`database.${type}db.user`), password: config.getEnv(`database.${type}db.password`), - schema: type === 'postgres' ? config.getEnv(`database.postgresdb.schema`) : undefined, + schema: type === 'postgres' ? config.getEnv('database.postgresdb.schema') : undefined, }); /** diff --git a/packages/cli/test/integration/shared/types.d.ts b/packages/cli/test/integration/shared/types.d.ts index e047045bd3..0a23a7b617 100644 --- a/packages/cli/test/integration/shared/types.d.ts +++ b/packages/cli/test/integration/shared/types.d.ts @@ -24,6 +24,7 @@ type EndpointGroup = | 'workflows' | 'publicApi' | 'nodes' + | 'ldap' | 'eventBus' | 'license'; diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts index b43c7a3167..17f57f6c7d 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -26,8 +26,6 @@ import type { N8nApp } from '@/UserManagement/Interfaces'; import superagent from 'superagent'; import request from 'supertest'; import { URL } from 'url'; -import { v4 as uuid } from 'uuid'; - import config from '@/config'; import * as Db from '@/Db'; import { WorkflowEntity } from '@db/entities/WorkflowEntity'; @@ -69,6 +67,10 @@ import type { import { licenseController } from '@/license/license.controller'; import { eventBusRouter } from '@/eventbus/eventBusRoutes'; +import { v4 as uuid } from 'uuid'; +import { handleLdapInit } from '../../../src/Ldap/helpers'; +import { ldapController } from '@/Ldap/routes/ldap.controller.ee'; + const loadNodesAndCredentials: INodesAndCredentials = { loaded: { nodes: {}, credentials: {} }, known: { nodes: {}, credentials: {} }, @@ -130,6 +132,7 @@ export async function initTestServer({ license: { controller: licenseController, path: 'license' }, eventBus: { controller: eventBusRouter, path: 'eventbus' }, publicApi: apiRouters, + ldap: { controller: ldapController, path: 'ldap' }, }; for (const group of routerEndpoints) { @@ -173,7 +176,15 @@ const classifyEndpointGroups = (endpointGroups: string[]) => { const routerEndpoints: string[] = []; const functionEndpoints: string[] = []; - const ROUTER_GROUP = ['credentials', 'nodes', 'workflows', 'publicApi', 'license', 'eventBus']; + const ROUTER_GROUP = [ + 'credentials', + 'nodes', + 'workflows', + 'publicApi', + 'ldap', + 'eventBus', + 'license', + ]; endpointGroups.forEach((group) => (ROUTER_GROUP.includes(group) ? routerEndpoints : functionEndpoints).push(group), @@ -239,6 +250,13 @@ export async function initCredentialsTypes(): Promise { }; } +/** + * Initialize LDAP manager. + */ +export async function initLdapManager(): Promise { + await handleLdapInit(); +} + /** * Initialize node types. */ diff --git a/packages/cli/test/integration/users.api.test.ts b/packages/cli/test/integration/users.api.test.ts index cc97105a17..bdecd1ae10 100644 --- a/packages/cli/test/integration/users.api.test.ts +++ b/packages/cli/test/integration/users.api.test.ts @@ -10,6 +10,7 @@ import { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { compareHash } from '@/UserManagement/UserManagementHelper'; import { SUCCESS_RESPONSE_BODY } from './shared/constants'; import { + randomCredentialPayload, randomEmail, randomInvalidPassword, randomName, @@ -208,7 +209,7 @@ test('DELETE /users/:id with transferId should perform transfer', async () => { const savedWorkflow = await testDb.createWorkflow(undefined, userToDelete); - const savedCredential = await testDb.saveCredential(undefined, { + const savedCredential = await testDb.saveCredential(randomCredentialPayload(), { user: userToDelete, role: credentialOwnerRole, }); diff --git a/packages/design-system/src/components/N8nFormInput/FormInput.vue b/packages/design-system/src/components/N8nFormInput/FormInput.vue index 76466abb11..b0d3f8000d 100644 --- a/packages/design-system/src/components/N8nFormInput/FormInput.vue +++ b/packages/design-system/src/components/N8nFormInput/FormInput.vue @@ -6,6 +6,23 @@ @focus="onFocus" ref="inputRef" /> + + + + (), { diff --git a/packages/design-system/src/components/N8nFormInputs/FormInputs.stories.ts b/packages/design-system/src/components/N8nFormInputs/FormInputs.stories.ts index 09f59bf2b7..4f4e17dba3 100644 --- a/packages/design-system/src/components/N8nFormInputs/FormInputs.stories.ts +++ b/packages/design-system/src/components/N8nFormInputs/FormInputs.stories.ts @@ -27,6 +27,7 @@ const Template: StoryFn = (args, { argTypes }) => ({ export const FormInputs = Template.bind({}); FormInputs.args = { + columnView: true, inputs: [ { name: 'email', @@ -79,5 +80,15 @@ FormInputs.args = { tooltipText: 'Check this if you agree to be contacted by our marketing team', }, }, + { + name: 'activate', + properties: { + type: 'toggle', + label: 'Activated', + activeColor: '#13ce66', + inactiveColor: '#8899AA', + tooltipText: 'Check this if you agree to be contacted by our marketing team', + }, + }, ], }; diff --git a/packages/design-system/src/components/N8nFormInputs/FormInputs.vue b/packages/design-system/src/components/N8nFormInputs/FormInputs.vue index c638a6f591..507c308bd8 100644 --- a/packages/design-system/src/components/N8nFormInputs/FormInputs.vue +++ b/packages/design-system/src/components/N8nFormInputs/FormInputs.vue @@ -2,12 +2,18 @@ @@ -47,6 +55,9 @@ export default mixins(Locale).extend({ email: { type: String, }, + isOwner: { + type: Boolean, + }, isPendingUser: { type: Boolean, }, @@ -55,7 +66,10 @@ export default mixins(Locale).extend({ }, disabled: { type: Boolean, - default: false, + }, + signInType: { + type: String, + required: false, }, }, computed: { diff --git a/packages/design-system/src/components/N8nUserSelect/UserSelect.stories.ts b/packages/design-system/src/components/N8nUserSelect/UserSelect.stories.ts index dfc574dcab..209af2648f 100644 --- a/packages/design-system/src/components/N8nUserSelect/UserSelect.stories.ts +++ b/packages/design-system/src/components/N8nUserSelect/UserSelect.stories.ts @@ -40,28 +40,16 @@ UserSelect.args = { firstName: 'Sunny', lastName: 'Side', email: 'sunny@n8n.io', - globalRole: { - name: 'owner', - id: '1', - }, }, { id: '2', firstName: 'Kobi', lastName: 'Dog', email: 'kobi@n8n.io', - globalRole: { - name: 'member', - id: '2', - }, }, { id: '3', email: 'invited@n8n.io', - globalRole: { - name: 'member', - id: '2', - }, }, ], placeholder: 'Select user to transfer to', diff --git a/packages/design-system/src/components/N8nUsersList/UsersList.stories.ts b/packages/design-system/src/components/N8nUsersList/UsersList.stories.ts index 9aada29140..c837990557 100644 --- a/packages/design-system/src/components/N8nUsersList/UsersList.stories.ts +++ b/packages/design-system/src/components/N8nUsersList/UsersList.stories.ts @@ -50,10 +50,8 @@ UsersList.args = { isDefaultUser: false, isPendingUser: false, isOwner: true, - globalRole: { - name: 'owner', - id: 1, - }, + signInType: 'email', + disabled: false, }, { id: '2', @@ -64,10 +62,8 @@ UsersList.args = { isDefaultUser: false, isPendingUser: false, isOwner: false, - globalRole: { - name: 'member', - id: '2', - }, + signInType: 'ldap', + disabled: true, }, { id: '3', @@ -75,10 +71,6 @@ UsersList.args = { isDefaultUser: false, isPendingUser: true, isOwner: false, - globalRole: { - name: 'member', - id: '2', - }, }, ], currentUserId: '1', diff --git a/packages/design-system/src/components/N8nUsersList/UsersList.vue b/packages/design-system/src/components/N8nUsersList/UsersList.vue index 2dbfec678d..9fcb7e3a34 100644 --- a/packages/design-system/src/components/N8nUsersList/UsersList.vue +++ b/packages/design-system/src/components/N8nUsersList/UsersList.vue @@ -13,7 +13,13 @@ boolean; }; diff --git a/packages/design-system/src/types/user.ts b/packages/design-system/src/types/user.ts index 53d51f06cf..a47f2f0421 100644 --- a/packages/design-system/src/types/user.ts +++ b/packages/design-system/src/types/user.ts @@ -7,6 +7,8 @@ export interface IUser { isOwner: boolean; isPendingUser: boolean; inviteAcceptUrl?: string; + disabled: boolean; + signInType: string; } export interface IUserListAction { diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 9a47cedfe9..39a3d935d0 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -46,6 +46,7 @@ "fast-json-stable-stringify": "^2.1.0", "file-saver": "^2.0.2", "flatted": "^3.2.4", + "humanize-duration": "^3.27.2", "jquery": "^3.4.1", "jsonpath": "^1.1.1", "jsplumb": "2.15.4", @@ -69,6 +70,7 @@ "vue-agile": "^2.0.0", "vue-fragment": "1.5.1", "vue-i18n": "^8.26.7", + "vue-infinite-loading": "^2.4.5", "vue-json-pretty": "1.9.3", "vue-prism-editor": "^0.3.0", "vue-router": "^3.6.5", @@ -86,6 +88,7 @@ "@types/dateformat": "^3.0.0", "@types/express": "^4.17.6", "@types/file-saver": "^2.0.1", + "@types/humanize-duration": "^3.27.1", "@types/jsonpath": "^0.2.0", "@types/lodash-es": "^4.17.6", "@types/lodash.camelcase": "^4.3.6", diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index d17fa649c2..21e5992dbc 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -40,6 +40,7 @@ import { IAbstractEventMessage, } from 'n8n-workflow'; import { FAKE_DOOR_FEATURES } from './constants'; +import { SignInType } from './constants'; import { BulkCommand, Undoable } from '@/models/history'; export * from 'n8n-design-system/types'; @@ -642,6 +643,7 @@ export interface IUserResponse { }; personalizationAnswers?: IPersonalizationSurveyVersions | null; isPending: boolean; + signInType?: SignInType; } export interface IUser extends IUserResponse { @@ -808,6 +810,10 @@ export interface IN8nUISettings { enabled: boolean; }; }; + ldap: { + loginLabel: string; + loginEnabled: boolean; + }; onboardingCallPromptEnabled: boolean; allowedModules: { builtIn?: string[]; @@ -1224,6 +1230,10 @@ export interface ISettingsState { enabled: boolean; }; }; + ldap: { + loginLabel: string; + loginEnabled: boolean; + }; onboardingCallPromptEnabled: boolean; saveDataErrorExecution: string; saveDataSuccessExecution: string; @@ -1385,6 +1395,50 @@ export type SchemaType = | 'function' | 'null' | 'undefined'; + +export interface ILdapSyncData { + id: number; + startedAt: string; + endedAt: string; + created: number; + updated: number; + disabled: number; + scanned: number; + status: string; + error: string; + runMode: string; +} + +export interface ILdapSyncTable { + status: string; + endedAt: string; + runTime: string; + runMode: string; + details: string; +} + +export interface ILdapConfig { + loginEnabled: boolean; + loginLabel: string; + connectionUrl: string; + allowUnauthorizedCerts: boolean; + connectionSecurity: string; + connectionPort: number; + baseDn: string; + bindingAdminDn: string; + bindingAdminPassword: string; + firstNameAttribute: string; + lastNameAttribute: string; + emailAttribute: string; + loginIdAttribute: string; + ldapIdAttribute: string; + userFilter: string; + synchronizationEnabled: boolean; + synchronizationInterval: number; // minutes + searchPageSize: number; + searchTimeout: number; +} + export type Schema = { type: SchemaType; key?: string; value: string | Schema[]; path: string }; export type UsageState = { diff --git a/packages/editor-ui/src/api/ldap.ts b/packages/editor-ui/src/api/ldap.ts new file mode 100644 index 0000000000..06bf040f75 --- /dev/null +++ b/packages/editor-ui/src/api/ldap.ts @@ -0,0 +1,29 @@ +import { ILdapConfig, ILdapSyncData, IRestApiContext } from '@/Interface'; +import { makeRestApiRequest } from '@/utils'; +import { IDataObject } from 'n8n-workflow'; + +export function getLdapConfig(context: IRestApiContext): Promise { + return makeRestApiRequest(context, 'GET', '/ldap/config'); +} + +export function testLdapConnection(context: IRestApiContext): Promise<{}> { + return makeRestApiRequest(context, 'POST', '/ldap/test-connection'); +} + +export function updateLdapConfig( + context: IRestApiContext, + adConfig: ILdapConfig, +): Promise { + return makeRestApiRequest(context, 'PUT', '/ldap/config', adConfig as unknown as IDataObject); +} + +export function runLdapSync(context: IRestApiContext, data: IDataObject): Promise<{}> { + return makeRestApiRequest(context, 'POST', '/ldap/sync', data as unknown as IDataObject); +} + +export function getLdapSynchronizations( + context: IRestApiContext, + pagination: { page: number }, +): Promise { + return makeRestApiRequest(context, 'GET', '/ldap/sync', pagination); +} diff --git a/packages/editor-ui/src/components/SettingsSidebar.vue b/packages/editor-ui/src/components/SettingsSidebar.vue index 2d95b07084..02dfe188bf 100644 --- a/packages/editor-ui/src/components/SettingsSidebar.vue +++ b/packages/editor-ui/src/components/SettingsSidebar.vue @@ -74,6 +74,14 @@ export default mixins(userHelpers, pushConnection).extend({ available: this.canAccessApiSettings(), activateOnRouteNames: [VIEWS.API_SETTINGS], }, + { + id: 'settings-ldap', + icon: 'network-wired', + label: this.$locale.baseText('settings.ldap'), + position: 'top', + available: this.canAccessLdapSettings(), + activateOnRouteNames: [VIEWS.LDAP_SETTINGS], + }, ]; for (const item of this.settingsFakeDoorFeatures) { @@ -126,6 +134,9 @@ export default mixins(userHelpers, pushConnection).extend({ canAccessApiSettings(): boolean { return this.canUserAccessRouteByName(VIEWS.API_SETTINGS); }, + canAccessLdapSettings(): boolean { + return this.canUserAccessRouteByName(VIEWS.LDAP_SETTINGS); + }, canAccessLogStreamingSettings(): boolean { return this.canUserAccessRouteByName(VIEWS.LOG_STREAMING_SETTINGS); }, @@ -155,6 +166,11 @@ export default mixins(userHelpers, pushConnection).extend({ this.$router.push({ name: VIEWS.API_SETTINGS }); } break; + case 'settings-ldap': + if (this.$router.currentRoute.name !== VIEWS.LDAP_SETTINGS) { + this.$router.push({ name: VIEWS.LDAP_SETTINGS }); + } + break; case 'settings-log-streaming': if (this.$router.currentRoute.name !== VIEWS.LOG_STREAMING_SETTINGS) { this.$router.push({ name: VIEWS.LOG_STREAMING_SETTINGS }); diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index f622a45296..7e6924654c 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -312,6 +312,7 @@ export enum VIEWS { FORGOT_PASSWORD = 'ForgotMyPasswordView', CHANGE_PASSWORD = 'ChangePasswordView', USERS_SETTINGS = 'UsersSettings', + LDAP_SETTINGS = 'LdapSettings', PERSONAL_SETTINGS = 'PersonalSettings', API_SETTINGS = 'APISettings', NOT_FOUND = 'NotFoundView', @@ -381,6 +382,7 @@ export enum WORKFLOW_MENU_ACTIONS { */ export enum EnterpriseEditionFeature { Sharing = 'sharing', + Ldap = 'ldap', LogStreaming = 'logStreaming', } export const MAIN_NODE_PANEL_WIDTH = 360; @@ -442,6 +444,15 @@ export enum STORES { HISTORY = 'history', } +export enum SignInType { + LDAP = 'ldap', + EMAIL = 'email', +} + +export const N8N_SALES_EMAIL = 'sales@n8n.io'; + +export const N8N_CONTACT_EMAIL = 'contact@n8n.io'; + export const EXPRESSION_EDITOR_PARSER_TIMEOUT = 15_000; // ms export const POSTHOG_ASSUMPTION_TEST = 'adore-assumption-tests'; diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 25ec639cb9..cb9e545790 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -527,6 +527,7 @@ "forgotPassword.recoveryEmailSent": "Recovery email sent", "forgotPassword.returnToSignIn": "Back to sign in", "forgotPassword.sendingEmailError": "Problem sending email", + "forgotPassword.ldapUserPasswordResetUnavailable": "Please contact your LDAP administrator to reset your password", "forgotPassword.smtpErrorContactAdministrator": "Please contact your administrator (problem with your SMTP setup)", "forms.resourceFiltersDropdown.filters": "Filters", "forms.resourceFiltersDropdown.ownedBy": "Owned by", @@ -1521,7 +1522,6 @@ "importParameter.showError.invalidProtocol1.title": "Use the {node} node", "importParameter.showError.invalidProtocol2.title": "Invalid Protocol", "importParameter.showError.invalidProtocol.message": "The HTTP node doesn’t support {protocol} requests", - "contextual.credentials.sharing.unavailable.title": "Upgrade to collaborate", "contextual.credentials.sharing.unavailable.title.cloud": "Upgrade to collaborate", "contextual.credentials.sharing.unavailable.title.desktop": "Upgrade to n8n Cloud to collaborate", @@ -1547,5 +1547,86 @@ "contextual.workflows.sharing.unavailable.button.desktop": "View plans", "contextual.upgradeLinkUrl": "https://subscription.n8n.io/", "contextual.upgradeLinkUrl.cloud": "https://app.n8n.cloud/manage?edition=cloud", - "contextual.upgradeLinkUrl.desktop": "https://n8n.io/pricing" + "contextual.upgradeLinkUrl.desktop": "https://n8n.io/pricing", + "settings.ldap": "LDAP", + "settings.ldap.infoTip": "Learn more about LDAP in the Docs", + "settings.ldap.save": "Save connection", + "settings.ldap.connectionTestError": "Problem testing LDAP connection", + "settings.ldap.connectionTest": "LDAP connection tested", + "settings.ldap.runSync.title": "LDAP synchronization done", + "settings.ldap.runSync.showError.message": "Problem during synchronization. Check the logs", + "settings.ldap.updateConfiguration": "LDAP configuration updated", + "settings.ldap.testingConnection": "Testing connection", + "settings.ldap.testConnection": "Test connection", + "settings.ldap.synchronizationTable.column.status": "Status", + "settings.ldap.synchronizationTable.column.endedAt": "Ended At", + "settings.ldap.synchronizationTable.column.runMode": "Run Mode", + "settings.ldap.synchronizationTable.column.runTime": "Run Time", + "settings.ldap.synchronizationTable.column.details": "Details", + "settings.ldap.synchronizationTable.empty.message": "Test synchronization to preview updates", + "settings.ldap.dryRun": "Test synchronization", + "settings.ldap.synchronizeNow": "Run synchronization", + "settings.ldap.synchronizationError": "LDAP Synchronization Error", + "settings.ldap.configurationError": "LDAP Configuration Error", + "settings.ldap.usersScanned": "Users scanned {scanned}", + "settings.ldap.confirmMessage.beforeSaveForm.cancelButtonText": "Yes, disable it", + "settings.ldap.confirmMessage.beforeSaveForm.confirmButtonText": "No", + "settings.ldap.confirmMessage.beforeSaveForm.headline": "Are you sure you want to disable LDAP login?", + "settings.ldap.confirmMessage.beforeSaveForm.message": "If you do so, all LDAP users will be converted to email users.", + "settings.ldap.disabled.title": "Available in custom plans", + "settings.ldap.disabled.description": "LDAP is available as a paid feature. Get in touch to learn more about it.", + "settings.ldap.disabled.buttonText": "Contact us", + "settings.ldap.toast.sync.success": "Synchronization succeeded", + "settings.ldap.toast.connection.success": "Connection succeeded", + "settings.ldap.form.loginEnabled.label": "Enable LDAP Login", + "settings.ldap.form.loginEnabled.tooltip": "Connection settings and data will still be saved if you disable LDAP Login", + "settings.ldap.form.loginLabel.label": "LDAP Login", + "settings.ldap.form.loginLabel.placeholder": "e.g. LDAP Username or email address", + "settings.ldap.form.loginLabel.infoText": "The placeholder text that appears in the login field on the login page", + "settings.ldap.form.serverAddress.label": "LDAP Server Address", + "settings.ldap.form.serverAddress.placeholder": "123.123.123.123", + "settings.ldap.form.serverAddress.infoText": "IP or domain of the LDAP server", + "settings.ldap.form.port.label": "LDAP Server Port", + "settings.ldap.form.port.infoText": "Port used to connect to the LDAP server", + "settings.ldap.form.connectionSecurity.label": "Connection Security", + "settings.ldap.form.connectionSecurity.infoText": "Type of connection security", + "settings.ldap.form.allowUnauthorizedCerts.label": "Ignore SSL/TLS Issues", + "settings.ldap.form.baseDn.label": "Base DN", + "settings.ldap.form.baseDn.placeholder": "o=acme,dc=example,dc=com", + "settings.ldap.form.baseDn.infoText": "Distinguished Name of the location where n8n should start its search for user in the AD/LDAP tree", + "settings.ldap.form.bindingType.label": "Binding as", + "settings.ldap.form.bindingType.infoText": "Type of binding used to connection to the LDAP server", + "settings.ldap.form.adminDn.label": "Binding DN", + "settings.ldap.form.adminDn.placeholder": "uid=2da2de69435c,ou=Users,o=Acme,dc=com", + "settings.ldap.form.adminDn.infoText": "Distinguished Name of the user to perform the search", + "settings.ldap.form.adminPassword.label": "Binding Password", + "settings.ldap.form.adminPassword.infoText": "Password of the user provided in the Binding DN field above", + "settings.ldap.form.userFilter.label": "User Filter", + "settings.ldap.form.userFilter.placeholder": "(ObjectClass=user)", + "settings.ldap.form.userFilter.infoText": "LDAP query to use when searching for user. Only users returned by this filter will be allowed to sign-in in n8n", + "settings.ldap.form.attributeMappingInfo.label": "Attribute mapping", + "settings.ldap.form.ldapId.label": "ID", + "settings.ldap.form.ldapId.placeholder": "uid", + "settings.ldap.form.ldapId.infoText": "The attribute in the LDAP server used as a unique identifier in n8n. It should be an unique LDAP attribute like uid", + "settings.ldap.form.loginId.label": "Login ID", + "settings.ldap.form.loginId.placeholder": "mail", + "settings.ldap.form.loginId.infoText": "The attribute in the LDAP server used to log-in in n8n", + "settings.ldap.form.email.label": "Email", + "settings.ldap.form.email.placeholder": "mail", + "settings.ldap.form.email.infoText": "The attribute in the LDAP server used to populate the email in n8n", + "settings.ldap.form.firstName.label": "First Name", + "settings.ldap.form.firstName.placeholder": "givenName", + "settings.ldap.form.firstName.infoText": "The attribute in the LDAP server used to populate the first name in n8n", + "settings.ldap.form.lastName.label": "Last Name", + "settings.ldap.form.lastName.placeholder": "sn", + "settings.ldap.form.lastName.infoText": "The attribute in the LDAP server used to populate the last name in n8n", + "settings.ldap.form.synchronizationEnabled.label": "Enable periodic LDAP synchronization", + "settings.ldap.form.synchronizationEnabled.tooltip": "Enable users to be synchronized periodically", + "settings.ldap.form.synchronizationInterval.label": "Synchronization Interval (Minutes)", + "settings.ldap.form.synchronizationInterval.infoText": "How often the synchronization should run", + "settings.ldap.form.pageSize.label": "Page Size", + "settings.ldap.form.pageSize.infoText": "Max number of records to return per page during synchronization. 0 for unlimited", + "settings.ldap.form.searchTimeout.label": "Search Timeout (Seconds)", + "settings.ldap.form.searchTimeout.infoText": "The timeout value for queries to the AD/LDAP server. Increase if you are getting timeout errors caused by a slow AD/LDAP server", + "settings.ldap.section.synchronization.title": "Synchronization" } diff --git a/packages/editor-ui/src/plugins/icons.ts b/packages/editor-ui/src/plugins/icons.ts index ac12aa57c3..86f4caf080 100644 --- a/packages/editor-ui/src/plugins/icons.ts +++ b/packages/editor-ui/src/plugins/icons.ts @@ -120,6 +120,7 @@ import { faUserFriends, faUsers, faVideo, + faTree, faStickyNote as faSolidStickyNote, } from '@fortawesome/free-solid-svg-icons'; import { faStickyNote } from '@fortawesome/free-regular-svg-icons'; @@ -250,5 +251,6 @@ addIcon(faUserCircle); addIcon(faUserFriends); addIcon(faUsers); addIcon(faVideo); +addIcon(faTree); Vue.component('font-awesome-icon', FontAwesomeIcon); diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index e57d9662ef..e5b067fb0f 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -10,6 +10,7 @@ import WorkflowExecutionsList from '@/components/ExecutionsView/ExecutionsList.v import ExecutionsLandingPage from '@/components/ExecutionsView/ExecutionsLandingPage.vue'; import ExecutionPreview from '@/components/ExecutionsView/ExecutionPreview.vue'; import SettingsView from './views/SettingsView.vue'; +import SettingsLdapView from './views/SettingsLdapView.vue'; import SettingsPersonalView from './views/SettingsPersonalView.vue'; import SettingsUsersView from './views/SettingsUsersView.vue'; import SettingsCommunityNodesView from './views/SettingsCommunityNodesView.vue'; @@ -30,7 +31,7 @@ import WorkflowsView from '@/views/WorkflowsView.vue'; import { IPermissions } from './Interface'; import { LOGIN_STATUS, ROLE } from '@/utils'; import { RouteConfigSingleView } from 'vue-router/types/router'; -import { VIEWS } from './constants'; +import { EnterpriseEditionFeature, VIEWS } from './constants'; import { useSettingsStore } from './stores/settings'; import { useTemplatesStore } from './stores/templates'; import SettingsUsageAndPlanVue from './views/SettingsUsageAndPlan.vue'; @@ -606,6 +607,20 @@ const router = new Router({ }, }, }, + { + path: 'ldap', + name: VIEWS.LDAP_SETTINGS, + components: { + settingsView: SettingsLdapView, + }, + meta: { + permissions: { + allow: { + role: [ROLE.Owner], + }, + }, + }, + }, ], }, { diff --git a/packages/editor-ui/src/stores/settings.ts b/packages/editor-ui/src/stores/settings.ts index 96dffdd23a..e86c9abc61 100644 --- a/packages/editor-ui/src/stores/settings.ts +++ b/packages/editor-ui/src/stores/settings.ts @@ -1,4 +1,11 @@ import { createApiKey, deleteApiKey, getApiKey } from '@/api/api-keys'; +import { + getLdapConfig, + getLdapSynchronizations, + runLdapSync, + testLdapConnection, + updateLdapConfig, +} from '@/api/ldap'; import { getPromptsData, getSettings, submitContactInfo, submitValueSurvey } from '@/api/settings'; import { testHealthEndpoint } from '@/api/templates'; import { @@ -15,6 +22,7 @@ import { IN8nValueSurveyData, ISettingsState, WorkflowCallerPolicyDefaultOption, + ILdapConfig, } from '@/Interface'; import { ITelemetrySettings } from 'n8n-workflow'; import { defineStore } from 'pinia'; @@ -42,6 +50,10 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, { enabled: false, }, }, + ldap: { + loginLabel: '', + loginEnabled: false, + }, onboardingCallPromptEnabled: false, saveDataErrorExecution: 'all', saveDataSuccessExecution: 'all', @@ -69,6 +81,12 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, { publicApiPath(): string { return this.api.path; }, + isLdapLoginEnabled(): boolean { + return this.ldap.loginEnabled; + }, + ldapLoginLabel(): string { + return this.ldap.loginLabel; + }, showSetupPage(): boolean { return this.userManagement.showSetupOnFirstLoad === true; }, @@ -147,6 +165,8 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, { this.userManagement.smtpSetup = settings.userManagement.smtpSetup; this.api = settings.publicApi; this.onboardingCallPromptEnabled = settings.onboardingCallPromptEnabled; + this.ldap.loginEnabled = settings.ldap.loginEnabled; + this.ldap.loginLabel = settings.ldap.loginLabel; }, async getSettings(): Promise { const rootStore = useRootStore(); @@ -253,6 +273,26 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, { const rootStore = useRootStore(); await deleteApiKey(rootStore.getRestApiContext); }, + async getLdapConfig() { + const rootStore = useRootStore(); + return await getLdapConfig(rootStore.getRestApiContext); + }, + async getLdapSynchronizations(pagination: { page: number }) { + const rootStore = useRootStore(); + return await getLdapSynchronizations(rootStore.getRestApiContext, pagination); + }, + async testLdapConnection() { + const rootStore = useRootStore(); + return await testLdapConnection(rootStore.getRestApiContext); + }, + async updateLdapConfig(ldapConfig: ILdapConfig) { + const rootStore = useRootStore(); + return await updateLdapConfig(rootStore.getRestApiContext, ldapConfig); + }, + async runLdapSync(data: IDataObject) { + const rootStore = useRootStore(); + return await runLdapSync(rootStore.getRestApiContext, data); + }, setSaveDataErrorExecution(newValue: string) { Vue.set(this, 'saveDataErrorExecution', newValue); }, diff --git a/packages/editor-ui/src/stores/users.ts b/packages/editor-ui/src/stores/users.ts index c47b910a03..bead449155 100644 --- a/packages/editor-ui/src/stores/users.ts +++ b/packages/editor-ui/src/stores/users.ts @@ -38,6 +38,7 @@ import { useUIStore } from './ui'; const isDefaultUser = (user: IUserResponse | null) => Boolean(user && user.isPending && user.globalRole && user.globalRole.name === ROLE.Owner); + const isPendingUser = (user: IUserResponse | null) => Boolean(user && user.isPending); export const useUsersStore = defineStore(STORES.USERS, { @@ -58,8 +59,8 @@ export const useUsersStore = defineStore(STORES.USERS, { getUserById(state) { return (userId: string): IUser | null => state.users[userId]; }, - globalRoleName(): string { - return this.currentUser?.globalRole?.name || ''; + globalRoleName(): IRole { + return this.currentUser?.globalRole?.name ?? 'default'; }, canUserDeleteTags(): boolean { return isAuthorized(PERMISSIONS.TAGS.CAN_DELETE_TAGS, this.currentUser); @@ -116,7 +117,7 @@ export const useUsersStore = defineStore(STORES.USERS, { : undefined, isDefaultUser: isDefaultUser(updatedUser), isPendingUser: isPendingUser(updatedUser), - isOwner: Boolean(updatedUser.globalRole && updatedUser.globalRole.name === ROLE.Owner), + isOwner: updatedUser.globalRole?.name === ROLE.Owner, }; Vue.set(this.users, user.id, user); }); diff --git a/packages/editor-ui/src/utils/htmlUtils.ts b/packages/editor-ui/src/utils/htmlUtils.ts index 1903a0274d..45ea9fcc1d 100644 --- a/packages/editor-ui/src/utils/htmlUtils.ts +++ b/packages/editor-ui/src/utils/htmlUtils.ts @@ -59,3 +59,7 @@ export function isChildOf(parent: Element, child: Element): boolean { return isChildOf(parent, child.parentElement); } + +export const capitalizeFirstLetter = (text: string): string => { + return text.charAt(0).toUpperCase() + text.slice(1); +}; diff --git a/packages/editor-ui/src/utils/userUtils.ts b/packages/editor-ui/src/utils/userUtils.ts index 407bf50d35..e5fba6f01a 100644 --- a/packages/editor-ui/src/utils/userUtils.ts +++ b/packages/editor-ui/src/utils/userUtils.ts @@ -142,7 +142,7 @@ export const isAuthorized = (permissions: IPermissions, currentUser: IUser | nul return false; } - if (currentUser && currentUser.globalRole) { + if (currentUser?.globalRole?.name) { const role = currentUser.isDefaultUser ? ROLE.Default : currentUser.globalRole.name; if (permissions.deny.role && permissions.deny.role.includes(role)) { return false; @@ -163,7 +163,7 @@ export const isAuthorized = (permissions: IPermissions, currentUser: IUser | nul return true; } - if (currentUser && currentUser.globalRole) { + if (currentUser?.globalRole?.name) { const role = currentUser.isDefaultUser ? ROLE.Default : currentUser.globalRole.name; if (permissions.allow.role && permissions.allow.role.includes(role)) { return true; diff --git a/packages/editor-ui/src/views/ForgotMyPasswordView.vue b/packages/editor-ui/src/views/ForgotMyPasswordView.vue index 8da514c3b7..e930f6285e 100644 --- a/packages/editor-ui/src/views/ForgotMyPasswordView.vue +++ b/packages/editor-ui/src/views/ForgotMyPasswordView.vue @@ -82,10 +82,14 @@ export default mixins(showMessage).extend({ }), }); } catch (error) { + let message = this.$locale.baseText('forgotPassword.smtpErrorContactAdministrator'); + if (error.httpStatusCode === 422) { + message = this.$locale.baseText(error.message); + } this.$showMessage({ type: 'error', title: this.$locale.baseText('forgotPassword.sendingEmailError'), - message: this.$locale.baseText('forgotPassword.smtpErrorContactAdministrator'), + message, }); } this.loading = false; diff --git a/packages/editor-ui/src/views/SettingsLdapView.vue b/packages/editor-ui/src/views/SettingsLdapView.vue new file mode 100644 index 0000000000..a11b8e1213 --- /dev/null +++ b/packages/editor-ui/src/views/SettingsLdapView.vue @@ -0,0 +1,786 @@ + + + + + diff --git a/packages/editor-ui/src/views/SettingsPersonalView.vue b/packages/editor-ui/src/views/SettingsPersonalView.vue index 374d34797e..86c7b14e2a 100644 --- a/packages/editor-ui/src/views/SettingsPersonalView.vue +++ b/packages/editor-ui/src/views/SettingsPersonalView.vue @@ -32,7 +32,7 @@ /> -
+
{{ $locale.baseText('settings.personal.security') }}
@@ -58,10 +58,11 @@