mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(core): Add LDAP support (#3835)
This commit is contained in:
@@ -31,6 +31,8 @@ beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['User']);
|
||||
|
||||
config.set('ldap.disabled', true);
|
||||
|
||||
config.set('userManagement.isInstanceOwnerSetUp', true);
|
||||
|
||||
await Db.collections.Settings.update(
|
||||
|
||||
630
packages/cli/test/integration/ldap/ldap.api.test.ts
Normal file
630
packages/cli/test/integration/ldap/ldap.api.test.ts
Normal file
@@ -0,0 +1,630 @@
|
||||
import express from 'express';
|
||||
import type { Entry as LdapUser } from 'ldapts';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import config from '@/config';
|
||||
import * as Db from '@/Db';
|
||||
import type { Role } from '@db/entities/Role';
|
||||
import type { User } from '@db/entities/User';
|
||||
import { LDAP_DEFAULT_CONFIGURATION, LDAP_ENABLED, LDAP_FEATURE_NAME } from '@/Ldap/constants';
|
||||
import { LdapManager } from '@/Ldap/LdapManager.ee';
|
||||
import { LdapService } from '@/Ldap/LdapService.ee';
|
||||
import { encryptPassword, saveLdapSynchronization } from '@/Ldap/helpers';
|
||||
import type { LdapConfig } from '@/Ldap/types';
|
||||
import { sanitizeUser } from '@/UserManagement/UserManagementHelper';
|
||||
import { randomEmail, randomName, uniqueId } from './../shared/random';
|
||||
import * as testDb from './../shared/testDb';
|
||||
import type { AuthAgent } from '../shared/types';
|
||||
import * as utils from '../shared/utils';
|
||||
|
||||
jest.mock('@/telemetry');
|
||||
jest.mock('@/UserManagement/email/NodeMailer');
|
||||
|
||||
let app: express.Application;
|
||||
let globalMemberRole: Role;
|
||||
let globalOwnerRole: Role;
|
||||
let owner: User;
|
||||
let authAgent: AuthAgent;
|
||||
|
||||
const defaultLdapConfig = {
|
||||
...LDAP_DEFAULT_CONFIGURATION,
|
||||
loginEnabled: true,
|
||||
loginLabel: '',
|
||||
ldapIdAttribute: 'uid',
|
||||
firstNameAttribute: 'givenName',
|
||||
lastNameAttribute: 'sn',
|
||||
emailAttribute: 'mail',
|
||||
loginIdAttribute: 'mail',
|
||||
baseDn: 'baseDn',
|
||||
bindingAdminDn: 'adminDn',
|
||||
bindingAdminPassword: 'adminPassword',
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
app = await utils.initTestServer({ endpointGroups: ['auth', 'ldap'], applyAuth: true });
|
||||
|
||||
const [fetchedGlobalOwnerRole, fetchedGlobalMemberRole] = await testDb.getAllRoles();
|
||||
|
||||
globalOwnerRole = fetchedGlobalOwnerRole;
|
||||
globalMemberRole = fetchedGlobalMemberRole;
|
||||
|
||||
authAgent = utils.createAuthAgent(app);
|
||||
|
||||
config.set(LDAP_ENABLED, true);
|
||||
defaultLdapConfig.bindingAdminPassword = await encryptPassword(
|
||||
defaultLdapConfig.bindingAdminPassword,
|
||||
);
|
||||
|
||||
utils.initConfigFile();
|
||||
utils.initTestLogger();
|
||||
utils.initTestTelemetry();
|
||||
await utils.initLdapManager();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate([
|
||||
'AuthIdentity',
|
||||
'AuthProviderSyncHistory',
|
||||
'SharedCredentials',
|
||||
'Credentials',
|
||||
'SharedWorkflow',
|
||||
'Workflow',
|
||||
'User',
|
||||
]);
|
||||
|
||||
owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||
|
||||
jest.mock('@/telemetry');
|
||||
|
||||
config.set('userManagement.disabled', false);
|
||||
config.set('userManagement.isInstanceOwnerSetUp', true);
|
||||
config.set('userManagement.emails.mode', '');
|
||||
config.set('enterprise.features.ldap', true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
const createLdapConfig = async (attributes: Partial<LdapConfig> = {}): Promise<LdapConfig> => {
|
||||
const { value: ldapConfig } = await Db.collections.Settings.save({
|
||||
key: LDAP_FEATURE_NAME,
|
||||
value: JSON.stringify({
|
||||
...defaultLdapConfig,
|
||||
...attributes,
|
||||
}),
|
||||
loadOnStartup: true,
|
||||
});
|
||||
return jsonParse(ldapConfig);
|
||||
};
|
||||
|
||||
test('Member role should not be able to access ldap routes', async () => {
|
||||
const member = await testDb.createUser({ globalRole: globalMemberRole });
|
||||
|
||||
let response = await authAgent(member).get('/ldap/config');
|
||||
expect(response.statusCode).toBe(403);
|
||||
|
||||
response = await authAgent(member).put('/ldap/config');
|
||||
expect(response.statusCode).toBe(403);
|
||||
|
||||
response = await authAgent(member).post('/ldap/test-connection');
|
||||
expect(response.statusCode).toBe(403);
|
||||
|
||||
response = await authAgent(member).post('/ldap/sync');
|
||||
expect(response.statusCode).toBe(403);
|
||||
|
||||
response = await authAgent(member).get('/ldap/sync');
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
describe('PUT /ldap/config', () => {
|
||||
test('route should validate payload', async () => {
|
||||
const invalidValuePayload = {
|
||||
...LDAP_DEFAULT_CONFIGURATION,
|
||||
loginEnabled: '', // enabled property only allows boolean
|
||||
loginLabel: '',
|
||||
};
|
||||
|
||||
const invalidExtraPropertyPayload = {
|
||||
...LDAP_DEFAULT_CONFIGURATION,
|
||||
example: true, // property not defined in the validation schema
|
||||
};
|
||||
|
||||
const missingPropertyPayload = {
|
||||
loginEnabled: true,
|
||||
loginLabel: '',
|
||||
// missing all other properties defined in the schema
|
||||
};
|
||||
|
||||
const invalidPayloads = [
|
||||
invalidValuePayload,
|
||||
invalidExtraPropertyPayload,
|
||||
missingPropertyPayload,
|
||||
];
|
||||
|
||||
for (const invalidPayload of invalidPayloads) {
|
||||
const response = await authAgent(owner).put('/ldap/config').send(invalidPayload);
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toHaveProperty('message');
|
||||
}
|
||||
});
|
||||
|
||||
test('route should update model', async () => {
|
||||
const validPayload = {
|
||||
...LDAP_DEFAULT_CONFIGURATION,
|
||||
loginEnabled: true,
|
||||
loginLabel: '',
|
||||
};
|
||||
|
||||
const response = await authAgent(owner).put('/ldap/config').send(validPayload);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.data.loginEnabled).toBe(true);
|
||||
expect(response.body.data.loginLabel).toBe('');
|
||||
});
|
||||
|
||||
test('should apply "Convert all LDAP users to email users" strategy when LDAP login disabled', async () => {
|
||||
const ldapConfig = await createLdapConfig();
|
||||
LdapManager.updateConfig(ldapConfig);
|
||||
|
||||
const member = await testDb.createLdapUser({ globalRole: globalMemberRole }, uniqueId());
|
||||
|
||||
const configuration = ldapConfig;
|
||||
|
||||
// disable the login, so the strategy is applied
|
||||
await authAgent(owner)
|
||||
.put('/ldap/config')
|
||||
.send({ ...configuration, loginEnabled: false });
|
||||
|
||||
const emailUser = await Db.collections.User.findOneByOrFail({ id: member.id });
|
||||
const localLdapIdentities = await testDb.getLdapIdentities();
|
||||
|
||||
expect(emailUser.email).toBe(member.email);
|
||||
expect(emailUser.lastName).toBe(member.lastName);
|
||||
expect(emailUser.firstName).toBe(member.firstName);
|
||||
expect(localLdapIdentities.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('GET /ldap/config route should retrieve current configuration', async () => {
|
||||
const validPayload = {
|
||||
...LDAP_DEFAULT_CONFIGURATION,
|
||||
loginEnabled: true,
|
||||
loginLabel: '',
|
||||
};
|
||||
|
||||
let response = await authAgent(owner).put('/ldap/config').send(validPayload);
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
response = await authAgent(owner).get('/ldap/config');
|
||||
|
||||
expect(response.body.data).toMatchObject(validPayload);
|
||||
});
|
||||
|
||||
describe('POST /ldap/test-connection', () => {
|
||||
test('route should success', async () => {
|
||||
jest
|
||||
.spyOn(LdapService.prototype, 'testConnection')
|
||||
.mockImplementation(async () => Promise.resolve());
|
||||
|
||||
const response = await authAgent(owner).post('/ldap/test-connection');
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
test('route should fail', async () => {
|
||||
const errorMessage = 'Invalid connection';
|
||||
|
||||
jest
|
||||
.spyOn(LdapService.prototype, 'testConnection')
|
||||
.mockImplementation(async () => Promise.reject(new Error(errorMessage)));
|
||||
|
||||
const response = await authAgent(owner).post('/ldap/test-connection');
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toHaveProperty('message');
|
||||
expect(response.body.message).toStrictEqual(errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /ldap/sync', () => {
|
||||
beforeEach(async () => {
|
||||
const ldapConfig = await createLdapConfig({
|
||||
ldapIdAttribute: 'uid',
|
||||
firstNameAttribute: 'givenName',
|
||||
lastNameAttribute: 'sn',
|
||||
emailAttribute: 'mail',
|
||||
});
|
||||
LdapManager.updateConfig(ldapConfig);
|
||||
});
|
||||
|
||||
describe('dry mode', () => {
|
||||
const runTest = async (ldapUsers: LdapUser[]) => {
|
||||
jest
|
||||
.spyOn(LdapService.prototype, 'searchWithAdminBinding')
|
||||
.mockImplementation(async () => Promise.resolve(ldapUsers));
|
||||
|
||||
const response = await authAgent(owner).post('/ldap/sync').send({ type: 'dry' });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const synchronization = await Db.collections.AuthProviderSyncHistory.findOneByOrFail({});
|
||||
|
||||
expect(synchronization.id).toBeDefined();
|
||||
expect(synchronization.startedAt).toBeDefined();
|
||||
expect(synchronization.endedAt).toBeDefined();
|
||||
expect(synchronization.created).toBeDefined();
|
||||
expect(synchronization.updated).toBeDefined();
|
||||
expect(synchronization.disabled).toBeDefined();
|
||||
expect(synchronization.status).toBeDefined();
|
||||
expect(synchronization.scanned).toBeDefined();
|
||||
expect(synchronization.error).toBeDefined();
|
||||
expect(synchronization.runMode).toBeDefined();
|
||||
expect(synchronization.runMode).toBe('dry');
|
||||
expect(synchronization.scanned).toBe(ldapUsers.length);
|
||||
return synchronization;
|
||||
};
|
||||
|
||||
test('should detect new user but not persist change in model', async () => {
|
||||
const synchronization = await runTest([
|
||||
{
|
||||
dn: '',
|
||||
mail: randomEmail(),
|
||||
sn: randomName(),
|
||||
givenName: randomName(),
|
||||
uid: uniqueId(),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(synchronization.created).toBe(1);
|
||||
|
||||
// Make sure only the instance owner is on the DB
|
||||
const localDbUsers = await Db.collections.User.find();
|
||||
expect(localDbUsers.length).toBe(1);
|
||||
expect(localDbUsers[0].id).toBe(owner.id);
|
||||
});
|
||||
|
||||
test('should detect updated user but not persist change in model', async () => {
|
||||
const ldapUserEmail = randomEmail();
|
||||
const ldapUserId = uniqueId();
|
||||
|
||||
const member = await testDb.createLdapUser(
|
||||
{ globalRole: globalMemberRole, email: ldapUserEmail },
|
||||
ldapUserId,
|
||||
);
|
||||
|
||||
const synchronization = await runTest([
|
||||
{
|
||||
dn: '',
|
||||
mail: ldapUserEmail,
|
||||
sn: randomName(),
|
||||
givenName: 'updated',
|
||||
uid: ldapUserId,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(synchronization.updated).toBe(1);
|
||||
|
||||
// Make sure the changes in the "LDAP server" were not persisted in the database
|
||||
const localLdapIdentities = await testDb.getLdapIdentities();
|
||||
const localLdapUsers = localLdapIdentities.map(({ user }) => user);
|
||||
expect(localLdapUsers.length).toBe(1);
|
||||
expect(localLdapUsers[0].id).toBe(member.id);
|
||||
expect(localLdapUsers[0].lastName).toBe(member.lastName);
|
||||
});
|
||||
|
||||
test('should detect disabled user but not persist change in model', async () => {
|
||||
const ldapUserEmail = randomEmail();
|
||||
const ldapUserId = uniqueId();
|
||||
|
||||
const member = await testDb.createLdapUser(
|
||||
{ globalRole: globalMemberRole, email: ldapUserEmail },
|
||||
ldapUserId,
|
||||
);
|
||||
|
||||
const synchronization = await runTest([]);
|
||||
|
||||
expect(synchronization.disabled).toBe(1);
|
||||
|
||||
// Make sure the changes in the "LDAP server" were not persisted in the database
|
||||
const localLdapIdentities = await testDb.getLdapIdentities();
|
||||
const localLdapUsers = localLdapIdentities.map(({ user }) => user);
|
||||
expect(localLdapUsers.length).toBe(1);
|
||||
expect(localLdapUsers[0].id).toBe(member.id);
|
||||
expect(localLdapUsers[0].disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('live mode', () => {
|
||||
const runTest = async (ldapUsers: LdapUser[]) => {
|
||||
jest
|
||||
.spyOn(LdapService.prototype, 'searchWithAdminBinding')
|
||||
.mockImplementation(async () => Promise.resolve(ldapUsers));
|
||||
|
||||
const response = await authAgent(owner).post('/ldap/sync').send({ type: 'live' });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const synchronization = await Db.collections.AuthProviderSyncHistory.findOneByOrFail({});
|
||||
|
||||
expect(synchronization.id).toBeDefined();
|
||||
expect(synchronization.startedAt).toBeDefined();
|
||||
expect(synchronization.endedAt).toBeDefined();
|
||||
expect(synchronization.created).toBeDefined();
|
||||
expect(synchronization.updated).toBeDefined();
|
||||
expect(synchronization.disabled).toBeDefined();
|
||||
expect(synchronization.status).toBeDefined();
|
||||
expect(synchronization.scanned).toBeDefined();
|
||||
expect(synchronization.error).toBeDefined();
|
||||
expect(synchronization.runMode).toBeDefined();
|
||||
expect(synchronization.runMode).toBe('live');
|
||||
expect(synchronization.scanned).toBe(ldapUsers.length);
|
||||
return synchronization;
|
||||
};
|
||||
|
||||
test('should detect new user and persist change in model', async () => {
|
||||
const ldapUser = {
|
||||
mail: randomEmail(),
|
||||
dn: '',
|
||||
sn: randomName(),
|
||||
givenName: randomName(),
|
||||
uid: uniqueId(),
|
||||
};
|
||||
|
||||
const synchronization = await runTest([ldapUser]);
|
||||
expect(synchronization.created).toBe(1);
|
||||
|
||||
// Make sure the changes in the "LDAP server" were persisted in the database
|
||||
const allUsers = await testDb.getAllUsers();
|
||||
expect(allUsers.length).toBe(2);
|
||||
|
||||
const ownerUser = allUsers.find((u) => u.email === owner.email)!;
|
||||
expect(ownerUser.email).toBe(owner.email);
|
||||
|
||||
const memberUser = allUsers.find((u) => u.email !== owner.email)!;
|
||||
expect(memberUser.email).toBe(ldapUser.mail);
|
||||
expect(memberUser.lastName).toBe(ldapUser.sn);
|
||||
expect(memberUser.firstName).toBe(ldapUser.givenName);
|
||||
|
||||
const authIdentities = await testDb.getLdapIdentities();
|
||||
expect(authIdentities.length).toBe(1);
|
||||
expect(authIdentities[0].providerId).toBe(ldapUser.uid);
|
||||
expect(authIdentities[0].providerType).toBe('ldap');
|
||||
});
|
||||
|
||||
test('should detect updated user and persist change in model', async () => {
|
||||
const ldapUser = {
|
||||
mail: randomEmail(),
|
||||
dn: '',
|
||||
sn: 'updated',
|
||||
givenName: randomName(),
|
||||
uid: uniqueId(),
|
||||
};
|
||||
|
||||
await testDb.createLdapUser(
|
||||
{
|
||||
globalRole: globalMemberRole,
|
||||
email: ldapUser.mail,
|
||||
firstName: ldapUser.givenName,
|
||||
lastName: randomName(),
|
||||
},
|
||||
ldapUser.uid,
|
||||
);
|
||||
|
||||
const synchronization = await runTest([ldapUser]);
|
||||
expect(synchronization.updated).toBe(1);
|
||||
|
||||
// Make sure the changes in the "LDAP server" were persisted in the database
|
||||
const localLdapIdentities = await testDb.getLdapIdentities();
|
||||
const localLdapUsers = localLdapIdentities.map(({ user }) => user);
|
||||
|
||||
expect(localLdapUsers.length).toBe(1);
|
||||
expect(localLdapUsers[0].email).toBe(ldapUser.mail);
|
||||
expect(localLdapUsers[0].lastName).toBe(ldapUser.sn);
|
||||
expect(localLdapUsers[0].firstName).toBe(ldapUser.givenName);
|
||||
expect(localLdapIdentities[0].providerId).toBe(ldapUser.uid);
|
||||
});
|
||||
|
||||
test('should detect disabled user and persist change in model', async () => {
|
||||
const ldapUser = {
|
||||
mail: randomEmail(),
|
||||
dn: '',
|
||||
sn: 'updated',
|
||||
givenName: randomName(),
|
||||
uid: uniqueId(),
|
||||
};
|
||||
|
||||
await testDb.createLdapUser(
|
||||
{
|
||||
globalRole: globalMemberRole,
|
||||
email: ldapUser.mail,
|
||||
firstName: ldapUser.givenName,
|
||||
lastName: ldapUser.sn,
|
||||
},
|
||||
ldapUser.uid,
|
||||
);
|
||||
|
||||
const synchronization = await runTest([]);
|
||||
expect(synchronization.disabled).toBe(1);
|
||||
|
||||
// Make sure the changes in the "LDAP server" were persisted in the database
|
||||
const allUsers = await testDb.getAllUsers();
|
||||
expect(allUsers.length).toBe(2);
|
||||
|
||||
const ownerUser = allUsers.find((u) => u.email === owner.email)!;
|
||||
expect(ownerUser.email).toBe(owner.email);
|
||||
|
||||
const memberUser = allUsers.find((u) => u.email !== owner.email)!;
|
||||
expect(memberUser.email).toBe(ldapUser.mail);
|
||||
expect(memberUser.lastName).toBe(ldapUser.sn);
|
||||
expect(memberUser.firstName).toBe(ldapUser.givenName);
|
||||
expect(memberUser.disabled).toBe(true);
|
||||
|
||||
const authIdentities = await testDb.getLdapIdentities();
|
||||
expect(authIdentities.length).toBe(0);
|
||||
});
|
||||
|
||||
test('should remove user instance access once the user is disabled during synchronization', async () => {
|
||||
const member = await testDb.createLdapUser({ globalRole: globalMemberRole }, uniqueId());
|
||||
|
||||
jest
|
||||
.spyOn(LdapService.prototype, 'searchWithAdminBinding')
|
||||
.mockImplementation(async () => Promise.resolve([]));
|
||||
|
||||
await authAgent(owner).post('/ldap/sync').send({ type: 'live' });
|
||||
|
||||
const response = await authAgent(member).get('/login');
|
||||
expect(response.body.code).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('GET /ldap/sync should return paginated synchronizations', async () => {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await saveLdapSynchronization({
|
||||
created: 0,
|
||||
scanned: 0,
|
||||
updated: 0,
|
||||
disabled: 0,
|
||||
startedAt: new Date(),
|
||||
endedAt: new Date(),
|
||||
status: 'success',
|
||||
error: '',
|
||||
runMode: 'dry',
|
||||
});
|
||||
}
|
||||
|
||||
let response = await authAgent(owner).get('/ldap/sync?perPage=1&page=0');
|
||||
expect(response.body.data.length).toBe(1);
|
||||
|
||||
response = await authAgent(owner).get('/ldap/sync?perPage=1&page=1');
|
||||
expect(response.body.data.length).toBe(1);
|
||||
});
|
||||
|
||||
describe('POST /login', () => {
|
||||
const runTest = async (ldapUser: LdapUser) => {
|
||||
const ldapConfig = await createLdapConfig();
|
||||
LdapManager.updateConfig(ldapConfig);
|
||||
|
||||
const authlessAgent = utils.createAgent(app);
|
||||
|
||||
jest
|
||||
.spyOn(LdapService.prototype, 'searchWithAdminBinding')
|
||||
.mockImplementation(async () => Promise.resolve([ldapUser]));
|
||||
|
||||
jest
|
||||
.spyOn(LdapService.prototype, 'validUser')
|
||||
.mockImplementation(async () => Promise.resolve());
|
||||
|
||||
const response = await authlessAgent
|
||||
.post('/login')
|
||||
.send({ email: ldapUser.mail, password: 'password' });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['set-cookie']).toBeDefined();
|
||||
expect(response.headers['set-cookie'][0] as string).toContain('n8n-auth=');
|
||||
|
||||
// Make sure the changes in the "LDAP server" were persisted in the database
|
||||
const localLdapIdentities = await testDb.getLdapIdentities();
|
||||
const localLdapUsers = localLdapIdentities.map(({ user }) => user);
|
||||
|
||||
expect(localLdapUsers.length).toBe(1);
|
||||
expect(localLdapUsers[0].email).toBe(ldapUser.mail);
|
||||
expect(localLdapUsers[0].lastName).toBe(ldapUser.sn);
|
||||
expect(localLdapUsers[0].firstName).toBe(ldapUser.givenName);
|
||||
expect(localLdapIdentities[0].providerId).toBe(ldapUser.uid);
|
||||
expect(localLdapUsers[0].disabled).toBe(false);
|
||||
};
|
||||
|
||||
test('should allow new LDAP user to login and synchronize data', async () => {
|
||||
const ldapUser = {
|
||||
mail: randomEmail(),
|
||||
dn: '',
|
||||
sn: '',
|
||||
givenName: randomName(),
|
||||
uid: uniqueId(),
|
||||
};
|
||||
await runTest(ldapUser);
|
||||
});
|
||||
|
||||
test('should allow existing LDAP user to login and synchronize data', async () => {
|
||||
const ldapUser = {
|
||||
mail: randomEmail(),
|
||||
dn: '',
|
||||
sn: 'updated',
|
||||
givenName: 'updated',
|
||||
uid: uniqueId(),
|
||||
};
|
||||
|
||||
await testDb.createLdapUser(
|
||||
{
|
||||
globalRole: globalMemberRole,
|
||||
email: ldapUser.mail,
|
||||
firstName: 'firstname',
|
||||
lastName: 'lastname',
|
||||
},
|
||||
ldapUser.uid,
|
||||
);
|
||||
|
||||
await runTest(ldapUser);
|
||||
});
|
||||
|
||||
test('should transform email user into LDAP user when match found', async () => {
|
||||
const ldapUser = {
|
||||
mail: randomEmail(),
|
||||
dn: '',
|
||||
sn: randomName(),
|
||||
givenName: randomName(),
|
||||
uid: uniqueId(),
|
||||
};
|
||||
|
||||
await testDb.createUser({
|
||||
globalRole: globalMemberRole,
|
||||
email: ldapUser.mail,
|
||||
firstName: ldapUser.givenName,
|
||||
lastName: 'lastname',
|
||||
});
|
||||
|
||||
await runTest(ldapUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Instance owner should able to delete LDAP users', () => {
|
||||
test("don't transfer workflows", async () => {
|
||||
const ldapConfig = await createLdapConfig();
|
||||
LdapManager.updateConfig(ldapConfig);
|
||||
|
||||
const member = await testDb.createLdapUser({ globalRole: globalMemberRole }, uniqueId());
|
||||
|
||||
await authAgent(owner).post(`/users/${member.id}`);
|
||||
});
|
||||
|
||||
test('transfer workflows and credentials', async () => {
|
||||
const ldapConfig = await createLdapConfig();
|
||||
LdapManager.updateConfig(ldapConfig);
|
||||
|
||||
const member = await testDb.createLdapUser({ globalRole: globalMemberRole }, uniqueId());
|
||||
|
||||
// delete the LDAP member and transfer its workflows/credentials to instance owner
|
||||
await authAgent(owner).post(`/users/${member.id}?transferId=${owner.id}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('Sign-type should be returned when listing users', async () => {
|
||||
const ldapConfig = await createLdapConfig();
|
||||
LdapManager.updateConfig(ldapConfig);
|
||||
|
||||
await testDb.createLdapUser(
|
||||
{
|
||||
globalRole: globalMemberRole,
|
||||
},
|
||||
uniqueId(),
|
||||
);
|
||||
|
||||
const allUsers = await testDb.getAllUsers();
|
||||
expect(allUsers.length).toBe(2);
|
||||
|
||||
const ownerUser = allUsers.find((u) => u.email === owner.email)!;
|
||||
expect(sanitizeUser(ownerUser).signInType).toStrictEqual('email');
|
||||
|
||||
const memberUser = allUsers.find((u) => u.email !== owner.email)!;
|
||||
expect(sanitizeUser(memberUser).signInType).toStrictEqual('ldap');
|
||||
});
|
||||
@@ -9,13 +9,3 @@ declare module 'supertest' {
|
||||
extends superagent.SuperAgent<T>,
|
||||
Record<string, any> {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent `repository.delete({})` (non-criteria) from triggering the type error
|
||||
* `Expression produces a union type that is too complex to represent.ts(2590)`
|
||||
*/
|
||||
declare module 'typeorm' {
|
||||
interface Repository<Entity extends ObjectLiteral> {
|
||||
delete(criteria: {}): Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '@db/entities/User';
|
||||
import type { CredentialPayload } from './types';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
/**
|
||||
* Create a random alphanumeric string of random length between two limits, both inclusive.
|
||||
@@ -59,3 +60,5 @@ export const randomCredentialPayload = (): CredentialPayload => ({
|
||||
nodesAccess: [{ nodeType: randomName() }],
|
||||
data: { accessToken: randomString(6, 16) },
|
||||
});
|
||||
|
||||
export const uniqueId = () => uuid();
|
||||
|
||||
@@ -10,17 +10,7 @@ import { mysqlMigrations } from '@db/migrations/mysqldb';
|
||||
import { postgresMigrations } from '@db/migrations/postgresdb';
|
||||
import { sqliteMigrations } from '@db/migrations/sqlite';
|
||||
import { hashPassword } from '@/UserManagement/UserManagementHelper';
|
||||
import { DB_INITIALIZATION_TIMEOUT, MAPPING_TABLES, MAPPING_TABLES_TO_CLEAR } from './constants';
|
||||
import {
|
||||
randomApiKey,
|
||||
randomCredentialPayload,
|
||||
randomEmail,
|
||||
randomName,
|
||||
randomString,
|
||||
randomValidPassword,
|
||||
} from './random';
|
||||
import { categorize, getPostgresSchemaSection } from './utils';
|
||||
|
||||
import { AuthIdentity } from '@/databases/entities/AuthIdentity';
|
||||
import { ExecutionEntity } from '@db/entities/ExecutionEntity';
|
||||
import { InstalledNodes } from '@db/entities/InstalledNodes';
|
||||
import { InstalledPackages } from '@db/entities/InstalledPackages';
|
||||
@@ -35,6 +25,10 @@ import type {
|
||||
InstalledPackagePayload,
|
||||
MappingName,
|
||||
} from './types';
|
||||
import { DB_INITIALIZATION_TIMEOUT, MAPPING_TABLES, MAPPING_TABLES_TO_CLEAR } from './constants';
|
||||
import { randomApiKey, randomEmail, randomName, randomString, randomValidPassword } from './random';
|
||||
import { categorize, getPostgresSchemaSection } from './utils';
|
||||
|
||||
import type { DatabaseType, ICredentialsDb } from '@/Interfaces';
|
||||
|
||||
export type TestDBType = 'postgres' | 'mysql';
|
||||
@@ -103,7 +97,7 @@ export async function terminate() {
|
||||
|
||||
async function truncateMappingTables(
|
||||
dbType: DatabaseType,
|
||||
collections: Array<CollectionName>,
|
||||
collections: CollectionName[],
|
||||
testDb: Connection,
|
||||
) {
|
||||
const mappingTables = collections.reduce<string[]>((acc, collection) => {
|
||||
@@ -115,7 +109,7 @@ async function truncateMappingTables(
|
||||
}, []);
|
||||
|
||||
if (dbType === 'sqlite') {
|
||||
const promises = mappingTables.map((tableName) =>
|
||||
const promises = mappingTables.map(async (tableName) =>
|
||||
testDb.query(
|
||||
`DELETE FROM ${tableName}; DELETE FROM sqlite_sequence WHERE name=${tableName};`,
|
||||
),
|
||||
@@ -152,16 +146,16 @@ async function truncateMappingTables(
|
||||
* @param collections Array of entity names whose tables to truncate.
|
||||
* @param testDbName Name of the test DB to truncate tables in.
|
||||
*/
|
||||
export async function truncate(collections: Array<CollectionName>) {
|
||||
export async function truncate(collections: CollectionName[]) {
|
||||
const dbType = config.getEnv('database.type');
|
||||
const testDb = Db.getConnection();
|
||||
|
||||
if (dbType === 'sqlite') {
|
||||
await testDb.query('PRAGMA foreign_keys=OFF');
|
||||
|
||||
const truncationPromises = collections.map((collection) => {
|
||||
const truncationPromises = collections.map(async (collection) => {
|
||||
const tableName = toTableName(collection);
|
||||
Db.collections[collection].clear();
|
||||
// Db.collections[collection].clear();
|
||||
return testDb.query(
|
||||
`DELETE FROM ${tableName}; DELETE FROM sqlite_sequence WHERE name=${tableName};`,
|
||||
);
|
||||
@@ -200,7 +194,7 @@ export async function truncate(collections: Array<CollectionName>) {
|
||||
|
||||
const hasIdColumn = await testDb
|
||||
.query(`SHOW COLUMNS FROM ${tableName}`)
|
||||
.then((columns: { Field: string }[]) => columns.find((c) => c.Field === 'id'));
|
||||
.then((columns: Array<{ Field: string }>) => columns.find((c) => c.Field === 'id'));
|
||||
|
||||
if (!hasIdColumn) continue;
|
||||
|
||||
@@ -218,18 +212,20 @@ function toTableName(sourceName: CollectionName | MappingName) {
|
||||
if (isMapping(sourceName)) return MAPPING_TABLES[sourceName];
|
||||
|
||||
return {
|
||||
AuthIdentity: 'auth_identity',
|
||||
AuthProviderSyncHistory: 'auth_provider_sync_history',
|
||||
Credentials: 'credentials_entity',
|
||||
Workflow: 'workflow_entity',
|
||||
Execution: 'execution_entity',
|
||||
Tag: 'tag_entity',
|
||||
Webhook: 'webhook_entity',
|
||||
InstalledNodes: 'installed_nodes',
|
||||
InstalledPackages: 'installed_packages',
|
||||
Role: 'role',
|
||||
User: 'user',
|
||||
Settings: 'settings',
|
||||
SharedCredentials: 'shared_credentials',
|
||||
SharedWorkflow: 'shared_workflow',
|
||||
Settings: 'settings',
|
||||
InstalledPackages: 'installed_packages',
|
||||
InstalledNodes: 'installed_nodes',
|
||||
Tag: 'tag_entity',
|
||||
User: 'user',
|
||||
Webhook: 'webhook_entity',
|
||||
Workflow: 'workflow_entity',
|
||||
WorkflowStatistics: 'workflow_statistics',
|
||||
EventDestinations: 'event_destinations',
|
||||
}[sourceName];
|
||||
@@ -243,7 +239,7 @@ function toTableName(sourceName: CollectionName | MappingName) {
|
||||
* Save a credential to the test DB, sharing it with a user.
|
||||
*/
|
||||
export async function saveCredential(
|
||||
credentialPayload: CredentialPayload = randomCredentialPayload(),
|
||||
credentialPayload: CredentialPayload,
|
||||
{ user, role }: { user: User; role: Role },
|
||||
) {
|
||||
const newCredential = new CredentialsEntity();
|
||||
@@ -280,7 +276,7 @@ export async function shareCredentialWithUsers(credential: CredentialsEntity, us
|
||||
}
|
||||
|
||||
export function affixRoleToSaveCredential(role: Role) {
|
||||
return (credentialPayload: CredentialPayload, { user }: { user: User }) =>
|
||||
return async (credentialPayload: CredentialPayload, { user }: { user: User }) =>
|
||||
saveCredential(credentialPayload, { user, role });
|
||||
}
|
||||
|
||||
@@ -293,7 +289,7 @@ export function affixRoleToSaveCredential(role: Role) {
|
||||
*/
|
||||
export async function createUser(attributes: Partial<User> = {}): Promise<User> {
|
||||
const { email, password, firstName, lastName, globalRole, ...rest } = attributes;
|
||||
const user = {
|
||||
const user: Partial<User> = {
|
||||
email: email ?? randomEmail(),
|
||||
password: await hashPassword(password ?? randomValidPassword()),
|
||||
firstName: firstName ?? randomName(),
|
||||
@@ -305,11 +301,17 @@ export async function createUser(attributes: Partial<User> = {}): Promise<User>
|
||||
return Db.collections.User.save(user);
|
||||
}
|
||||
|
||||
export async function createLdapUser(attributes: Partial<User>, ldapId: string): Promise<User> {
|
||||
const user = await createUser(attributes);
|
||||
await Db.collections.AuthIdentity.save(AuthIdentity.create(user, ldapId, 'ldap'));
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function createOwner() {
|
||||
return createUser({ globalRole: await getGlobalOwnerRole() });
|
||||
}
|
||||
|
||||
export function createUserShell(globalRole: Role): Promise<User> {
|
||||
export async function createUserShell(globalRole: Role): Promise<User> {
|
||||
if (globalRole.scope !== 'global') {
|
||||
throw new Error(`Invalid role received: ${JSON.stringify(globalRole)}`);
|
||||
}
|
||||
@@ -366,7 +368,7 @@ export async function saveInstalledPackage(
|
||||
return savedInstalledPackage;
|
||||
}
|
||||
|
||||
export function saveInstalledNode(
|
||||
export async function saveInstalledNode(
|
||||
installedNodePayload: InstalledNodePayload,
|
||||
): Promise<InstalledNodes> {
|
||||
const newInstalledNode = new InstalledNodes();
|
||||
@@ -376,7 +378,7 @@ export function saveInstalledNode(
|
||||
return Db.collections.InstalledNodes.save(newInstalledNode);
|
||||
}
|
||||
|
||||
export function addApiKey(user: User): Promise<User> {
|
||||
export async function addApiKey(user: User): Promise<User> {
|
||||
user.apiKey = randomApiKey();
|
||||
return Db.collections.User.save(user);
|
||||
}
|
||||
@@ -385,42 +387,42 @@ export function addApiKey(user: User): Promise<User> {
|
||||
// role fetchers
|
||||
// ----------------------------------
|
||||
|
||||
export function getGlobalOwnerRole() {
|
||||
export async function getGlobalOwnerRole() {
|
||||
return Db.collections.Role.findOneByOrFail({
|
||||
name: 'owner',
|
||||
scope: 'global',
|
||||
});
|
||||
}
|
||||
|
||||
export function getGlobalMemberRole() {
|
||||
export async function getGlobalMemberRole() {
|
||||
return Db.collections.Role.findOneByOrFail({
|
||||
name: 'member',
|
||||
scope: 'global',
|
||||
});
|
||||
}
|
||||
|
||||
export function getWorkflowOwnerRole() {
|
||||
export async function getWorkflowOwnerRole() {
|
||||
return Db.collections.Role.findOneByOrFail({
|
||||
name: 'owner',
|
||||
scope: 'workflow',
|
||||
});
|
||||
}
|
||||
|
||||
export function getWorkflowEditorRole() {
|
||||
export async function getWorkflowEditorRole() {
|
||||
return Db.collections.Role.findOneByOrFail({
|
||||
name: 'editor',
|
||||
scope: 'workflow',
|
||||
});
|
||||
}
|
||||
|
||||
export function getCredentialOwnerRole() {
|
||||
export async function getCredentialOwnerRole() {
|
||||
return Db.collections.Role.findOneByOrFail({
|
||||
name: 'owner',
|
||||
scope: 'credential',
|
||||
});
|
||||
}
|
||||
|
||||
export function getAllRoles() {
|
||||
export async function getAllRoles() {
|
||||
return Promise.all([
|
||||
getGlobalOwnerRole(),
|
||||
getGlobalMemberRole(),
|
||||
@@ -429,6 +431,17 @@ export function getAllRoles() {
|
||||
]);
|
||||
}
|
||||
|
||||
export const getAllUsers = async () =>
|
||||
Db.collections.User.find({
|
||||
relations: ['globalRole', 'authIdentities'],
|
||||
});
|
||||
|
||||
export const getLdapIdentities = async () =>
|
||||
Db.collections.AuthIdentity.find({
|
||||
where: { providerType: 'ldap' },
|
||||
relations: ['user'],
|
||||
});
|
||||
|
||||
// ----------------------------------
|
||||
// Execution helpers
|
||||
// ----------------------------------
|
||||
@@ -438,17 +451,14 @@ export async function createManyExecutions(
|
||||
workflow: WorkflowEntity,
|
||||
callback: (workflow: WorkflowEntity) => Promise<ExecutionEntity>,
|
||||
) {
|
||||
const executionsRequests = [...Array(amount)].map((_) => callback(workflow));
|
||||
const executionsRequests = [...Array(amount)].map(async (_) => callback(workflow));
|
||||
return Promise.all(executionsRequests);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a execution in the DB and assign it to a workflow.
|
||||
*/
|
||||
export async function createExecution(
|
||||
attributes: Partial<ExecutionEntity> = {},
|
||||
workflow: WorkflowEntity,
|
||||
) {
|
||||
async function createExecution(attributes: Partial<ExecutionEntity>, workflow: WorkflowEntity) {
|
||||
const { data, finished, mode, startedAt, stoppedAt, waitTill } = attributes;
|
||||
|
||||
const execution = await Db.collections.Execution.save({
|
||||
@@ -468,38 +478,21 @@ export async function createExecution(
|
||||
* Store a successful execution in the DB and assign it to a workflow.
|
||||
*/
|
||||
export async function createSuccessfulExecution(workflow: WorkflowEntity) {
|
||||
return await createExecution(
|
||||
{
|
||||
finished: true,
|
||||
},
|
||||
workflow,
|
||||
);
|
||||
return createExecution({ finished: true }, workflow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an error execution in the DB and assign it to a workflow.
|
||||
*/
|
||||
export async function createErrorExecution(workflow: WorkflowEntity) {
|
||||
return await createExecution(
|
||||
{
|
||||
finished: false,
|
||||
stoppedAt: new Date(),
|
||||
},
|
||||
workflow,
|
||||
);
|
||||
return createExecution({ finished: false, stoppedAt: new Date() }, workflow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a waiting execution in the DB and assign it to a workflow.
|
||||
*/
|
||||
export async function createWaitingExecution(workflow: WorkflowEntity) {
|
||||
return await createExecution(
|
||||
{
|
||||
finished: false,
|
||||
waitTill: new Date(),
|
||||
},
|
||||
workflow,
|
||||
);
|
||||
return createExecution({ finished: false, waitTill: new Date() }, workflow);
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
@@ -509,7 +502,7 @@ export async function createWaitingExecution(workflow: WorkflowEntity) {
|
||||
export async function createTag(attributes: Partial<TagEntity> = {}) {
|
||||
const { name } = attributes;
|
||||
|
||||
return await Db.collections.Tag.save({
|
||||
return Db.collections.Tag.save({
|
||||
name: name ?? randomName(),
|
||||
...attributes,
|
||||
});
|
||||
@@ -524,7 +517,7 @@ export async function createManyWorkflows(
|
||||
attributes: Partial<WorkflowEntity> = {},
|
||||
user?: User,
|
||||
) {
|
||||
const workflowRequests = [...Array(amount)].map((_) => createWorkflow(attributes, user));
|
||||
const workflowRequests = [...Array(amount)].map(async (_) => createWorkflow(attributes, user));
|
||||
return Promise.all(workflowRequests);
|
||||
}
|
||||
|
||||
@@ -653,7 +646,7 @@ const baseOptions = (type: TestDBType) => ({
|
||||
port: config.getEnv(`database.${type}db.port`),
|
||||
username: config.getEnv(`database.${type}db.user`),
|
||||
password: config.getEnv(`database.${type}db.password`),
|
||||
schema: type === 'postgres' ? config.getEnv(`database.postgresdb.schema`) : undefined,
|
||||
schema: type === 'postgres' ? config.getEnv('database.postgresdb.schema') : undefined,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,7 @@ type EndpointGroup =
|
||||
| 'workflows'
|
||||
| 'publicApi'
|
||||
| 'nodes'
|
||||
| 'ldap'
|
||||
| 'eventBus'
|
||||
| 'license';
|
||||
|
||||
|
||||
@@ -26,8 +26,6 @@ import type { N8nApp } from '@/UserManagement/Interfaces';
|
||||
import superagent from 'superagent';
|
||||
import request from 'supertest';
|
||||
import { URL } from 'url';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import config from '@/config';
|
||||
import * as Db from '@/Db';
|
||||
import { WorkflowEntity } from '@db/entities/WorkflowEntity';
|
||||
@@ -69,6 +67,10 @@ import type {
|
||||
import { licenseController } from '@/license/license.controller';
|
||||
import { eventBusRouter } from '@/eventbus/eventBusRoutes';
|
||||
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { handleLdapInit } from '../../../src/Ldap/helpers';
|
||||
import { ldapController } from '@/Ldap/routes/ldap.controller.ee';
|
||||
|
||||
const loadNodesAndCredentials: INodesAndCredentials = {
|
||||
loaded: { nodes: {}, credentials: {} },
|
||||
known: { nodes: {}, credentials: {} },
|
||||
@@ -130,6 +132,7 @@ export async function initTestServer({
|
||||
license: { controller: licenseController, path: 'license' },
|
||||
eventBus: { controller: eventBusRouter, path: 'eventbus' },
|
||||
publicApi: apiRouters,
|
||||
ldap: { controller: ldapController, path: 'ldap' },
|
||||
};
|
||||
|
||||
for (const group of routerEndpoints) {
|
||||
@@ -173,7 +176,15 @@ const classifyEndpointGroups = (endpointGroups: string[]) => {
|
||||
const routerEndpoints: string[] = [];
|
||||
const functionEndpoints: string[] = [];
|
||||
|
||||
const ROUTER_GROUP = ['credentials', 'nodes', 'workflows', 'publicApi', 'license', 'eventBus'];
|
||||
const ROUTER_GROUP = [
|
||||
'credentials',
|
||||
'nodes',
|
||||
'workflows',
|
||||
'publicApi',
|
||||
'ldap',
|
||||
'eventBus',
|
||||
'license',
|
||||
];
|
||||
|
||||
endpointGroups.forEach((group) =>
|
||||
(ROUTER_GROUP.includes(group) ? routerEndpoints : functionEndpoints).push(group),
|
||||
@@ -239,6 +250,13 @@ export async function initCredentialsTypes(): Promise<void> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize LDAP manager.
|
||||
*/
|
||||
export async function initLdapManager(): Promise<void> {
|
||||
await handleLdapInit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize node types.
|
||||
*/
|
||||
|
||||
@@ -10,6 +10,7 @@ import { WorkflowEntity } from '@db/entities/WorkflowEntity';
|
||||
import { compareHash } from '@/UserManagement/UserManagementHelper';
|
||||
import { SUCCESS_RESPONSE_BODY } from './shared/constants';
|
||||
import {
|
||||
randomCredentialPayload,
|
||||
randomEmail,
|
||||
randomInvalidPassword,
|
||||
randomName,
|
||||
@@ -208,7 +209,7 @@ test('DELETE /users/:id with transferId should perform transfer', async () => {
|
||||
|
||||
const savedWorkflow = await testDb.createWorkflow(undefined, userToDelete);
|
||||
|
||||
const savedCredential = await testDb.saveCredential(undefined, {
|
||||
const savedCredential = await testDb.saveCredential(randomCredentialPayload(), {
|
||||
user: userToDelete,
|
||||
role: credentialOwnerRole,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user