mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
feat(core): Add LDAP support (#3835)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
38
packages/cli/src/Ldap/LdapManager.ee.ts
Normal file
38
packages/cli/src/Ldap/LdapManager.ee.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
104
packages/cli/src/Ldap/LdapService.ee.ts
Normal file
104
packages/cli/src/Ldap/LdapService.ee.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
216
packages/cli/src/Ldap/LdapSync.ee.ts
Normal file
216
packages/cli/src/Ldap/LdapSync.ee.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
133
packages/cli/src/Ldap/constants.ts
Normal file
133
packages/cli/src/Ldap/constants.ts
Normal 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',
|
||||
];
|
||||
474
packages/cli/src/Ldap/helpers.ts
Normal file
474
packages/cli/src/Ldap/helpers.ts
Normal 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' });
|
||||
};
|
||||
76
packages/cli/src/Ldap/routes/ldap.controller.ee.ts
Normal file
76
packages/cli/src/Ldap/routes/ldap.controller.ee.ts
Normal 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 });
|
||||
});
|
||||
32
packages/cli/src/Ldap/types.ts
Normal file
32
packages/cli/src/Ldap/types.ts
Normal 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 }>;
|
||||
}
|
||||
@@ -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() ?? [];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
if (!user?.password || !(await compareHash(req.body.password, user.password))) {
|
||||
throw new ResponseHelper.AuthError('Wrong username or password. Do you have caps lock on?');
|
||||
const localUser = await handleEmailLogin(email, password);
|
||||
|
||||
if (localUser) {
|
||||
await issueCookie(res, localUser);
|
||||
|
||||
return sanitizeUser(localUser);
|
||||
}
|
||||
|
||||
await issueCookie(res, user);
|
||||
|
||||
return sanitizeUser(user);
|
||||
throw new ResponseHelper.AuthError('Wrong username or password. Do you have caps lock on?');
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -64,6 +66,11 @@ export function authenticationMethods(this: N8nApp): void {
|
||||
// If logged in, return user
|
||||
try {
|
||||
user = await resolveJwt(cookieContents);
|
||||
|
||||
if (!config.get('userManagement.isInstanceOwnerSetUp')) {
|
||||
res.cookie(AUTH_COOKIE_NAME, cookieContents);
|
||||
}
|
||||
|
||||
return sanitizeUser(user);
|
||||
} catch (error) {
|
||||
res.clearCookie(AUTH_COOKIE_NAME);
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
@@ -13,9 +13,10 @@ import { InternalHooksManager } from '@/InternalHooksManager';
|
||||
import { N8nApp } from '../Interfaces';
|
||||
import { getInstanceBaseUrl, hashPassword, validatePassword } from '../UserManagementHelper';
|
||||
import * as UserManagementMailer from '../email';
|
||||
import type { PasswordResetRequest } from '../../requests';
|
||||
import type { PasswordResetRequest } from '@/requests';
|
||||
import { issueCookie } from '../auth/jwt';
|
||||
import config from '@/config';
|
||||
import { isLdapEnabled } from '@/Ldap/helpers';
|
||||
|
||||
export function passwordResetNamespace(this: N8nApp): void {
|
||||
/**
|
||||
@@ -52,9 +53,16 @@ export function passwordResetNamespace(this: N8nApp): void {
|
||||
}
|
||||
|
||||
// User should just be able to reset password if one is already present
|
||||
const user = await Db.collections.User.findOneBy({ email, password: Not(IsNull()) });
|
||||
const user = await Db.collections.User.findOne({
|
||||
where: {
|
||||
email,
|
||||
password: Not(IsNull()),
|
||||
},
|
||||
relations: ['authIdentities'],
|
||||
});
|
||||
const ldapIdentity = user?.authIdentities?.find((i) => i.providerType === 'ldap');
|
||||
|
||||
if (!user?.password) {
|
||||
if (!user?.password || (ldapIdentity && user.disabled)) {
|
||||
Logger.debug(
|
||||
'Request to send password reset email failed because no user was found for the provided email',
|
||||
{ invalidEmail: email },
|
||||
@@ -62,6 +70,12 @@ export function passwordResetNamespace(this: N8nApp): void {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLdapEnabled() && ldapIdentity) {
|
||||
throw new ResponseHelper.UnprocessableRequestError(
|
||||
'forgotPassword.ldapUserPasswordResetUnavailable',
|
||||
);
|
||||
}
|
||||
|
||||
user.resetPasswordToken = uuid();
|
||||
|
||||
const { id, firstName, lastName, resetPasswordToken } = user;
|
||||
@@ -184,10 +198,13 @@ export function passwordResetNamespace(this: N8nApp): void {
|
||||
// Timestamp is saved in seconds
|
||||
const currentTimestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
const user = await Db.collections.User.findOneBy({
|
||||
id: userId,
|
||||
resetPasswordToken,
|
||||
resetPasswordTokenExpiration: MoreThanOrEqual(currentTimestamp),
|
||||
const user = await Db.collections.User.findOne({
|
||||
where: {
|
||||
id: userId,
|
||||
resetPasswordToken,
|
||||
resetPasswordTokenExpiration: MoreThanOrEqual(currentTimestamp),
|
||||
},
|
||||
relations: ['authIdentities'],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
@@ -216,6 +233,15 @@ export function passwordResetNamespace(this: N8nApp): void {
|
||||
fields_changed: ['password'],
|
||||
});
|
||||
|
||||
// if this user used to be an LDAP users
|
||||
const ldapIdentity = user?.authIdentities?.find((i) => i.providerType === 'ldap');
|
||||
if (ldapIdentity) {
|
||||
void InternalHooksManager.getInstance().onUserSignup(user, {
|
||||
user_type: 'email',
|
||||
was_disabled_ldap_user: true,
|
||||
});
|
||||
}
|
||||
|
||||
await this.externalHooks.run('user.password.update', [user.email, password]);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ if (process.env.E2E_TESTS !== 'true') {
|
||||
}
|
||||
|
||||
const tablesToTruncate = [
|
||||
'auth_identity',
|
||||
'auth_provider_sync_history',
|
||||
'event_destinations',
|
||||
'shared_workflow',
|
||||
'shared_credentials',
|
||||
|
||||
2
packages/cli/src/auth/index.ts
Normal file
2
packages/cli/src/auth/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './methods/email';
|
||||
export * from './methods/ldap';
|
||||
32
packages/cli/src/auth/methods/email.ts
Normal file
32
packages/cli/src/auth/methods/email.ts
Normal 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;
|
||||
};
|
||||
69
packages/cli/src/auth/methods/ldap.ts
Normal file
69
packages/cli/src/auth/methods/ldap.ts
Normal 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;
|
||||
};
|
||||
27
packages/cli/src/commands/ldap/reset.ts
Normal file
27
packages/cli/src/commands/ldap/reset.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -972,6 +972,10 @@ export const schema = {
|
||||
format: Boolean,
|
||||
default: false,
|
||||
},
|
||||
ldap: {
|
||||
format: Boolean,
|
||||
default: false,
|
||||
},
|
||||
logStreaming: {
|
||||
format: Boolean,
|
||||
default: false,
|
||||
|
||||
2
packages/cli/src/config/types.d.ts
vendored
2
packages/cli/src/config/types.d.ts
vendored
@@ -81,6 +81,8 @@ type ExceptionPaths = {
|
||||
'nodes.include': string[] | undefined;
|
||||
'userManagement.isInstanceOwnerSetUp': boolean;
|
||||
'userManagement.skipInstanceOwnerSetup': boolean;
|
||||
'ldap.loginLabel': string;
|
||||
'ldap.loginEnabled': boolean;
|
||||
};
|
||||
|
||||
// -----------------------------------
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
34
packages/cli/src/databases/entities/AuthIdentity.ts
Normal file
34
packages/cli/src/databases/entities/AuthIdentity.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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(
|
||||
|
||||
630
packages/cli/test/integration/ldap/ldap.api.test.ts
Normal file
630
packages/cli/test/integration/ldap/ldap.api.test.ts
Normal 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');
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,7 @@ type EndpointGroup =
|
||||
| 'workflows'
|
||||
| 'publicApi'
|
||||
| 'nodes'
|
||||
| 'ldap'
|
||||
| 'eventBus'
|
||||
| 'license';
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user