feat(core): Add LDAP support (#3835)

This commit is contained in:
Ricardo Espinoza 2023-01-24 20:18:39 -05:00 committed by GitHub
parent 259296c5c9
commit 0c70a40317
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
77 changed files with 3686 additions and 192 deletions

View file

@ -5,7 +5,7 @@ const tsJestOptions = {
tsconfig: {
...compilerOptions,
declaration: false,
sourceMap: false,
sourceMap: true,
skipLibCheck: true,
},
};

View file

@ -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",

View file

@ -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);

View file

@ -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<AuthIdentity>;
AuthProviderSyncHistory: Repository<AuthProviderSyncHistory>;
Credentials: Repository<ICredentialsDb>;
Execution: Repository<IExecutionFlattedDb>;
Workflow: Repository<WorkflowEntity>;
@ -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<void>;
onUserPasswordResetRequestClick(userPasswordResetData: { user: User }): Promise<void>;
onInstanceOwnerSetup(instanceOwnerSetupData: { user_id: string }, user?: User): Promise<void>;
onUserSignup(userSignupData: { user: User }): Promise<void>;
onUserSignup(
user: User,
userSignupData: {
user_type: AuthProviderType;
was_disabled_ldap_user: boolean;
},
): Promise<void>;
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;

View file

@ -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<void> {
async onUserSignup(
user: User,
userSignupData: {
user_type: AuthProviderType;
was_disabled_ldap_user: boolean;
},
): Promise<void> {
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<void> {
return this.telemetry.track('Ldap general sync finished', data);
}
async onLdapUsersDisabled(data: {
reason: 'ldap_update' | 'ldap_feature_deactivated';
users: number;
user_ids: string[];
}): Promise<void> {
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<void> {
return this.telemetry.track('Ldap general sync finished', data);
}
async onLdapLoginSyncFailed(data: { error: string }): Promise<void> {
return this.telemetry.track('Ldap login sync failed', data);
}
async userLoginFailedDueToLdapDisabled(data: { user_id: string }): Promise<void> {
return this.telemetry.track('User login failed since ldap disabled', data);
}
/*
* Execution Statistics
*/
async onFirstProductionWorkflowSuccess(data: {

View file

@ -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;
}
}

View file

@ -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<void> {
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<LdapUser[]> {
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<void> {
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<void> {
await this.bindAdmin();
}
}

View file

@ -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<void> {
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));
}
}

View file

@ -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<keyof LdapConfig> = [
'loginEnabled',
'emailAttribute',
'firstNameAttribute',
'lastNameAttribute',
'loginIdAttribute',
'ldapIdAttribute',
'synchronizationEnabled',
'synchronizationInterval',
'searchPageSize',
'searchTimeout',
'loginLabel',
];

View file

@ -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<Role> => {
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<string> => {
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<string> => {
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<LdapConfig> => {
const configuration = await Db.collections.Settings.findOneByOrFail({
key: LDAP_FEATURE_NAME,
});
const configurationData = jsonParse<LdapConfig>(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<void> => {
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<void> => {
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<LdapUser | undefined> => {
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<AuthIdentity | null> => {
return Db.collections.AuthIdentity.findOne({
relations: ['user', 'user.globalRole'],
where: {
providerId: idAttributeValue,
providerType: 'ldap',
},
});
};
export const getUserByEmail = async (email: string): Promise<User | null> => {
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<User, 'email' | 'firstName' | 'lastName'>] => {
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<string[]> => {
const identities = await Db.collections.AuthIdentity.find({
select: ['providerId'],
where: {
providerType: 'ldap',
},
});
return identities.map((i) => i.providerId);
};
export const getLdapUsers = async (): Promise<User[]> => {
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<void> => {
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<AuthProviderSyncHistory, 'id' | 'providerType'>,
): Promise<void> => {
await Db.collections.AuthProviderSyncHistory.save({
...data,
providerType: 'ldap',
});
};
/**
* Retrieve all LDAP synchronizations in the database
*/
export const getLdapSynchronizations = async (
page: number,
perPage: number,
): Promise<AuthProviderSyncHistory[]> => {
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<User>, 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<User>) => {
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' });
};

View file

@ -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 });
});

View file

@ -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 }>;
}

View file

@ -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() ?? [];
}

View file

@ -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);

View file

@ -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<void> {
binaryDataMode: binaryDataConfig.mode,
n8n_multi_user_allowed: isUserManagementEnabled(),
smtp_set_up: config.getEnv('userManagement.emails.mode') === 'smtp',
ldap_allowed: isLdapEnabled(),
};
// Set up event handling

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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<User> {
.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)

View file

@ -72,7 +72,7 @@ export class UserManagementMailer {
}
async passwordReset(passwordResetData: PasswordResetData): Promise<SendEmailResult> {
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',

View file

@ -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<PublicUser> => {
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);
}
const localUser = await handleEmailLogin(email, password);
if (localUser) {
await issueCookie(res, localUser);
return sanitizeUser(localUser);
}
if (!user?.password || !(await compareHash(req.body.password, user.password))) {
throw new ResponseHelper.AuthError('Wrong username or password. Do you have caps lock on?');
}
await issueCookie(res, user);
return sanitizeUser(user);
}),
);
@ -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);

View file

@ -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' &&

View file

@ -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({
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]);
}),
);

View file

@ -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 });
});

View file

@ -19,6 +19,8 @@ if (process.env.E2E_TESTS !== 'true') {
}
const tablesToTruncate = [
'auth_identity',
'auth_provider_sync_history',
'event_destinations',
'shared_workflow',
'shared_credentials',

View file

@ -0,0 +1,2 @@
export * from './methods/email';
export * from './methods/ldap';

View file

@ -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<User | undefined> => {
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;
};

View file

@ -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<User | undefined> => {
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;
};

View file

@ -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<void> {
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<void> {
this.logger.error('Error resetting database. See log messages for details.');
this.logger.error(error.message);
this.exit(1);
}
}

View file

@ -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}`);

View file

@ -972,6 +972,10 @@ export const schema = {
format: Boolean,
default: false,
},
ldap: {
format: Boolean,
default: false,
},
logStreaming: {
format: Boolean,
default: false,

View file

@ -81,6 +81,8 @@ type ExceptionPaths = {
'nodes.include': string[] | undefined;
'userManagement.isInstanceOwnerSetUp': boolean;
'userManagement.skipInstanceOwnerSetup': boolean;
'ldap.loginLabel': string;
'ldap.loginEnabled': boolean;
};
// -----------------------------------

View file

@ -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',
}

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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 {

View file

@ -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,
};

View file

@ -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<void> {
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<void> {
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`);
}
}

