feat: add endpoint for workflow sharing (#4172) (no changelog)

* feat: add endpoint for workflow sharing

Co-authored-by: Ben Hesseldieck <b.hesseldieck@gmail.com>
This commit is contained in:
Omar Ajoue 2022-10-11 14:55:05 +02:00 committed by GitHub
parent 3390b509aa
commit 07d21d2c5d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 483 additions and 65 deletions

View file

@ -882,6 +882,12 @@ export const schema = {
default: false, default: false,
}, },
}, },
// This is a temporary flag (acting as feature toggle)
// Will be removed when feature goes live
workflowSharingEnabled: {
format: Boolean,
default: false,
},
}, },
hiringBanner: { hiringBanner: {

View file

@ -518,6 +518,7 @@ export interface IN8nUISettings {
isNpmAvailable: boolean; isNpmAvailable: boolean;
enterprise: { enterprise: {
sharing: boolean; sharing: boolean;
workflowSharing: boolean;
}; };
} }

View file

@ -90,7 +90,7 @@ import { WEBHOOK_METHODS } from './WebhookHelpers';
import { getSharedWorkflowIds, whereClause } from './WorkflowHelpers'; import { getSharedWorkflowIds, whereClause } from './WorkflowHelpers';
import { nodesController } from './api/nodes.api'; import { nodesController } from './api/nodes.api';
import { workflowsController } from './api/workflows.api'; import { workflowsController } from './workflows/workflows.controller';
import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from './constants'; import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from './constants';
import { credentialsController } from './credentials/credentials.controller'; import { credentialsController } from './credentials/credentials.controller';
import { oauth2CredentialController } from './credentials/oauth2Credential.api'; import { oauth2CredentialController } from './credentials/oauth2Credential.api';
@ -108,12 +108,12 @@ import { resolveJwt } from './UserManagement/auth/jwt';
import { executionsController } from './api/executions.api'; import { executionsController } from './api/executions.api';
import { nodeTypesController } from './api/nodeTypes.api'; import { nodeTypesController } from './api/nodeTypes.api';
import { tagsController } from './api/tags.api'; import { tagsController } from './api/tags.api';
import { isCredentialsSharingEnabled } from './credentials/helpers';
import { loadPublicApiVersions } from './PublicApi'; import { loadPublicApiVersions } from './PublicApi';
import * as telemetryScripts from './telemetry/scripts'; import * as telemetryScripts from './telemetry/scripts';
import { import {
getInstanceBaseUrl, getInstanceBaseUrl,
isEmailSetUp, isEmailSetUp,
isSharingEnabled,
isUserManagementEnabled, isUserManagementEnabled,
} from './UserManagement/UserManagementHelper'; } from './UserManagement/UserManagementHelper';
import { import {
@ -330,6 +330,7 @@ class App {
isNpmAvailable: false, isNpmAvailable: false,
enterprise: { enterprise: {
sharing: false, sharing: false,
workflowSharing: false,
}, },
}; };
} }
@ -357,7 +358,8 @@ class App {
// refresh enterprise status // refresh enterprise status
Object.assign(this.frontendSettings.enterprise, { Object.assign(this.frontendSettings.enterprise, {
sharing: isCredentialsSharingEnabled(), sharing: isSharingEnabled(),
workflowSharing: config.getEnv('enterprise.workflowSharingEnabled'),
}); });
if (config.get('nodes.packagesMissing').length > 0) { if (config.get('nodes.packagesMissing').length > 0) {

View file

@ -39,6 +39,10 @@ export function isUserManagementEnabled(): boolean {
); );
} }
export function isSharingEnabled(): boolean {
return isUserManagementEnabled() && config.getEnv('enterprise.features.sharing');
}
export function isUserManagementDisabled(): boolean { export function isUserManagementDisabled(): boolean {
return ( return (
config.getEnv('userManagement.disabled') && config.getEnv('userManagement.disabled') &&
@ -276,3 +280,24 @@ export async function compareHash(plaintext: string, hashed: string): Promise<bo
throw new Error(error); throw new Error(error);
} }
} }
// return the difference between two arrays
export function rightDiff<T1, T2>(
[arr1, keyExtractor1]: [T1[], (item: T1) => string],
[arr2, keyExtractor2]: [T2[], (item: T2) => string],
): T2[] {
// create map { itemKey => true } for fast lookup for diff
const keyMap = arr1.reduce<{ [key: string]: true }>((map, item) => {
// eslint-disable-next-line no-param-reassign
map[keyExtractor1(item)] = true;
return map;
}, {});
// diff against map
return arr2.reduce<T2[]>((acc, item) => {
if (!keyMap[keyExtractor2(item)]) {
acc.push(item);
}
return acc;
}, []);
}

View file

