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
25 changed files with 483 additions and 65 deletions

View File

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

View File

@@ -90,7 +90,7 @@ import { WEBHOOK_METHODS } from './WebhookHelpers';
import { getSharedWorkflowIds, whereClause } from './WorkflowHelpers';
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 { credentialsController } from './credentials/credentials.controller';
import { oauth2CredentialController } from './credentials/oauth2Credential.api';
@@ -108,12 +108,12 @@ import { resolveJwt } from './UserManagement/auth/jwt';
import { executionsController } from './api/executions.api';
import { nodeTypesController } from './api/nodeTypes.api';
import { tagsController } from './api/tags.api';
import { isCredentialsSharingEnabled } from './credentials/helpers';
import { loadPublicApiVersions } from './PublicApi';
import * as telemetryScripts from './telemetry/scripts';
import {
getInstanceBaseUrl,
isEmailSetUp,
isSharingEnabled,
isUserManagementEnabled,
} from './UserManagement/UserManagementHelper';
import {
@@ -330,6 +330,7 @@ class App {
isNpmAvailable: false,
enterprise: {
sharing: false,
workflowSharing: false,
},
};
}
@@ -357,7 +358,8 @@ class App {
// refresh enterprise status
Object.assign(this.frontendSettings.enterprise, {
sharing: isCredentialsSharingEnabled(),
sharing: isSharingEnabled(),
workflowSharing: config.getEnv('enterprise.workflowSharingEnabled'),
});
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 {
return (
config.getEnv('userManagement.disabled') &&
@@ -276,3 +280,24 @@ export async function compareHash(plaintext: string, hashed: string): Promise<bo
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 { CredentialRequest } from '../requests';
import { isSharingEnabled, rightDiff } from '../UserManagement/UserManagementHelper';
import { EECredentialsService as EECredentials } from './credentials.service.ee';
import type { CredentialWithSharings } from './credentials.types';
import { isCredentialsSharingEnabled, rightDiff } from './helpers';
// eslint-disable-next-line @typescript-eslint/naming-convention
export const EECredentialsController = express.Router();
EECredentialsController.use((req, res, next) => {
if (!isCredentialsSharingEnabled()) {
if (!isSharingEnabled()) {
// skip ee router and use free one
next('router');
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 { SharedCredentials } from './SharedCredentials';
type RoleNames = 'owner' | 'member' | 'user';
type RoleNames = 'owner' | 'member' | 'user' | 'editor';
type RoleScopes = 'global' | 'workflow' | 'credential';
// 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 { AddJsonKeyPinData1659895550980 } from './1659895550980-AddJsonKeyPinData';
import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole';
import { CreateWorkflowsEditorRole1663755770894 } from './1663755770894-CreateWorkflowsEditorRole';
export const mysqlMigrations = [
InitialMigration1588157391238,
@@ -44,4 +45,5 @@ export const mysqlMigrations = [
AddNodeIds1658932910559,
AddJsonKeyPinData1659895550980,
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 { AddJsonKeyPinData1659902242948 } from './1659902242948-AddJsonKeyPinData';
import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole';
import { CreateWorkflowsEditorRole1663755770893 } from './1663755770893-CreateWorkflowsEditorRole';
export const postgresMigrations = [
InitialMigration1587669153312,
@@ -40,4 +41,5 @@ export const postgresMigrations = [
CreateCredentialsUserRole1660062385367,
AddNodeIds1658932090381,
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 { AddJsonKeyPinData1659888469333 } from './1659888469333-AddJsonKeyPinData';
import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole';
import { CreateWorkflowsEditorRole1663755770892 } from './1663755770892-CreateWorkflowsUserRole';
const sqliteMigrations = [
InitialMigration1588102412422,
@@ -38,6 +39,7 @@ const sqliteMigrations = [
AddNodeIds1658930531669,
AddJsonKeyPinData1659888469333,
CreateCredentialsUserRole1660062385367,
CreateWorkflowsEditorRole1663755770892,
];
export { sqliteMigrations };

View File

@@ -77,6 +77,8 @@ export declare namespace WorkflowRequest {
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 { InternalHooksManager } from '../InternalHooksManager';
import { externalHooks } from '../Server';
import { getLogger } from '../Logger';
import type { WorkflowRequest } from '../requests';
import { isBelowOnboardingThreshold } from '../WorkflowHelpers';
import { EEWorkflowController } from './workflows.controller.ee';
const activeWorkflowRunner = ActiveWorkflowRunner.getInstance();
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) =>
['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);
}
}