View file

@ -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,
];

View file

@ -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<void> {
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<void> {
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`);
}
}

View file

@ -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,
];

View file

@ -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

View file

@ -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<void> {
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<void> {
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`);
}
}

View file

@ -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 };

View file

@ -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(

View file

@ -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<LdapConfig> = {}): Promise<LdapConfig> => {
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');
});

View file

@ -9,13 +9,3 @@ declare module 'supertest' {
extends superagent.SuperAgent<T>,
Record<string, any> {}
}
/**
* 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<Entity extends ObjectLiteral> {
delete(criteria: {}): Promise<void>;
}
}

View file

@ -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();

View file

@ -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<CollectionName>,
collections: CollectionName[],
testDb: Connection,
) {
const mappingTables = collections.reduce<string[]>((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<CollectionName>) {
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<CollectionName>) {
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<User> = {}): Promise<User> {
const { email, password, firstName, lastName, globalRole, ...rest } = attributes;
const user = {
const user: Partial<User> = {
email: email ?? randomEmail(),
password: await hashPassword(password ?? randomValidPassword()),
firstName: firstName ?? randomName(),
@ -305,11 +301,17 @@ export async function createUser(attributes: Partial<User> = {}): Promise<User>
return Db.collections.User.save(user);
}
export async function createLdapUser(attributes: Partial<User>, ldapId: string): Promise<User> {
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<User> {
export async function createUserShell(globalRole: Role): Promise<User> {
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<InstalledNodes> {
const newInstalledNode = new InstalledNodes();
@ -376,7 +378,7 @@ export function saveInstalledNode(
return Db.collections.InstalledNodes.save(newInstalledNode);
}
export function addApiKey(user: User): Promise<User> {
export async function addApiKey(user: User): Promise<User> {
user.apiKey = randomApiKey();
return Db.collections.User.save(user);
}
@ -385,42 +387,42 @@ export function addApiKey(user: User): Promise<User> {
// 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<ExecutionEntity>,
) {
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<ExecutionEntity> = {},
workflow: WorkflowEntity,
) {
async function createExecution(attributes: Partial<ExecutionEntity>, 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<TagEntity> = {}) {
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<WorkflowEntity> = {},
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,
});
/**

View file

@ -24,6 +24,7 @@ type EndpointGroup =
| 'workflows'
| 'publicApi'
| 'nodes'
| 'ldap'
| 'eventBus'
| 'license';

View file

@ -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<void> {
};
}
/**
* Initialize LDAP manager.
*/
export async function initLdapManager(): Promise<void> {
await handleLdapInit();
}
/**
* Initialize node types.
*/

View file

@ -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,
});

View file

@ -6,6 +6,23 @@
@focus="onFocus"
ref="inputRef"
/>
<n8n-input-label
v-else-if="type === 'toggle'"
:inputName="name"
:label="label"
:tooltipText="tooltipText"
:required="required && showRequiredAsterisk"
>
<template #content>
{{ tooltipText }}
</template>
<el-switch
:value="value"
@change="onInput"
:active-color="activeColor"
:inactive-color="inactiveColor"
></el-switch>
</n8n-input-label>
<n8n-input-label
v-else
:inputName="name"
@ -20,6 +37,7 @@
:value="value"
:placeholder="placeholder"
:multiple="type === 'multi-select'"
:disabled="disabled"
@change="onInput"
@focus="onFocus"
@blur="onBlur"
@ -41,6 +59,7 @@
:value="value"
:maxlength="maxlength"
:autocomplete="autocomplete"
:disabled="disabled"
@input="onInput"
@blur="onBlur"
@focus="onFocus"
@ -73,6 +92,7 @@ import N8nSelect from '../N8nSelect';
import N8nOption from '../N8nOption';
import N8nInputLabel from '../N8nInputLabel';
import N8nCheckbox from '../N8nCheckbox';
import ElSwitch from 'element-ui/lib/switch';
import { getValidationError, VALIDATORS } from './validators';
import { Rule, RuleGroup, IValidator, Validatable, FormState } from '../../types';
@ -100,6 +120,11 @@ export interface Props {
name?: string;
focusInitially?: boolean;
labelSize?: 'small' | 'medium';
disabled?: boolean;
activeLabel?: string;
activeColor?: string;
inactiveLabel?: string;
inactiveColor?: string;
}
const props = withDefaults(defineProps<Props>(), {

View file

@ -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',
},
},
],
};

View file