@ -5,15 +5,15 @@ import { Db, InternalHooksManager, ResponseHelper } from '..';
import type { CredentialsEntity } from '../databases/entities/CredentialsEntity'; import type { CredentialsEntity } from '../databases/entities/CredentialsEntity';
import type { CredentialRequest } from '../requests'; import type { CredentialRequest } from '../requests';
import { isSharingEnabled, rightDiff } from '../UserManagement/UserManagementHelper';
import { EECredentialsService as EECredentials } from './credentials.service.ee'; import { EECredentialsService as EECredentials } from './credentials.service.ee';
import type { CredentialWithSharings } from './credentials.types'; import type { CredentialWithSharings } from './credentials.types';
import { isCredentialsSharingEnabled, rightDiff } from './helpers';
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
export const EECredentialsController = express.Router(); export const EECredentialsController = express.Router();
EECredentialsController.use((req, res, next) => { EECredentialsController.use((req, res, next) => {
if (!isCredentialsSharingEnabled()) { if (!isSharingEnabled()) {
// skip ee router and use free one // skip ee router and use free one
next('router'); next('router');
return; return;

View file

@ -1,28 +0,0 @@
/* eslint-disable import/no-cycle */
import config from '../../config';
import { isUserManagementEnabled } from '../UserManagement/UserManagementHelper';
export function isCredentialsSharingEnabled(): boolean {
return isUserManagementEnabled() && config.getEnv('enterprise.features.sharing');
}
// return the difference between two arrays
export function rightDiff<T1, T2>(
[arr1, keyExtractor1]: [T1[], (item: T1) => string],
[arr2, keyExtractor2]: [T2[], (item: T2) => string],
): T2[] {
// create map { itemKey => true } for fast lookup for diff
const keyMap = arr1.reduce<{ [key: string]: true }>((map, item) => {
// eslint-disable-next-line no-param-reassign
map[keyExtractor1(item)] = true;
return map;
}, {});
// diff against map
return arr2.reduce<T2[]>((acc, item) => {
if (!keyMap[keyExtractor2(item)]) {
acc.push(item);
}
return acc;
}, []);
}

View file

@ -17,7 +17,7 @@ import { User } from './User';
import { SharedWorkflow } from './SharedWorkflow'; import { SharedWorkflow } from './SharedWorkflow';
import { SharedCredentials } from './SharedCredentials'; import { SharedCredentials } from './SharedCredentials';
type RoleNames = 'owner' | 'member' | 'user'; type RoleNames = 'owner' | 'member' | 'user' | 'editor';
type RoleScopes = 'global' | 'workflow' | 'credential'; type RoleScopes = 'global' | 'workflow' | 'credential';
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types

View file

@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { getTablePrefix, logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers';
export class CreateWorkflowsEditorRole1663755770894 implements MigrationInterface {
name = 'CreateWorkflowsEditorRole1663755770894';
async up(queryRunner: QueryRunner) {
logMigrationStart(this.name);
const tablePrefix = getTablePrefix();
await queryRunner.query(`
INSERT IGNORE INTO ${tablePrefix}role (name, scope)
VALUES ("editor", "workflow")
`);
logMigrationEnd(this.name);
}
async down(queryRunner: QueryRunner) {
const tablePrefix = getTablePrefix();
await queryRunner.query(`
DELETE FROM ${tablePrefix}role WHERE name='user' AND scope='workflow';
`);
}
}

View file

@ -20,6 +20,7 @@ import { IntroducePinData1654090101303 } from './1654090101303-IntroducePinData'
import { AddNodeIds1658932910559 } from './1658932910559-AddNodeIds'; import { AddNodeIds1658932910559 } from './1658932910559-AddNodeIds';
import { AddJsonKeyPinData1659895550980 } from './1659895550980-AddJsonKeyPinData'; import { AddJsonKeyPinData1659895550980 } from './1659895550980-AddJsonKeyPinData';
import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole'; import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole';
import { CreateWorkflowsEditorRole1663755770894 } from './1663755770894-CreateWorkflowsEditorRole';
export const mysqlMigrations = [ export const mysqlMigrations = [
InitialMigration1588157391238, InitialMigration1588157391238,
@ -44,4 +45,5 @@ export const mysqlMigrations = [
AddNodeIds1658932910559, AddNodeIds1658932910559,
AddJsonKeyPinData1659895550980, AddJsonKeyPinData1659895550980,
CreateCredentialsUserRole1660062385367, CreateCredentialsUserRole1660062385367,
CreateWorkflowsEditorRole1663755770894,
]; ];

View file

@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { getTablePrefix, logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers';
export class CreateWorkflowsEditorRole1663755770893 implements MigrationInterface {
name = 'CreateWorkflowsEditorRole1663755770893';
async up(queryRunner: QueryRunner) {
logMigrationStart(this.name);
const tablePrefix = getTablePrefix();
await queryRunner.query(`
INSERT INTO ${tablePrefix}role (name, scope)
VALUES ('editor', 'workflow')
ON CONFLICT DO NOTHING;
`);
logMigrationEnd(this.name);
}
async down(queryRunner: QueryRunner) {
const tablePrefix = getTablePrefix();
await queryRunner.query(`
DELETE FROM ${tablePrefix}role WHERE name='user' AND scope='workflow';
`);
}
}

View file

@ -18,6 +18,7 @@ import { IntroducePinData1654090467022 } from './1654090467022-IntroducePinData'
import { AddNodeIds1658932090381 } from './1658932090381-AddNodeIds'; import { AddNodeIds1658932090381 } from './1658932090381-AddNodeIds';
import { AddJsonKeyPinData1659902242948 } from './1659902242948-AddJsonKeyPinData'; import { AddJsonKeyPinData1659902242948 } from './1659902242948-AddJsonKeyPinData';
import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole'; import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole';
import { CreateWorkflowsEditorRole1663755770893 } from './1663755770893-CreateWorkflowsEditorRole';
export const postgresMigrations = [ export const postgresMigrations = [
InitialMigration1587669153312, InitialMigration1587669153312,
@ -40,4 +41,5 @@ export const postgresMigrations = [
CreateCredentialsUserRole1660062385367, CreateCredentialsUserRole1660062385367,
AddNodeIds1658932090381, AddNodeIds1658932090381,
AddJsonKeyPinData1659902242948, AddJsonKeyPinData1659902242948,
CreateWorkflowsEditorRole1663755770893,
]; ];

View file

@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { getTablePrefix, logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers';
export class CreateWorkflowsEditorRole1663755770892 implements MigrationInterface {
name = 'CreateWorkflowsEditorRole1663755770892';
async up(queryRunner: QueryRunner) {
logMigrationStart(this.name);
const tablePrefix = getTablePrefix();
await queryRunner.query(`
INSERT INTO "${tablePrefix}role" (name, scope)
VALUES ("editor", "workflow")
ON CONFLICT DO NOTHING;
`);
logMigrationEnd(this.name);
}
async down(queryRunner: QueryRunner) {
const tablePrefix = getTablePrefix();
await queryRunner.query(`
DELETE FROM "${tablePrefix}role" WHERE name='user' AND scope='workflow';
`);
}
}

View file

@ -17,6 +17,7 @@ import { IntroducePinData1654089251344 } from './1654089251344-IntroducePinData'
import { AddNodeIds1658930531669 } from './1658930531669-AddNodeIds'; import { AddNodeIds1658930531669 } from './1658930531669-AddNodeIds';
import { AddJsonKeyPinData1659888469333 } from './1659888469333-AddJsonKeyPinData'; import { AddJsonKeyPinData1659888469333 } from './1659888469333-AddJsonKeyPinData';
import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole'; import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole';
import { CreateWorkflowsEditorRole1663755770892 } from './1663755770892-CreateWorkflowsUserRole';
const sqliteMigrations = [ const sqliteMigrations = [
InitialMigration1588102412422, InitialMigration1588102412422,
@ -38,6 +39,7 @@ const sqliteMigrations = [
AddNodeIds1658930531669, AddNodeIds1658930531669,
AddJsonKeyPinData1659888469333, AddJsonKeyPinData1659888469333,
CreateCredentialsUserRole1660062385367, CreateCredentialsUserRole1660062385367,
CreateWorkflowsEditorRole1663755770892,
]; ];
export { sqliteMigrations }; export { sqliteMigrations };

View file

@ -77,6 +77,8 @@ export declare namespace WorkflowRequest {
destinationNode?: string; destinationNode?: string;
} }
>; >;
type Share = AuthenticatedRequest<{ workflowId: string }, {}, { shareWithIds: string[] }>;
} }
// ---------------------------------- // ----------------------------------

View file

@ -0,0 +1,60 @@
import express from 'express';
import { Db } from '..';
import config from '../../config';
import type { WorkflowRequest } from '../requests';
import { isSharingEnabled, rightDiff } from '../UserManagement/UserManagementHelper';
import { EEWorkflowsService as EEWorkflows } from './workflows.services.ee';
// eslint-disable-next-line @typescript-eslint/naming-convention
export const EEWorkflowController = express.Router();
EEWorkflowController.use((req, res, next) => {
if (!isSharingEnabled() || !config.getEnv('enterprise.workflowSharingEnabled')) {
// skip ee router and use free one
next('router');
return;
}
// use ee router
next();
});
/**
* (EE) PUT /workflows/:id/share
*
* Grant or remove users' access to a workflow.
*/
EEWorkflowController.put('/:workflowId/share', async (req: WorkflowRequest.Share, res) => {
const { workflowId } = req.params;
const { shareWithIds } = req.body;
if (!Array.isArray(shareWithIds) || !shareWithIds.every((userId) => typeof userId === 'string')) {
return res.status(400).send('Bad Request');
}
const { ownsWorkflow, workflow } = await EEWorkflows.isOwned(req.user, workflowId);
if (!ownsWorkflow || !workflow) {
return res.status(403).send();
}
let newShareeIds: string[] = [];
await Db.transaction(async (trx) => {
// remove all sharings that are not supposed to exist anymore
await EEWorkflows.pruneSharings(trx, workflowId, [req.user.id, ...shareWithIds]);
const sharings = await EEWorkflows.getSharings(trx, workflowId);
// extract the new sharings that need to be added
newShareeIds = rightDiff(
[sharings, (sharing) => sharing.userId],
[shareWithIds, (shareeId) => shareeId],
);
if (newShareeIds.length) {
await EEWorkflows.share(trx, workflow, newShareeIds);
}
});
return res.status(200).send();
});

View file

@ -29,12 +29,28 @@ import { WorkflowEntity } from '../databases/entities/WorkflowEntity';
import { validateEntity } from '../GenericHelpers'; import { validateEntity } from '../GenericHelpers';
import { InternalHooksManager } from '../InternalHooksManager'; import { InternalHooksManager } from '../InternalHooksManager';
import { externalHooks } from '../Server'; import { externalHooks } from '../Server';
import { getLogger } from '../Logger';
import type { WorkflowRequest } from '../requests'; import type { WorkflowRequest } from '../requests';
import { isBelowOnboardingThreshold } from '../WorkflowHelpers'; import { isBelowOnboardingThreshold } from '../WorkflowHelpers';
import { EEWorkflowController } from './workflows.controller.ee';
const activeWorkflowRunner = ActiveWorkflowRunner.getInstance(); const activeWorkflowRunner = ActiveWorkflowRunner.getInstance();
export const workflowsController = express.Router(); export const workflowsController = express.Router();
/**
* Initialize Logger if needed
*/
workflowsController.use((req, res, next) => {
try {
LoggerProxy.getInstance();
} catch (error) {
LoggerProxy.init(getLogger());
}
next();
});
workflowsController.use('/', EEWorkflowController);
const isTrigger = (nodeType: string) => const isTrigger = (nodeType: string) =>
['trigger', 'webhook'].some((suffix) => nodeType.toLowerCase().includes(suffix)); ['trigger', 'webhook'].some((suffix) => nodeType.toLowerCase().includes(suffix));

View file

@ -0,0 +1,73 @@
import { DeleteResult, EntityManager, In, Not } from 'typeorm';
import { Db } from '..';
import { SharedWorkflow } from '../databases/entities/SharedWorkflow';
import { User } from '../databases/entities/User';
import { WorkflowEntity } from '../databases/entities/WorkflowEntity';
import { RoleService } from '../role/role.service';
import { UserService } from '../user/user.service';
import { WorkflowsService } from './workflows.services';
export class EEWorkflowsService extends WorkflowsService {
static async isOwned(
user: User,
workflowId: string,
): Promise<{ ownsWorkflow: boolean; workflow?: WorkflowEntity }> {
const sharing = await this.getSharing(user, workflowId, ['workflow', 'role'], {
allowGlobalOwner: false,
});
if (!sharing || sharing.role.name !== 'owner') return { ownsWorkflow: false };
const { workflow } = sharing;
return { ownsWorkflow: true, workflow };
}
static async getSharings(
transaction: EntityManager,
workflowId: string,
): Promise<SharedWorkflow[]> {
const workflow = await transaction.findOne(WorkflowEntity, workflowId, {
relations: ['shared'],
});
return workflow?.shared ?? [];
}
static async pruneSharings(
transaction: EntityManager,
workflowId: string,
userIds: string[],
): Promise<DeleteResult> {
return transaction.delete(SharedWorkflow, {
workflow: { id: workflowId },
user: { id: Not(In(userIds)) },
});
}
static async share(
transaction: EntityManager,
workflow: WorkflowEntity,
shareWithIds: string[],
): Promise<SharedWorkflow[]> {
const [users, role] = await Promise.all([
UserService.getByIds(transaction, shareWithIds),
RoleService.trxGet(transaction, { scope: 'workflow', name: 'editor' }),
]);
const newSharedWorkflows = users.reduce<SharedWorkflow[]>((acc, user) => {
if (user.isPending) {
return acc;
}
acc.push(
Db.collections.SharedWorkflow.create({
workflow,
user,
role,
}),
);
return acc;
}, []);
return transaction.save(newSharedWorkflows);
}
}

View file

@ -0,0 +1,32 @@
import { FindOneOptions, ObjectLiteral } from 'typeorm';
import { Db } from '..';
import { SharedWorkflow } from '../databases/entities/SharedWorkflow';
import { User } from '../databases/entities/User';
export class WorkflowsService {
static async getSharing(
user: User,
workflowId: number | string,
relations: string[] = ['workflow'],
{ allowGlobalOwner } = { allowGlobalOwner: true },
): Promise<SharedWorkflow | undefined> {
const options: FindOneOptions<SharedWorkflow> & { where: ObjectLiteral } = {
where: {
workflow: { id: workflowId },
},
};
// Omit user from where if the requesting user is the global
// owner. This allows the global owner to view and delete
// workflows they don't own.
if (!allowGlobalOwner || user.globalRole.name !== 'owner') {
options.where.user = { id: user.id };
}
if (relations?.length) {
options.relations = relations;
}
return Db.collections.SharedWorkflow.findOne(options);
}
}

View file

@ -5,7 +5,7 @@ import { In } from 'typeorm';
import { Db, IUser } from '../../src'; import { Db, IUser } from '../../src';
import { RESPONSE_ERROR_MESSAGES } from '../../src/constants'; import { RESPONSE_ERROR_MESSAGES } from '../../src/constants';
import type { CredentialWithSharings } from '../../src/credentials/credentials.types'; import type { CredentialWithSharings } from '../../src/credentials/credentials.types';
import * as CredentialHelpers from '../../src/credentials/helpers'; import * as UserManagementHelpers from '../../src/UserManagement/UserManagementHelper';
import type { Role } from '../../src/databases/entities/Role'; import type { Role } from '../../src/databases/entities/Role';
import { randomCredentialPayload } from './shared/random'; import { randomCredentialPayload } from './shared/random';
import * as testDb from './shared/testDb'; import * as testDb from './shared/testDb';
@ -15,10 +15,7 @@ import * as utils from './shared/utils';
jest.mock('../../src/telemetry'); jest.mock('../../src/telemetry');
// mock whether credentialsSharing is enabled or not // mock whether credentialsSharing is enabled or not
const mockIsCredentialsSharingEnabled = jest.spyOn( const mockIsCredentialsSharingEnabled = jest.spyOn(UserManagementHelpers, 'isSharingEnabled');
CredentialHelpers,
'isCredentialsSharingEnabled',
);
mockIsCredentialsSharingEnabled.mockReturnValue(true); mockIsCredentialsSharingEnabled.mockReturnValue(true);
let app: express.Application; let app: express.Application;
@ -121,9 +118,9 @@ test('GET /credentials should return all creds for owner', async () => {
const response = await authAgent(owner).get('/credentials'); const response = await authAgent(owner).get('/credentials');
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(2); // owner retrieved owner cred and member cred expect(response.body.data).toHaveLength(2); // owner retrieved owner cred and member cred
const [ownerCredential, memberCredential] = response.body.data; const [ownerCredential, memberCredential] = response.body.data as CredentialWithSharings[];
validateMainCredentialData(ownerCredential); validateMainCredentialData(ownerCredential);
expect(ownerCredential.data).toBeUndefined(); expect(ownerCredential.data).toBeUndefined();
@ -139,14 +136,20 @@ test('GET /credentials should return all creds for owner', async () => {
}); });
expect(Array.isArray(ownerCredential.sharedWith)).toBe(true); expect(Array.isArray(ownerCredential.sharedWith)).toBe(true);
expect(ownerCredential.sharedWith.length).toBe(3); expect(ownerCredential.sharedWith).toHaveLength(3);
ownerCredential.sharedWith.forEach((sharee: IUser, idx: number) => { // Fix order issue (MySQL might return items in any order)
const ownerCredentialsSharedWithOrdered = [...ownerCredential.sharedWith!].sort(
(a: IUser, b: IUser) => (a.email < b.email ? -1 : 1),
);
const orderedSharedWith = [...sharedWith].sort((a, b) => (a.email < b.email ? -1 : 1));
ownerCredentialsSharedWithOrdered.forEach((sharee: IUser, idx: number) => {
expect(sharee).toMatchObject({ expect(sharee).toMatchObject({
id: sharedWith[idx].id, id: orderedSharedWith[idx].id,
email: sharedWith[idx].email, email: orderedSharedWith[idx].email,
firstName: sharedWith[idx].firstName, firstName: orderedSharedWith[idx].firstName,
lastName: sharedWith[idx].lastName, lastName: orderedSharedWith[idx].lastName,
}); });
}); });
@ -158,7 +161,7 @@ test('GET /credentials should return all creds for owner', async () => {
}); });
expect(Array.isArray(memberCredential.sharedWith)).toBe(true); expect(Array.isArray(memberCredential.sharedWith)).toBe(true);
expect(memberCredential.sharedWith.length).toBe(0); expect(memberCredential.sharedWith).toHaveLength(0);
}); });
test('GET /credentials should return only relevant creds for member', async () => { test('GET /credentials should return only relevant creds for member', async () => {
@ -174,7 +177,7 @@ test('GET /credentials should return only relevant creds for member', async () =
const response = await authAgent(member1).get('/credentials'); const response = await authAgent(member1).get('/credentials');
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(1); // member retrieved only member cred expect(response.body.data).toHaveLength(1); // member retrieved only member cred
const [member1Credential] = response.body.data; const [member1Credential] = response.body.data;
@ -189,7 +192,7 @@ test('GET /credentials should return only relevant creds for member', async () =
}); });
expect(Array.isArray(member1Credential.sharedWith)).toBe(true); expect(Array.isArray(member1Credential.sharedWith)).toBe(true);
expect(member1Credential.sharedWith.length).toBe(1); expect(member1Credential.sharedWith).toHaveLength(1);
const [sharee] = member1Credential.sharedWith; const [sharee] = member1Credential.sharedWith;
@ -223,7 +226,7 @@ test('GET /credentials/:id should retrieve owned cred for owner', async () => {
firstName: ownerShell.firstName, firstName: ownerShell.firstName,
lastName: ownerShell.lastName, lastName: ownerShell.lastName,
}); });
expect(firstCredential.sharedWith.length).toBe(0); expect(firstCredential.sharedWith).toHaveLength(0);
const secondResponse = await authOwnerAgent const secondResponse = await authOwnerAgent
.get(`/credentials/${savedCredential.id}`) .get(`/credentials/${savedCredential.id}`)
@ -258,7 +261,7 @@ test('GET /credentials/:id should retrieve non-owned cred for owner', async () =
firstName: member1.firstName, firstName: member1.firstName,
lastName: member1.lastName, lastName: member1.lastName,
}); });
expect(response1.body.data.sharedWith.length).toBe(1); expect(response1.body.data.sharedWith).toHaveLength(1);
expect(response1.body.data.sharedWith[0]).toMatchObject({ expect(response1.body.data.sharedWith[0]).toMatchObject({
id: member2.id, id: member2.id,
email: member2.email, email: member2.email,
@ -274,7 +277,7 @@ test('GET /credentials/:id should retrieve non-owned cred for owner', async () =
validateMainCredentialData(response2.body.data); validateMainCredentialData(response2.body.data);
expect(response2.body.data.data).toBeUndefined(); expect(response2.body.data.data).toBeUndefined();
expect(response2.body.data.sharedWith.length).toBe(1); expect(response2.body.data.sharedWith).toHaveLength(1);
}); });
test('GET /credentials/:id should retrieve owned cred for member', async () => { test('GET /credentials/:id should retrieve owned cred for member', async () => {
@ -298,7 +301,7 @@ test('GET /credentials/:id should retrieve owned cred for member', async () => {
firstName: member1.firstName, firstName: member1.firstName,
lastName: member1.lastName, lastName: member1.lastName,
}); });
expect(firstCredential.sharedWith.length).toBe(2); expect(firstCredential.sharedWith).toHaveLength(2);
firstCredential.sharedWith.forEach((sharee: IUser, idx: number) => { firstCredential.sharedWith.forEach((sharee: IUser, idx: number) => {
expect([member2.id, member3.id]).toContain(sharee.id); expect([member2.id, member3.id]).toContain(sharee.id);
}); });
@ -312,7 +315,7 @@ test('GET /credentials/:id should retrieve owned cred for member', async () => {
const { data: secondCredential } = secondResponse.body; const { data: secondCredential } = secondResponse.body;
validateMainCredentialData(secondCredential); validateMainCredentialData(secondCredential);
expect(secondCredential.data).toBeDefined(); expect(secondCredential.data).toBeDefined();
expect(firstCredential.sharedWith.length).toBe(2); expect(firstCredential.sharedWith).toHaveLength(2);
}); });
test('GET /credentials/:id should not retrieve non-owned cred for member', async () => { test('GET /credentials/:id should not retrieve non-owned cred for member', async () => {
@ -478,7 +481,7 @@ test('PUT /credentials/:id/share should ignore pending sharee', async () => {
where: { credentials: savedCredential }, where: { credentials: savedCredential },
}); });
expect(sharedCredentials.length).toBe(1); expect(sharedCredentials).toHaveLength(1);
expect(sharedCredentials[0].userId).toBe(owner.id); expect(sharedCredentials[0].userId).toBe(owner.id);
}); });
@ -496,7 +499,7 @@ test('PUT /credentials/:id/share should ignore non-existing sharee', async () =>
where: { credentials: savedCredential }, where: { credentials: savedCredential },
}); });
expect(sharedCredentials.length).toBe(1); expect(sharedCredentials).toHaveLength(1);
expect(sharedCredentials[0].userId).toBe(owner.id); expect(sharedCredentials[0].userId).toBe(owner.id);
}); });
@ -538,7 +541,7 @@ test('PUT /credentials/:id/share should unshare the credential', async () => {
where: { credentials: savedCredential }, where: { credentials: savedCredential },
}); });
expect(sharedCredentials.length).toBe(1); expect(sharedCredentials).toHaveLength(1);
expect(sharedCredentials[0].userId).toBe(owner.id); expect(sharedCredentials[0].userId).toBe(owner.id);
}); });

