mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 04:34:06 -08:00
feat: add saving new workflow endpoint (#4330) (no-changelog)
* feat: add saving new workflow endpoint
This commit is contained in:
parent
d4b74bd66a
commit
d45bc4999c
|
@ -202,6 +202,7 @@ export async function init(
|
||||||
collections.Settings = linkRepository(entities.Settings);
|
collections.Settings = linkRepository(entities.Settings);
|
||||||
collections.InstalledPackages = linkRepository(entities.InstalledPackages);
|
collections.InstalledPackages = linkRepository(entities.InstalledPackages);
|
||||||
collections.InstalledNodes = linkRepository(entities.InstalledNodes);
|
collections.InstalledNodes = linkRepository(entities.InstalledNodes);
|
||||||
|
collections.CredentialUsage = linkRepository(entities.CredentialUsage);
|
||||||
|
|
||||||
isInitialized = true;
|
isInitialized = true;
|
||||||
|
|
||||||
|
|
|
@ -40,6 +40,7 @@ import type { SharedWorkflow } from './databases/entities/SharedWorkflow';
|
||||||
import type { TagEntity } from './databases/entities/TagEntity';
|
import type { TagEntity } from './databases/entities/TagEntity';
|
||||||
import type { User } from './databases/entities/User';
|
import type { User } from './databases/entities/User';
|
||||||
import type { WorkflowEntity } from './databases/entities/WorkflowEntity';
|
import type { WorkflowEntity } from './databases/entities/WorkflowEntity';
|
||||||
|
import { CredentialUsage } from './databases/entities/CredentialUsage';
|
||||||
|
|
||||||
export interface IActivationError {
|
export interface IActivationError {
|
||||||
time: number;
|
time: number;
|
||||||
|
@ -83,6 +84,7 @@ export interface IDatabaseCollections {
|
||||||
Settings: Repository<Settings>;
|
Settings: Repository<Settings>;
|
||||||
InstalledPackages: Repository<InstalledPackages>;
|
InstalledPackages: Repository<InstalledPackages>;
|
||||||
InstalledNodes: Repository<InstalledNodes>;
|
InstalledNodes: Repository<InstalledNodes>;
|
||||||
|
CredentialUsage: Repository<CredentialUsage>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWebhookDb {
|
export interface IWebhookDb {
|
||||||
|
|
72
packages/cli/src/databases/entities/CredentialUsage.ts
Normal file
72
packages/cli/src/databases/entities/CredentialUsage.ts
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
/* eslint-disable import/no-cycle */
|
||||||
|
import {
|
||||||
|
BeforeUpdate,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryColumn,
|
||||||
|
RelationId,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { IsDate, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
|
import config = require('../../../config');
|
||||||
|
import { DatabaseType } from '../../index';
|
||||||
|
import { WorkflowEntity } from './WorkflowEntity';
|
||||||
|
import { CredentialsEntity } from './CredentialsEntity';
|
||||||
|
|
||||||
|
function getTimestampSyntax() {
|
||||||
|
const dbType = config.get('database.type') as DatabaseType;
|
||||||
|
|
||||||
|
const map: { [key in DatabaseType]: string } = {
|
||||||
|
sqlite: "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')",
|
||||||
|
postgresdb: 'CURRENT_TIMESTAMP(3)',
|
||||||
|
mysqldb: 'CURRENT_TIMESTAMP(3)',
|
||||||
|
mariadb: 'CURRENT_TIMESTAMP(3)',
|
||||||
|
};
|
||||||
|
|
||||||
|
return map[dbType];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class CredentialUsage {
|
||||||
|
@ManyToOne(() => WorkflowEntity, {
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
|
workflow: WorkflowEntity;
|
||||||
|
|
||||||
|
@ManyToOne(() => CredentialsEntity, {
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
|
credential: CredentialsEntity;
|
||||||
|
|
||||||
|
@RelationId((credentialUsage: CredentialUsage) => credentialUsage.workflow)
|
||||||
|
@PrimaryColumn()
|
||||||
|
workflowId: number;
|
||||||
|
|
||||||
|
@PrimaryColumn()
|
||||||
|
nodeId: string;
|
||||||
|
|
||||||
|
@RelationId((credentialUsage: CredentialUsage) => credentialUsage.credential)
|
||||||
|
@PrimaryColumn()
|
||||||
|
credentialId: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ precision: 3, default: () => getTimestampSyntax() })
|
||||||
|
@IsOptional() // ignored by validation because set at DB level
|
||||||
|
@IsDate()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
precision: 3,
|
||||||
|
default: () => getTimestampSyntax(),
|
||||||
|
onUpdate: getTimestampSyntax(),
|
||||||
|
})
|
||||||
|
@IsOptional() // ignored by validation because set at DB level
|
||||||
|
@IsDate()
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
setUpdateDate(): void {
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,6 +12,7 @@ import { SharedWorkflow } from './SharedWorkflow';
|
||||||
import { SharedCredentials } from './SharedCredentials';
|
import { SharedCredentials } from './SharedCredentials';
|
||||||
import { InstalledPackages } from './InstalledPackages';
|
import { InstalledPackages } from './InstalledPackages';
|
||||||
import { InstalledNodes } from './InstalledNodes';
|
import { InstalledNodes } from './InstalledNodes';
|
||||||
|
import { CredentialUsage } from './CredentialUsage';
|
||||||
|
|
||||||
export const entities = {
|
export const entities = {
|
||||||
CredentialsEntity,
|
CredentialsEntity,
|
||||||
|
@ -26,4 +27,5 @@ export const entities = {
|
||||||
SharedCredentials,
|
SharedCredentials,
|
||||||
InstalledPackages,
|
InstalledPackages,
|
||||||
InstalledNodes,
|
InstalledNodes,
|
||||||
|
CredentialUsage,
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
import { getTablePrefix, logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers';
|
||||||
|
|
||||||
|
export class CreateCredentialUsageTable1665484192213 implements MigrationInterface {
|
||||||
|
name = 'CreateCredentialUsageTable1665484192213';
|
||||||
|
|
||||||
|
async up(queryRunner: QueryRunner) {
|
||||||
|
logMigrationStart(this.name);
|
||||||
|
const tablePrefix = getTablePrefix();
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE \`${tablePrefix}credential_usage\` (` +
|
||||||
|
'`workflowId` int NOT NULL,' +
|
||||||
|
'`nodeId` char(200) NOT NULL,' +
|
||||||
|
"`credentialId` int NOT NULL DEFAULT '1'," +
|
||||||
|
`\`createdAt\` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,` +
|
||||||
|
`\`updatedAt\` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,` +
|
||||||
|
'PRIMARY KEY (`workflowId`, `nodeId`, `credentialId`)' +
|
||||||
|
") ENGINE='InnoDB';",
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE \`${tablePrefix}credential_usage\` ADD CONSTRAINT \`FK_${tablePrefix}518e1ece107b859ca6ce9ed2487f7e23\` FOREIGN KEY (\`workflowId\`) REFERENCES \`${tablePrefix}workflow_entity\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE \`${tablePrefix}credential_usage\` ADD CONSTRAINT \`FK_${tablePrefix}7ce200a20ade7ae89fa7901da896993f\` FOREIGN KEY (\`credentialId\`) REFERENCES \`${tablePrefix}credentials_entity\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||||
|
);
|
||||||
|
|
||||||
|
logMigrationEnd(this.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner) {
|
||||||
|
const tablePrefix = getTablePrefix();
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
DELETE FROM ${tablePrefix}role WHERE name='user' AND scope='workflow';
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -21,6 +21,7 @@ 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';
|
import { CreateWorkflowsEditorRole1663755770894 } from './1663755770894-CreateWorkflowsEditorRole';
|
||||||
|
import { CreateCredentialUsageTable1665484192213 } from './1665484192213-CreateCredentialUsageTable';
|
||||||
|
|
||||||
export const mysqlMigrations = [
|
export const mysqlMigrations = [
|
||||||
InitialMigration1588157391238,
|
InitialMigration1588157391238,
|
||||||
|
@ -46,4 +47,5 @@ export const mysqlMigrations = [
|
||||||
AddJsonKeyPinData1659895550980,
|
AddJsonKeyPinData1659895550980,
|
||||||
CreateCredentialsUserRole1660062385367,
|
CreateCredentialsUserRole1660062385367,
|
||||||
CreateWorkflowsEditorRole1663755770894,
|
CreateWorkflowsEditorRole1663755770894,
|
||||||
|
CreateCredentialUsageTable1665484192213,
|
||||||
];
|
];
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
import { getTablePrefix, logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers';
|
||||||
|
|
||||||
|
export class CreateCredentialUsageTable1665484192212 implements MigrationInterface {
|
||||||
|
name = 'CreateCredentialUsageTable1665484192212';
|
||||||
|
|
||||||
|
async up(queryRunner: QueryRunner) {
|
||||||
|
logMigrationStart(this.name);
|
||||||
|
const tablePrefix = getTablePrefix();
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE ${tablePrefix}credential_usage (` +
|
||||||
|
'"workflowId" int NOT NULL,' +
|
||||||
|
'"nodeId" UUID NOT NULL,' +
|
||||||
|
'"credentialId" int NULL,' +
|
||||||
|
'"createdAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,' +
|
||||||
|
'"updatedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,' +
|
||||||
|
`CONSTRAINT "PK_${tablePrefix}feb7a6545aa714ac6e7f6b14825f0efc9353dd3a" PRIMARY KEY ("workflowId", "nodeId", "credentialId"), ` +
|
||||||
|
`CONSTRAINT "FK_${tablePrefix}518e1ece107b859ca6ce9ed2487f7e23" FOREIGN KEY ("workflowId") REFERENCES ${tablePrefix}workflow_entity ("id") ON DELETE CASCADE ON UPDATE CASCADE, ` +
|
||||||
|
`CONSTRAINT "FK_${tablePrefix}7ce200a20ade7ae89fa7901da896993f" FOREIGN KEY ("credentialId") REFERENCES ${tablePrefix}credentials_entity ("id") ON DELETE CASCADE ON UPDATE CASCADE ` +
|
||||||
|
');',
|
||||||
|
);
|
||||||
|
|
||||||
|
logMigrationEnd(this.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner) {
|
||||||
|
const tablePrefix = getTablePrefix();
|
||||||
|
|
||||||
|
await queryRunner.query(`DROP TABLE "${tablePrefix}credential_usage"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,6 +19,7 @@ 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';
|
import { CreateWorkflowsEditorRole1663755770893 } from './1663755770893-CreateWorkflowsEditorRole';
|
||||||
|
import { CreateCredentialUsageTable1665484192212 } from './1665484192212-CreateCredentialUsageTable';
|
||||||
|
|
||||||
export const postgresMigrations = [
|
export const postgresMigrations = [
|
||||||
InitialMigration1587669153312,
|
InitialMigration1587669153312,
|
||||||
|
@ -42,4 +43,5 @@ export const postgresMigrations = [
|
||||||
AddNodeIds1658932090381,
|
AddNodeIds1658932090381,
|
||||||
AddJsonKeyPinData1659902242948,
|
AddJsonKeyPinData1659902242948,
|
||||||
CreateWorkflowsEditorRole1663755770893,
|
CreateWorkflowsEditorRole1663755770893,
|
||||||
|
CreateCredentialUsageTable1665484192212,
|
||||||
];
|
];
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
import { getTablePrefix, logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers';
|
||||||
|
|
||||||
|
export class CreateCredentialUsageTable1665484192211 implements MigrationInterface {
|
||||||
|
name = 'CreateCredentialUsageTable1665484192211';
|
||||||
|
|
||||||
|
async up(queryRunner: QueryRunner) {
|
||||||
|
logMigrationStart(this.name);
|
||||||
|
|
||||||
|
const tablePrefix = getTablePrefix();
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "${tablePrefix}credential_usage" (` +
|
||||||
|
`"workflowId" integer NOT NULL,` +
|
||||||
|
`"nodeId" varchar NOT NULL,` +
|
||||||
|
`"credentialId" integer NOT NULL,` +
|
||||||
|
`"createdAt" datetime(3) NOT NULL DEFAULT 'STRFTIME(''%Y-%m-%d %H:%M:%f'', ''NOW'')',` +
|
||||||
|
`"updatedAt" datetime(3) NOT NULL DEFAULT 'STRFTIME(''%Y-%m-%d %H:%M:%f'', ''NOW'')',` +
|
||||||
|
`PRIMARY KEY("workflowId", "nodeId", "credentialId"), ` +
|
||||||
|
`CONSTRAINT "FK_${tablePrefix}518e1ece107b859ca6ce9ed2487f7e23" FOREIGN KEY ("workflowId") REFERENCES "${tablePrefix}workflow_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, ` +
|
||||||
|
`CONSTRAINT "FK_${tablePrefix}7ce200a20ade7ae89fa7901da896993f" FOREIGN KEY ("credentialId") REFERENCES "${tablePrefix}credentials_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION ` +
|
||||||
|
`);`,
|
||||||
|
);
|
||||||
|
|
||||||
|
logMigrationEnd(this.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner) {
|
||||||
|
const tablePrefix = getTablePrefix();
|
||||||
|
|
||||||
|
await queryRunner.query(`DROP TABLE "${tablePrefix}credential_usage"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,6 +18,7 @@ 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';
|
import { CreateWorkflowsEditorRole1663755770892 } from './1663755770892-CreateWorkflowsUserRole';
|
||||||
|
import { CreateCredentialUsageTable1665484192211 } from './1665484192211-CreateCredentialUsageTable';
|
||||||
|
|
||||||
const sqliteMigrations = [
|
const sqliteMigrations = [
|
||||||
InitialMigration1588102412422,
|
InitialMigration1588102412422,
|
||||||
|
@ -40,6 +41,7 @@ const sqliteMigrations = [
|
||||||
AddJsonKeyPinData1659888469333,
|
AddJsonKeyPinData1659888469333,
|
||||||
CreateCredentialsUserRole1660062385367,
|
CreateCredentialsUserRole1660062385367,
|
||||||
CreateWorkflowsEditorRole1663755770892,
|
CreateWorkflowsEditorRole1663755770892,
|
||||||
|
CreateCredentialUsageTable1665484192211,
|
||||||
];
|
];
|
||||||
|
|
||||||
export { sqliteMigrations };
|
export { sqliteMigrations };
|
||||||
|
|
|
@ -1,9 +1,17 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { Db, ResponseHelper } from '..';
|
import { Db, InternalHooksManager, ResponseHelper, WorkflowHelpers } from '..';
|
||||||
import config from '../../config';
|
import config from '../../config';
|
||||||
|
import { WorkflowEntity } from '../databases/entities/WorkflowEntity';
|
||||||
|
import { validateEntity } from '../GenericHelpers';
|
||||||
import type { WorkflowRequest } from '../requests';
|
import type { WorkflowRequest } from '../requests';
|
||||||
import { isSharingEnabled, rightDiff } from '../UserManagement/UserManagementHelper';
|
import { isSharingEnabled, rightDiff } from '../UserManagement/UserManagementHelper';
|
||||||
import { EEWorkflowsService as EEWorkflows } from './workflows.services.ee';
|
import { EEWorkflowsService as EEWorkflows } from './workflows.services.ee';
|
||||||
|
import { externalHooks } from '../Server';
|
||||||
|
import { SharedWorkflow } from '../databases/entities/SharedWorkflow';
|
||||||
|
import { LoggerProxy } from 'n8n-workflow';
|
||||||
|
import * as TagHelpers from '../TagHelpers';
|
||||||
|
import { EECredentialsService as EECredentials } from '../credentials/credentials.service.ee';
|
||||||
|
import { CredentialUsage } from '../databases/entities/CredentialUsage';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
export const EEWorkflowController = express.Router();
|
export const EEWorkflowController = express.Router();
|
||||||
|
@ -60,15 +68,10 @@ EEWorkflowController.put('/:workflowId/share', async (req: WorkflowRequest.Share
|
||||||
});
|
});
|
||||||
|
|
||||||
EEWorkflowController.get(
|
EEWorkflowController.get(
|
||||||
'/:id',
|
'/:id(\\d+)',
|
||||||
(req: WorkflowRequest.Get, res, next) => (req.params.id === 'new' ? next('router') : next()), // skip ee router and use free one for naming
|
|
||||||
ResponseHelper.send(async (req: WorkflowRequest.Get) => {
|
ResponseHelper.send(async (req: WorkflowRequest.Get) => {
|
||||||
const { id: workflowId } = req.params;
|
const { id: workflowId } = req.params;
|
||||||
|
|
||||||
if (Number.isNaN(Number(workflowId))) {
|
|
||||||
throw new ResponseHelper.ResponseError(`Workflow ID must be a number.`, undefined, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const workflow = await EEWorkflows.get(
|
const workflow = await EEWorkflows.get(
|
||||||
{ id: parseInt(workflowId, 10) },
|
{ id: parseInt(workflowId, 10) },
|
||||||
{ relations: ['shared', 'shared.user', 'shared.role'] },
|
{ relations: ['shared', 'shared.user', 'shared.role'] },
|
||||||
|
@ -92,3 +95,110 @@ EEWorkflowController.get(
|
||||||
return EEWorkflows.addOwnerAndSharings(workflow);
|
return EEWorkflows.addOwnerAndSharings(workflow);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
EEWorkflowController.post(
|
||||||
|
'/',
|
||||||
|
ResponseHelper.send(async (req: WorkflowRequest.Create) => {
|
||||||
|
delete req.body.id; // delete if sent
|
||||||
|
|
||||||
|
const newWorkflow = new WorkflowEntity();
|
||||||
|
|
||||||
|
Object.assign(newWorkflow, req.body);
|
||||||
|
|
||||||
|
await validateEntity(newWorkflow);
|
||||||
|
|
||||||
|
await externalHooks.run('workflow.create', [newWorkflow]);
|
||||||
|
|
||||||
|
const { tags: tagIds } = req.body;
|
||||||
|
|
||||||
|
if (tagIds?.length && !config.getEnv('workflowTagsDisabled')) {
|
||||||
|
newWorkflow.tags = await Db.collections.Tag.findByIds(tagIds, {
|
||||||
|
select: ['id', 'name'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await WorkflowHelpers.replaceInvalidCredentials(newWorkflow);
|
||||||
|
|
||||||
|
WorkflowHelpers.addNodeIds(newWorkflow);
|
||||||
|
|
||||||
|
// This is a new workflow, so we simply check if the user has access to
|
||||||
|
// all used workflows
|
||||||
|
|
||||||
|
const allCredentials = await EECredentials.getAll(req.user);
|
||||||
|
|
||||||
|
try {
|
||||||
|
EEWorkflows.validateCredentialPermissionsToUser(newWorkflow, allCredentials);
|
||||||
|
} catch (error) {
|
||||||
|
throw new ResponseHelper.ResponseError(
|
||||||
|
'The workflow contains credentials that you do not have access to',
|
||||||
|
undefined,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let savedWorkflow: undefined | WorkflowEntity;
|
||||||
|
|
||||||
|
await Db.transaction(async (transactionManager) => {
|
||||||
|
savedWorkflow = await transactionManager.save<WorkflowEntity>(newWorkflow);
|
||||||
|
|
||||||
|
const role = await Db.collections.Role.findOneOrFail({
|
||||||
|
name: 'owner',
|
||||||
|
scope: 'workflow',
|
||||||
|
});
|
||||||
|
|
||||||
|
const newSharedWorkflow = new SharedWorkflow();
|
||||||
|
|
||||||
|
Object.assign(newSharedWorkflow, {
|
||||||
|
role,
|
||||||
|
user: req.user,
|
||||||
|
workflow: savedWorkflow,
|
||||||
|
});
|
||||||
|
|
||||||
|
await transactionManager.save<SharedWorkflow>(newSharedWorkflow);
|
||||||
|
|
||||||
|
const credentialUsage: CredentialUsage[] = [];
|
||||||
|
newWorkflow.nodes.forEach((node) => {
|
||||||
|
if (!node.credentials) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Object.keys(node.credentials).forEach((credentialType) => {
|
||||||
|
const credentialId = node.credentials?.[credentialType].id;
|
||||||
|
if (credentialId) {
|
||||||
|
const newCredentialusage = new CredentialUsage();
|
||||||
|
Object.assign(newCredentialusage, {
|
||||||
|
credentialId,
|
||||||
|
nodeId: node.id,
|
||||||
|
workflowId: savedWorkflow?.id,
|
||||||
|
});
|
||||||
|
credentialUsage.push(newCredentialusage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (credentialUsage.length) {
|
||||||
|
await transactionManager.save<CredentialUsage>(credentialUsage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!savedWorkflow) {
|
||||||
|
LoggerProxy.error('Failed to create workflow', { userId: req.user.id });
|
||||||
|
throw new ResponseHelper.ResponseError('Failed to save workflow');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagIds && !config.getEnv('workflowTagsDisabled') && savedWorkflow.tags) {
|
||||||
|
savedWorkflow.tags = TagHelpers.sortByRequestOrder(savedWorkflow.tags, {
|
||||||
|
requestOrder: tagIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await externalHooks.run('workflow.afterCreate', [savedWorkflow]);
|
||||||
|
void InternalHooksManager.getInstance().onWorkflowCreated(req.user.id, newWorkflow, false);
|
||||||
|
|
||||||
|
const { id, ...rest } = savedWorkflow;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id.toString(),
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
|
@ -279,7 +279,7 @@ workflowsController.get(
|
||||||
* GET /workflows/:id
|
* GET /workflows/:id
|
||||||
*/
|
*/
|
||||||
workflowsController.get(
|
workflowsController.get(
|
||||||
'/:id',
|
'/:id(\\d+)',
|
||||||
ResponseHelper.send(async (req: WorkflowRequest.Get) => {
|
ResponseHelper.send(async (req: WorkflowRequest.Get) => {
|
||||||
const { id: workflowId } = req.params;
|
const { id: workflowId } = req.params;
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { DeleteResult, EntityManager, In, Not } from 'typeorm';
|
import { DeleteResult, EntityManager, In, Not } from 'typeorm';
|
||||||
import { Db } from '..';
|
import { Db, ICredentialsDb } from '..';
|
||||||
import { SharedWorkflow } from '../databases/entities/SharedWorkflow';
|
import { SharedWorkflow } from '../databases/entities/SharedWorkflow';
|
||||||
import { User } from '../databases/entities/User';
|
import { User } from '../databases/entities/User';
|
||||||
import { WorkflowEntity } from '../databases/entities/WorkflowEntity';
|
import { WorkflowEntity } from '../databases/entities/WorkflowEntity';
|
||||||
|
@ -94,4 +94,24 @@ export class EEWorkflowsService extends WorkflowsService {
|
||||||
|
|
||||||
return workflow;
|
return workflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static validateCredentialPermissionsToUser(
|
||||||
|
workflow: WorkflowEntity,
|
||||||
|
allowedCredentials: ICredentialsDb[],
|
||||||
|
) {
|
||||||
|
workflow.nodes.forEach((node) => {
|
||||||
|
if (!node.credentials) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Object.keys(node.credentials).forEach((credentialType) => {
|
||||||
|
const credentialId = parseInt(node.credentials?.[credentialType].id ?? '', 10);
|
||||||
|
const matchedCredential = allowedCredentials.find(
|
||||||
|
(credential) => credential.id === credentialId,
|
||||||
|
);
|
||||||
|
if (!matchedCredential) {
|
||||||
|
throw new Error('The workflow contains credentials that you do not have access to');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -681,6 +681,18 @@ export async function getWorkflowSharing(workflow: WorkflowEntity) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------
|
||||||
|
// credential usage
|
||||||
|
// ----------------------------------
|
||||||
|
|
||||||
|
export async function getCredentialUsageInWorkflow(workflowId: number) {
|
||||||
|
return Db.collections.CredentialUsage.find({
|
||||||
|
where: {
|
||||||
|
workflowId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
// connection options
|
// connection options
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
|
|
|
@ -10,6 +10,7 @@ import {
|
||||||
ICredentialType,
|
ICredentialType,
|
||||||
IDataObject,
|
IDataObject,
|
||||||
IExecuteFunctions,
|
IExecuteFunctions,
|
||||||
|
INode,
|
||||||
INodeExecutionData,
|
INodeExecutionData,
|
||||||
INodeParameters,
|
INodeParameters,
|
||||||
INodeTypeData,
|
INodeTypeData,
|
||||||
|
@ -65,6 +66,8 @@ import type {
|
||||||
InstalledPackagePayload,
|
InstalledPackagePayload,
|
||||||
PostgresSchemaSection,
|
PostgresSchemaSection,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { WorkflowEntity } from '../../../src/databases/entities/WorkflowEntity';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize a test server.
|
* Initialize a test server.
|
||||||
|
@ -698,3 +701,45 @@ export const emptyPackage = () => {
|
||||||
|
|
||||||
return Promise.resolve(installedPackage);
|
return Promise.resolve(installedPackage);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ----------------------------------
|
||||||
|
// workflow
|
||||||
|
// ----------------------------------
|
||||||
|
|
||||||
|
export function makeWorkflow({
|
||||||
|
withPinData,
|
||||||
|
withCredential,
|
||||||
|
}: {
|
||||||
|
withPinData: boolean;
|
||||||
|
withCredential?: { id: string; name: string };
|
||||||
|
}) {
|
||||||
|
const workflow = new WorkflowEntity();
|
||||||
|
|
||||||
|
const node: INode = {
|
||||||
|
id: uuid(),
|
||||||
|
name: 'Spotify',
|
||||||
|
type: 'n8n-nodes-base.spotify',
|
||||||
|
parameters: { resource: 'track', operation: 'get', id: '123' },
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [740, 240],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (withCredential) {
|
||||||
|
node.credentials = {
|
||||||
|
spotifyApi: withCredential,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
workflow.name = 'My Workflow';
|
||||||
|
workflow.active = false;
|
||||||
|
workflow.connections = {};
|
||||||
|
workflow.nodes = [node];
|
||||||
|
|
||||||
|
if (withPinData) {
|
||||||
|
workflow.pinData = MOCK_PINDATA;
|
||||||
|
}
|
||||||
|
|
||||||
|
return workflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MOCK_PINDATA = { Spotify: [{ json: { myKey: 'myValue' } }] };
|
||||||
|
|
|
@ -8,7 +8,9 @@ import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
import type { Role } from '../../src/databases/entities/Role';
|
import type { Role } from '../../src/databases/entities/Role';
|
||||||
import config from '../../config';
|
import config from '../../config';
|
||||||
import type { AuthAgent } from './shared/types';
|
import type { AuthAgent, SaveCredentialFunction } from './shared/types';
|
||||||
|
import { makeWorkflow } from './shared/utils';
|
||||||
|
import { randomCredentialPayload } from './shared/random';
|
||||||
|
|
||||||
jest.mock('../../src/telemetry');
|
jest.mock('../../src/telemetry');
|
||||||
|
|
||||||
|
@ -20,7 +22,9 @@ let testDbName = '';
|
||||||
|
|
||||||
let globalOwnerRole: Role;
|
let globalOwnerRole: Role;
|
||||||
let globalMemberRole: Role;
|
let globalMemberRole: Role;
|
||||||
|
let credentialOwnerRole: Role;
|
||||||
let authAgent: AuthAgent;
|
let authAgent: AuthAgent;
|
||||||
|
let saveCredential: SaveCredentialFunction;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await utils.initTestServer({
|
app = await utils.initTestServer({
|
||||||
|
@ -32,6 +36,9 @@ beforeAll(async () => {
|
||||||
|
|
||||||
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
globalOwnerRole = await testDb.getGlobalOwnerRole();
|
||||||
globalMemberRole = await testDb.getGlobalMemberRole();
|
globalMemberRole = await testDb.getGlobalMemberRole();
|
||||||
|
credentialOwnerRole = await testDb.getCredentialOwnerRole();
|
||||||
|
|
||||||
|
saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole);
|
||||||
|
|
||||||
authAgent = utils.createAuthAgent(app);
|
authAgent = utils.createAuthAgent(app);
|
||||||
|
|
||||||
|
@ -123,21 +130,27 @@ describe('PUT /workflows/:id', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /workflows/:id', () => {
|
describe('GET /workflows/:id', () => {
|
||||||
test('GET should fail with invalid id', async () => {
|
test('GET should fail with invalid id due to route rule', async () => {
|
||||||
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
|
|
||||||
|
|
||||||
const response = await authOwnerAgent.get('/workflows/potatoes');
|
const response = await authAgent(owner).get('/workflows/potatoes');
|
||||||
|
|
||||||
expect(response.statusCode).toBe(400);
|
expect(response.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET should return 404 for non existing workflow', async () => {
|
||||||
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
|
|
||||||
|
const response = await authAgent(owner).get('/workflows/9001');
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('GET should return a workflow with owner', async () => {
|
test('GET should return a workflow with owner', async () => {
|
||||||
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
const workflow = await createWorkflow({}, owner);
|
const workflow = await createWorkflow({}, owner);
|
||||||
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
|
|
||||||
|
|
||||||
const response = await authOwnerAgent.get(`/workflows/${workflow.id}`);
|
const response = await authAgent(owner).get(`/workflows/${workflow.id}`);
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.body.data.ownedBy).toMatchObject({
|
expect(response.body.data.ownedBy).toMatchObject({
|
||||||
|
@ -154,10 +167,9 @@ describe('GET /workflows/:id', () => {
|
||||||
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
const member = await testDb.createUser({ globalRole: globalMemberRole });
|
const member = await testDb.createUser({ globalRole: globalMemberRole });
|
||||||
const workflow = await createWorkflow({}, owner);
|
const workflow = await createWorkflow({}, owner);
|
||||||
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
|
|
||||||
await testDb.shareWorkflowWithUsers(workflow, [member]);
|
await testDb.shareWorkflowWithUsers(workflow, [member]);
|
||||||
|
|
||||||
const response = await authOwnerAgent.get(`/workflows/${workflow.id}`);
|
const response = await authAgent(owner).get(`/workflows/${workflow.id}`);
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.body.data.ownedBy).toMatchObject({
|
expect(response.body.data.ownedBy).toMatchObject({
|
||||||
|
@ -181,10 +193,9 @@ describe('GET /workflows/:id', () => {
|
||||||
const member1 = await testDb.createUser({ globalRole: globalMemberRole });
|
const member1 = await testDb.createUser({ globalRole: globalMemberRole });
|
||||||
const member2 = await testDb.createUser({ globalRole: globalMemberRole });
|
const member2 = await testDb.createUser({ globalRole: globalMemberRole });
|
||||||
const workflow = await createWorkflow({}, owner);
|
const workflow = await createWorkflow({}, owner);
|
||||||
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
|
|
||||||
await testDb.shareWorkflowWithUsers(workflow, [member1, member2]);
|
await testDb.shareWorkflowWithUsers(workflow, [member1, member2]);
|
||||||
|
|
||||||
const response = await authOwnerAgent.get(`/workflows/${workflow.id}`);
|
const response = await authAgent(owner).get(`/workflows/${workflow.id}`);
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.body.data.ownedBy).toMatchObject({
|
expect(response.body.data.ownedBy).toMatchObject({
|
||||||
|
@ -197,3 +208,90 @@ describe('GET /workflows/:id', () => {
|
||||||
expect(response.body.data.sharedWith).toHaveLength(2);
|
expect(response.body.data.sharedWith).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /workflows', () => {
|
||||||
|
it('Should create a workflow that uses no credential', async () => {
|
||||||
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
|
|
||||||
|
const workflow = makeWorkflow({ withPinData: false });
|
||||||
|
|
||||||
|
const response = await authAgent(owner).post('/workflows').send(workflow);
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const usedCredentials = await testDb.getCredentialUsageInWorkflow(response.body.data.id);
|
||||||
|
expect(usedCredentials).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should save credential usage when saving a new workflow', async () => {
|
||||||
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
|
|
||||||
|
const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner });
|
||||||
|
const workflow = makeWorkflow({
|
||||||
|
withPinData: false,
|
||||||
|
withCredential: { id: savedCredential.id.toString(), name: savedCredential.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await authAgent(owner).post('/workflows').send(workflow);
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const usedCredentials = await testDb.getCredentialUsageInWorkflow(response.body.data.id);
|
||||||
|
expect(usedCredentials).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should not allow saving a workflow using credential you have no access', async () => {
|
||||||
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
|
const member = await testDb.createUser({ globalRole: globalMemberRole });
|
||||||
|
|
||||||
|
// Credential belongs to owner, member cannot use it.
|
||||||
|
const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner });
|
||||||
|
const workflow = makeWorkflow({
|
||||||
|
withPinData: false,
|
||||||
|
withCredential: { id: savedCredential.id.toString(), name: savedCredential.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await authAgent(member).post('/workflows').send(workflow);
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.body.message).toBe(
|
||||||
|
'The workflow contains credentials that you do not have access to',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should allow owner to save a workflow using credential owned by others', async () => {
|
||||||
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
|
const member = await testDb.createUser({ globalRole: globalMemberRole });
|
||||||
|
|
||||||
|
// Credential belongs to owner, member cannot use it.
|
||||||
|
const savedCredential = await saveCredential(randomCredentialPayload(), { user: member });
|
||||||
|
const workflow = makeWorkflow({
|
||||||
|
withPinData: false,
|
||||||
|
withCredential: { id: savedCredential.id.toString(), name: savedCredential.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await authAgent(owner).post('/workflows').send(workflow);
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const usedCredentials = await testDb.getCredentialUsageInWorkflow(response.body.data.id);
|
||||||
|
expect(usedCredentials).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should allow saving a workflow using a credential owned by others and shared with you', async () => {
|
||||||
|
const member1 = await testDb.createUser({ globalRole: globalMemberRole });
|
||||||
|
const member2 = await testDb.createUser({ globalRole: globalMemberRole });
|
||||||
|
|
||||||
|
const savedCredential = await saveCredential(randomCredentialPayload(), { user: member1 });
|
||||||
|
await testDb.shareCredentialWithUsers(savedCredential, [member2]);
|
||||||
|
|
||||||
|
const workflow = makeWorkflow({
|
||||||
|
withPinData: false,
|
||||||
|
withCredential: { id: savedCredential.id.toString(), name: savedCredential.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await authAgent(member2).post('/workflows').send(workflow);
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const usedCredentials = await testDb.getCredentialUsageInWorkflow(response.body.data.id);
|
||||||
|
expect(usedCredentials).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -7,6 +7,7 @@ import * as UserManagementHelpers from '../../src/UserManagement/UserManagementH
|
||||||
|
|
||||||
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';
|
||||||
|
import { makeWorkflow, MOCK_PINDATA } from './shared/utils';
|
||||||
|
|
||||||
jest.mock('../../src/telemetry');
|
jest.mock('../../src/telemetry');
|
||||||
|
|
||||||
|
@ -87,29 +88,3 @@ test('GET /workflows/:id should return pin data', async () => {
|
||||||
|
|
||||||
expect(pinData).toMatchObject(MOCK_PINDATA);
|
expect(pinData).toMatchObject(MOCK_PINDATA);
|
||||||
});
|
});
|
||||||
|
|
||||||
function makeWorkflow({ withPinData }: { withPinData: boolean }) {
|
|
||||||
const workflow = new WorkflowEntity();
|
|
||||||
|
|
||||||
workflow.name = 'My Workflow';
|
|
||||||
workflow.active = false;
|
|
||||||
workflow.connections = {};
|
|
||||||
workflow.nodes = [
|
|
||||||
{
|
|
||||||
id: 'uuid-1234',
|
|
||||||
name: 'Spotify',
|
|
||||||
type: 'n8n-nodes-base.spotify',
|
|
||||||
parameters: { resource: 'track', operation: 'get', id: '123' },
|
|
||||||
typeVersion: 1,
|
|
||||||
position: [740, 240],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (withPinData) {
|
|
||||||
workflow.pinData = MOCK_PINDATA;
|
|
||||||
}
|
|
||||||
|
|
||||||
return workflow;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MOCK_PINDATA = { Spotify: [{ json: { myKey: 'myValue' } }] };
|
|
||||||
|
|
Loading…
Reference in a new issue