@ -2,12 +2,18 @@
<ResizeObserver :breakpoints="[{ bp: 'md', width: 500 }]">
<template #default="{ bp }">
<div :class="bp === 'md' || columnView ? $style.grid : $style.gridMulti">
<div v-for="input in filteredInputs" :key="input.name">
<div
v-for="(input, index) in filteredInputs"
:key="input.name"
:class="{ [`mt-${verticalSpacing}`]: index > 0 }"
>
<n8n-text
color="text-base"
v-if="input.properties.type === 'info'"
tag="div"
align="center"
:size="input.properties.labelSize"
:align="input.properties.labelAlignment"
class="form-text"
>
{{ input.properties.label }}
</n8n-text>
@ -15,11 +21,13 @@
v-else
v-bind="input.properties"
:name="input.name"
:label="input.properties.label || ''"
:value="values[input.name]"
:data-test-id="input.name"
:showValidationWarnings="showValidationWarnings"
@input="(value) => onInput(input.name, value)"
@validate="(value) => onValidate(input.name, value)"
@change="(value) => onInput(input.name, value)"
@enter="onSubmit"
/>
</div>
@ -52,6 +60,12 @@ export default Vue.extend({
},
columnView: {
type: Boolean,
default: false,
},
verticalSpacing: {
type: String,
required: false,
validator: (value: string): boolean => ['xs', 's', 'm', 'm', 'l', 'xl'].includes(value),
},
},
data() {

View file

@ -10,14 +10,22 @@
</div>
<div v-else :class="$style.infoContainer">
<div>
<n8n-text :bold="true" color="text-dark"
>{{ firstName }} {{ lastName }}
{{ isCurrentUser ? this.t('nds.userInfo.you') : '' }}</n8n-text
>
<n8n-text :bold="true" color="text-dark">
{{ firstName }} {{ lastName }}
{{ isCurrentUser ? this.t('nds.userInfo.you') : '' }}
</n8n-text>
<span v-if="disabled" :class="$style.pendingBadge">
<n8n-badge :bold="true">Disabled</n8n-badge>
</span>
</div>
<div>
<n8n-text size="small" color="text-light">{{ email }}</n8n-text>
</div>
<div v-if="!isOwner">
<n8n-text v-if="signInType" size="small" color="text-light">
Sign-in type: {{ signInType }}
</n8n-text>
</div>
</div>
</div>
</template>
@ -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: {

View file

@ -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',

View file

@ -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',

View file

@ -13,7 +13,13 @@
</n8n-badge>
<slot v-if="!user.isOwner && !readonly" name="actions" :user="user" />
<n8n-action-toggle
v-if="!user.isOwner && !readonly && getActions(user).length > 0 && actions.length > 0"
v-if="
!user.isOwner &&
user.signInType !== 'ldap' &&
!readonly &&
getActions(user).length > 0 &&
actions.length > 0
"
placement="bottom"
:actions="getActions(user)"
theme="dark"

View file

@ -24,7 +24,16 @@ export type IFormInput = {
initialValue?: string | number | boolean | null;
properties: {
label?: string;
type?: 'text' | 'email' | 'password' | 'select' | 'multi-select' | 'info' | 'checkbox';
type?:
| 'text'
| 'email'
| 'password'
| 'select'
| 'multi-select'
| 'number'
| 'info'
| 'checkbox'
| 'toggle';
maxlength?: number;
required?: boolean;
showRequiredAsterisk?: boolean;
@ -45,6 +54,9 @@ export type IFormInput = {
| 'email'; // https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete
capitalize?: boolean;
focusInitially?: boolean;
disabled?: boolean;
labelSize?: 'small' | 'medium' | 'large';
labelAlignment?: 'left' | 'right' | 'center';
};
shouldDisplay?: (values: { [key: string]: unknown }) => boolean;
};

View file

@ -7,6 +7,8 @@ export interface IUser {
isOwner: boolean;
isPendingUser: boolean;
inviteAcceptUrl?: string;
disabled: boolean;
signInType: string;
}
export interface IUserListAction {

View file

@ -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",

View file

@ -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 = {

View file

@ -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<ILdapConfig> {
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<ILdapConfig> {
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<ILdapSyncData[]> {
return makeRestApiRequest(context, 'GET', '/ldap/sync', pagination);
}

View file

@ -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 });

View file

@ -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';

View file

@ -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 doesnt 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 <a href='https://docs.n8n.io/user-management/ldap/' target='_blank'>LDAP in the Docs</a>",
"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"
}

View file

@ -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);

View file

@ -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],
},
},
},
},
],
},
{

View file

@ -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<void> {
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);
},

View file

@ -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);
});

View file

@ -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);
};

View file

@ -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;

View file

@ -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;

View file