View file

@ -3,7 +3,7 @@ import { UserSettings } from 'n8n-core';
import { Db } from '../../src'; import { Db } from '../../src';
import { RESPONSE_ERROR_MESSAGES } from '../../src/constants'; import { RESPONSE_ERROR_MESSAGES } from '../../src/constants';
import * as CredentialHelpers from '../../src/credentials/helpers'; import * as UserManagementHelpers from '../../src/UserManagement/UserManagementHelper';
import type { Role } from '../../src/databases/entities/Role'; import type { Role } from '../../src/databases/entities/Role';
import { randomCredentialPayload, randomName, randomString } from './shared/random'; import { randomCredentialPayload, randomName, randomString } from './shared/random';
import * as testDb from './shared/testDb'; import * as testDb from './shared/testDb';
@ -17,10 +17,7 @@ import type { AuthAgent } from './shared/types';
jest.mock('../../src/telemetry'); jest.mock('../../src/telemetry');
// mock that credentialsSharing is not enabled // mock that credentialsSharing is not enabled
const mockIsCredentialsSharingEnabled = jest.spyOn( const mockIsCredentialsSharingEnabled = jest.spyOn(UserManagementHelpers, 'isSharingEnabled');
CredentialHelpers,
'isCredentialsSharingEnabled',
);
mockIsCredentialsSharingEnabled.mockReturnValue(false); mockIsCredentialsSharingEnabled.mockReturnValue(false);
let app: express.Application; let app: express.Application;

