mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat: check for cred when updating workflow and remove credential_usage table (#4350) (no-changelog)
* feat: check for cred when updating workflow and remove credential_usage table
This commit is contained in:
parent
e8935de3b2
commit
c65deb2949
|
@ -202,7 +202,6 @@ 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,7 +40,6 @@ 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;
|
||||||
|
@ -84,7 +83,6 @@ 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 {
|
||||||
|
|
|
@ -29,6 +29,7 @@ import { v4 as uuid } from 'uuid';
|
||||||
import {
|
import {
|
||||||
CredentialTypes,
|
CredentialTypes,
|
||||||
Db,
|
Db,
|
||||||
|
ICredentialsDb,
|
||||||
ICredentialsTypeData,
|
ICredentialsTypeData,
|
||||||
ITransferNodeTypes,
|
ITransferNodeTypes,
|
||||||
IWorkflowErrorData,
|
IWorkflowErrorData,
|
||||||
|
@ -704,3 +705,83 @@ export function generateFailedExecutionFromError(
|
||||||
stoppedAt: new Date(),
|
stoppedAt: new Date(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get all nodes in a workflow where the node credential is not accessible to the user. */
|
||||||
|
export function getNodesWithInaccessibleCreds(workflow: WorkflowEntity, userCredIds: string[]) {
|
||||||
|
if (!workflow.nodes) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return workflow.nodes.filter((node) => {
|
||||||
|
if (!node.credentials) return false;
|
||||||
|
|
||||||
|
const allUsedCredentials = Object.values(node.credentials);
|
||||||
|
|
||||||
|
const allUsedCredentialIds = allUsedCredentials.map((nodeCred) => nodeCred.id?.toString());
|
||||||
|
return allUsedCredentialIds.some(
|
||||||
|
(nodeCredId) => nodeCredId && !userCredIds.includes(nodeCredId),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateWorkflowCredentialUsage(
|
||||||
|
newWorkflowVersion: WorkflowEntity,
|
||||||
|
previousWorkflowVersion: WorkflowEntity,
|
||||||
|
credentialsUserHasAccessTo: ICredentialsDb[],
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* We only need to check nodes that use credentials the current user cannot access,
|
||||||
|
* since these can be 2 possibilities:
|
||||||
|
* - Same ID already exist: it's a read only node and therefore cannot be changed
|
||||||
|
* - It's a new node which indicates tampering and therefore must fail saving
|
||||||
|
*/
|
||||||
|
|
||||||
|
const allowedCredentialIds = credentialsUserHasAccessTo.map((cred) => cred.id.toString());
|
||||||
|
|
||||||
|
const nodesWithCredentialsUserDoesNotHaveAccessTo = getNodesWithInaccessibleCreds(
|
||||||
|
newWorkflowVersion,
|
||||||
|
allowedCredentialIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
// If there are no nodes with credentials the user does not have access to we can skip the rest
|
||||||
|
if (nodesWithCredentialsUserDoesNotHaveAccessTo.length === 0) {
|
||||||
|
return newWorkflowVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previouslyExistingNodeIds = previousWorkflowVersion.nodes.map((node) => node.id);
|
||||||
|
|
||||||
|
// If it's a new node we can't allow it to be saved
|
||||||
|
// since it uses creds the node doesn't have access
|
||||||
|
const isTamperingAttempt = (inaccessibleCredNodeId: string) =>
|
||||||
|
!previouslyExistingNodeIds.includes(inaccessibleCredNodeId);
|
||||||
|
|
||||||
|
nodesWithCredentialsUserDoesNotHaveAccessTo.forEach((node) => {
|
||||||
|
if (isTamperingAttempt(node.id)) {
|
||||||
|
Logger.info('Blocked workflow update due to tampering attempt', {
|
||||||
|
nodeType: node.type,
|
||||||
|
nodeName: node.name,
|
||||||
|
nodeId: node.id,
|
||||||
|
nodeCredentials: node.credentials,
|
||||||
|
});
|
||||||
|
// Node is new, so this is probably a tampering attempt. Throw an error
|
||||||
|
throw new Error(
|
||||||
|
'Workflow contains new nodes with credentials the user does not have access to',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Replace the node with the previous version of the node
|
||||||
|
// Since it cannot be modified (read only node)
|
||||||
|
const nodeIdx = newWorkflowVersion.nodes.findIndex(
|
||||||
|
(newWorkflowNode) => newWorkflowNode.id === node.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
Logger.debug('Replacing node with previous version when saving updated workflow', {
|
||||||
|
nodeType: node.type,
|
||||||
|
nodeName: node.name,
|
||||||
|
nodeId: node.id,
|
||||||
|
});
|
||||||
|
newWorkflowVersion.nodes[nodeIdx] = previousWorkflowVersion.nodes.find(
|
||||||
|
(previousNode) => previousNode.id === node.id,
|
||||||
|
)!;
|
||||||
|
});
|
||||||
|
|
||||||
|
return newWorkflowVersion;
|
||||||
|
}
|
||||||
|
|
|
@ -1,28 +0,0 @@
|
||||||
import { Entity, ManyToOne, PrimaryColumn, RelationId } from 'typeorm';
|
|
||||||
import { WorkflowEntity } from './WorkflowEntity';
|
|
||||||
import { CredentialsEntity } from './CredentialsEntity';
|
|
||||||
import { AbstractEntity } from './AbstractEntity';
|
|
||||||
|
|
||||||
@Entity()
|
|
||||||
export class CredentialUsage extends AbstractEntity {
|
|
||||||
@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;
|
|
||||||
}
|
|
|
@ -11,7 +11,6 @@ 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,5 +25,4 @@ export const entities = {
|
||||||
SharedCredentials,
|
SharedCredentials,
|
||||||
InstalledPackages,
|
InstalledPackages,
|
||||||
InstalledNodes,
|
InstalledNodes,
|
||||||
CredentialUsage,
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -33,8 +33,6 @@ export class CreateCredentialUsageTable1665484192213 implements MigrationInterfa
|
||||||
async down(queryRunner: QueryRunner) {
|
async down(queryRunner: QueryRunner) {
|
||||||
const tablePrefix = getTablePrefix();
|
const tablePrefix = getTablePrefix();
|
||||||
|
|
||||||
await queryRunner.query(`
|
await queryRunner.query(`DROP TABLE "${tablePrefix}credential_usage"`);
|
||||||
DELETE FROM ${tablePrefix}role WHERE name='user' AND scope='workflow';
|
|
||||||
`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
import { getTablePrefix, logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers';
|
||||||
|
|
||||||
|
export class RemoveCredentialUsageTable1665754637026 implements MigrationInterface {
|
||||||
|
name = 'RemoveCredentialUsageTable1665754637026';
|
||||||
|
|
||||||
|
async up(queryRunner: QueryRunner) {
|
||||||
|
logMigrationStart(this.name);
|
||||||
|
const tablePrefix = getTablePrefix();
|
||||||
|
await queryRunner.query(`DROP TABLE \`${tablePrefix}credential_usage\``);
|
||||||
|
|
||||||
|
logMigrationEnd(this.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner) {
|
||||||
|
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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -22,6 +22,7 @@ import { AddJsonKeyPinData1659895550980 } from './1659895550980-AddJsonKeyPinDat
|
||||||
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';
|
import { CreateCredentialUsageTable1665484192213 } from './1665484192213-CreateCredentialUsageTable';
|
||||||
|
import { RemoveCredentialUsageTable1665754637026 } from './1665754637026-RemoveCredentialUsageTable';
|
||||||
|
|
||||||
export const mysqlMigrations = [
|
export const mysqlMigrations = [
|
||||||
InitialMigration1588157391238,
|
InitialMigration1588157391238,
|
||||||
|
@ -48,4 +49,5 @@ export const mysqlMigrations = [
|
||||||
CreateCredentialsUserRole1660062385367,
|
CreateCredentialsUserRole1660062385367,
|
||||||
CreateWorkflowsEditorRole1663755770894,
|
CreateWorkflowsEditorRole1663755770894,
|
||||||
CreateCredentialUsageTable1665484192213,
|
CreateCredentialUsageTable1665484192213,
|
||||||
|
RemoveCredentialUsageTable1665754637026,
|
||||||
];
|
];
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
import { getTablePrefix, logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers';
|
||||||
|
|
||||||
|
export class RemoveCredentialUsageTable1665754637025 implements MigrationInterface {
|
||||||
|
name = 'RemoveCredentialUsageTable1665754637025';
|
||||||
|
|
||||||
|
async up(queryRunner: QueryRunner) {
|
||||||
|
logMigrationStart(this.name);
|
||||||
|
const tablePrefix = getTablePrefix();
|
||||||
|
await queryRunner.query(`DROP TABLE ${tablePrefix}credential_usage`);
|
||||||
|
logMigrationEnd(this.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner) {
|
||||||
|
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 ` +
|
||||||
|
');',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -20,6 +20,7 @@ import { AddJsonKeyPinData1659902242948 } from './1659902242948-AddJsonKeyPinDat
|
||||||
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';
|
import { CreateCredentialUsageTable1665484192212 } from './1665484192212-CreateCredentialUsageTable';
|
||||||
|
import { RemoveCredentialUsageTable1665754637025 } from './1665754637025-RemoveCredentialUsageTable';
|
||||||
|
|
||||||
export const postgresMigrations = [
|
export const postgresMigrations = [
|
||||||
InitialMigration1587669153312,
|
InitialMigration1587669153312,
|
||||||
|
@ -44,4 +45,5 @@ export const postgresMigrations = [
|
||||||
AddJsonKeyPinData1659902242948,
|
AddJsonKeyPinData1659902242948,
|
||||||
CreateWorkflowsEditorRole1663755770893,
|
CreateWorkflowsEditorRole1663755770893,
|
||||||
CreateCredentialUsageTable1665484192212,
|
CreateCredentialUsageTable1665484192212,
|
||||||
|
RemoveCredentialUsageTable1665754637025,
|
||||||
];
|
];
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
import { getTablePrefix, logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers';
|
||||||
|
|
||||||
|
export class RemoveCredentialUsageTable1665754637024 implements MigrationInterface {
|
||||||
|
name = 'RemoveCredentialUsageTable1665754637024';
|
||||||
|
|
||||||
|
async up(queryRunner: QueryRunner) {
|
||||||
|
logMigrationStart(this.name);
|
||||||
|
const tablePrefix = getTablePrefix();
|
||||||
|
await queryRunner.query(`DROP TABLE "${tablePrefix}credential_usage"`);
|
||||||
|
logMigrationEnd(this.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner) {
|
||||||
|
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 ` +
|
||||||
|
`);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,6 +19,7 @@ import { AddJsonKeyPinData1659888469333 } from './1659888469333-AddJsonKeyPinDat
|
||||||
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';
|
import { CreateCredentialUsageTable1665484192211 } from './1665484192211-CreateCredentialUsageTable';
|
||||||
|
import { RemoveCredentialUsageTable1665754637024 } from './1665754637024-RemoveCredentialUsageTable';
|
||||||
|
|
||||||
const sqliteMigrations = [
|
const sqliteMigrations = [
|
||||||
InitialMigration1588102412422,
|
InitialMigration1588102412422,
|
||||||
|
@ -42,6 +43,7 @@ const sqliteMigrations = [
|
||||||
CreateCredentialsUserRole1660062385367,
|
CreateCredentialsUserRole1660062385367,
|
||||||
CreateWorkflowsEditorRole1663755770892,
|
CreateWorkflowsEditorRole1663755770892,
|
||||||
CreateCredentialUsageTable1665484192211,
|
CreateCredentialUsageTable1665484192211,
|
||||||
|
RemoveCredentialUsageTable1665754637024,
|
||||||
];
|
];
|
||||||
|
|
||||||
export { sqliteMigrations };
|
export { sqliteMigrations };
|
||||||
|
|
|
@ -11,7 +11,6 @@ import { SharedWorkflow } from '../databases/entities/SharedWorkflow';
|
||||||
import { LoggerProxy } from 'n8n-workflow';
|
import { LoggerProxy } from 'n8n-workflow';
|
||||||
import * as TagHelpers from '../TagHelpers';
|
import * as TagHelpers from '../TagHelpers';
|
||||||
import { EECredentialsService as EECredentials } from '../credentials/credentials.service.ee';
|
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();
|
||||||
|
@ -155,29 +154,6 @@ EEWorkflowController.post(
|
||||||
});
|
});
|
||||||
|
|
||||||
await transactionManager.save<SharedWorkflow>(newSharedWorkflow);
|
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) {
|
if (!savedWorkflow) {
|
||||||
|
@ -202,3 +178,28 @@ EEWorkflowController.post(
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
EEWorkflowController.patch(
|
||||||
|
'/:id(\\d+)',
|
||||||
|
ResponseHelper.send(async (req: WorkflowRequest.Update) => {
|
||||||
|
const { id: workflowId } = req.params;
|
||||||
|
|
||||||
|
const updateData = new WorkflowEntity();
|
||||||
|
const { tags, ...rest } = req.body;
|
||||||
|
Object.assign(updateData, rest);
|
||||||
|
|
||||||
|
const updatedWorkflow = await EEWorkflows.updateWorkflow(
|
||||||
|
req.user,
|
||||||
|
updateData,
|
||||||
|
workflowId,
|
||||||
|
tags,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { id, ...remainder } = updatedWorkflow;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id.toString(),
|
||||||
|
...remainder,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
|
@ -33,6 +33,7 @@ import { getLogger } from '../Logger';
|
||||||
import type { WorkflowRequest } from '../requests';
|
import type { WorkflowRequest } from '../requests';
|
||||||
import { getSharedWorkflowIds, isBelowOnboardingThreshold } from '../WorkflowHelpers';
|
import { getSharedWorkflowIds, isBelowOnboardingThreshold } from '../WorkflowHelpers';
|
||||||
import { EEWorkflowController } from './workflows.controller.ee';
|
import { EEWorkflowController } from './workflows.controller.ee';
|
||||||
|
import { WorkflowsService } from './workflows.services';
|
||||||
import { validate as jsonSchemaValidate } from 'jsonschema';
|
import { validate as jsonSchemaValidate } from 'jsonschema';
|
||||||
|
|
||||||
const activeWorkflowRunner = ActiveWorkflowRunner.getInstance();
|
const activeWorkflowRunner = ActiveWorkflowRunner.getInstance();
|
||||||
|
@ -364,128 +365,12 @@ workflowsController.patch(
|
||||||
const { tags, ...rest } = req.body;
|
const { tags, ...rest } = req.body;
|
||||||
Object.assign(updateData, rest);
|
Object.assign(updateData, rest);
|
||||||
|
|
||||||
const shared = await Db.collections.SharedWorkflow.findOne({
|
const updatedWorkflow = await WorkflowsService.updateWorkflow(
|
||||||
relations: ['workflow'],
|
req.user,
|
||||||
where: whereClause({
|
updateData,
|
||||||
user: req.user,
|
workflowId,
|
||||||
entityType: 'workflow',
|
tags,
|
||||||
entityId: workflowId,
|
);
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!shared) {
|
|
||||||
LoggerProxy.info('User attempted to update a workflow without permissions', {
|
|
||||||
workflowId,
|
|
||||||
userId: req.user.id,
|
|
||||||
});
|
|
||||||
throw new ResponseHelper.ResponseError(
|
|
||||||
`Workflow with ID "${workflowId}" could not be found to be updated.`,
|
|
||||||
undefined,
|
|
||||||
404,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// check credentials for old format
|
|
||||||
await WorkflowHelpers.replaceInvalidCredentials(updateData);
|
|
||||||
|
|
||||||
WorkflowHelpers.addNodeIds(updateData);
|
|
||||||
|
|
||||||
await externalHooks.run('workflow.update', [updateData]);
|
|
||||||
|
|
||||||
if (shared.workflow.active) {
|
|
||||||
// When workflow gets saved always remove it as the triggers could have been
|
|
||||||
// changed and so the changes would not take effect
|
|
||||||
await activeWorkflowRunner.remove(workflowId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updateData.settings) {
|
|
||||||
if (updateData.settings.timezone === 'DEFAULT') {
|
|
||||||
// Do not save the default timezone
|
|
||||||
delete updateData.settings.timezone;
|
|
||||||
}
|
|
||||||
if (updateData.settings.saveDataErrorExecution === 'DEFAULT') {
|
|
||||||
// Do not save when default got set
|
|
||||||
delete updateData.settings.saveDataErrorExecution;
|
|
||||||
}
|
|
||||||
if (updateData.settings.saveDataSuccessExecution === 'DEFAULT') {
|
|
||||||
// Do not save when default got set
|
|
||||||
delete updateData.settings.saveDataSuccessExecution;
|
|
||||||
}
|
|
||||||
if (updateData.settings.saveManualExecutions === 'DEFAULT') {
|
|
||||||
// Do not save when default got set
|
|
||||||
delete updateData.settings.saveManualExecutions;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
parseInt(updateData.settings.executionTimeout as string, 10) ===
|
|
||||||
config.get('executions.timeout')
|
|
||||||
) {
|
|
||||||
// Do not save when default got set
|
|
||||||
delete updateData.settings.executionTimeout;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updateData.name) {
|
|
||||||
updateData.updatedAt = new Date(); // required due to atomic update
|
|
||||||
await validateEntity(updateData);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Db.collections.Workflow.update(workflowId, updateData);
|
|
||||||
|
|
||||||
if (tags && !config.getEnv('workflowTagsDisabled')) {
|
|
||||||
const tablePrefix = config.getEnv('database.tablePrefix');
|
|
||||||
await TagHelpers.removeRelations(workflowId, tablePrefix);
|
|
||||||
|
|
||||||
if (tags.length) {
|
|
||||||
await TagHelpers.createRelations(workflowId, tags, tablePrefix);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const options: FindManyOptions<WorkflowEntity> = {
|
|
||||||
relations: ['tags'],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (config.getEnv('workflowTagsDisabled')) {
|
|
||||||
delete options.relations;
|
|
||||||
}
|
|
||||||
|
|
||||||
// We sadly get nothing back from "update". Neither if it updated a record
|
|
||||||
// nor the new value. So query now the hopefully updated entry.
|
|
||||||
const updatedWorkflow = await Db.collections.Workflow.findOne(workflowId, options);
|
|
||||||
|
|
||||||
if (updatedWorkflow === undefined) {
|
|
||||||
throw new ResponseHelper.ResponseError(
|
|
||||||
`Workflow with ID "${workflowId}" could not be found to be updated.`,
|
|
||||||
undefined,
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updatedWorkflow.tags?.length && tags?.length) {
|
|
||||||
updatedWorkflow.tags = TagHelpers.sortByRequestOrder(updatedWorkflow.tags, {
|
|
||||||
requestOrder: tags,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await externalHooks.run('workflow.afterUpdate', [updatedWorkflow]);
|
|
||||||
void InternalHooksManager.getInstance().onWorkflowSaved(req.user.id, updatedWorkflow, false);
|
|
||||||
|
|
||||||
if (updatedWorkflow.active) {
|
|
||||||
// When the workflow is supposed to be active add it again
|
|
||||||
try {
|
|
||||||
await externalHooks.run('workflow.activate', [updatedWorkflow]);
|
|
||||||
await activeWorkflowRunner.add(workflowId, shared.workflow.active ? 'update' : 'activate');
|
|
||||||
} catch (error) {
|
|
||||||
// If workflow could not be activated set it again to inactive
|
|
||||||
updateData.active = false;
|
|
||||||
await Db.collections.Workflow.update(workflowId, updateData);
|
|
||||||
|
|
||||||
// Also set it in the returned data
|
|
||||||
updatedWorkflow.active = false;
|
|
||||||
|
|
||||||
// Now return the original error for UI to display
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id, ...remainder } = updatedWorkflow;
|
const { id, ...remainder } = updatedWorkflow;
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { DeleteResult, EntityManager, In, Not } from 'typeorm';
|
import { DeleteResult, EntityManager, In, Not } from 'typeorm';
|
||||||
import { Db, ICredentialsDb } from '..';
|
import { Db, ICredentialsDb, ResponseHelper, WorkflowHelpers } 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';
|
||||||
|
@ -7,6 +7,7 @@ import { RoleService } from '../role/role.service';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service';
|
||||||
import { WorkflowsService } from './workflows.services';
|
import { WorkflowsService } from './workflows.services';
|
||||||
import { WorkflowWithSharings } from './workflows.types';
|
import { WorkflowWithSharings } from './workflows.types';
|
||||||
|
import { EECredentialsService as EECredentials } from '../credentials/credentials.service.ee';
|
||||||
|
|
||||||
export class EEWorkflowsService extends WorkflowsService {
|
export class EEWorkflowsService extends WorkflowsService {
|
||||||
static async isOwned(
|
static async isOwned(
|
||||||
|
@ -114,4 +115,33 @@ export class EEWorkflowsService extends WorkflowsService {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async updateWorkflow(
|
||||||
|
user: User,
|
||||||
|
workflow: WorkflowEntity,
|
||||||
|
workflowId: string,
|
||||||
|
tags?: string[],
|
||||||
|
): Promise<WorkflowEntity> {
|
||||||
|
const previousVersion = await EEWorkflowsService.get({ id: parseInt(workflowId, 10) });
|
||||||
|
if (!previousVersion) {
|
||||||
|
throw new ResponseHelper.ResponseError('Workflow not found', undefined, 404);
|
||||||
|
}
|
||||||
|
const allCredentials = await EECredentials.getAll(user);
|
||||||
|
try {
|
||||||
|
workflow = WorkflowHelpers.validateWorkflowCredentialUsage(
|
||||||
|
workflow,
|
||||||
|
previousVersion,
|
||||||
|
allCredentials,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
throw new ResponseHelper.ResponseError(
|
||||||
|
'Invalid workflow credentials - make sure you have access to all credentials and try again.',
|
||||||
|
undefined,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.updateWorkflow(user, workflow, workflowId, tags);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,20 @@
|
||||||
import { FindOneOptions, ObjectLiteral } from 'typeorm';
|
import { LoggerProxy } from 'n8n-workflow';
|
||||||
import { Db } from '..';
|
import { FindManyOptions, FindOneOptions, ObjectLiteral } from 'typeorm';
|
||||||
|
import {
|
||||||
|
ActiveWorkflowRunner,
|
||||||
|
Db,
|
||||||
|
InternalHooksManager,
|
||||||
|
ResponseHelper,
|
||||||
|
whereClause,
|
||||||
|
WorkflowHelpers,
|
||||||
|
} from '..';
|
||||||
|
import config from '../../config';
|
||||||
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';
|
||||||
|
import { validateEntity } from '../GenericHelpers';
|
||||||
|
import { externalHooks } from '../Server';
|
||||||
|
import * as TagHelpers from '../TagHelpers';
|
||||||
|
|
||||||
export class WorkflowsService {
|
export class WorkflowsService {
|
||||||
static async getSharing(
|
static async getSharing(
|
||||||
|
@ -34,4 +46,139 @@ export class WorkflowsService {
|
||||||
static async get(workflow: Partial<WorkflowEntity>, options?: { relations: string[] }) {
|
static async get(workflow: Partial<WorkflowEntity>, options?: { relations: string[] }) {
|
||||||
return Db.collections.Workflow.findOne(workflow, options);
|
return Db.collections.Workflow.findOne(workflow, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async updateWorkflow(
|
||||||
|
user: User,
|
||||||
|
workflow: WorkflowEntity,
|
||||||
|
workflowId: string,
|
||||||
|
tags?: string[],
|
||||||
|
): Promise<WorkflowEntity> {
|
||||||
|
const shared = await Db.collections.SharedWorkflow.findOne({
|
||||||
|
relations: ['workflow'],
|
||||||
|
where: whereClause({
|
||||||
|
user,
|
||||||
|
entityType: 'workflow',
|
||||||
|
entityId: workflowId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!shared) {
|
||||||
|
LoggerProxy.info('User attempted to update a workflow without permissions', {
|
||||||
|
workflowId,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
throw new ResponseHelper.ResponseError(
|
||||||
|
`Workflow with ID "${workflowId}" could not be found to be updated.`,
|
||||||
|
undefined,
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// check credentials for old format
|
||||||
|
await WorkflowHelpers.replaceInvalidCredentials(workflow);
|
||||||
|
|
||||||
|
WorkflowHelpers.addNodeIds(workflow);
|
||||||
|
|
||||||
|
await externalHooks.run('workflow.update', [workflow]);
|
||||||
|
|
||||||
|
if (shared.workflow.active) {
|
||||||
|
// When workflow gets saved always remove it as the triggers could have been
|
||||||
|
// changed and so the changes would not take effect
|
||||||
|
await ActiveWorkflowRunner.getInstance().remove(workflowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workflow.settings) {
|
||||||
|
if (workflow.settings.timezone === 'DEFAULT') {
|
||||||
|
// Do not save the default timezone
|
||||||
|
delete workflow.settings.timezone;
|
||||||
|
}
|
||||||
|
if (workflow.settings.saveDataErrorExecution === 'DEFAULT') {
|
||||||
|
// Do not save when default got set
|
||||||
|
delete workflow.settings.saveDataErrorExecution;
|
||||||
|
}
|
||||||
|
if (workflow.settings.saveDataSuccessExecution === 'DEFAULT') {
|
||||||
|
// Do not save when default got set
|
||||||
|
delete workflow.settings.saveDataSuccessExecution;
|
||||||
|
}
|
||||||
|
if (workflow.settings.saveManualExecutions === 'DEFAULT') {
|
||||||
|
// Do not save when default got set
|
||||||
|
delete workflow.settings.saveManualExecutions;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
parseInt(workflow.settings.executionTimeout as string, 10) ===
|
||||||
|
config.get('executions.timeout')
|
||||||
|
) {
|
||||||
|
// Do not save when default got set
|
||||||
|
delete workflow.settings.executionTimeout;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workflow.name) {
|
||||||
|
workflow.updatedAt = new Date(); // required due to atomic update
|
||||||
|
await validateEntity(workflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Db.collections.Workflow.update(workflowId, workflow);
|
||||||
|
|
||||||
|
if (tags && !config.getEnv('workflowTagsDisabled')) {
|
||||||
|
const tablePrefix = config.getEnv('database.tablePrefix');
|
||||||
|
await TagHelpers.removeRelations(workflowId, tablePrefix);
|
||||||
|
|
||||||
|
if (tags.length) {
|
||||||
|
await TagHelpers.createRelations(workflowId, tags, tablePrefix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: FindManyOptions<WorkflowEntity> = {
|
||||||
|
relations: ['tags'],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.getEnv('workflowTagsDisabled')) {
|
||||||
|
delete options.relations;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We sadly get nothing back from "update". Neither if it updated a record
|
||||||
|
// nor the new value. So query now the hopefully updated entry.
|
||||||
|
const updatedWorkflow = await Db.collections.Workflow.findOne(workflowId, options);
|
||||||
|
|
||||||
|
if (updatedWorkflow === undefined) {
|
||||||
|
throw new ResponseHelper.ResponseError(
|
||||||
|
`Workflow with ID "${workflowId}" could not be found to be updated.`,
|
||||||
|
undefined,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedWorkflow.tags?.length && tags?.length) {
|
||||||
|
updatedWorkflow.tags = TagHelpers.sortByRequestOrder(updatedWorkflow.tags, {
|
||||||
|
requestOrder: tags,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await externalHooks.run('workflow.afterUpdate', [updatedWorkflow]);
|
||||||
|
void InternalHooksManager.getInstance().onWorkflowSaved(user.id, updatedWorkflow, false);
|
||||||
|
|
||||||
|
if (updatedWorkflow.active) {
|
||||||
|
// When the workflow is supposed to be active add it again
|
||||||
|
try {
|
||||||
|
await externalHooks.run('workflow.activate', [updatedWorkflow]);
|
||||||
|
await ActiveWorkflowRunner.getInstance().add(
|
||||||
|
workflowId,
|
||||||
|
shared.workflow.active ? 'update' : 'activate',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// If workflow could not be activated set it again to inactive
|
||||||
|
workflow.active = false;
|
||||||
|
await Db.collections.Workflow.update(workflowId, workflow);
|
||||||
|
|
||||||
|
// Also set it in the returned data
|
||||||
|
updatedWorkflow.active = false;
|
||||||
|
|
||||||
|
// Now return the original error for UI to display
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedWorkflow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -237,7 +237,6 @@ function toTableName(sourceName: CollectionName | MappingName) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
Credentials: 'credentials_entity',
|
Credentials: 'credentials_entity',
|
||||||
CredentialUsage: 'credential_usage',
|
|
||||||
Workflow: 'workflow_entity',
|
Workflow: 'workflow_entity',
|
||||||
Execution: 'execution_entity',
|
Execution: 'execution_entity',
|
||||||
Tag: 'tag_entity',
|
Tag: 'tag_entity',
|
||||||
|
@ -642,18 +641,6 @@ 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
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
|
|
|
@ -11,6 +11,7 @@ import config from '../../config';
|
||||||
import type { AuthAgent, SaveCredentialFunction } from './shared/types';
|
import type { AuthAgent, SaveCredentialFunction } from './shared/types';
|
||||||
import { makeWorkflow } from './shared/utils';
|
import { makeWorkflow } from './shared/utils';
|
||||||
import { randomCredentialPayload } from './shared/random';
|
import { randomCredentialPayload } from './shared/random';
|
||||||
|
import { INode, INodes } from 'n8n-workflow';
|
||||||
|
|
||||||
jest.mock('../../src/telemetry');
|
jest.mock('../../src/telemetry');
|
||||||
|
|
||||||
|
@ -72,7 +73,7 @@ describe('PUT /workflows/:id', () => {
|
||||||
expect(sharedWorkflows).toHaveLength(2);
|
expect(sharedWorkflows).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('PUT /workflows/:id/share should not fail when sharing with invalid user-id', async () => {
|
test('PUT /workflows/:id/share should succeed when sharing with invalid user-id', 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);
|
||||||
|
|
||||||
|
@ -218,12 +219,9 @@ describe('POST /workflows', () => {
|
||||||
const response = await authAgent(owner).post('/workflows').send(workflow);
|
const response = await authAgent(owner).post('/workflows').send(workflow);
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
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 () => {
|
it('Should save a new workflow with credentials', async () => {
|
||||||
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
|
|
||||||
const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner });
|
const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner });
|
||||||
|
@ -235,9 +233,6 @@ describe('POST /workflows', () => {
|
||||||
const response = await authAgent(owner).post('/workflows').send(workflow);
|
const response = await authAgent(owner).post('/workflows').send(workflow);
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
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 () => {
|
it('Should not allow saving a workflow using credential you have no access', async () => {
|
||||||
|
@ -273,8 +268,6 @@ describe('POST /workflows', () => {
|
||||||
const response = await authAgent(owner).post('/workflows').send(workflow);
|
const response = await authAgent(owner).post('/workflows').send(workflow);
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
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 () => {
|
it('Should allow saving a workflow using a credential owned by others and shared with you', async () => {
|
||||||
|
@ -291,7 +284,162 @@ describe('POST /workflows', () => {
|
||||||
|
|
||||||
const response = await authAgent(member2).post('/workflows').send(workflow);
|
const response = await authAgent(member2).post('/workflows').send(workflow);
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
const usedCredentials = await testDb.getCredentialUsageInWorkflow(response.body.data.id);
|
});
|
||||||
expect(usedCredentials).toHaveLength(1);
|
});
|
||||||
|
|
||||||
|
describe('PATCH /workflows/:id', () => {
|
||||||
|
it('Should succeed when saving unchanged workflow nodes', async () => {
|
||||||
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
|
|
||||||
|
const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner });
|
||||||
|
const workflow = await createWorkflow(
|
||||||
|
{
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'uuid-1234',
|
||||||
|
name: 'Start',
|
||||||
|
parameters: {},
|
||||||
|
position: [-20, 260],
|
||||||
|
type: 'n8n-nodes-base.start',
|
||||||
|
typeVersion: 1,
|
||||||
|
credentials: {
|
||||||
|
default: {
|
||||||
|
id: savedCredential.id.toString(),
|
||||||
|
name: savedCredential.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
owner,
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await authAgent(owner).patch(`/workflows/${workflow.id}`).send({
|
||||||
|
name: 'new name',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should allow owner to add node containing credential not shared with the owner', async () => {
|
||||||
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
|
const member = await testDb.createUser({ globalRole: globalMemberRole });
|
||||||
|
|
||||||
|
const savedCredential = await saveCredential(randomCredentialPayload(), { user: member });
|
||||||
|
const workflow = await createWorkflow({}, owner);
|
||||||
|
|
||||||
|
const response = await authAgent(owner)
|
||||||
|
.patch(`/workflows/${workflow.id}`)
|
||||||
|
.send({
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'uuid-1234',
|
||||||
|
name: 'Start',
|
||||||
|
parameters: {},
|
||||||
|
position: [-20, 260],
|
||||||
|
type: 'n8n-nodes-base.start',
|
||||||
|
typeVersion: 1,
|
||||||
|
credentials: {
|
||||||
|
default: {
|
||||||
|
id: savedCredential.id.toString(),
|
||||||
|
name: savedCredential.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should prevent member from adding node containing credential inaccessible to member', async () => {
|
||||||
|
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
|
||||||
|
const member = await testDb.createUser({ globalRole: globalMemberRole });
|
||||||
|
|
||||||
|
const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner });
|
||||||
|
const workflow = await createWorkflow({}, member);
|
||||||
|
|
||||||
|
const response = await authAgent(member)
|
||||||
|
.patch(`/workflows/${workflow.id}`)
|
||||||
|
.send({
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'uuid-1234',
|
||||||
|
name: 'Start',
|
||||||
|
parameters: {},
|
||||||
|
position: [-20, 260],
|
||||||
|
type: 'n8n-nodes-base.start',
|
||||||
|
typeVersion: 1,
|
||||||
|
credentials: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uuid-12345',
|
||||||
|
name: 'Start',
|
||||||
|
parameters: {},
|
||||||
|
position: [-20, 260],
|
||||||
|
type: 'n8n-nodes-base.start',
|
||||||
|
typeVersion: 1,
|
||||||
|
credentials: {
|
||||||
|
default: {
|
||||||
|
id: savedCredential.id.toString(),
|
||||||
|
name: savedCredential.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should succeed but prevent modifying nodes that are read-only for the requester', async () => {
|
||||||
|
const member1 = await testDb.createUser({ globalRole: globalMemberRole });
|
||||||
|
const member2 = await testDb.createUser({ globalRole: globalMemberRole });
|
||||||
|
|
||||||
|
const savedCredential = await saveCredential(randomCredentialPayload(), { user: member1 });
|
||||||
|
|
||||||
|
const originalNodes: INode[] = [
|
||||||
|
{
|
||||||
|
id: 'uuid-1234',
|
||||||
|
name: 'Start',
|
||||||
|
parameters: {},
|
||||||
|
position: [-20, 260],
|
||||||
|
type: 'n8n-nodes-base.start',
|
||||||
|
typeVersion: 1,
|
||||||
|
credentials: {
|
||||||
|
default: {
|
||||||
|
id: savedCredential.id.toString(),
|
||||||
|
name: savedCredential.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const changedNodes: INode[] = [
|
||||||
|
{
|
||||||
|
id: 'uuid-1234',
|
||||||
|
name: 'End',
|
||||||
|
parameters: {},
|
||||||
|
position: [-20, 260],
|
||||||
|
type: 'n8n-nodes-base.no-op',
|
||||||
|
typeVersion: 1,
|
||||||
|
credentials: {
|
||||||
|
default: {
|
||||||
|
id: '200',
|
||||||
|
name: 'fake credential',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const workflow = await createWorkflow({ nodes: originalNodes }, member1);
|
||||||
|
await testDb.shareWorkflowWithUsers(workflow, [member2]);
|
||||||
|
|
||||||
|
const response = await authAgent(member2).patch(`/workflows/${workflow.id}`).send({
|
||||||
|
nodes: changedNodes,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.body.data.nodes).toMatchObject(originalNodes);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
224
packages/cli/test/unit/WorkflowHelpers.test.ts
Normal file
224
packages/cli/test/unit/WorkflowHelpers.test.ts
Normal file
|
@ -0,0 +1,224 @@
|
||||||
|
import { INode, LoggerProxy } from 'n8n-workflow';
|
||||||
|
import { WorkflowEntity } from '../../src/databases/entities/WorkflowEntity';
|
||||||
|
import { CredentialsEntity } from '../../src/databases/entities/CredentialsEntity';
|
||||||
|
import {
|
||||||
|
getNodesWithInaccessibleCreds,
|
||||||
|
validateWorkflowCredentialUsage,
|
||||||
|
} from '../../src/WorkflowHelpers';
|
||||||
|
import { getLogger } from '../../src/Logger';
|
||||||
|
|
||||||
|
const FIRST_CREDENTIAL_ID = '1';
|
||||||
|
const SECOND_CREDENTIAL_ID = '2';
|
||||||
|
const THIRD_CREDENTIAL_ID = '3';
|
||||||
|
|
||||||
|
const NODE_WITH_NO_CRED = '0133467b-df4a-473d-9295-fdd9d01fa45a';
|
||||||
|
const NODE_WITH_ONE_CRED = '4673f869-f2dc-4a33-b053-ca3193bc5226';
|
||||||
|
const NODE_WITH_TWO_CRED = '9b4208bd-8f10-4a6a-ad3b-da47a326f7da';
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
LoggerProxy.init(getLogger());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WorkflowHelpers', () => {
|
||||||
|
describe('getNodesWithInaccessibleCreds', () => {
|
||||||
|
test('Should return an empty list for a workflow without nodes', () => {
|
||||||
|
const workflow = getWorkflow();
|
||||||
|
const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, []);
|
||||||
|
expect(nodesWithInaccessibleCreds).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Should return an empty list for a workflow with nodes without credentials', () => {
|
||||||
|
const workflow = getWorkflow({ addNodeWithoutCreds: true });
|
||||||
|
const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, []);
|
||||||
|
expect(nodesWithInaccessibleCreds).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Should return an element for a node with a credential without access', () => {
|
||||||
|
const workflow = getWorkflow({ addNodeWithOneCred: true });
|
||||||
|
const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, []);
|
||||||
|
expect(nodesWithInaccessibleCreds).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Should return an empty list for a node with a credential with access', () => {
|
||||||
|
const workflow = getWorkflow({ addNodeWithOneCred: true });
|
||||||
|
const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, [
|
||||||
|
FIRST_CREDENTIAL_ID,
|
||||||
|
]);
|
||||||
|
expect(nodesWithInaccessibleCreds).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Should return an element for a node with two credentials and mixed access', () => {
|
||||||
|
const workflow = getWorkflow({ addNodeWithTwoCreds: true });
|
||||||
|
const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, [
|
||||||
|
SECOND_CREDENTIAL_ID,
|
||||||
|
]);
|
||||||
|
expect(nodesWithInaccessibleCreds).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Should return one node for a workflow with two nodes and two credentials', () => {
|
||||||
|
const workflow = getWorkflow({ addNodeWithOneCred: true, addNodeWithTwoCreds: true });
|
||||||
|
const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, [
|
||||||
|
SECOND_CREDENTIAL_ID,
|
||||||
|
THIRD_CREDENTIAL_ID,
|
||||||
|
]);
|
||||||
|
expect(nodesWithInaccessibleCreds).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Should return one element for a workflows with two nodes and one credential', () => {
|
||||||
|
const workflow = getWorkflow({
|
||||||
|
addNodeWithoutCreds: true,
|
||||||
|
addNodeWithOneCred: true,
|
||||||
|
addNodeWithTwoCreds: true,
|
||||||
|
});
|
||||||
|
const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, [
|
||||||
|
FIRST_CREDENTIAL_ID,
|
||||||
|
]);
|
||||||
|
expect(nodesWithInaccessibleCreds).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Should return one element for a workflows with two nodes and partial credential access', () => {
|
||||||
|
const workflow = getWorkflow({ addNodeWithOneCred: true, addNodeWithTwoCreds: true });
|
||||||
|
const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, [
|
||||||
|
FIRST_CREDENTIAL_ID,
|
||||||
|
SECOND_CREDENTIAL_ID,
|
||||||
|
]);
|
||||||
|
expect(nodesWithInaccessibleCreds).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Should return two elements for a workflows with two nodes and partial credential access', () => {
|
||||||
|
const workflow = getWorkflow({ addNodeWithOneCred: true, addNodeWithTwoCreds: true });
|
||||||
|
const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, [
|
||||||
|
SECOND_CREDENTIAL_ID,
|
||||||
|
]);
|
||||||
|
expect(nodesWithInaccessibleCreds).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Should return two elements for a workflows with two nodes and no credential access', () => {
|
||||||
|
const workflow = getWorkflow({ addNodeWithOneCred: true, addNodeWithTwoCreds: true });
|
||||||
|
const nodesWithInaccessibleCreds = getNodesWithInaccessibleCreds(workflow, []);
|
||||||
|
expect(nodesWithInaccessibleCreds).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateWorkflowCredentialUsage', () => {
|
||||||
|
it('Should throw error saving a workflow using credential without access', () => {
|
||||||
|
const newWorkflowVersion = getWorkflow({ addNodeWithOneCred: true });
|
||||||
|
const previousWorkflowVersion = getWorkflow();
|
||||||
|
expect(() => {
|
||||||
|
validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, []);
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should not throw error when saving a workflow using credential with access', () => {
|
||||||
|
const newWorkflowVersion = getWorkflow({ addNodeWithOneCred: true });
|
||||||
|
const previousWorkflowVersion = getWorkflow();
|
||||||
|
expect(() => {
|
||||||
|
validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, [
|
||||||
|
generateCredentialEntity(FIRST_CREDENTIAL_ID),
|
||||||
|
]);
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should not throw error when saving a workflow removing node without credential access', () => {
|
||||||
|
const newWorkflowVersion = getWorkflow();
|
||||||
|
const previousWorkflowVersion = getWorkflow({ addNodeWithOneCred: true });
|
||||||
|
expect(() => {
|
||||||
|
validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, [
|
||||||
|
generateCredentialEntity(FIRST_CREDENTIAL_ID),
|
||||||
|
]);
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should save fine when not making changes to workflow without access', () => {
|
||||||
|
const workflowWithOneCredential = getWorkflow({ addNodeWithOneCred: true });
|
||||||
|
expect(() => {
|
||||||
|
validateWorkflowCredentialUsage(workflowWithOneCredential, workflowWithOneCredential, []);
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should throw error saving a workflow adding node without credential access', () => {
|
||||||
|
const newWorkflowVersion = getWorkflow({
|
||||||
|
addNodeWithOneCred: true,
|
||||||
|
addNodeWithTwoCreds: true,
|
||||||
|
});
|
||||||
|
const previousWorkflowVersion = getWorkflow({ addNodeWithOneCred: true });
|
||||||
|
expect(() => {
|
||||||
|
validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, []);
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function generateCredentialEntity(credentialId: string) {
|
||||||
|
const credentialEntity = new CredentialsEntity();
|
||||||
|
credentialEntity.id = parseInt(credentialId, 10);
|
||||||
|
return credentialEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorkflow(options?: {
|
||||||
|
addNodeWithoutCreds?: boolean;
|
||||||
|
addNodeWithOneCred?: boolean;
|
||||||
|
addNodeWithTwoCreds?: boolean;
|
||||||
|
}) {
|
||||||
|
const workflow = new WorkflowEntity();
|
||||||
|
|
||||||
|
workflow.nodes = [];
|
||||||
|
|
||||||
|
if (options?.addNodeWithoutCreds) {
|
||||||
|
workflow.nodes.push(nodeWithNoCredentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.addNodeWithOneCred) {
|
||||||
|
workflow.nodes.push(nodeWithOneCredential);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.addNodeWithTwoCreds) {
|
||||||
|
workflow.nodes.push(nodeWithTwoCredentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
return workflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeWithNoCredentials: INode = {
|
||||||
|
id: NODE_WITH_NO_CRED,
|
||||||
|
name: 'Node with no Credential',
|
||||||
|
typeVersion: 1,
|
||||||
|
type: 'n8n-nodes-base.fakeNode',
|
||||||
|
position: [0, 0],
|
||||||
|
credentials: {},
|
||||||
|
parameters: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeWithOneCredential: INode = {
|
||||||
|
id: NODE_WITH_ONE_CRED,
|
||||||
|
name: 'Node with a single credential',
|
||||||
|
typeVersion: 1,
|
||||||
|
type: '',
|
||||||
|
position: [0, 0],
|
||||||
|
credentials: {
|
||||||
|
test: {
|
||||||
|
id: FIRST_CREDENTIAL_ID,
|
||||||
|
name: 'First fake credential',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parameters: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeWithTwoCredentials: INode = {
|
||||||
|
id: NODE_WITH_TWO_CRED,
|
||||||
|
name: 'Node with two credentials',
|
||||||
|
typeVersion: 1,
|
||||||
|
type: '',
|
||||||
|
position: [0, 0],
|
||||||
|
credentials: {
|
||||||
|
mcTest: {
|
||||||
|
id: SECOND_CREDENTIAL_ID,
|
||||||
|
name: 'Second fake credential',
|
||||||
|
},
|
||||||
|
mcTest2: {
|
||||||
|
id: THIRD_CREDENTIAL_ID,
|
||||||
|
name: 'Third fake credential',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parameters: {},
|
||||||
|
};
|
Loading…
Reference in a new issue