@ -0,0 +1,786 @@
<template>
<div v-if="!isLDAPFeatureEnabled">
<div :class="[$style.header, 'mb-2xl']">
<n8n-heading size="2xlarge">
{{ $locale.baseText('settings.ldap') }}
</n8n-heading>
</div>
<n8n-info-tip type="note" theme="info-light" tooltipPlacement="right">
<div>
LDAP allows users to authenticate with their centralized account. It's compatible with
services that provide an LDAP interface like Active Directory, Okta and Jumpcloud.
</div>
<br />
</n8n-info-tip>
<n8n-action-box
:description="$locale.baseText('settings.ldap.disabled.description')"
:buttonText="$locale.baseText('settings.ldap.disabled.buttonText')"
@click="onContactUsClick"
>
<template #heading>
<span>{{ $locale.baseText('settings.ldap.disabled.title') }}</span>
</template>
</n8n-action-box>
</div>
<div v-else>
<div :class="$style.container">
<div :class="$style.header">
<n8n-heading size="2xlarge">
{{ $locale.baseText('settings.ldap') }}
</n8n-heading>
</div>
<div :class="$style.docsInfoTip">
<n8n-info-tip theme="info" type="note">
<template>
<span v-html="$locale.baseText('settings.ldap.infoTip')"></span>
</template>
</n8n-info-tip>
</div>
<div :class="$style.settingsForm">
<n8n-form-inputs
v-if="formInputs"
ref="ldapConfigForm"
:inputs="formInputs"
:eventBus="formBus"
:columnView="true"
verticalSpacing="l"
@input="onInput"
@ready="onReadyToSubmit"
@submit="onSubmit"
/>
</div>
<div>
<n8n-button
v-if="loginEnabled"
:label="
loadingTestConnection
? $locale.baseText('settings.ldap.testingConnection')
: $locale.baseText('settings.ldap.testConnection')
"
size="large"
class="mr-s"
:disabled="hasAnyChanges || !readyToSubmit"
:loading="loadingTestConnection"
@click="onTestConnectionClick"
/>
<n8n-button
:label="$locale.baseText('settings.ldap.save')"
size="large"
:disabled="!hasAnyChanges || !readyToSubmit"
@click="onSaveClick"
/>
</div>
</div>
<div v-if="loginEnabled">
<n8n-heading tag="h1" class="mb-xl mt-3xl" size="medium">{{
$locale.baseText('settings.ldap.section.synchronization.title')
}}</n8n-heading>
<div :class="$style.syncTable">
<el-table
v-loading="loadingTable"
:border="true"
:stripe="true"
:data="dataTable"
:cell-style="cellClassStyle"
style="width: 100%"
height="250"
:key="tableKey"
>
<el-table-column
prop="status"
:label="$locale.baseText('settings.ldap.synchronizationTable.column.status')"
>
</el-table-column>
<el-table-column
prop="endedAt"
:label="$locale.baseText('settings.ldap.synchronizationTable.column.endedAt')"
>
</el-table-column>
<el-table-column
prop="runMode"
:label="$locale.baseText('settings.ldap.synchronizationTable.column.runMode')"
>
</el-table-column>
<el-table-column
prop="runTime"
:label="$locale.baseText('settings.ldap.synchronizationTable.column.runTime')"
>
</el-table-column>
<el-table-column
prop="details"
:label="$locale.baseText('settings.ldap.synchronizationTable.column.details')"
>
</el-table-column>
<template #empty>{{
$locale.baseText('settings.ldap.synchronizationTable.empty.message')
}}</template>
<template #append>
<infinite-loading
@infinite="getLdapSynchronizations"
force-use-infinite-wrapper=".el-table__body-wrapper"
>
</infinite-loading>
</template>
</el-table>
</div>
<div class="pb-3xl">
<n8n-button
:label="$locale.baseText('settings.ldap.dryRun')"
type="secondary"
size="large"
class="mr-s"
:disabled="hasAnyChanges || !readyToSubmit"
:loading="loadingDryRun"
@click="onDryRunClick"
/>
<n8n-button
:label="$locale.baseText('settings.ldap.synchronizeNow')"
size="large"
:disabled="hasAnyChanges || !readyToSubmit"
:loading="loadingLiveRun"
@click="onLiveRunClick"
/>
</div>
</div>
</div>
</template>
<script lang="ts">
import { convertToDisplayDate } from '@/utils';
import { showMessage } from '@/mixins/showMessage';
import {
ILdapConfig,
ILdapSyncData,
ILdapSyncTable,
IFormInput,
IFormInputs,
IUser,
} from '@/Interface';
import Vue from 'vue';
import mixins from 'vue-typed-mixins';
import humanizeDuration from 'humanize-duration';
import { rowCallbackParams, cellCallbackParams, ElTable } from 'element-ui/types/table';
import { capitalizeFirstLetter } from '@/utils';
import InfiniteLoading from 'vue-infinite-loading';
import { mapStores } from 'pinia';
import { useUsersStore } from '@/stores/users';
import { useSettingsStore } from '@/stores/settings';
import { getLdapSynchronizations } from '@/api/ldap';
import { N8N_CONTACT_EMAIL, N8N_SALES_EMAIL } from '@/constants';
import { ElTableColumn } from 'element-ui/types/table-column';
type FormValues = {
loginEnabled: boolean;
loginLabel: string;
serverAddress: string;
baseDn: string;
bindingType: string;
adminDn: string;
adminPassword: string;
loginId: string;
email: string;
lastName: string;
firstName: string;
ldapId: string;
synchronizationEnabled: boolean;
allowUnauthorizedCerts: boolean;
synchronizationInterval: number;
userFilter: string;
pageSize: number;
searchTimeout: number;
port: number;
connectionSecurity: string;
};
type tableRow = {
status: string;
startAt: string;
endedAt: string;
error: string;
runMode: string;
};
type rowType = rowCallbackParams & tableRow;
type cellType = cellCallbackParams & { property: keyof tableRow };
export default mixins(showMessage).extend({
name: 'SettingsLdapView',
components: {
InfiniteLoading,
},
data() {
return {
dataTable: [] as ILdapSyncTable[],
tableKey: 0,
adConfig: {} as ILdapConfig,
loadingTestConnection: false,
loadingDryRun: false,
loadingLiveRun: false,
loadingTable: false,
hasAnyChanges: false,
formInputs: null as null | IFormInputs,
formBus: new Vue(),
readyToSubmit: false,
page: 0,
loginEnabled: false,
syncEnabled: false,
};
},
async mounted() {
if (!this.isLDAPFeatureEnabled) return;
await this.getLdapConfig();
},
computed: {
...mapStores(useUsersStore, useSettingsStore),
currentUser(): null | IUser {
return this.usersStore.currentUser;
},
isLDAPFeatureEnabled(): boolean {
return this.settingsStore.settings.enterprise.ldap === true;
},
},
methods: {
onContactUsClick(event: MouseEvent): void {
const email = this.settingsStore.isCloudDeployment ? N8N_CONTACT_EMAIL : N8N_SALES_EMAIL;
location.href = `mailto:${email}`;
},
cellClassStyle({ row, column }: { row: rowType; column: cellType }) {
if (column.property === 'status') {
if (row.status === 'Success') {
return { color: 'green' };
} else if (row.status === 'Error') {
return { color: 'red' };
}
}
if (column.property === 'runMode') {
if (row.runMode === 'Dry') {
return { color: 'orange' };
} else if (row.runMode === 'Live') {
return { color: 'blue' };
}
}
return {};
},
onInput(input: { name: string; value: string | number | boolean }) {
if (input.name === 'loginEnabled' && typeof input.value === 'boolean') {
this.loginEnabled = input.value;
}
if (input.name === 'synchronizationEnabled' && typeof input.value === 'boolean') {
this.syncEnabled = input.value;
}
this.hasAnyChanges = true;
},
onReadyToSubmit(ready: boolean) {
this.readyToSubmit = ready;
},
syncDataMapper(sync: ILdapSyncData): ILdapSyncTable {
const startedAt = new Date(sync.startedAt);
const endedAt = new Date(sync.endedAt);
const runTimeInMinutes = endedAt.getTime() - startedAt.getTime();
return {
runTime: humanizeDuration(runTimeInMinutes),
runMode: capitalizeFirstLetter(sync.runMode),
status: capitalizeFirstLetter(sync.status),
endedAt: convertToDisplayDate(endedAt.getTime()),
details: this.$locale.baseText('settings.ldap.usersScanned', {
interpolate: {
scanned: sync.scanned.toString(),
},
}),
};
},
async onSubmit(): Promise<void> {
// We want to save all form values (incl. the hidden onces), so we are using
// `values` data prop of the `FormInputs` child component since they are all preserved there
const formInputs = this.$refs.ldapConfigForm as (Vue & { values: FormValues }) | undefined;
if (!this.hasAnyChanges || !formInputs) {
return;
}
const newConfiguration: ILdapConfig = {
loginEnabled: formInputs.values.loginEnabled,
loginLabel: formInputs.values.loginLabel,
connectionUrl: formInputs.values.serverAddress,
allowUnauthorizedCerts: formInputs.values.allowUnauthorizedCerts,
connectionPort: +formInputs.values.port,
connectionSecurity: formInputs.values.connectionSecurity,
baseDn: formInputs.values.baseDn,
bindingAdminDn: formInputs.values.bindingType === 'admin' ? formInputs.values.adminDn : '',
bindingAdminPassword:
formInputs.values.bindingType === 'admin' ? formInputs.values.adminPassword : '',
emailAttribute: formInputs.values.email,
firstNameAttribute: formInputs.values.firstName,
lastNameAttribute: formInputs.values.lastName,
loginIdAttribute: formInputs.values.loginId,
ldapIdAttribute: formInputs.values.ldapId,
userFilter: formInputs.values.userFilter,
synchronizationEnabled: formInputs.values.synchronizationEnabled,
synchronizationInterval: +formInputs.values.synchronizationInterval,
searchPageSize: +formInputs.values.pageSize,
searchTimeout: +formInputs.values.searchTimeout,
};
let saveForm = true;
try {
if (this.adConfig.loginEnabled === true && newConfiguration.loginEnabled === false) {
saveForm = await this.confirmMessage(
this.$locale.baseText('settings.ldap.confirmMessage.beforeSaveForm.message'),
this.$locale.baseText('settings.ldap.confirmMessage.beforeSaveForm.headline'),
null,
this.$locale.baseText('settings.ldap.confirmMessage.beforeSaveForm.cancelButtonText'),
this.$locale.baseText('settings.ldap.confirmMessage.beforeSaveForm.confirmButtonText'),
);
}
if (!saveForm) {
this.hasAnyChanges = true;
}
this.adConfig = await this.settingsStore.updateLdapConfig(newConfiguration);
this.$showToast({
title: this.$locale.baseText('settings.ldap.updateConfiguration'),
message: '',
type: 'success',
});
} catch (error) {
this.$showError(error, this.$locale.baseText('settings.ldap.configurationError'));
} finally {
if (saveForm) {
this.hasAnyChanges = false;
}
}
},
onSaveClick() {
this.formBus.$emit('submit');
},
async onTestConnectionClick() {
this.loadingTestConnection = true;
try {
await this.settingsStore.testLdapConnection();
this.$showToast({
title: this.$locale.baseText('settings.ldap.connectionTest'),
message: this.$locale.baseText('settings.ldap.toast.connection.success'),
type: 'success',
});
} catch (error) {
this.$showToast({
title: this.$locale.baseText('settings.ldap.connectionTestError'),
message: error.message,
type: 'error',
});
} finally {
this.loadingTestConnection = false;
}
},
async onDryRunClick() {
this.loadingDryRun = true;
try {
await this.settingsStore.runLdapSync({ type: 'dry' });
this.$showToast({
title: this.$locale.baseText('settings.ldap.runSync.title'),
message: this.$locale.baseText('settings.ldap.toast.sync.success'),
type: 'success',
});
} catch (error) {
this.$showError(error, this.$locale.baseText('settings.ldap.synchronizationError'));
} finally {
this.loadingDryRun = false;
await this.reloadLdapSynchronizations();
}
},
async onLiveRunClick() {
this.loadingLiveRun = true;
try {
await this.settingsStore.runLdapSync({ type: 'live' });
this.$showToast({
title: this.$locale.baseText('settings.ldap.runSync.title'),
message: this.$locale.baseText('settings.ldap.toast.sync.success'),
type: 'success',
});
} catch (error) {
this.$showError(error, this.$locale.baseText('settings.ldap.synchronizationError'));
} finally {
this.loadingLiveRun = false;
await this.reloadLdapSynchronizations();
}
},
async getLdapConfig() {
try {
this.adConfig = await this.settingsStore.getLdapConfig();
this.loginEnabled = this.adConfig.loginEnabled;
this.syncEnabled = this.adConfig.synchronizationEnabled;
const whenLoginEnabled: IFormInput['shouldDisplay'] = (values) =>
values['loginEnabled'] === true;
const whenSyncAndLoginEnabled: IFormInput['shouldDisplay'] = (values) =>
values['synchronizationEnabled'] === true && values['loginEnabled'] === true;
const whenAdminBindingAndLoginEnabled: IFormInput['shouldDisplay'] = (values) =>
values['bindingType'] === 'admin' && values['loginEnabled'] === true;
this.formInputs = [
{
name: 'loginEnabled',
initialValue: this.adConfig.loginEnabled,
properties: {
type: 'toggle',
label: this.$locale.baseText('settings.ldap.form.loginEnabled.label'),
tooltipText: this.$locale.baseText('settings.ldap.form.loginEnabled.tooltip'),
required: true,
},
},
{
name: 'loginLabel',
initialValue: this.adConfig.loginLabel,
properties: {
label: this.$locale.baseText('settings.ldap.form.loginLabel.label'),
required: true,
placeholder: this.$locale.baseText('settings.ldap.form.loginLabel.placeholder'),
infoText: this.$locale.baseText('settings.ldap.form.loginLabel.infoText'),
},
shouldDisplay: whenLoginEnabled,
},
{
name: 'serverAddress',
initialValue: this.adConfig.connectionUrl,
properties: {
label: this.$locale.baseText('settings.ldap.form.serverAddress.label'),
required: true,
capitalize: true,
placeholder: this.$locale.baseText('settings.ldap.form.serverAddress.placeholder'),
infoText: this.$locale.baseText('settings.ldap.form.serverAddress.infoText'),
},
shouldDisplay: whenLoginEnabled,
},
{
name: 'port',
initialValue: this.adConfig.connectionPort,
properties: {
type: 'number',
label: this.$locale.baseText('settings.ldap.form.port.label'),
capitalize: true,
infoText: this.$locale.baseText('settings.ldap.form.port.infoText'),
},
shouldDisplay: whenLoginEnabled,
},
{
name: 'connectionSecurity',
initialValue: this.adConfig.connectionSecurity,
properties: {
type: 'select',
label: this.$locale.baseText('settings.ldap.form.connectionSecurity.label'),
infoText: this.$locale.baseText('settings.ldap.form.connectionSecurity.infoText'),
options: [
{
label: 'None',
value: 'none',
},
{
label: 'TLS',
value: 'tls',
},
{
label: 'STARTTLS',
value: 'startTls',
},
],
required: true,
capitalize: true,
},
shouldDisplay: whenLoginEnabled,
},
{
name: 'allowUnauthorizedCerts',
initialValue: this.adConfig.allowUnauthorizedCerts,
properties: {
type: 'toggle',
label: this.$locale.baseText('settings.ldap.form.allowUnauthorizedCerts.label'),
required: false,
},
shouldDisplay(values): boolean {
return values['connectionSecurity'] !== 'none' && values['loginEnabled'] === true;
},
},
{
name: 'baseDn',
initialValue: this.adConfig.baseDn,
properties: {
label: this.$locale.baseText('settings.ldap.form.baseDn.label'),
required: true,
capitalize: true,
placeholder: this.$locale.baseText('settings.ldap.form.baseDn.placeholder'),
infoText: this.$locale.baseText('settings.ldap.form.baseDn.infoText'),
},
shouldDisplay: whenLoginEnabled,
},
{
name: 'bindingType',
initialValue: 'admin',
properties: {
type: 'select',
label: this.$locale.baseText('settings.ldap.form.bindingType.label'),
infoText: this.$locale.baseText('settings.ldap.form.bindingType.infoText'),
options: [
{
value: 'admin',
label: 'Admin',
},
{
value: 'anonymous',
label: 'Anonymous',
},
],
},
shouldDisplay: whenLoginEnabled,
},
{
name: 'adminDn',
initialValue: this.adConfig.bindingAdminDn,
properties: {
label: this.$locale.baseText('settings.ldap.form.adminDn.label'),
placeholder: this.$locale.baseText('settings.ldap.form.adminDn.placeholder'),
infoText: this.$locale.baseText('settings.ldap.form.adminDn.infoText'),
capitalize: true,
},
shouldDisplay: whenAdminBindingAndLoginEnabled,
},
{
name: 'adminPassword',
initialValue: this.adConfig.bindingAdminPassword,
properties: {
label: this.$locale.baseText('settings.ldap.form.adminPassword.label'),
type: 'password',
capitalize: true,
infoText: this.$locale.baseText('settings.ldap.form.adminPassword.infoText'),
},
shouldDisplay: whenAdminBindingAndLoginEnabled,
},
{
name: 'userFilter',
initialValue: this.adConfig.userFilter,
properties: {
label: this.$locale.baseText('settings.ldap.form.userFilter.label'),
type: 'text',
required: false,
capitalize: true,
placeholder: this.$locale.baseText('settings.ldap.form.userFilter.placeholder'),
infoText: this.$locale.baseText('settings.ldap.form.userFilter.infoText'),
},
shouldDisplay: whenLoginEnabled,
},
{
name: 'attributeMappingInfo',
properties: {
label: this.$locale.baseText('settings.ldap.form.attributeMappingInfo.label'),
type: 'info',
labelSize: 'large',
labelAlignment: 'left',
},
shouldDisplay: whenLoginEnabled,
},
{
name: 'ldapId',
initialValue: this.adConfig.ldapIdAttribute,
properties: {
label: this.$locale.baseText('settings.ldap.form.ldapId.label'),
type: 'text',
required: true,
capitalize: true,
placeholder: this.$locale.baseText('settings.ldap.form.ldapId.placeholder'),
infoText: this.$locale.baseText('settings.ldap.form.ldapId.infoText'),
},
shouldDisplay: whenLoginEnabled,
},
{
name: 'loginId',
initialValue: this.adConfig.loginIdAttribute,
properties: {
label: this.$locale.baseText('settings.ldap.form.loginId.label'),
type: 'text',
autocomplete: 'email',
required: true,
capitalize: true,
placeholder: this.$locale.baseText('settings.ldap.form.loginId.placeholder'),
infoText: this.$locale.baseText('settings.ldap.form.loginId.infoText'),
},
shouldDisplay: whenLoginEnabled,
},
{
name: 'email',
initialValue: this.adConfig.emailAttribute,
properties: {
label: this.$locale.baseText('settings.ldap.form.email.label'),
type: 'text',
autocomplete: 'email',
required: true,
capitalize: true,
placeholder: this.$locale.baseText('settings.ldap.form.email.placeholder'),
infoText: this.$locale.baseText('settings.ldap.form.email.infoText'),
},
shouldDisplay: whenLoginEnabled,
},
{
name: 'firstName',
initialValue: this.adConfig.firstNameAttribute,
properties: {
label: this.$locale.baseText('settings.ldap.form.firstName.label'),
type: 'text',
autocomplete: 'email',
required: true,
capitalize: true,
placeholder: this.$locale.baseText('settings.ldap.form.firstName.placeholder'),
infoText: this.$locale.baseText('settings.ldap.form.firstName.infoText'),
},
shouldDisplay: whenLoginEnabled,
},
{
name: 'lastName',
initialValue: this.adConfig.lastNameAttribute,
properties: {
label: this.$locale.baseText('settings.ldap.form.lastName.label'),
type: 'text',
autocomplete: 'email',
required: true,
capitalize: true,
placeholder: this.$locale.baseText('settings.ldap.form.lastName.placeholder'),
infoText: this.$locale.baseText('settings.ldap.form.lastName.infoText'),
},
shouldDisplay: whenLoginEnabled,
},
{
name: 'synchronizationEnabled',
initialValue: this.adConfig.synchronizationEnabled,
properties: {
type: 'toggle',
label: this.$locale.baseText('settings.ldap.form.synchronizationEnabled.label'),
tooltipText: this.$locale.baseText(
'settings.ldap.form.synchronizationEnabled.tooltip',
),
required: true,
},
shouldDisplay: whenLoginEnabled,
},
{
name: 'synchronizationInterval',
initialValue: this.adConfig.synchronizationInterval,
properties: {
type: 'number',
label: this.$locale.baseText('settings.ldap.form.synchronizationInterval.label'),
infoText: this.$locale.baseText(
'settings.ldap.form.synchronizationInterval.infoText',
),
},
shouldDisplay: whenSyncAndLoginEnabled,
},
{
name: 'pageSize',
initialValue: this.adConfig.searchPageSize,
properties: {
type: 'number',
label: this.$locale.baseText('settings.ldap.form.pageSize.label'),
infoText: this.$locale.baseText('settings.ldap.form.pageSize.infoText'),
},
shouldDisplay: whenSyncAndLoginEnabled,
},
{
name: 'searchTimeout',
initialValue: this.adConfig.searchTimeout,
properties: {
type: 'number',
label: this.$locale.baseText('settings.ldap.form.searchTimeout.label'),
infoText: this.$locale.baseText('settings.ldap.form.searchTimeout.infoText'),
},
shouldDisplay: whenSyncAndLoginEnabled,
},
];
} catch (error) {
this.$showError(error, this.$locale.baseText('settings.ldap.configurationError'));
}
},
async getLdapSynchronizations(state: any) {
try {
this.loadingTable = true;
const data = await this.settingsStore.getLdapSynchronizations({
page: this.page,
});
if (data.length !== 0) {
this.dataTable.push(...data.map(this.syncDataMapper));
this.page += 1;
state.loaded();
} else {
state.complete();
}
this.loadingTable = false;
} catch (error) {
this.$showError(error, this.$locale.baseText('settings.ldap.synchronizationError'));
}
},
async reloadLdapSynchronizations() {
try {
this.page = 0;
this.tableKey += 1;
this.dataTable = [];
} catch (error) {
this.$showError(error, this.$locale.baseText('settings.ldap.synchronizationError'));
}
},
},
});
</script>
<style lang="scss" module>
.container {
> * {
margin-bottom: var(--spacing-2xl);
}
}
.syncTable {
margin-bottom: var(--spacing-2xl);
}
.header {
display: flex;
align-items: center;
white-space: nowrap;
*:first-child {
flex-grow: 1;
}
}
.enableFeatureContainer {
margin-bottom: var(--spacing-1xl);
}
.enableFeatureContainer > span {
font-size: var(--font-size-s);
font-weight: var(--font-weight-bold);
padding: 0;
}
.enableFeatureContainer {
> * {
padding: 0.5em;
}
}
.sectionHeader {
margin-bottom: var(--spacing-s);
}
.settingsForm {
:global(.form-text) {
margin-top: var(--spacing-xl);
}
}
.docsInfoTip {
&,
& > div {
margin-bottom: var(--spacing-xl);
}
}
</style>