View file

@ -96,7 +96,12 @@ export async function init() {
try { try {
const schema = config.getEnv('database.postgresdb.schema'); const schema = config.getEnv('database.postgresdb.schema');
await exec(`psql -d ${testDbName} -c "CREATE SCHEMA IF NOT EXISTS ${schema}";`); const exportPasswordCli = pgOptions.password
? `export PGPASSWORD=${pgOptions.password} && `
: '';
await exec(
`${exportPasswordCli} psql -h ${pgOptions.host} -U ${pgOptions.username} -d ${testDbName} -c "CREATE SCHEMA IF NOT EXISTS ${schema}";`,
);
} catch (error) { } catch (error) {
if (error instanceof Error && error.message.includes('command not found')) { if (error instanceof Error && error.message.includes('command not found')) {
console.error( console.error(
@ -647,6 +652,18 @@ export async function createWorkflowWithTrigger(
return workflow; return workflow;
} }
// ----------------------------------
// workflow sharing
// ----------------------------------
export async function getWorkflowSharing(workflow: WorkflowEntity) {
return Db.collections.SharedWorkflow.find({
where: {
workflow,
},
});
}
// ---------------------------------- // ----------------------------------
// connection options // connection options
// ---------------------------------- // ----------------------------------

View file

@ -41,7 +41,7 @@ import { authenticationMethods as authEndpoints } from '../../../src/UserManagem
import { ownerNamespace as ownerEndpoints } from '../../../src/UserManagement/routes/owner'; import { ownerNamespace as ownerEndpoints } from '../../../src/UserManagement/routes/owner';
import { passwordResetNamespace as passwordResetEndpoints } from '../../../src/UserManagement/routes/passwordReset'; import { passwordResetNamespace as passwordResetEndpoints } from '../../../src/UserManagement/routes/passwordReset';
import { nodesController } from '../../../src/api/nodes.api'; import { nodesController } from '../../../src/api/nodes.api';
import { workflowsController } from '../../../src/api/workflows.api'; import { workflowsController } from '../../../src/workflows/workflows.controller';
import { AUTH_COOKIE_NAME, NODE_PACKAGE_PREFIX } from '../../../src/constants'; import { AUTH_COOKIE_NAME, NODE_PACKAGE_PREFIX } from '../../../src/constants';
import { credentialsController } from '../../../src/credentials/credentials.controller'; import { credentialsController } from '../../../src/credentials/credentials.controller';
import { InstalledPackages } from '../../../src/databases/entities/InstalledPackages'; import { InstalledPackages } from '../../../src/databases/entities/InstalledPackages';

View file

@ -0,0 +1,121 @@
import express from 'express';
import * as utils from './shared/utils';
import * as testDb from './shared/testDb';
import { createWorkflow } from './shared/testDb';
import * as UserManagementHelpers from '../../src/UserManagement/UserManagementHelper';
import { v4 as uuid } from 'uuid';
import type { Role } from '../../src/databases/entities/Role';
import config from '../../config';
import type { AuthAgent } from './shared/types';
jest.mock('../../src/telemetry');
// mock whether sharing is enabled or not
jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true);
let app: express.Application;
let testDbName = '';
let globalOwnerRole: Role;
let globalMemberRole: Role;
let authAgent: AuthAgent;
beforeAll(async () => {
app = await utils.initTestServer({
endpointGroups: ['workflows'],
applyAuth: true,
});
const initResult = await testDb.init();
testDbName = initResult.testDbName;
globalOwnerRole = await testDb.getGlobalOwnerRole();
globalMemberRole = await testDb.getGlobalMemberRole();
authAgent = utils.createAuthAgent(app);
utils.initTestLogger();
utils.initTestTelemetry();
config.set('enterprise.workflowSharingEnabled', true);
});
beforeEach(async () => {
await testDb.truncate(['User', 'Workflow', 'SharedWorkflow'], testDbName);
});
afterAll(async () => {
await testDb.terminate(testDbName);
});
test('PUT /workflows/:id/share should save sharing with new users', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const member = await testDb.createUser({ globalRole: globalMemberRole });
const workflow = await createWorkflow({}, owner);
const response = await authAgent(owner)
.put(`/workflows/${workflow.id}/share`)
.send({ shareWithIds: [member.id] });
expect(response.statusCode).toBe(200);
const sharedWorkflows = await testDb.getWorkflowSharing(workflow);
expect(sharedWorkflows).toHaveLength(2);
});
test('PUT /workflows/:id/share should not fail when sharing with invalid user-id', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const workflow = await createWorkflow({}, owner);
const response = await authAgent(owner)
.put(`/workflows/${workflow.id}/share`)
.send({ shareWithIds: [uuid()] });
expect(response.statusCode).toBe(200);
const sharedWorkflows = await testDb.getWorkflowSharing(workflow);
expect(sharedWorkflows).toHaveLength(1);
});
test('PUT /workflows/:id/share should allow sharing with multiple users', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const member = await testDb.createUser({ globalRole: globalMemberRole });
const anotherMember = await testDb.createUser({ globalRole: globalMemberRole });
const workflow = await createWorkflow({}, owner);
const response = await authAgent(owner)
.put(`/workflows/${workflow.id}/share`)
.send({ shareWithIds: [member.id, anotherMember.id] });
expect(response.statusCode).toBe(200);
const sharedWorkflows = await testDb.getWorkflowSharing(workflow);
expect(sharedWorkflows).toHaveLength(3);
});
test('PUT /workflows/:id/share should override sharing', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const member = await testDb.createUser({ globalRole: globalMemberRole });
const anotherMember = await testDb.createUser({ globalRole: globalMemberRole });
const workflow = await createWorkflow({}, owner);
const authOwnerAgent = authAgent(owner);
const response = await authOwnerAgent
.put(`/workflows/${workflow.id}/share`)
.send({ shareWithIds: [member.id, anotherMember.id] });
expect(response.statusCode).toBe(200);
const sharedWorkflows = await testDb.getWorkflowSharing(workflow);
expect(sharedWorkflows).toHaveLength(3);
const secondResponse = await authOwnerAgent
.put(`/workflows/${workflow.id}/share`)
.send({ shareWithIds: [member.id] });
expect(secondResponse.statusCode).toBe(200);
const secondSharedWorkflows = await testDb.getWorkflowSharing(workflow);
expect(secondSharedWorkflows).toHaveLength(2);
});

View file

@ -3,6 +3,7 @@ import express from 'express';
import * as utils from './shared/utils'; import * as utils from './shared/utils';
import * as testDb from './shared/testDb'; import * as testDb from './shared/testDb';
import { WorkflowEntity } from '../../src/databases/entities/WorkflowEntity'; import { WorkflowEntity } from '../../src/databases/entities/WorkflowEntity';
import * as UserManagementHelpers from '../../src/UserManagement/UserManagementHelper';
import type { Role } from '../../src/databases/entities/Role'; import type { Role } from '../../src/databases/entities/Role';
import type { IPinData } from 'n8n-workflow'; import type { IPinData } from 'n8n-workflow';
@ -13,6 +14,9 @@ let app: express.Application;
let testDbName = ''; let testDbName = '';
let globalOwnerRole: Role; let globalOwnerRole: Role;
// mock whether sharing is enabled or not
jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(false);
beforeAll(async () => { beforeAll(async () => {
app = await utils.initTestServer({ app = await utils.initTestServer({
endpointGroups: ['workflows'], endpointGroups: ['workflows'],