mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -08:00
feat(core): Add LDAP support (#3835)
This commit is contained in:
parent
259296c5c9
commit
0c70a40317
|
@ -5,7 +5,7 @@ const tsJestOptions = {
|
|||
tsconfig: {
|
||||
...compilerOptions,
|
||||
declaration: false,
|
||||
sourceMap: false,
|
||||
sourceMap: true,
|
||||
skipLibCheck: true,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
const localUser = await handleEmailLogin(email, password);
|
||||
|
||||
if (localUser) {
|
||||
await issueCookie(res, localUser);
|
||||
|
||||
return sanitizeUser(localUser);
|
||||
}
|
||||
|
||||
if (!user?.password || !(await compareHash(req.body.password, user.password))) {
|
||||
throw new ResponseHelper.AuthError('Wrong username or password. Do you have caps lock on?');
|
||||
}
|
||||
|
||||
await issueCookie(res, user);
|
||||
|
||||
return sanitizeUser(user);
|
||||
}),
|
||||
);
|
||||
|
||||
|
@ -64,6 +66,11 @@ export function authenticationMethods(this: N8nApp): void {
|
|||
// If logged in, return user
|
||||
try {
|
||||
user = await resolveJwt(cookieContents);
|
||||
|
||||
if (!config.get('userManagement.isInstanceOwnerSetUp')) {
|
||||
res.cookie(AUTH_COOKIE_NAME, cookieContents);
|
||||
}
|
||||
|
||||
return sanitizeUser(user);
|
||||
} catch (error) {
|
||||
res.clearCookie(AUTH_COOKIE_NAME);
|
||||
|
|
|
@ -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({
|
||||
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,
|
||||
});
|
||||
|
|
|
@ -6,6 +6,23 @@
|
|||
@focus="onFocus"
|
||||
ref="inputRef"
|
||||
/>
|
||||
<n8n-input-label
|
||||
v-else-if="type === 'toggle'"
|
||||
:inputName="name"
|
||||
:label="label"
|
||||
:tooltipText="tooltipText"
|
||||
:required="required && showRequiredAsterisk"
|
||||
>
|
||||
<template #content>
|
||||
{{ tooltipText }}
|
||||
</template>
|
||||
<el-switch
|
||||
:value="value"
|
||||
@change="onInput"
|
||||
:active-color="activeColor"
|
||||
:inactive-color="inactiveColor"
|
||||
></el-switch>
|
||||
</n8n-input-label>
|
||||
<n8n-input-label
|
||||
v-else
|
||||
:inputName="name"
|
||||
|
@ -20,6 +37,7 @@
|
|||
:value="value"
|
||||
:placeholder="placeholder"
|
||||
:multiple="type === 'multi-select'"
|
||||
:disabled="disabled"
|
||||
@change="onInput"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
|
@ -41,6 +59,7 @@
|
|||
:value="value"
|
||||
:maxlength="maxlength"
|
||||
:autocomplete="autocomplete"
|
||||
:disabled="disabled"
|
||||
@input="onInput"
|
||||
@blur="onBlur"
|
||||
@focus="onFocus"
|
||||
|
@ -73,6 +92,7 @@ import N8nSelect from '../N8nSelect';
|
|||
import N8nOption from '../N8nOption';
|
||||
import N8nInputLabel from '../N8nInputLabel';
|
||||
import N8nCheckbox from '../N8nCheckbox';
|
||||
import ElSwitch from 'element-ui/lib/switch';
|
||||
|
||||
import { getValidationError, VALIDATORS } from './validators';
|
||||
import { Rule, RuleGroup, IValidator, Validatable, FormState } from '../../types';
|
||||
|
@ -100,6 +120,11 @@ export interface Props {
|
|||
name?: string;
|
||||
focusInitially?: boolean;
|
||||
labelSize?: 'small' | 'medium';
|
||||
disabled?: boolean;
|
||||
activeLabel?: string;
|
||||
activeColor?: string;
|
||||
inactiveLabel?: string;
|
||||
inactiveColor?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
|
|
|
@ -27,6 +27,7 @@ const Template: StoryFn = (args, { argTypes }) => ({
|
|||
|
||||
export const FormInputs = Template.bind({});
|
||||
FormInputs.args = {
|
||||
columnView: true,
|
||||
inputs: [
|
||||
{
|
||||
name: 'email',
|
||||
|
@ -79,5 +80,15 @@ FormInputs.args = {
|
|||
tooltipText: 'Check this if you agree to be contacted by our marketing team',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'activate',
|
||||
properties: {
|
||||
type: 'toggle',
|
||||
label: 'Activated',
|
||||
activeColor: '#13ce66',
|
||||
inactiveColor: '#8899AA',
|
||||
tooltipText: 'Check this if you agree to be contacted by our marketing team',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
@ -2,12 +2,18 @@
|
|||
<ResizeObserver :breakpoints="[{ bp: 'md', width: 500 }]">
|
||||
<template #default="{ bp }">
|
||||
<div :class="bp === 'md' || columnView ? $style.grid : $style.gridMulti">
|
||||
<div v-for="input in filteredInputs" :key="input.name">
|
||||
<div
|
||||
v-for="(input, index) in filteredInputs"
|
||||
:key="input.name"
|
||||
:class="{ [`mt-${verticalSpacing}`]: index > 0 }"
|
||||
>
|
||||
<n8n-text
|
||||
color="text-base"
|
||||
v-if="input.properties.type === 'info'"
|
||||
tag="div"
|
||||
align="center"
|
||||
:size="input.properties.labelSize"
|
||||
:align="input.properties.labelAlignment"
|
||||
class="form-text"
|
||||
>
|
||||
{{ input.properties.label }}
|
||||
</n8n-text>
|
||||
|
@ -15,11 +21,13 @@
|
|||
v-else
|
||||
v-bind="input.properties"
|
||||
:name="input.name"
|
||||
:label="input.properties.label || ''"
|
||||
:value="values[input.name]"
|
||||
:data-test-id="input.name"
|
||||
:showValidationWarnings="showValidationWarnings"
|
||||
@input="(value) => onInput(input.name, value)"
|
||||
@validate="(value) => onValidate(input.name, value)"
|
||||
@change="(value) => onInput(input.name, value)"
|
||||
@enter="onSubmit"
|
||||
/>
|
||||
</div>
|
||||
|
@ -52,6 +60,12 @@ export default Vue.extend({
|
|||
},
|
||||
columnView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
verticalSpacing: {
|
||||
type: String,
|
||||
required: false,
|
||||
validator: (value: string): boolean => ['xs', 's', 'm', 'm', 'l', 'xl'].includes(value),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
|
|
|
@ -10,14 +10,22 @@
|
|||
</div>
|
||||
<div v-else :class="$style.infoContainer">
|
||||
<div>
|
||||
<n8n-text :bold="true" color="text-dark"
|
||||
>{{ firstName }} {{ lastName }}
|
||||
{{ isCurrentUser ? this.t('nds.userInfo.you') : '' }}</n8n-text
|
||||
>
|
||||
<n8n-text :bold="true" color="text-dark">
|
||||
{{ firstName }} {{ lastName }}
|
||||
{{ isCurrentUser ? this.t('nds.userInfo.you') : '' }}
|
||||
</n8n-text>
|
||||
<span v-if="disabled" :class="$style.pendingBadge">
|
||||
<n8n-badge :bold="true">Disabled</n8n-badge>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<n8n-text size="small" color="text-light">{{ email }}</n8n-text>
|
||||
</div>
|
||||
<div v-if="!isOwner">
|
||||
<n8n-text v-if="signInType" size="small" color="text-light">
|
||||
Sign-in type: {{ signInType }}
|
||||
</n8n-text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -47,6 +55,9 @@ export default mixins(Locale).extend({
|
|||
email: {
|
||||
type: String,
|
||||
},
|
||||
isOwner: {
|
||||
type: Boolean,
|
||||
},
|
||||
isPendingUser: {
|
||||
type: Boolean,
|
||||
},
|
||||
|
@ -55,7 +66,10 @@ export default mixins(Locale).extend({
|
|||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
signInType: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
|
|
|
@ -40,28 +40,16 @@ UserSelect.args = {
|
|||
firstName: 'Sunny',
|
||||
lastName: 'Side',
|
||||
email: 'sunny@n8n.io',
|
||||
globalRole: {
|
||||
name: 'owner',
|
||||
id: '1',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Kobi',
|
||||
lastName: 'Dog',
|
||||
email: 'kobi@n8n.io',
|
||||
globalRole: {
|
||||
name: 'member',
|
||||
id: '2',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
email: 'invited@n8n.io',
|
||||
globalRole: {
|
||||
name: 'member',
|
||||
id: '2',
|
||||
},
|
||||
},
|
||||
],
|
||||
placeholder: 'Select user to transfer to',
|
||||
|
|
|
@ -50,10 +50,8 @@ UsersList.args = {
|
|||
isDefaultUser: false,
|
||||
isPendingUser: false,
|
||||
isOwner: true,
|
||||
globalRole: {
|
||||
name: 'owner',
|
||||
id: 1,
|
||||
},
|
||||
signInType: 'email',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
|
@ -64,10 +62,8 @@ UsersList.args = {
|
|||
isDefaultUser: false,
|
||||
isPendingUser: false,
|
||||
isOwner: false,
|
||||
globalRole: {
|
||||
name: 'member',
|
||||
id: '2',
|
||||
},
|
||||
signInType: 'ldap',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
|
@ -75,10 +71,6 @@ UsersList.args = {
|
|||
isDefaultUser: false,
|
||||
isPendingUser: true,
|
||||
isOwner: false,
|
||||
globalRole: {
|
||||
name: 'member',
|
||||
id: '2',
|
||||
},
|
||||
},
|
||||
],
|
||||
currentUserId: '1',
|
||||
|
|
|
@ -13,7 +13,13 @@
|
|||
</n8n-badge>
|
||||
<slot v-if="!user.isOwner && !readonly" name="actions" :user="user" />
|
||||
<n8n-action-toggle
|
||||
v-if="!user.isOwner && !readonly && getActions(user).length > 0 && actions.length > 0"
|
||||
v-if="
|
||||
!user.isOwner &&
|
||||
user.signInType !== 'ldap' &&
|
||||
!readonly &&
|
||||
getActions(user).length > 0 &&
|
||||
actions.length > 0
|
||||
"
|
||||
placement="bottom"
|
||||
:actions="getActions(user)"
|
||||
theme="dark"
|
||||
|
|
|
@ -24,7 +24,16 @@ export type IFormInput = {
|
|||
initialValue?: string | number | boolean | null;
|
||||
properties: {
|
||||
label?: string;
|
||||
type?: 'text' | 'email' | 'password' | 'select' | 'multi-select' | 'info' | 'checkbox';
|
||||
type?:
|
||||
| 'text'
|
||||
| 'email'
|
||||
| 'password'
|
||||
| 'select'
|
||||
| 'multi-select'
|
||||
| 'number'
|
||||
| 'info'
|
||||
| 'checkbox'
|
||||
| 'toggle';
|
||||
maxlength?: number;
|
||||
required?: boolean;
|
||||
showRequiredAsterisk?: boolean;
|
||||
|
@ -45,6 +54,9 @@ export type IFormInput = {
|
|||
| 'email'; // https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete
|
||||
capitalize?: boolean;
|
||||
focusInitially?: boolean;
|
||||
disabled?: boolean;
|
||||
labelSize?: 'small' | 'medium' | 'large';
|
||||
labelAlignment?: 'left' | 'right' | 'center';
|
||||
};
|
||||
shouldDisplay?: (values: { [key: string]: unknown }) => boolean;
|
||||
};
|
||||
|
|
|
@ -7,6 +7,8 @@ export interface IUser {
|
|||
isOwner: boolean;
|
||||
isPendingUser: boolean;
|
||||
inviteAcceptUrl?: string;
|
||||
disabled: boolean;
|
||||
signInType: string;
|
||||
}
|
||||
|
||||
export interface IUserListAction {
|
||||
|
|
|
@ -46,6 +46,7 @@
|
|||
"fast-json-stable-stringify": "^2.1.0",
|
||||
"file-saver": "^2.0.2",
|
||||
"flatted": "^3.2.4",
|
||||
"humanize-duration": "^3.27.2",
|
||||
"jquery": "^3.4.1",
|
||||
"jsonpath": "^1.1.1",
|
||||
"jsplumb": "2.15.4",
|
||||
|
@ -69,6 +70,7 @@
|
|||
"vue-agile": "^2.0.0",
|
||||
"vue-fragment": "1.5.1",
|
||||
"vue-i18n": "^8.26.7",
|
||||
"vue-infinite-loading": "^2.4.5",
|
||||
"vue-json-pretty": "1.9.3",
|
||||
"vue-prism-editor": "^0.3.0",
|
||||
"vue-router": "^3.6.5",
|
||||
|
@ -86,6 +88,7 @@
|
|||
"@types/dateformat": "^3.0.0",
|
||||
"@types/express": "^4.17.6",
|
||||
"@types/file-saver": "^2.0.1",
|
||||
"@types/humanize-duration": "^3.27.1",
|
||||
"@types/jsonpath": "^0.2.0",
|
||||
"@types/lodash-es": "^4.17.6",
|
||||
"@types/lodash.camelcase": "^4.3.6",
|
||||
|
|
|
@ -40,6 +40,7 @@ import {
|
|||
IAbstractEventMessage,
|
||||
} from 'n8n-workflow';
|
||||
import { FAKE_DOOR_FEATURES } from './constants';
|
||||
import { SignInType } from './constants';
|
||||
import { BulkCommand, Undoable } from '@/models/history';
|
||||
|
||||
export * from 'n8n-design-system/types';
|
||||
|
@ -642,6 +643,7 @@ export interface IUserResponse {
|
|||
};
|
||||
personalizationAnswers?: IPersonalizationSurveyVersions | null;
|
||||
isPending: boolean;
|
||||
signInType?: SignInType;
|
||||
}
|
||||
|
||||
export interface IUser extends IUserResponse {
|
||||
|
@ -808,6 +810,10 @@ export interface IN8nUISettings {
|
|||
enabled: boolean;
|
||||
};
|
||||
};
|
||||
ldap: {
|
||||
loginLabel: string;
|
||||
loginEnabled: boolean;
|
||||
};
|
||||
onboardingCallPromptEnabled: boolean;
|
||||
allowedModules: {
|
||||
builtIn?: string[];
|
||||
|
@ -1224,6 +1230,10 @@ export interface ISettingsState {
|
|||
enabled: boolean;
|
||||
};
|
||||
};
|
||||
ldap: {
|
||||
loginLabel: string;
|
||||
loginEnabled: boolean;
|
||||
};
|
||||
onboardingCallPromptEnabled: boolean;
|
||||
saveDataErrorExecution: string;
|
||||
saveDataSuccessExecution: string;
|
||||
|
@ -1385,6 +1395,50 @@ export type SchemaType =
|
|||
| 'function'
|
||||
| 'null'
|
||||
| 'undefined';
|
||||
|
||||
export interface ILdapSyncData {
|
||||
id: number;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
created: number;
|
||||
updated: number;
|
||||
disabled: number;
|
||||
scanned: number;
|
||||
status: string;
|
||||
error: string;
|
||||
runMode: string;
|
||||
}
|
||||
|
||||
export interface ILdapSyncTable {
|
||||
status: string;
|
||||
endedAt: string;
|
||||
runTime: string;
|
||||
runMode: string;
|
||||
details: string;
|
||||
}
|
||||
|
||||
export interface ILdapConfig {
|
||||
loginEnabled: boolean;
|
||||
loginLabel: string;
|
||||
connectionUrl: string;
|
||||
allowUnauthorizedCerts: boolean;
|
||||
connectionSecurity: string;
|
||||
connectionPort: number;
|
||||
baseDn: string;
|
||||
bindingAdminDn: string;
|
||||
bindingAdminPassword: string;
|
||||
firstNameAttribute: string;
|
||||
lastNameAttribute: string;
|
||||
emailAttribute: string;
|
||||
loginIdAttribute: string;
|
||||
ldapIdAttribute: string;
|
||||
userFilter: string;
|
||||
synchronizationEnabled: boolean;
|
||||
synchronizationInterval: number; // minutes
|
||||
searchPageSize: number;
|
||||
searchTimeout: number;
|
||||
}
|
||||
|
||||
export type Schema = { type: SchemaType; key?: string; value: string | Schema[]; path: string };
|
||||
|
||||
export type UsageState = {
|
||||
|
|
29
packages/editor-ui/src/api/ldap.ts
Normal file
29
packages/editor-ui/src/api/ldap.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { ILdapConfig, ILdapSyncData, IRestApiContext } from '@/Interface';
|
||||
import { makeRestApiRequest } from '@/utils';
|
||||
import { IDataObject } from 'n8n-workflow';
|
||||
|
||||
export function getLdapConfig(context: IRestApiContext): Promise<ILdapConfig> {
|
||||
return makeRestApiRequest(context, 'GET', '/ldap/config');
|
||||
}
|
||||
|
||||
export function testLdapConnection(context: IRestApiContext): Promise<{}> {
|
||||
return makeRestApiRequest(context, 'POST', '/ldap/test-connection');
|
||||
}
|
||||
|
||||
export function updateLdapConfig(
|
||||
context: IRestApiContext,
|
||||
adConfig: ILdapConfig,
|
||||
): Promise<ILdapConfig> {
|
||||
return makeRestApiRequest(context, 'PUT', '/ldap/config', adConfig as unknown as IDataObject);
|
||||
}
|
||||
|
||||
export function runLdapSync(context: IRestApiContext, data: IDataObject): Promise<{}> {
|
||||
return makeRestApiRequest(context, 'POST', '/ldap/sync', data as unknown as IDataObject);
|
||||
}
|
||||
|
||||
export function getLdapSynchronizations(
|
||||
context: IRestApiContext,
|
||||
pagination: { page: number },
|
||||
): Promise<ILdapSyncData[]> {
|
||||
return makeRestApiRequest(context, 'GET', '/ldap/sync', pagination);
|
||||
}
|
|
@ -74,6 +74,14 @@ export default mixins(userHelpers, pushConnection).extend({
|
|||
available: this.canAccessApiSettings(),
|
||||
activateOnRouteNames: [VIEWS.API_SETTINGS],
|
||||
},
|
||||
{
|
||||
id: 'settings-ldap',
|
||||
icon: 'network-wired',
|
||||
label: this.$locale.baseText('settings.ldap'),
|
||||
position: 'top',
|
||||
available: this.canAccessLdapSettings(),
|
||||
activateOnRouteNames: [VIEWS.LDAP_SETTINGS],
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of this.settingsFakeDoorFeatures) {
|
||||
|
@ -126,6 +134,9 @@ export default mixins(userHelpers, pushConnection).extend({
|
|||
canAccessApiSettings(): boolean {
|
||||
return this.canUserAccessRouteByName(VIEWS.API_SETTINGS);
|
||||
},
|
||||
canAccessLdapSettings(): boolean {
|
||||
return this.canUserAccessRouteByName(VIEWS.LDAP_SETTINGS);
|
||||
},
|
||||
canAccessLogStreamingSettings(): boolean {
|
||||
return this.canUserAccessRouteByName(VIEWS.LOG_STREAMING_SETTINGS);
|
||||
},
|
||||
|
@ -155,6 +166,11 @@ export default mixins(userHelpers, pushConnection).extend({
|
|||
this.$router.push({ name: VIEWS.API_SETTINGS });
|
||||
}
|
||||
break;
|
||||
case 'settings-ldap':
|
||||
if (this.$router.currentRoute.name !== VIEWS.LDAP_SETTINGS) {
|
||||
this.$router.push({ name: VIEWS.LDAP_SETTINGS });
|
||||
}
|
||||
break;
|
||||
case 'settings-log-streaming':
|
||||
if (this.$router.currentRoute.name !== VIEWS.LOG_STREAMING_SETTINGS) {
|
||||
this.$router.push({ name: VIEWS.LOG_STREAMING_SETTINGS });
|
||||
|
|
|
@ -312,6 +312,7 @@ export enum VIEWS {
|
|||
FORGOT_PASSWORD = 'ForgotMyPasswordView',
|
||||
CHANGE_PASSWORD = 'ChangePasswordView',
|
||||
USERS_SETTINGS = 'UsersSettings',
|
||||
LDAP_SETTINGS = 'LdapSettings',
|
||||
PERSONAL_SETTINGS = 'PersonalSettings',
|
||||
API_SETTINGS = 'APISettings',
|
||||
NOT_FOUND = 'NotFoundView',
|
||||
|
@ -381,6 +382,7 @@ export enum WORKFLOW_MENU_ACTIONS {
|
|||
*/
|
||||
export enum EnterpriseEditionFeature {
|
||||
Sharing = 'sharing',
|
||||
Ldap = 'ldap',
|
||||
LogStreaming = 'logStreaming',
|
||||
}
|
||||
export const MAIN_NODE_PANEL_WIDTH = 360;
|
||||
|
@ -442,6 +444,15 @@ export enum STORES {
|
|||
HISTORY = 'history',
|
||||
}
|
||||
|
||||
export enum SignInType {
|
||||
LDAP = 'ldap',
|
||||
EMAIL = 'email',
|
||||
}
|
||||
|
||||
export const N8N_SALES_EMAIL = 'sales@n8n.io';
|
||||
|
||||
export const N8N_CONTACT_EMAIL = 'contact@n8n.io';
|
||||
|
||||
export const EXPRESSION_EDITOR_PARSER_TIMEOUT = 15_000; // ms
|
||||
|
||||
export const POSTHOG_ASSUMPTION_TEST = 'adore-assumption-tests';
|
||||
|
|
|
@ -527,6 +527,7 @@
|
|||
"forgotPassword.recoveryEmailSent": "Recovery email sent",
|
||||
"forgotPassword.returnToSignIn": "Back to sign in",
|
||||
"forgotPassword.sendingEmailError": "Problem sending email",
|
||||
"forgotPassword.ldapUserPasswordResetUnavailable": "Please contact your LDAP administrator to reset your password",
|
||||
"forgotPassword.smtpErrorContactAdministrator": "Please contact your administrator (problem with your SMTP setup)",
|
||||
"forms.resourceFiltersDropdown.filters": "Filters",
|
||||
"forms.resourceFiltersDropdown.ownedBy": "Owned by",
|
||||
|
@ -1521,7 +1522,6 @@
|
|||
"importParameter.showError.invalidProtocol1.title": "Use the {node} node",
|
||||
"importParameter.showError.invalidProtocol2.title": "Invalid Protocol",
|
||||
"importParameter.showError.invalidProtocol.message": "The HTTP node doesn’t support {protocol} requests",
|
||||
|
||||
"contextual.credentials.sharing.unavailable.title": "Upgrade to collaborate",
|
||||
"contextual.credentials.sharing.unavailable.title.cloud": "Upgrade to collaborate",
|
||||
"contextual.credentials.sharing.unavailable.title.desktop": "Upgrade to n8n Cloud to collaborate",
|
||||
|
@ -1547,5 +1547,86 @@
|
|||
"contextual.workflows.sharing.unavailable.button.desktop": "View plans",
|
||||
"contextual.upgradeLinkUrl": "https://subscription.n8n.io/",
|
||||
"contextual.upgradeLinkUrl.cloud": "https://app.n8n.cloud/manage?edition=cloud",
|
||||
"contextual.upgradeLinkUrl.desktop": "https://n8n.io/pricing"
|
||||
"contextual.upgradeLinkUrl.desktop": "https://n8n.io/pricing",
|
||||
"settings.ldap": "LDAP",
|
||||
"settings.ldap.infoTip": "Learn more about <a href='https://docs.n8n.io/user-management/ldap/' target='_blank'>LDAP in the Docs</a>",
|
||||
"settings.ldap.save": "Save connection",
|
||||
"settings.ldap.connectionTestError": "Problem testing LDAP connection",
|
||||
"settings.ldap.connectionTest": "LDAP connection tested",
|
||||
"settings.ldap.runSync.title": "LDAP synchronization done",
|
||||
"settings.ldap.runSync.showError.message": "Problem during synchronization. Check the logs",
|
||||
"settings.ldap.updateConfiguration": "LDAP configuration updated",
|
||||
"settings.ldap.testingConnection": "Testing connection",
|
||||
"settings.ldap.testConnection": "Test connection",
|
||||
"settings.ldap.synchronizationTable.column.status": "Status",
|
||||
"settings.ldap.synchronizationTable.column.endedAt": "Ended At",
|
||||
"settings.ldap.synchronizationTable.column.runMode": "Run Mode",
|
||||
"settings.ldap.synchronizationTable.column.runTime": "Run Time",
|
||||
"settings.ldap.synchronizationTable.column.details": "Details",
|
||||
"settings.ldap.synchronizationTable.empty.message": "Test synchronization to preview updates",
|
||||
"settings.ldap.dryRun": "Test synchronization",
|
||||
"settings.ldap.synchronizeNow": "Run synchronization",
|
||||
"settings.ldap.synchronizationError": "LDAP Synchronization Error",
|
||||
"settings.ldap.configurationError": "LDAP Configuration Error",
|
||||
"settings.ldap.usersScanned": "Users scanned {scanned}",
|
||||
"settings.ldap.confirmMessage.beforeSaveForm.cancelButtonText": "Yes, disable it",
|
||||
"settings.ldap.confirmMessage.beforeSaveForm.confirmButtonText": "No",
|
||||
"settings.ldap.confirmMessage.beforeSaveForm.headline": "Are you sure you want to disable LDAP login?",
|
||||
"settings.ldap.confirmMessage.beforeSaveForm.message": "If you do so, all LDAP users will be converted to email users.",
|
||||
"settings.ldap.disabled.title": "Available in custom plans",
|
||||
"settings.ldap.disabled.description": "LDAP is available as a paid feature. Get in touch to learn more about it.",
|
||||
"settings.ldap.disabled.buttonText": "Contact us",
|
||||
"settings.ldap.toast.sync.success": "Synchronization succeeded",
|
||||
"settings.ldap.toast.connection.success": "Connection succeeded",
|
||||
"settings.ldap.form.loginEnabled.label": "Enable LDAP Login",
|
||||
"settings.ldap.form.loginEnabled.tooltip": "Connection settings and data will still be saved if you disable LDAP Login",
|
||||
"settings.ldap.form.loginLabel.label": "LDAP Login",
|
||||
"settings.ldap.form.loginLabel.placeholder": "e.g. LDAP Username or email address",
|
||||
"settings.ldap.form.loginLabel.infoText": "The placeholder text that appears in the login field on the login page",
|
||||
"settings.ldap.form.serverAddress.label": "LDAP Server Address",
|
||||
"settings.ldap.form.serverAddress.placeholder": "123.123.123.123",
|
||||
"settings.ldap.form.serverAddress.infoText": "IP or domain of the LDAP server",
|
||||
"settings.ldap.form.port.label": "LDAP Server Port",
|
||||
"settings.ldap.form.port.infoText": "Port used to connect to the LDAP server",
|
||||
"settings.ldap.form.connectionSecurity.label": "Connection Security",
|
||||
"settings.ldap.form.connectionSecurity.infoText": "Type of connection security",
|
||||
"settings.ldap.form.allowUnauthorizedCerts.label": "Ignore SSL/TLS Issues",
|
||||
"settings.ldap.form.baseDn.label": "Base DN",
|
||||
"settings.ldap.form.baseDn.placeholder": "o=acme,dc=example,dc=com",
|
||||
"settings.ldap.form.baseDn.infoText": "Distinguished Name of the location where n8n should start its search for user in the AD/LDAP tree",
|
||||
"settings.ldap.form.bindingType.label": "Binding as",
|
||||
"settings.ldap.form.bindingType.infoText": "Type of binding used to connection to the LDAP server",
|
||||
"settings.ldap.form.adminDn.label": "Binding DN",
|
||||
"settings.ldap.form.adminDn.placeholder": "uid=2da2de69435c,ou=Users,o=Acme,dc=com",
|
||||
"settings.ldap.form.adminDn.infoText": "Distinguished Name of the user to perform the search",
|
||||
"settings.ldap.form.adminPassword.label": "Binding Password",
|
||||
"settings.ldap.form.adminPassword.infoText": "Password of the user provided in the Binding DN field above",
|
||||
"settings.ldap.form.userFilter.label": "User Filter",
|
||||
"settings.ldap.form.userFilter.placeholder": "(ObjectClass=user)",
|
||||
"settings.ldap.form.userFilter.infoText": "LDAP query to use when searching for user. Only users returned by this filter will be allowed to sign-in in n8n",
|
||||
"settings.ldap.form.attributeMappingInfo.label": "Attribute mapping",
|
||||
"settings.ldap.form.ldapId.label": "ID",
|
||||
"settings.ldap.form.ldapId.placeholder": "uid",
|
||||
"settings.ldap.form.ldapId.infoText": "The attribute in the LDAP server used as a unique identifier in n8n. It should be an unique LDAP attribute like uid",
|
||||
"settings.ldap.form.loginId.label": "Login ID",
|
||||
"settings.ldap.form.loginId.placeholder": "mail",
|
||||
"settings.ldap.form.loginId.infoText": "The attribute in the LDAP server used to log-in in n8n",
|
||||
"settings.ldap.form.email.label": "Email",
|
||||
"settings.ldap.form.email.placeholder": "mail",
|
||||
"settings.ldap.form.email.infoText": "The attribute in the LDAP server used to populate the email in n8n",
|
||||
"settings.ldap.form.firstName.label": "First Name",
|
||||
"settings.ldap.form.firstName.placeholder": "givenName",
|
||||
"settings.ldap.form.firstName.infoText": "The attribute in the LDAP server used to populate the first name in n8n",
|
||||
"settings.ldap.form.lastName.label": "Last Name",
|
||||
"settings.ldap.form.lastName.placeholder": "sn",
|
||||
"settings.ldap.form.lastName.infoText": "The attribute in the LDAP server used to populate the last name in n8n",
|
||||
"settings.ldap.form.synchronizationEnabled.label": "Enable periodic LDAP synchronization",
|
||||
"settings.ldap.form.synchronizationEnabled.tooltip": "Enable users to be synchronized periodically",
|
||||
"settings.ldap.form.synchronizationInterval.label": "Synchronization Interval (Minutes)",
|
||||
"settings.ldap.form.synchronizationInterval.infoText": "How often the synchronization should run",
|
||||
"settings.ldap.form.pageSize.label": "Page Size",
|
||||
"settings.ldap.form.pageSize.infoText": "Max number of records to return per page during synchronization. 0 for unlimited",
|
||||
"settings.ldap.form.searchTimeout.label": "Search Timeout (Seconds)",
|
||||
"settings.ldap.form.searchTimeout.infoText": "The timeout value for queries to the AD/LDAP server. Increase if you are getting timeout errors caused by a slow AD/LDAP server",
|
||||
"settings.ldap.section.synchronization.title": "Synchronization"
|
||||
}
|
||||
|
|
|
@ -120,6 +120,7 @@ import {
|
|||
faUserFriends,
|
||||
faUsers,
|
||||
faVideo,
|
||||
faTree,
|
||||
faStickyNote as faSolidStickyNote,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { faStickyNote } from '@fortawesome/free-regular-svg-icons';
|
||||
|
@ -250,5 +251,6 @@ addIcon(faUserCircle);
|
|||
addIcon(faUserFriends);
|
||||
addIcon(faUsers);
|
||||
addIcon(faVideo);
|
||||
addIcon(faTree);
|
||||
|
||||
Vue.component('font-awesome-icon', FontAwesomeIcon);
|
||||
|
|
|
@ -10,6 +10,7 @@ import WorkflowExecutionsList from '@/components/ExecutionsView/ExecutionsList.v
|
|||
import ExecutionsLandingPage from '@/components/ExecutionsView/ExecutionsLandingPage.vue';
|
||||
import ExecutionPreview from '@/components/ExecutionsView/ExecutionPreview.vue';
|
||||
import SettingsView from './views/SettingsView.vue';
|
||||
import SettingsLdapView from './views/SettingsLdapView.vue';
|
||||
import SettingsPersonalView from './views/SettingsPersonalView.vue';
|
||||
import SettingsUsersView from './views/SettingsUsersView.vue';
|
||||
import SettingsCommunityNodesView from './views/SettingsCommunityNodesView.vue';
|
||||
|
@ -30,7 +31,7 @@ import WorkflowsView from '@/views/WorkflowsView.vue';
|
|||
import { IPermissions } from './Interface';
|
||||
import { LOGIN_STATUS, ROLE } from '@/utils';
|
||||
import { RouteConfigSingleView } from 'vue-router/types/router';
|
||||
import { VIEWS } from './constants';
|
||||
import { EnterpriseEditionFeature, VIEWS } from './constants';
|
||||
import { useSettingsStore } from './stores/settings';
|
||||
import { useTemplatesStore } from './stores/templates';
|
||||
import SettingsUsageAndPlanVue from './views/SettingsUsageAndPlan.vue';
|
||||
|
@ -606,6 +607,20 @@ const router = new Router({
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'ldap',
|
||||
name: VIEWS.LDAP_SETTINGS,
|
||||
components: {
|
||||
settingsView: SettingsLdapView,
|
||||
},
|
||||
meta: {
|
||||
permissions: {
|
||||
allow: {
|
||||
role: [ROLE.Owner],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
@ -1,4 +1,11 @@
|
|||
import { createApiKey, deleteApiKey, getApiKey } from '@/api/api-keys';
|
||||
import {
|
||||
getLdapConfig,
|
||||
getLdapSynchronizations,
|
||||
runLdapSync,
|
||||
testLdapConnection,
|
||||
updateLdapConfig,
|
||||
} from '@/api/ldap';
|
||||
import { getPromptsData, getSettings, submitContactInfo, submitValueSurvey } from '@/api/settings';
|
||||
import { testHealthEndpoint } from '@/api/templates';
|
||||
import {
|
||||
|
@ -15,6 +22,7 @@ import {
|
|||
IN8nValueSurveyData,
|
||||
ISettingsState,
|
||||
WorkflowCallerPolicyDefaultOption,
|
||||
ILdapConfig,
|
||||
} from '@/Interface';
|
||||
import { ITelemetrySettings } from 'n8n-workflow';
|
||||
import { defineStore } from 'pinia';
|
||||
|
@ -42,6 +50,10 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
|
|||
enabled: false,
|
||||
},
|
||||
},
|
||||
ldap: {
|
||||
loginLabel: '',
|
||||
loginEnabled: false,
|
||||
},
|
||||
onboardingCallPromptEnabled: false,
|
||||
saveDataErrorExecution: 'all',
|
||||
saveDataSuccessExecution: 'all',
|
||||
|
@ -69,6 +81,12 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
|
|||
publicApiPath(): string {
|
||||
return this.api.path;
|
||||
},
|
||||
isLdapLoginEnabled(): boolean {
|
||||
return this.ldap.loginEnabled;
|
||||
},
|
||||
ldapLoginLabel(): string {
|
||||
return this.ldap.loginLabel;
|
||||
},
|
||||
showSetupPage(): boolean {
|
||||
return this.userManagement.showSetupOnFirstLoad === true;
|
||||
},
|
||||
|
@ -147,6 +165,8 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
|
|||
this.userManagement.smtpSetup = settings.userManagement.smtpSetup;
|
||||
this.api = settings.publicApi;
|
||||
this.onboardingCallPromptEnabled = settings.onboardingCallPromptEnabled;
|
||||
this.ldap.loginEnabled = settings.ldap.loginEnabled;
|
||||
this.ldap.loginLabel = settings.ldap.loginLabel;
|
||||
},
|
||||
async getSettings(): Promise<void> {
|
||||
const rootStore = useRootStore();
|
||||
|
@ -253,6 +273,26 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
|
|||
const rootStore = useRootStore();
|
||||
await deleteApiKey(rootStore.getRestApiContext);
|
||||
},
|
||||
async getLdapConfig() {
|
||||
const rootStore = useRootStore();
|
||||
return await getLdapConfig(rootStore.getRestApiContext);
|
||||
},
|
||||
async getLdapSynchronizations(pagination: { page: number }) {
|
||||
const rootStore = useRootStore();
|
||||
return await getLdapSynchronizations(rootStore.getRestApiContext, pagination);
|
||||
},
|
||||
async testLdapConnection() {
|
||||
const rootStore = useRootStore();
|
||||
return await testLdapConnection(rootStore.getRestApiContext);
|
||||
},
|
||||
async updateLdapConfig(ldapConfig: ILdapConfig) {
|
||||
const rootStore = useRootStore();
|
||||
return await updateLdapConfig(rootStore.getRestApiContext, ldapConfig);
|
||||
},
|
||||
async runLdapSync(data: IDataObject) {
|
||||
const rootStore = useRootStore();
|
||||
return await runLdapSync(rootStore.getRestApiContext, data);
|
||||
},
|
||||
setSaveDataErrorExecution(newValue: string) {
|
||||
Vue.set(this, 'saveDataErrorExecution', newValue);
|
||||
},
|
||||
|
|
|
@ -38,6 +38,7 @@ import { useUIStore } from './ui';
|
|||
|
||||
const isDefaultUser = (user: IUserResponse | null) =>
|
||||
Boolean(user && user.isPending && user.globalRole && user.globalRole.name === ROLE.Owner);
|
||||
|
||||
const isPendingUser = (user: IUserResponse | null) => Boolean(user && user.isPending);
|
||||
|
||||
export const useUsersStore = defineStore(STORES.USERS, {
|
||||
|
@ -58,8 +59,8 @@ export const useUsersStore = defineStore(STORES.USERS, {
|
|||
getUserById(state) {
|
||||
return (userId: string): IUser | null => state.users[userId];
|
||||
},
|
||||
globalRoleName(): string {
|
||||
return this.currentUser?.globalRole?.name || '';
|
||||
globalRoleName(): IRole {
|
||||
return this.currentUser?.globalRole?.name ?? 'default';
|
||||
},
|
||||
canUserDeleteTags(): boolean {
|
||||
return isAuthorized(PERMISSIONS.TAGS.CAN_DELETE_TAGS, this.currentUser);
|
||||
|
@ -116,7 +117,7 @@ export const useUsersStore = defineStore(STORES.USERS, {
|
|||
: undefined,
|
||||
isDefaultUser: isDefaultUser(updatedUser),
|
||||
isPendingUser: isPendingUser(updatedUser),
|
||||
isOwner: Boolean(updatedUser.globalRole && updatedUser.globalRole.name === ROLE.Owner),
|
||||
isOwner: updatedUser.globalRole?.name === ROLE.Owner,
|
||||
};
|
||||
Vue.set(this.users, user.id, user);
|
||||
});
|
||||
|
|
|
@ -59,3 +59,7 @@ export function isChildOf(parent: Element, child: Element): boolean {
|
|||
|
||||
return isChildOf(parent, child.parentElement);
|
||||
}
|
||||
|
||||
export const capitalizeFirstLetter = (text: string): string => {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
};
|
||||
|
|
|
@ -142,7 +142,7 @@ export const isAuthorized = (permissions: IPermissions, currentUser: IUser | nul
|
|||
return false;
|
||||
}
|
||||
|
||||
if (currentUser && currentUser.globalRole) {
|
||||
if (currentUser?.globalRole?.name) {
|
||||
const role = currentUser.isDefaultUser ? ROLE.Default : currentUser.globalRole.name;
|
||||
if (permissions.deny.role && permissions.deny.role.includes(role)) {
|
||||
return false;
|
||||
|
@ -163,7 +163,7 @@ export const isAuthorized = (permissions: IPermissions, currentUser: IUser | nul
|
|||
return true;
|
||||
}
|
||||
|
||||
if (currentUser && currentUser.globalRole) {
|
||||
if (currentUser?.globalRole?.name) {
|
||||
const role = currentUser.isDefaultUser ? ROLE.Default : currentUser.globalRole.name;
|
||||
if (permissions.allow.role && permissions.allow.role.includes(role)) {
|
||||
return true;
|
||||
|
|
|
@ -82,10 +82,14 @@ export default mixins(showMessage).extend({
|
|||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
let message = this.$locale.baseText('forgotPassword.smtpErrorContactAdministrator');
|
||||
if (error.httpStatusCode === 422) {
|
||||
message = this.$locale.baseText(error.message);
|
||||
}
|
||||
this.$showMessage({
|
||||
type: 'error',
|
||||
title: this.$locale.baseText('forgotPassword.sendingEmailError'),
|
||||
message: this.$locale.baseText('forgotPassword.smtpErrorContactAdministrator'),
|
||||
message,
|
||||
});
|
||||
}
|
||||
this.loading = false;
|
||||
|
|
786
packages/editor-ui/src/views/SettingsLdapView.vue
Normal file
786
packages/editor-ui/src/views/SettingsLdapView.vue
Normal file
|
@ -0,0 +1,786 @@
|
|||
<template>
|
||||
<div v-if="!isLDAPFeatureEnabled">
|
||||
<div :class="[$style.header, 'mb-2xl']">
|
||||
<n8n-heading size="2xlarge">
|
||||
{{ $locale.baseText('settings.ldap') }}
|
||||
</n8n-heading>
|
||||
</div>
|
||||
|
||||
<n8n-info-tip type="note" theme="info-light" tooltipPlacement="right">
|
||||
<div>
|
||||
LDAP allows users to authenticate with their centralized account. It's compatible with
|
||||
services that provide an LDAP interface like Active Directory, Okta and Jumpcloud.
|
||||
</div>
|
||||
<br />
|
||||
</n8n-info-tip>
|
||||
<n8n-action-box
|
||||
:description="$locale.baseText('settings.ldap.disabled.description')"
|
||||
:buttonText="$locale.baseText('settings.ldap.disabled.buttonText')"
|
||||
@click="onContactUsClick"
|
||||
>
|
||||
<template #heading>
|
||||
<span>{{ $locale.baseText('settings.ldap.disabled.title') }}</span>
|
||||
</template>
|
||||
</n8n-action-box>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div :class="$style.container">
|
||||
<div :class="$style.header">
|
||||
<n8n-heading size="2xlarge">
|
||||
{{ $locale.baseText('settings.ldap') }}
|
||||
</n8n-heading>
|
||||
</div>
|
||||
<div :class="$style.docsInfoTip">
|
||||
<n8n-info-tip theme="info" type="note">
|
||||
<template>
|
||||
<span v-html="$locale.baseText('settings.ldap.infoTip')"></span>
|
||||
</template>
|
||||
</n8n-info-tip>
|
||||
</div>
|
||||
<div :class="$style.settingsForm">
|
||||
<n8n-form-inputs
|
||||
v-if="formInputs"
|
||||
ref="ldapConfigForm"
|
||||
:inputs="formInputs"
|
||||
:eventBus="formBus"
|
||||
:columnView="true"
|
||||
verticalSpacing="l"
|
||||
@input="onInput"
|
||||
@ready="onReadyToSubmit"
|
||||
@submit="onSubmit"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<n8n-button
|
||||
v-if="loginEnabled"
|
||||
:label="
|
||||
loadingTestConnection
|
||||
? $locale.baseText('settings.ldap.testingConnection')
|
||||
: $locale.baseText('settings.ldap.testConnection')
|
||||
"
|
||||
size="large"
|
||||
class="mr-s"
|
||||
:disabled="hasAnyChanges || !readyToSubmit"
|
||||
:loading="loadingTestConnection"
|
||||
@click="onTestConnectionClick"
|
||||
/>
|
||||
<n8n-button
|
||||
:label="$locale.baseText('settings.ldap.save')"
|
||||
size="large"
|
||||
:disabled="!hasAnyChanges || !readyToSubmit"
|
||||
@click="onSaveClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loginEnabled">
|
||||
<n8n-heading tag="h1" class="mb-xl mt-3xl" size="medium">{{
|
||||
$locale.baseText('settings.ldap.section.synchronization.title')
|
||||
}}</n8n-heading>
|
||||
<div :class="$style.syncTable">
|
||||
<el-table
|
||||
v-loading="loadingTable"
|
||||
:border="true"
|
||||
:stripe="true"
|
||||
:data="dataTable"
|
||||
:cell-style="cellClassStyle"
|
||||
style="width: 100%"
|
||||
height="250"
|
||||
:key="tableKey"
|
||||
>
|
||||
<el-table-column
|
||||
prop="status"
|
||||
:label="$locale.baseText('settings.ldap.synchronizationTable.column.status')"
|
||||
>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="endedAt"
|
||||
:label="$locale.baseText('settings.ldap.synchronizationTable.column.endedAt')"
|
||||
>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="runMode"
|
||||
:label="$locale.baseText('settings.ldap.synchronizationTable.column.runMode')"
|
||||
>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="runTime"
|
||||
:label="$locale.baseText('settings.ldap.synchronizationTable.column.runTime')"
|
||||
>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="details"
|
||||
:label="$locale.baseText('settings.ldap.synchronizationTable.column.details')"
|
||||
>
|
||||
</el-table-column>
|
||||
<template #empty>{{
|
||||
$locale.baseText('settings.ldap.synchronizationTable.empty.message')
|
||||
}}</template>
|
||||
<template #append>
|
||||
<infinite-loading
|
||||
@infinite="getLdapSynchronizations"
|
||||
force-use-infinite-wrapper=".el-table__body-wrapper"
|
||||
>
|
||||
</infinite-loading>
|
||||
</template>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="pb-3xl">
|
||||
<n8n-button
|
||||
:label="$locale.baseText('settings.ldap.dryRun')"
|
||||
type="secondary"
|
||||
size="large"
|
||||
class="mr-s"
|
||||
:disabled="hasAnyChanges || !readyToSubmit"
|
||||
:loading="loadingDryRun"
|
||||
@click="onDryRunClick"
|
||||
/>
|
||||
<n8n-button
|
||||
:label="$locale.baseText('settings.ldap.synchronizeNow')"
|
||||
size="large"
|
||||
:disabled="hasAnyChanges || !readyToSubmit"
|
||||
:loading="loadingLiveRun"
|
||||
@click="onLiveRunClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { convertToDisplayDate } from '@/utils';
|
||||
import { showMessage } from '@/mixins/showMessage';
|
||||
import {
|
||||
ILdapConfig,
|
||||
ILdapSyncData,
|
||||
ILdapSyncTable,
|
||||
IFormInput,
|
||||
IFormInputs,
|
||||
IUser,
|
||||
} from '@/Interface';
|
||||
import Vue from 'vue';
|
||||
import mixins from 'vue-typed-mixins';
|
||||
|
||||
import humanizeDuration from 'humanize-duration';
|
||||
import { rowCallbackParams, cellCallbackParams, ElTable } from 'element-ui/types/table';
|
||||
import { capitalizeFirstLetter } from '@/utils';
|
||||
import InfiniteLoading from 'vue-infinite-loading';
|
||||
import { mapStores } from 'pinia';
|
||||
import { useUsersStore } from '@/stores/users';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { getLdapSynchronizations } from '@/api/ldap';
|
||||
import { N8N_CONTACT_EMAIL, N8N_SALES_EMAIL } from '@/constants';
|
||||
import { ElTableColumn } from 'element-ui/types/table-column';
|
||||
|
||||
type FormValues = {
|
||||
loginEnabled: boolean;
|
||||
loginLabel: string;
|
||||
serverAddress: string;
|
||||
baseDn: string;
|
||||
bindingType: string;
|
||||
adminDn: string;
|
||||
adminPassword: string;
|
||||
loginId: string;
|
||||
email: string;
|
||||
lastName: string;
|
||||
firstName: string;
|
||||
ldapId: string;
|
||||
synchronizationEnabled: boolean;
|
||||
allowUnauthorizedCerts: boolean;
|
||||
synchronizationInterval: number;
|
||||
userFilter: string;
|
||||
pageSize: number;
|
||||
searchTimeout: number;
|
||||
port: number;
|
||||
connectionSecurity: string;
|
||||
};
|
||||
|
||||
type tableRow = {
|
||||
status: string;
|
||||
startAt: string;
|
||||
endedAt: string;
|
||||
error: string;
|
||||
runMode: string;
|
||||
};
|
||||
|
||||
type rowType = rowCallbackParams & tableRow;
|
||||
|
||||
type cellType = cellCallbackParams & { property: keyof tableRow };
|
||||
|
||||
export default mixins(showMessage).extend({
|
||||
name: 'SettingsLdapView',
|
||||
components: {
|
||||
InfiniteLoading,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dataTable: [] as ILdapSyncTable[],
|
||||
tableKey: 0,
|
||||
adConfig: {} as ILdapConfig,
|
||||
loadingTestConnection: false,
|
||||
loadingDryRun: false,
|
||||
loadingLiveRun: false,
|
||||
loadingTable: false,
|
||||
hasAnyChanges: false,
|
||||
formInputs: null as null | IFormInputs,
|
||||
formBus: new Vue(),
|
||||
readyToSubmit: false,
|
||||
page: 0,
|
||||
loginEnabled: false,
|
||||
syncEnabled: false,
|
||||
};
|
||||
},
|
||||
async mounted() {
|
||||
if (!this.isLDAPFeatureEnabled) return;
|
||||
await this.getLdapConfig();
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useUsersStore, useSettingsStore),
|
||||
currentUser(): null | IUser {
|
||||
return this.usersStore.currentUser;
|
||||
},
|
||||
isLDAPFeatureEnabled(): boolean {
|
||||
return this.settingsStore.settings.enterprise.ldap === true;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onContactUsClick(event: MouseEvent): void {
|
||||
const email = this.settingsStore.isCloudDeployment ? N8N_CONTACT_EMAIL : N8N_SALES_EMAIL;
|
||||
location.href = `mailto:${email}`;
|
||||
},
|
||||
cellClassStyle({ row, column }: { row: rowType; column: cellType }) {
|
||||
if (column.property === 'status') {
|
||||
if (row.status === 'Success') {
|
||||
return { color: 'green' };
|
||||
} else if (row.status === 'Error') {
|
||||
return { color: 'red' };
|
||||
}
|
||||
}
|
||||
if (column.property === 'runMode') {
|
||||
if (row.runMode === 'Dry') {
|
||||
return { color: 'orange' };
|
||||
} else if (row.runMode === 'Live') {
|
||||
return { color: 'blue' };
|
||||
}
|
||||
}
|
||||
return {};
|
||||
},
|
||||
onInput(input: { name: string; value: string | number | boolean }) {
|
||||
if (input.name === 'loginEnabled' && typeof input.value === 'boolean') {
|
||||
this.loginEnabled = input.value;
|
||||
}
|
||||
if (input.name === 'synchronizationEnabled' && typeof input.value === 'boolean') {
|
||||
this.syncEnabled = input.value;
|
||||
}
|
||||
this.hasAnyChanges = true;
|
||||
},
|
||||
onReadyToSubmit(ready: boolean) {
|
||||
this.readyToSubmit = ready;
|
||||
},
|
||||
syncDataMapper(sync: ILdapSyncData): ILdapSyncTable {
|
||||
const startedAt = new Date(sync.startedAt);
|
||||
const endedAt = new Date(sync.endedAt);
|
||||
const runTimeInMinutes = endedAt.getTime() - startedAt.getTime();
|
||||
return {
|
||||
runTime: humanizeDuration(runTimeInMinutes),
|
||||
runMode: capitalizeFirstLetter(sync.runMode),
|
||||
status: capitalizeFirstLetter(sync.status),
|
||||
endedAt: convertToDisplayDate(endedAt.getTime()),
|
||||
details: this.$locale.baseText('settings.ldap.usersScanned', {
|
||||
interpolate: {
|
||||
scanned: sync.scanned.toString(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
async onSubmit(): Promise<void> {
|
||||
// We want to save all form values (incl. the hidden onces), so we are using
|
||||
// `values` data prop of the `FormInputs` child component since they are all preserved there
|
||||
const formInputs = this.$refs.ldapConfigForm as (Vue & { values: FormValues }) | undefined;
|
||||
if (!this.hasAnyChanges || !formInputs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newConfiguration: ILdapConfig = {
|
||||
loginEnabled: formInputs.values.loginEnabled,
|
||||
loginLabel: formInputs.values.loginLabel,
|
||||
connectionUrl: formInputs.values.serverAddress,
|
||||
allowUnauthorizedCerts: formInputs.values.allowUnauthorizedCerts,
|
||||
connectionPort: +formInputs.values.port,
|
||||
connectionSecurity: formInputs.values.connectionSecurity,
|
||||
baseDn: formInputs.values.baseDn,
|
||||
bindingAdminDn: formInputs.values.bindingType === 'admin' ? formInputs.values.adminDn : '',
|
||||
bindingAdminPassword:
|
||||
formInputs.values.bindingType === 'admin' ? formInputs.values.adminPassword : '',
|
||||
emailAttribute: formInputs.values.email,
|
||||
firstNameAttribute: formInputs.values.firstName,
|
||||
lastNameAttribute: formInputs.values.lastName,
|
||||
loginIdAttribute: formInputs.values.loginId,
|
||||
ldapIdAttribute: formInputs.values.ldapId,
|
||||
userFilter: formInputs.values.userFilter,
|
||||
synchronizationEnabled: formInputs.values.synchronizationEnabled,
|
||||
synchronizationInterval: +formInputs.values.synchronizationInterval,
|
||||
searchPageSize: +formInputs.values.pageSize,
|
||||
searchTimeout: +formInputs.values.searchTimeout,
|
||||
};
|
||||
|
||||
let saveForm = true;
|
||||
|
||||
try {
|
||||
if (this.adConfig.loginEnabled === true && newConfiguration.loginEnabled === false) {
|
||||
saveForm = await this.confirmMessage(
|
||||
this.$locale.baseText('settings.ldap.confirmMessage.beforeSaveForm.message'),
|
||||
this.$locale.baseText('settings.ldap.confirmMessage.beforeSaveForm.headline'),
|
||||
null,
|
||||
this.$locale.baseText('settings.ldap.confirmMessage.beforeSaveForm.cancelButtonText'),
|
||||
this.$locale.baseText('settings.ldap.confirmMessage.beforeSaveForm.confirmButtonText'),
|
||||
);
|
||||
}
|
||||
|
||||
if (!saveForm) {
|
||||
this.hasAnyChanges = true;
|
||||
}
|
||||
|
||||
this.adConfig = await this.settingsStore.updateLdapConfig(newConfiguration);
|
||||
this.$showToast({
|
||||
title: this.$locale.baseText('settings.ldap.updateConfiguration'),
|
||||
message: '',
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
this.$showError(error, this.$locale.baseText('settings.ldap.configurationError'));
|
||||
} finally {
|
||||
if (saveForm) {
|
||||
this.hasAnyChanges = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
onSaveClick() {
|
||||
this.formBus.$emit('submit');
|
||||
},
|
||||
async onTestConnectionClick() {
|
||||
this.loadingTestConnection = true;
|
||||
try {
|
||||
await this.settingsStore.testLdapConnection();
|
||||
this.$showToast({
|
||||
title: this.$locale.baseText('settings.ldap.connectionTest'),
|
||||
message: this.$locale.baseText('settings.ldap.toast.connection.success'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
this.$showToast({
|
||||
title: this.$locale.baseText('settings.ldap.connectionTestError'),
|
||||
message: error.message,
|
||||
type: 'error',
|
||||
});
|
||||
} finally {
|
||||
this.loadingTestConnection = false;
|
||||
}
|
||||
},
|
||||
async onDryRunClick() {
|
||||
this.loadingDryRun = true;
|
||||
try {
|
||||
await this.settingsStore.runLdapSync({ type: 'dry' });
|
||||
this.$showToast({
|
||||
title: this.$locale.baseText('settings.ldap.runSync.title'),
|
||||
message: this.$locale.baseText('settings.ldap.toast.sync.success'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
this.$showError(error, this.$locale.baseText('settings.ldap.synchronizationError'));
|
||||
} finally {
|
||||
this.loadingDryRun = false;
|
||||
await this.reloadLdapSynchronizations();
|
||||
}
|
||||
},
|
||||
async onLiveRunClick() {
|
||||
this.loadingLiveRun = true;
|
||||
try {
|
||||
await this.settingsStore.runLdapSync({ type: 'live' });
|
||||
this.$showToast({
|
||||
title: this.$locale.baseText('settings.ldap.runSync.title'),
|
||||
message: this.$locale.baseText('settings.ldap.toast.sync.success'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
this.$showError(error, this.$locale.baseText('settings.ldap.synchronizationError'));
|
||||
} finally {
|
||||
this.loadingLiveRun = false;
|
||||
await this.reloadLdapSynchronizations();
|
||||
}
|
||||
},
|
||||
async getLdapConfig() {
|
||||
try {
|
||||
this.adConfig = await this.settingsStore.getLdapConfig();
|
||||
this.loginEnabled = this.adConfig.loginEnabled;
|
||||
this.syncEnabled = this.adConfig.synchronizationEnabled;
|
||||
const whenLoginEnabled: IFormInput['shouldDisplay'] = (values) =>
|
||||
values['loginEnabled'] === true;
|
||||
const whenSyncAndLoginEnabled: IFormInput['shouldDisplay'] = (values) =>
|
||||
values['synchronizationEnabled'] === true && values['loginEnabled'] === true;
|
||||
const whenAdminBindingAndLoginEnabled: IFormInput['shouldDisplay'] = (values) =>
|
||||
values['bindingType'] === 'admin' && values['loginEnabled'] === true;
|
||||
this.formInputs = [
|
||||
{
|
||||
name: 'loginEnabled',
|
||||
initialValue: this.adConfig.loginEnabled,
|
||||
properties: {
|
||||
type: 'toggle',
|
||||
label: this.$locale.baseText('settings.ldap.form.loginEnabled.label'),
|
||||
tooltipText: this.$locale.baseText('settings.ldap.form.loginEnabled.tooltip'),
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'loginLabel',
|
||||
initialValue: this.adConfig.loginLabel,
|
||||
properties: {
|
||||
label: this.$locale.baseText('settings.ldap.form.loginLabel.label'),
|
||||
required: true,
|
||||
placeholder: this.$locale.baseText('settings.ldap.form.loginLabel.placeholder'),
|
||||
infoText: this.$locale.baseText('settings.ldap.form.loginLabel.infoText'),
|
||||
},
|
||||
shouldDisplay: whenLoginEnabled,
|
||||
},
|
||||
{
|
||||
name: 'serverAddress',
|
||||
initialValue: this.adConfig.connectionUrl,
|
||||
properties: {
|
||||
label: this.$locale.baseText('settings.ldap.form.serverAddress.label'),
|
||||
required: true,
|
||||
capitalize: true,
|
||||
placeholder: this.$locale.baseText('settings.ldap.form.serverAddress.placeholder'),
|
||||
infoText: this.$locale.baseText('settings.ldap.form.serverAddress.infoText'),
|
||||
},
|
||||
shouldDisplay: whenLoginEnabled,
|
||||
},
|
||||
{
|
||||
name: 'port',
|
||||
initialValue: this.adConfig.connectionPort,
|
||||
properties: {
|
||||
type: 'number',
|
||||
label: this.$locale.baseText('settings.ldap.form.port.label'),
|
||||
capitalize: true,
|
||||
infoText: this.$locale.baseText('settings.ldap.form.port.infoText'),
|
||||
},
|
||||
shouldDisplay: whenLoginEnabled,
|
||||
},
|
||||
{
|
||||
name: 'connectionSecurity',
|
||||
initialValue: this.adConfig.connectionSecurity,
|
||||
properties: {
|
||||
type: 'select',
|
||||
label: this.$locale.baseText('settings.ldap.form.connectionSecurity.label'),
|
||||
infoText: this.$locale.baseText('settings.ldap.form.connectionSecurity.infoText'),
|
||||
options: [
|
||||
{
|
||||
label: 'None',
|
||||
value: 'none',
|
||||
},
|
||||
{
|
||||
label: 'TLS',
|
||||
value: 'tls',
|
||||
},
|
||||
{
|
||||
label: 'STARTTLS',
|
||||
value: 'startTls',
|
||||
},
|
||||
],
|
||||
required: true,
|
||||
capitalize: true,
|
||||
},
|
||||
shouldDisplay: whenLoginEnabled,
|
||||
},
|
||||
{
|
||||
name: 'allowUnauthorizedCerts',
|
||||
initialValue: this.adConfig.allowUnauthorizedCerts,
|
||||
properties: {
|
||||
type: 'toggle',
|
||||
label: this.$locale.baseText('settings.ldap.form.allowUnauthorizedCerts.label'),
|
||||
required: false,
|
||||
},
|
||||
shouldDisplay(values): boolean {
|
||||
return values['connectionSecurity'] !== 'none' && values['loginEnabled'] === true;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'baseDn',
|
||||
initialValue: this.adConfig.baseDn,
|
||||
properties: {
|
||||
label: this.$locale.baseText('settings.ldap.form.baseDn.label'),
|
||||
required: true,
|
||||
capitalize: true,
|
||||
placeholder: this.$locale.baseText('settings.ldap.form.baseDn.placeholder'),
|
||||
infoText: this.$locale.baseText('settings.ldap.form.baseDn.infoText'),
|
||||
},
|
||||
shouldDisplay: whenLoginEnabled,
|
||||
},
|
||||
{
|
||||
name: 'bindingType',
|
||||
initialValue: 'admin',
|
||||
properties: {
|
||||
type: 'select',
|
||||
label: this.$locale.baseText('settings.ldap.form.bindingType.label'),
|
||||
infoText: this.$locale.baseText('settings.ldap.form.bindingType.infoText'),
|
||||
options: [
|
||||
{
|
||||
value: 'admin',
|
||||
label: 'Admin',
|
||||
},
|
||||
{
|
||||
value: 'anonymous',
|
||||
label: 'Anonymous',
|
||||
},
|
||||
],
|
||||
},
|
||||
shouldDisplay: whenLoginEnabled,
|
||||
},
|
||||
{
|
||||
name: 'adminDn',
|
||||
initialValue: this.adConfig.bindingAdminDn,
|
||||
properties: {
|
||||
label: this.$locale.baseText('settings.ldap.form.adminDn.label'),
|
||||
placeholder: this.$locale.baseText('settings.ldap.form.adminDn.placeholder'),
|
||||
infoText: this.$locale.baseText('settings.ldap.form.adminDn.infoText'),
|
||||
capitalize: true,
|
||||
},
|
||||
shouldDisplay: whenAdminBindingAndLoginEnabled,
|
||||
},
|
||||
{
|
||||
name: 'adminPassword',
|
||||
initialValue: this.adConfig.bindingAdminPassword,
|
||||
properties: {
|
||||
label: this.$locale.baseText('settings.ldap.form.adminPassword.label'),
|
||||
type: 'password',
|
||||
capitalize: true,
|
||||
infoText: this.$locale.baseText('settings.ldap.form.adminPassword.infoText'),
|
||||
},
|
||||
shouldDisplay: whenAdminBindingAndLoginEnabled,
|
||||
},
|
||||
{
|
||||
name: 'userFilter',
|
||||
initialValue: this.adConfig.userFilter,
|
||||
properties: {
|
||||
label: this.$locale.baseText('settings.ldap.form.userFilter.label'),
|
||||
type: 'text',
|
||||
required: false,
|
||||
capitalize: true,
|
||||
placeholder: this.$locale.baseText('settings.ldap.form.userFilter.placeholder'),
|
||||
infoText: this.$locale.baseText('settings.ldap.form.userFilter.infoText'),
|
||||
},
|
||||
shouldDisplay: whenLoginEnabled,
|
||||
},
|
||||
{
|
||||
name: 'attributeMappingInfo',
|
||||
properties: {
|
||||
label: this.$locale.baseText('settings.ldap.form.attributeMappingInfo.label'),
|
||||
type: 'info',
|
||||
labelSize: 'large',
|
||||
labelAlignment: 'left',
|
||||
},
|
||||
shouldDisplay: whenLoginEnabled,
|
||||
},
|
||||
{
|
||||
name: 'ldapId',
|
||||
initialValue: this.adConfig.ldapIdAttribute,
|
||||
properties: {
|
||||
label: this.$locale.baseText('settings.ldap.form.ldapId.label'),
|
||||
type: 'text',
|
||||
required: true,
|
||||
capitalize: true,
|
||||
placeholder: this.$locale.baseText('settings.ldap.form.ldapId.placeholder'),
|
||||
infoText: this.$locale.baseText('settings.ldap.form.ldapId.infoText'),
|
||||
},
|
||||
shouldDisplay: whenLoginEnabled,
|
||||
},
|
||||
{
|
||||
name: 'loginId',
|
||||
initialValue: this.adConfig.loginIdAttribute,
|
||||
properties: {
|
||||
label: this.$locale.baseText('settings.ldap.form.loginId.label'),
|
||||
type: 'text',
|
||||
autocomplete: 'email',
|
||||
required: true,
|
||||
capitalize: true,
|
||||
placeholder: this.$locale.baseText('settings.ldap.form.loginId.placeholder'),
|
||||
infoText: this.$locale.baseText('settings.ldap.form.loginId.infoText'),
|
||||
},
|
||||
shouldDisplay: whenLoginEnabled,
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
initialValue: this.adConfig.emailAttribute,
|
||||
properties: {
|
||||
label: this.$locale.baseText('settings.ldap.form.email.label'),
|
||||
type: 'text',
|
||||
autocomplete: 'email',
|
||||
required: true,
|
||||
capitalize: true,
|
||||
placeholder: this.$locale.baseText('settings.ldap.form.email.placeholder'),
|
||||
infoText: this.$locale.baseText('settings.ldap.form.email.infoText'),
|
||||
},
|
||||
shouldDisplay: whenLoginEnabled,
|
||||
},
|
||||
{
|
||||
name: 'firstName',
|
||||
initialValue: this.adConfig.firstNameAttribute,
|
||||
properties: {
|
||||
label: this.$locale.baseText('settings.ldap.form.firstName.label'),
|
||||
type: 'text',
|
||||
autocomplete: 'email',
|
||||
required: true,
|
||||
capitalize: true,
|
||||
placeholder: this.$locale.baseText('settings.ldap.form.firstName.placeholder'),
|
||||
infoText: this.$locale.baseText('settings.ldap.form.firstName.infoText'),
|
||||
},
|
||||
shouldDisplay: whenLoginEnabled,
|
||||
},
|
||||
{
|
||||
name: 'lastName',
|
||||
initialValue: this.adConfig.lastNameAttribute,
|
||||
properties: {
|
||||
label: this.$locale.baseText('settings.ldap.form.lastName.label'),
|
||||
type: 'text',
|
||||
autocomplete: 'email',
|
||||
required: true,
|
||||
capitalize: true,
|
||||
placeholder: this.$locale.baseText('settings.ldap.form.lastName.placeholder'),
|
||||
infoText: this.$locale.baseText('settings.ldap.form.lastName.infoText'),
|
||||
},
|
||||
shouldDisplay: whenLoginEnabled,
|
||||
},
|
||||
{
|
||||
name: 'synchronizationEnabled',
|
||||
initialValue: this.adConfig.synchronizationEnabled,
|
||||
properties: {
|
||||
type: 'toggle',
|
||||
label: this.$locale.baseText('settings.ldap.form.synchronizationEnabled.label'),
|
||||
tooltipText: this.$locale.baseText(
|
||||
'settings.ldap.form.synchronizationEnabled.tooltip',
|
||||
),
|
||||
required: true,
|
||||
},
|
||||
shouldDisplay: whenLoginEnabled,
|
||||
},
|
||||
{
|
||||
name: 'synchronizationInterval',
|
||||
initialValue: this.adConfig.synchronizationInterval,
|
||||
properties: {
|
||||
type: 'number',
|
||||
label: this.$locale.baseText('settings.ldap.form.synchronizationInterval.label'),
|
||||
infoText: this.$locale.baseText(
|
||||
'settings.ldap.form.synchronizationInterval.infoText',
|
||||
),
|
||||
},
|
||||
shouldDisplay: whenSyncAndLoginEnabled,
|
||||
},
|
||||
{
|
||||
name: 'pageSize',
|
||||
initialValue: this.adConfig.searchPageSize,
|
||||
properties: {
|
||||
type: 'number',
|
||||
label: this.$locale.baseText('settings.ldap.form.pageSize.label'),
|
||||
infoText: this.$locale.baseText('settings.ldap.form.pageSize.infoText'),
|
||||
},
|
||||
shouldDisplay: whenSyncAndLoginEnabled,
|
||||
},
|
||||
{
|
||||
name: 'searchTimeout',
|
||||
initialValue: this.adConfig.searchTimeout,
|
||||
properties: {
|
||||
type: 'number',
|
||||
label: this.$locale.baseText('settings.ldap.form.searchTimeout.label'),
|
||||
infoText: this.$locale.baseText('settings.ldap.form.searchTimeout.infoText'),
|
||||
},
|
||||
shouldDisplay: whenSyncAndLoginEnabled,
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
this.$showError(error, this.$locale.baseText('settings.ldap.configurationError'));
|
||||
}
|
||||
},
|
||||
async getLdapSynchronizations(state: any) {
|
||||
try {
|
||||
this.loadingTable = true;
|
||||
const data = await this.settingsStore.getLdapSynchronizations({
|
||||
page: this.page,
|
||||
});
|
||||
|
||||
if (data.length !== 0) {
|
||||
this.dataTable.push(...data.map(this.syncDataMapper));
|
||||
this.page += 1;
|
||||
state.loaded();
|
||||
} else {
|
||||
state.complete();
|
||||
}
|
||||
this.loadingTable = false;
|
||||
} catch (error) {
|
||||
this.$showError(error, this.$locale.baseText('settings.ldap.synchronizationError'));
|
||||
}
|
||||
},
|
||||
async reloadLdapSynchronizations() {
|
||||
try {
|
||||
this.page = 0;
|
||||
this.tableKey += 1;
|
||||
this.dataTable = [];
|
||||
} catch (error) {
|
||||
this.$showError(error, this.$locale.baseText('settings.ldap.synchronizationError'));
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
> * {
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
}
|
||||
|
||||
.syncTable {
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
|
||||
*:first-child {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.enableFeatureContainer {
|
||||
margin-bottom: var(--spacing-1xl);
|
||||
}
|
||||
|
||||
.enableFeatureContainer > span {
|
||||
font-size: var(--font-size-s);
|
||||
font-weight: var(--font-weight-bold);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.enableFeatureContainer {
|
||||
> * {
|
||||
padding: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.settingsForm {
|
||||
:global(.form-text) {
|
||||
margin-top: var(--spacing-xl);
|
||||
}
|
||||
}
|
||||
|
||||
.docsInfoTip {
|
||||
&,
|
||||
& > div {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -32,7 +32,7 @@
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="!signInWithLdap">
|
||||
<div :class="$style.sectionHeader">
|
||||
<n8n-heading size="large">{{ $locale.baseText('settings.personal.security') }}</n8n-heading>
|
||||
</div>
|
||||
|
@ -58,10 +58,11 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { showMessage } from '@/mixins/showMessage';
|
||||
import { CHANGE_PASSWORD_MODAL_KEY } from '@/constants';
|
||||
import { CHANGE_PASSWORD_MODAL_KEY, SignInType } from '@/constants';
|
||||
import { IFormInputs, IUser } from '@/Interface';
|
||||
import { useUIStore } from '@/stores/ui';
|
||||
import { useUsersStore } from '@/stores/users';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { mapStores } from 'pinia';
|
||||
import Vue from 'vue';
|
||||
import mixins from 'vue-typed-mixins';
|
||||
|
@ -87,6 +88,7 @@ export default mixins(showMessage).extend({
|
|||
required: true,
|
||||
autocomplete: 'given-name',
|
||||
capitalize: true,
|
||||
disabled: this.isLDAPFeatureEnabled && this.signInWithLdap,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -98,6 +100,7 @@ export default mixins(showMessage).extend({
|
|||
required: true,
|
||||
autocomplete: 'family-name',
|
||||
capitalize: true,
|
||||
disabled: this.isLDAPFeatureEnabled && this.signInWithLdap,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -110,15 +113,22 @@ export default mixins(showMessage).extend({
|
|||
validationRules: [{ name: 'VALID_EMAIL' }],
|
||||
autocomplete: 'email',
|
||||
capitalize: true,
|
||||
disabled: this.isLDAPFeatureEnabled && this.signInWithLdap,
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useUIStore, useUsersStore),
|
||||
...mapStores(useUIStore, useUsersStore, useSettingsStore),
|
||||
currentUser(): IUser | null {
|
||||
return this.usersStore.currentUser;
|
||||
},
|
||||
signInWithLdap(): boolean {
|
||||
return this.currentUser?.signInType === 'ldap';
|
||||
},
|
||||
isLDAPFeatureEnabled(): boolean {
|
||||
return this.settingsStore.settings.enterprise.ldap === true;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onInput() {
|
||||
|
|
|
@ -16,6 +16,7 @@ import { IFormBoxConfig } from '@/Interface';
|
|||
import { VIEWS } from '@/constants';
|
||||
import { mapStores } from 'pinia';
|
||||
import { useUsersStore } from '@/stores/users';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
|
||||
export default mixins(showMessage).extend({
|
||||
name: 'SigninView',
|
||||
|
@ -23,7 +24,22 @@ export default mixins(showMessage).extend({
|
|||
AuthView,
|
||||
},
|
||||
data() {
|
||||
const FORM_CONFIG: IFormBoxConfig = {
|
||||
return {
|
||||
FORM_CONFIG: {} as IFormBoxConfig,
|
||||
loading: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useUsersStore, useSettingsStore),
|
||||
},
|
||||
mounted() {
|
||||
let emailLabel = this.$locale.baseText('auth.email');
|
||||
const ldapLoginLabel = this.settingsStore.ldapLoginLabel;
|
||||
const isLdapLoginEnabled = this.settingsStore.isLdapLoginEnabled;
|
||||
if (isLdapLoginEnabled && ldapLoginLabel) {
|
||||
emailLabel = ldapLoginLabel;
|
||||
}
|
||||
this.FORM_CONFIG = {
|
||||
title: this.$locale.baseText('auth.signin'),
|
||||
buttonText: this.$locale.baseText('auth.signin'),
|
||||
redirectText: this.$locale.baseText('forgotPassword'),
|
||||
|
@ -32,10 +48,10 @@ export default mixins(showMessage).extend({
|
|||
{
|
||||
name: 'email',
|
||||
properties: {
|
||||
label: this.$locale.baseText('auth.email'),
|
||||
label: emailLabel,
|
||||
type: 'email',
|
||||
required: true,
|
||||
validationRules: [{ name: 'VALID_EMAIL' }],
|
||||
...(!isLdapLoginEnabled && { validationRules: [{ name: 'VALID_EMAIL' }] }),
|
||||
showRequiredAsterisk: false,
|
||||
validateOnBlur: false,
|
||||
autocomplete: 'email',
|
||||
|
@ -56,14 +72,6 @@ export default mixins(showMessage).extend({
|
|||
},
|
||||
],
|
||||
};
|
||||
|
||||
return {
|
||||
FORM_CONFIG,
|
||||
loading: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useUsersStore),
|
||||
},
|
||||
methods: {
|
||||
async onSubmit(values: { [key: string]: string }) {
|
||||
|
|
|
@ -171,6 +171,7 @@ importers:
|
|||
jsonschema: ^1.4.1
|
||||
jsonwebtoken: 9.0.0
|
||||
jwks-rsa: ~1.12.1
|
||||
ldapts: ^4.2.2
|
||||
localtunnel: ^2.0.0
|
||||
lodash.get: ^4.4.2
|
||||
lodash.intersection: ^4.4.0
|
||||
|
@ -264,6 +265,7 @@ importers:
|
|||
jsonschema: 1.4.1
|
||||
jsonwebtoken: 9.0.0
|
||||
jwks-rsa: 1.12.3
|
||||
ldapts: 4.2.2
|
||||
localtunnel: 2.0.2
|
||||
lodash.get: 4.4.2
|
||||
lodash.intersection: 4.4.0
|
||||
|
@ -528,6 +530,7 @@ importers:
|
|||
'@types/dateformat': ^3.0.0
|
||||
'@types/express': ^4.17.6
|
||||
'@types/file-saver': ^2.0.1
|
||||
'@types/humanize-duration': ^3.27.1
|
||||
'@types/jsonpath': ^0.2.0
|
||||
'@types/lodash-es': ^4.17.6
|
||||
'@types/lodash.camelcase': ^4.3.6
|
||||
|
@ -545,6 +548,7 @@ importers:
|
|||
fast-json-stable-stringify: ^2.1.0
|
||||
file-saver: ^2.0.2
|
||||
flatted: ^3.2.4
|
||||
humanize-duration: ^3.27.2
|
||||
jquery: ^3.4.1
|
||||
jshint: ^2.9.7
|
||||
jsonpath: ^1.1.1
|
||||
|
@ -576,6 +580,7 @@ importers:
|
|||
vue-agile: ^2.0.0
|
||||
vue-fragment: 1.5.1
|
||||
vue-i18n: ^8.26.7
|
||||
vue-infinite-loading: ^2.4.5
|
||||
vue-json-pretty: 1.9.3
|
||||
vue-prism-editor: ^0.3.0
|
||||
vue-router: ^3.6.5
|
||||
|
@ -606,6 +611,7 @@ importers:
|
|||
fast-json-stable-stringify: 2.1.0
|
||||
file-saver: 2.0.5
|
||||
flatted: 3.2.7
|
||||
humanize-duration: 3.27.3
|
||||
jquery: 3.6.1
|
||||
jsonpath: 1.1.1
|
||||
jsplumb: 2.15.4
|
||||
|
@ -629,6 +635,7 @@ importers:
|
|||
vue-agile: 2.0.0
|
||||
vue-fragment: 1.5.1_vue@2.7.13
|
||||
vue-i18n: 8.27.2_vue@2.7.13
|
||||
vue-infinite-loading: 2.4.5_vue@2.7.13
|
||||
vue-json-pretty: 1.9.3
|
||||
vue-prism-editor: 0.3.0
|
||||
vue-router: 3.6.5_vue@2.7.13
|
||||
|
@ -645,6 +652,7 @@ importers:
|
|||
'@types/dateformat': 3.0.1
|
||||
'@types/express': 4.17.14
|
||||
'@types/file-saver': 2.0.5
|
||||
'@types/humanize-duration': 3.27.1
|
||||
'@types/jsonpath': 0.2.0
|
||||
'@types/lodash-es': 4.17.6
|
||||
'@types/lodash.camelcase': 4.3.7
|
||||
|
@ -5525,6 +5533,12 @@ packages:
|
|||
resolution: {integrity: sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==}
|
||||
dev: true
|
||||
|
||||
/@types/asn1/0.2.0:
|
||||
resolution: {integrity: sha512-5TMxIpYbIA9c1J0hYQjQDX3wr+rTgQEAXaW2BI8ECM8FO53wSW4HFZplTalrKSHuZUc76NtXcePRhwuOHqGD5g==}
|
||||
dependencies:
|
||||
'@types/node': 16.11.65
|
||||
dev: false
|
||||
|
||||
/@types/aws4/1.11.2:
|
||||
resolution: {integrity: sha512-x0f96eBPrCCJzJxdPbUvDFRva4yPpINJzTuXXpmS2j9qLUpF2nyGzvXPlRziuGbCsPukwY4JfuO+8xwsoZLzGw==}
|
||||
dependencies:
|
||||
|
@ -5774,6 +5788,10 @@ packages:
|
|||
resolution: {integrity: sha512-h4lTMgMJctJybDp8CQrxTUiiYmedihHWkjnF/8Pxseu2S6Nlfcy8kwboQ8yejh456rP2yWoEVm1sS/FVsfM48w==}
|
||||
dev: true
|
||||
|
||||
/@types/humanize-duration/3.27.1:
|
||||
resolution: {integrity: sha512-K3e+NZlpCKd6Bd/EIdqjFJRFHbrq5TzPPLwREk5Iv/YoIjQrs6ljdAUCo+Lb2xFlGNOjGSE0dqsVD19cZL137w==}
|
||||
dev: true
|
||||
|
||||
/@types/imap-simple/4.2.5:
|
||||
resolution: {integrity: sha512-Sfu70sdFXzVIhivsflpanlED8gZr4VRzz2AVU9i1ARU8gskr9nDd4tVGkqYtxfwajQfZDklkXbeHSOZYEeJmTQ==}
|
||||
dependencies:
|
||||
|
@ -6332,6 +6350,10 @@ packages:
|
|||
resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==}
|
||||
dev: true
|
||||
|
||||
/@types/uuid/9.0.0:
|
||||
resolution: {integrity: sha512-kr90f+ERiQtKWMz5rP32ltJ/BtULDI5RVO0uavn1HQUOwjx0R1h0rnDYNL0CepF1zL5bSY6FISAfd9tOdDhU5Q==}
|
||||
dev: false
|
||||
|
||||
/@types/validator/13.7.10:
|
||||
resolution: {integrity: sha512-t1yxFAR2n0+VO6hd/FJ9F2uezAZVWHLmpmlJzm1eX03+H7+HsuTAp7L8QJs+2pQCfWkP1+EXsGK9Z9v7o/qPVQ==}
|
||||
dev: false
|
||||
|
@ -13103,6 +13125,10 @@ packages:
|
|||
engines: {node: '>=10.17.0'}
|
||||
dev: true
|
||||
|
||||
/humanize-duration/3.27.3:
|
||||
resolution: {integrity: sha512-iimHkHPfIAQ8zCDQLgn08pRqSVioyWvnGfaQ8gond2wf7Jq2jJ+24ykmnRyiz3fIldcn4oUuQXpjqKLhSVR7lw==}
|
||||
dev: false
|
||||
|
||||
/humanize-ms/1.2.1:
|
||||
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
|
||||
dependencies:
|
||||
|
@ -14962,6 +14988,21 @@ packages:
|
|||
invert-kv: 1.0.0
|
||||
dev: true
|
||||
|
||||
/ldapts/4.2.2:
|
||||
resolution: {integrity: sha512-UHe7BtEhPUFHZZ6XHnRvLHWQrftTap3PgGU0nOLtrFeigZvfpXSsqJ8C9uXNouDV+iDHqoWwplS0eHoDu/GIEQ==}
|
||||
engines: {node: '>=14'}
|
||||
dependencies:
|
||||
'@types/asn1': 0.2.0
|
||||
'@types/node': 16.11.65
|
||||
'@types/uuid': 9.0.0
|
||||
asn1: 0.2.6
|
||||
debug: 4.3.4
|
||||
strict-event-emitter-types: 2.0.0
|
||||
uuid: 9.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/lead/1.0.0:
|
||||
resolution: {integrity: sha512-IpSVCk9AYvLHo5ctcIXxOBpMWUe+4TKN3VPWAKUbJikkmsGp0VrSM8IttVc32D6J4WUsiPE6aEFRNmIoF/gdow==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
@ -19825,6 +19866,10 @@ packages:
|
|||
engines: {node: '>=10.0.0'}
|
||||
dev: false
|
||||
|
||||
/strict-event-emitter-types/2.0.0:
|
||||
resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==}
|
||||
dev: false
|
||||
|
||||
/strict-uri-encode/2.0.0:
|
||||
resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==}
|
||||
engines: {node: '>=4'}
|
||||
|
@ -21435,6 +21480,11 @@ packages:
|
|||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
hasBin: true
|
||||
|
||||
/uuid/9.0.0:
|
||||
resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==}
|
||||
hasBin: true
|
||||
dev: false
|
||||
|
||||
/v-click-outside/3.2.0:
|
||||
resolution: {integrity: sha512-QD0bDy38SHJXQBjgnllmkI/rbdiwmq9RC+/+pvrFjYJKTn8dtp7Penf9q1lLBta280fYG2q53mgLhQ+3l3z74w==}
|
||||
engines: {node: '>=6'}
|
||||
|
@ -21844,6 +21894,14 @@ packages:
|
|||
vue: 2.7.13
|
||||
dev: true
|
||||
|
||||
/vue-infinite-loading/2.4.5_vue@2.7.13:
|
||||
resolution: {integrity: sha512-xhq95Mxun060bRnsOoLE2Be6BR7jYwuC89kDe18+GmCLVrRA/dU0jrGb12Xu6NjmKs+iTW0AA6saSEmEW4cR7g==}
|
||||
peerDependencies:
|
||||
vue: ^2.6.10
|
||||
dependencies:
|
||||
vue: 2.7.13
|
||||
dev: false
|
||||
|
||||
/vue-json-pretty/1.9.3:
|
||||
resolution: {integrity: sha512-b13DP1WGQ+ACUU2K5hmwFfHrHnydCFSTerE7fppeYMojSWN/5EOPODQECfIIRaJ7zzHtPW9OifkThFGPyY0xRg==}
|
||||
engines: {node: '>= 10.0.0', npm: '>= 5.0.0'}
|
||||
|
|
Loading…
Reference in a new issue