View file

@ -32,7 +32,7 @@
/>
</div>
</div>
<div>
<div v-if="!signInWithLdap">
<div :class="$style.sectionHeader">
<n8n-heading size="large">{{ $locale.baseText('settings.personal.security') }}</n8n-heading>
</div>
@ -58,10 +58,11 @@
<script lang="ts">
import { showMessage } from '@/mixins/showMessage';
import { CHANGE_PASSWORD_MODAL_KEY } from '@/constants';
import { CHANGE_PASSWORD_MODAL_KEY, SignInType } from '@/constants';
import { IFormInputs, IUser } from '@/Interface';
import { useUIStore } from '@/stores/ui';
import { useUsersStore } from '@/stores/users';
import { useSettingsStore } from '@/stores/settings';
import { mapStores } from 'pinia';
import Vue from 'vue';
import mixins from 'vue-typed-mixins';
@ -87,6 +88,7 @@ export default mixins(showMessage).extend({
required: true,
autocomplete: 'given-name',
capitalize: true,
disabled: this.isLDAPFeatureEnabled && this.signInWithLdap,
},
},
{
@ -98,6 +100,7 @@ export default mixins(showMessage).extend({
required: true,
autocomplete: 'family-name',
capitalize: true,
disabled: this.isLDAPFeatureEnabled && this.signInWithLdap,
},
},
{
@ -110,15 +113,22 @@ export default mixins(showMessage).extend({
validationRules: [{ name: 'VALID_EMAIL' }],
autocomplete: 'email',
capitalize: true,
disabled: this.isLDAPFeatureEnabled && this.signInWithLdap,
},
},
];
},
computed: {
...mapStores(useUIStore, useUsersStore),
...mapStores(useUIStore, useUsersStore, useSettingsStore),
currentUser(): IUser | null {
return this.usersStore.currentUser;
},
signInWithLdap(): boolean {
return this.currentUser?.signInType === 'ldap';
},
isLDAPFeatureEnabled(): boolean {
return this.settingsStore.settings.enterprise.ldap === true;
},
},
methods: {
onInput() {

View file

@ -16,6 +16,7 @@ import { IFormBoxConfig } from '@/Interface';
import { VIEWS } from '@/constants';
import { mapStores } from 'pinia';
import { useUsersStore } from '@/stores/users';
import { useSettingsStore } from '@/stores/settings';
export default mixins(showMessage).extend({
name: 'SigninView',
@ -23,7 +24,22 @@ export default mixins(showMessage).extend({
AuthView,
},
data() {
const FORM_CONFIG: IFormBoxConfig = {
return {
FORM_CONFIG: {} as IFormBoxConfig,
loading: false,
};
},
computed: {
...mapStores(useUsersStore, useSettingsStore),
},
mounted() {
let emailLabel = this.$locale.baseText('auth.email');
const ldapLoginLabel = this.settingsStore.ldapLoginLabel;
const isLdapLoginEnabled = this.settingsStore.isLdapLoginEnabled;
if (isLdapLoginEnabled && ldapLoginLabel) {
emailLabel = ldapLoginLabel;
}
this.FORM_CONFIG = {
title: this.$locale.baseText('auth.signin'),
buttonText: this.$locale.baseText('auth.signin'),
redirectText: this.$locale.baseText('forgotPassword'),
@ -32,10 +48,10 @@ export default mixins(showMessage).extend({
{
name: 'email',
properties: {
label: this.$locale.baseText('auth.email'),
label: emailLabel,
type: 'email',
required: true,
validationRules: [{ name: 'VALID_EMAIL' }],
...(!isLdapLoginEnabled && { validationRules: [{ name: 'VALID_EMAIL' }] }),
showRequiredAsterisk: false,
validateOnBlur: false,
autocomplete: 'email',
@ -56,14 +72,6 @@ export default mixins(showMessage).extend({
},
],
};
return {
FORM_CONFIG,
loading: false,
};
},
computed: {
...mapStores(useUsersStore),
},
methods: {
async onSubmit(values: { [key: string]: string }) {

View file

@ -171,6 +171,7 @@ importers:
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
@ -264,6 +265,7 @@ importers:
jsonschema: 1.4.1
jsonwebtoken: 9.0.0
jwks-rsa: 1.12.3
ldapts: 4.2.2
localtunnel: 2.0.2
lodash.get: 4.4.2
lodash.intersection: 4.4.0
@ -528,6 +530,7 @@ importers:
'@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
@ -545,6 +548,7 @@ importers:
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
jshint: ^2.9.7
jsonpath: ^1.1.1
@ -576,6 +580,7 @@ importers:
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
@ -606,6 +611,7 @@ importers:
fast-json-stable-stringify: 2.1.0
file-saver: 2.0.5
flatted: 3.2.7
humanize-duration: 3.27.3
jquery: 3.6.1
jsonpath: 1.1.1
jsplumb: 2.15.4
@ -629,6 +635,7 @@ importers:
vue-agile: 2.0.0
vue-fragment: 1.5.1_vue@2.7.13
vue-i18n: 8.27.2_vue@2.7.13
vue-infinite-loading: 2.4.5_vue@2.7.13
vue-json-pretty: 1.9.3
vue-prism-editor: 0.3.0
vue-router: 3.6.5_vue@2.7.13
@ -645,6 +652,7 @@ importers:
'@types/dateformat': 3.0.1
'@types/express': 4.17.14
'@types/file-saver': 2.0.5
'@types/humanize-duration': 3.27.1
'@types/jsonpath': 0.2.0
'@types/lodash-es': 4.17.6
'@types/lodash.camelcase': 4.3.7
@ -5525,6 +5533,12 @@ packages:
resolution: {integrity: sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==}
dev: true
/@types/asn1/0.2.0:
resolution: {integrity: sha512-5TMxIpYbIA9c1J0hYQjQDX3wr+rTgQEAXaW2BI8ECM8FO53wSW4HFZplTalrKSHuZUc76NtXcePRhwuOHqGD5g==}
dependencies:
'@types/node': 16.11.65
dev: false
/@types/aws4/1.11.2:
resolution: {integrity: sha512-x0f96eBPrCCJzJxdPbUvDFRva4yPpINJzTuXXpmS2j9qLUpF2nyGzvXPlRziuGbCsPukwY4JfuO+8xwsoZLzGw==}
dependencies:
@ -5774,6 +5788,10 @@ packages:
resolution: {integrity: sha512-h4lTMgMJctJybDp8CQrxTUiiYmedihHWkjnF/8Pxseu2S6Nlfcy8kwboQ8yejh456rP2yWoEVm1sS/FVsfM48w==}
dev: true
/@types/humanize-duration/3.27.1:
resolution: {integrity: sha512-K3e+NZlpCKd6Bd/EIdqjFJRFHbrq5TzPPLwREk5Iv/YoIjQrs6ljdAUCo+Lb2xFlGNOjGSE0dqsVD19cZL137w==}
dev: true
/@types/imap-simple/4.2.5:
resolution: {integrity: sha512-Sfu70sdFXzVIhivsflpanlED8gZr4VRzz2AVU9i1ARU8gskr9nDd4tVGkqYtxfwajQfZDklkXbeHSOZYEeJmTQ==}
dependencies:
@ -6332,6 +6350,10 @@ packages:
resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==}
dev: true
/@types/uuid/9.0.0:
resolution: {integrity: sha512-kr90f+ERiQtKWMz5rP32ltJ/BtULDI5RVO0uavn1HQUOwjx0R1h0rnDYNL0CepF1zL5bSY6FISAfd9tOdDhU5Q==}
dev: false
/@types/validator/13.7.10:
resolution: {integrity: sha512-t1yxFAR2n0+VO6hd/FJ9F2uezAZVWHLmpmlJzm1eX03+H7+HsuTAp7L8QJs+2pQCfWkP1+EXsGK9Z9v7o/qPVQ==}
dev: false
@ -13103,6 +13125,10 @@ packages:
engines: {node: '>=10.17.0'}
dev: true
/humanize-duration/3.27.3:
resolution: {integrity: sha512-iimHkHPfIAQ8zCDQLgn08pRqSVioyWvnGfaQ8gond2wf7Jq2jJ+24ykmnRyiz3fIldcn4oUuQXpjqKLhSVR7lw==}
dev: false
/humanize-ms/1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
dependencies:
@ -14962,6 +14988,21 @@ packages:
invert-kv: 1.0.0
dev: true
/ldapts/4.2.2:
resolution: {integrity: sha512-UHe7BtEhPUFHZZ6XHnRvLHWQrftTap3PgGU0nOLtrFeigZvfpXSsqJ8C9uXNouDV+iDHqoWwplS0eHoDu/GIEQ==}
engines: {node: '>=14'}
dependencies:
'@types/asn1': 0.2.0
'@types/node': 16.11.65
'@types/uuid': 9.0.0
asn1: 0.2.6
debug: 4.3.4
strict-event-emitter-types: 2.0.0
uuid: 9.0.0
transitivePeerDependencies:
- supports-color
dev: false
/lead/1.0.0:
resolution: {integrity: sha512-IpSVCk9AYvLHo5ctcIXxOBpMWUe+4TKN3VPWAKUbJikkmsGp0VrSM8IttVc32D6J4WUsiPE6aEFRNmIoF/gdow==}
engines: {node: '>= 0.10'}
@ -19825,6 +19866,10 @@ packages:
engines: {node: '>=10.0.0'}
dev: false
/strict-event-emitter-types/2.0.0:
resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==}
dev: false
/strict-uri-encode/2.0.0:
resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==}
engines: {node: '>=4'}
@ -21435,6 +21480,11 @@ packages:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
/uuid/9.0.0:
resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==}
hasBin: true
dev: false
/v-click-outside/3.2.0:
resolution: {integrity: sha512-QD0bDy38SHJXQBjgnllmkI/rbdiwmq9RC+/+pvrFjYJKTn8dtp7Penf9q1lLBta280fYG2q53mgLhQ+3l3z74w==}
engines: {node: '>=6'}
@ -21844,6 +21894,14 @@ packages:
vue: 2.7.13
dev: true
/vue-infinite-loading/2.4.5_vue@2.7.13:
resolution: {integrity: sha512-xhq95Mxun060bRnsOoLE2Be6BR7jYwuC89kDe18+GmCLVrRA/dU0jrGb12Xu6NjmKs+iTW0AA6saSEmEW4cR7g==}
peerDependencies:
vue: ^2.6.10
dependencies:
vue: 2.7.13
dev: false
/vue-json-pretty/1.9.3:
resolution: {integrity: sha512-b13DP1WGQ+ACUU2K5hmwFfHrHnydCFSTerE7fppeYMojSWN/5EOPODQECfIIRaJ7zzHtPW9OifkThFGPyY0xRg==}
engines: {node: '>= 10.0.0', npm: '>= 5.0.0'}