diff --git a/packages/cli/src/Db.ts b/packages/cli/src/Db.ts index a58c543869..0d80467cfa 100644 --- a/packages/cli/src/Db.ts +++ b/packages/cli/src/Db.ts @@ -202,6 +202,7 @@ export async function init( collections.Settings = linkRepository(entities.Settings); collections.InstalledPackages = linkRepository(entities.InstalledPackages); collections.InstalledNodes = linkRepository(entities.InstalledNodes); + collections.CredentialUsage = linkRepository(entities.CredentialUsage); isInitialized = true; diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 9892d3f8e4..79a92fb61c 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -40,6 +40,7 @@ import type { SharedWorkflow } from './databases/entities/SharedWorkflow'; import type { TagEntity } from './databases/entities/TagEntity'; import type { User } from './databases/entities/User'; import type { WorkflowEntity } from './databases/entities/WorkflowEntity'; +import { CredentialUsage } from './databases/entities/CredentialUsage'; export interface IActivationError { time: number; @@ -83,6 +84,7 @@ export interface IDatabaseCollections { Settings: Repository; InstalledPackages: Repository; InstalledNodes: Repository; + CredentialUsage: Repository; } export interface IWebhookDb { diff --git a/packages/cli/src/databases/entities/CredentialUsage.ts b/packages/cli/src/databases/entities/CredentialUsage.ts new file mode 100644 index 0000000000..6bdc8a8fcb --- /dev/null +++ b/packages/cli/src/databases/entities/CredentialUsage.ts @@ -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(); + } +} diff --git a/packages/cli/src/databases/entities/index.ts b/packages/cli/src/databases/entities/index.ts index 97628e8ef8..d22b4c4e53 100644 --- a/packages/cli/src/databases/entities/index.ts +++ b/packages/cli/src/databases/entities/index.ts @@ -12,6 +12,7 @@ import { SharedWorkflow } from './SharedWorkflow'; import { SharedCredentials } from './SharedCredentials'; import { InstalledPackages } from './InstalledPackages'; import { InstalledNodes } from './InstalledNodes'; +import { CredentialUsage } from './CredentialUsage'; export const entities = { CredentialsEntity, @@ -26,4 +27,5 @@ export const entities = { SharedCredentials, InstalledPackages, InstalledNodes, + CredentialUsage, }; diff --git a/packages/cli/src/databases/migrations/mysqldb/1665484192213-CreateCredentialUsageTable.ts b/packages/cli/src/databases/migrations/mysqldb/1665484192213-CreateCredentialUsageTable.ts new file mode 100644 index 0000000000..741e74b6c0 --- /dev/null +++ b/packages/cli/src/databases/migrations/mysqldb/1665484192213-CreateCredentialUsageTable.ts @@ -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'; + `); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index bd5875ee42..3668d95956 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -21,6 +21,7 @@ import { AddNodeIds1658932910559 } from './1658932910559-AddNodeIds'; import { AddJsonKeyPinData1659895550980 } from './1659895550980-AddJsonKeyPinData'; import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole'; import { CreateWorkflowsEditorRole1663755770894 } from './1663755770894-CreateWorkflowsEditorRole'; +import { CreateCredentialUsageTable1665484192213 } from './1665484192213-CreateCredentialUsageTable'; export const mysqlMigrations = [ InitialMigration1588157391238, @@ -46,4 +47,5 @@ export const mysqlMigrations = [ AddJsonKeyPinData1659895550980, CreateCredentialsUserRole1660062385367, CreateWorkflowsEditorRole1663755770894, + CreateCredentialUsageTable1665484192213, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/1665484192212-CreateCredentialUsageTable.ts b/packages/cli/src/databases/migrations/postgresdb/1665484192212-CreateCredentialUsageTable.ts new file mode 100644 index 0000000000..123d828f99 --- /dev/null +++ b/packages/cli/src/databases/migrations/postgresdb/1665484192212-CreateCredentialUsageTable.ts @@ -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"`); + } +} diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index d71905464e..ba386056bf 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -19,6 +19,7 @@ import { AddNodeIds1658932090381 } from './1658932090381-AddNodeIds'; import { AddJsonKeyPinData1659902242948 } from './1659902242948-AddJsonKeyPinData'; import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole'; import { CreateWorkflowsEditorRole1663755770893 } from './1663755770893-CreateWorkflowsEditorRole'; +import { CreateCredentialUsageTable1665484192212 } from './1665484192212-CreateCredentialUsageTable'; export const postgresMigrations = [ InitialMigration1587669153312, @@ -42,4 +43,5 @@ export const postgresMigrations = [ AddNodeIds1658932090381, AddJsonKeyPinData1659902242948, CreateWorkflowsEditorRole1663755770893, + CreateCredentialUsageTable1665484192212, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/1665484192211-CreateCredentialUsageTable.ts b/packages/cli/src/databases/migrations/sqlite/1665484192211-CreateCredentialUsageTable.ts new file mode 100644 index 0000000000..3b3c1e39be --- /dev/null +++ b/packages/cli/src/databases/migrations/sqlite/1665484192211-CreateCredentialUsageTable.ts @@ -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"`); + } +} diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index 4d0b177832..ae0c221c15 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -18,6 +18,7 @@ import { AddNodeIds1658930531669 } from './1658930531669-AddNodeIds'; import { AddJsonKeyPinData1659888469333 } from './1659888469333-AddJsonKeyPinData'; import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCredentialsUserRole'; import { CreateWorkflowsEditorRole1663755770892 } from './1663755770892-CreateWorkflowsUserRole'; +import { CreateCredentialUsageTable1665484192211 } from './1665484192211-CreateCredentialUsageTable'; const sqliteMigrations = [ InitialMigration1588102412422, @@ -40,6 +41,7 @@ const sqliteMigrations = [ AddJsonKeyPinData1659888469333, CreateCredentialsUserRole1660062385367, CreateWorkflowsEditorRole1663755770892, + CreateCredentialUsageTable1665484192211, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/workflows/workflows.controller.ee.ts b/packages/cli/src/workflows/workflows.controller.ee.ts index dc9000b3d2..8cd5af5069 100644 --- a/packages/cli/src/workflows/workflows.controller.ee.ts +++ b/packages/cli/src/workflows/workflows.controller.ee.ts @@ -1,9 +1,17 @@ import express from 'express'; -import { Db, ResponseHelper } from '..'; +import { Db, InternalHooksManager, ResponseHelper, WorkflowHelpers } from '..'; import config from '../../config'; +import { WorkflowEntity } from '../databases/entities/WorkflowEntity'; +import { validateEntity } from '../GenericHelpers'; import type { WorkflowRequest } from '../requests'; import { isSharingEnabled, rightDiff } from '../UserManagement/UserManagementHelper'; 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 export const EEWorkflowController = express.Router(); @@ -60,15 +68,10 @@ EEWorkflowController.put('/:workflowId/share', async (req: WorkflowRequest.Share }); EEWorkflowController.get( - '/:id', - (req: WorkflowRequest.Get, res, next) => (req.params.id === 'new' ? next('router') : next()), // skip ee router and use free one for naming + '/:id(\\d+)', ResponseHelper.send(async (req: WorkflowRequest.Get) => { 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( { id: parseInt(workflowId, 10) }, { relations: ['shared', 'shared.user', 'shared.role'] }, @@ -92,3 +95,110 @@ EEWorkflowController.get( 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(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(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); + } + }); + + 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, + }; + }), +); diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 737662b07c..e6a2ea88ab 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -279,7 +279,7 @@ workflowsController.get( * GET /workflows/:id */ workflowsController.get( - '/:id', + '/:id(\\d+)', ResponseHelper.send(async (req: WorkflowRequest.Get) => { const { id: workflowId } = req.params; diff --git a/packages/cli/src/workflows/workflows.services.ee.ts b/packages/cli/src/workflows/workflows.services.ee.ts index 89ab7e168f..3ba791a118 100644 --- a/packages/cli/src/workflows/workflows.services.ee.ts +++ b/packages/cli/src/workflows/workflows.services.ee.ts @@ -1,5 +1,5 @@ import { DeleteResult, EntityManager, In, Not } from 'typeorm'; -import { Db } from '..'; +import { Db, ICredentialsDb } from '..'; import { SharedWorkflow } from '../databases/entities/SharedWorkflow'; import { User } from '../databases/entities/User'; import { WorkflowEntity } from '../databases/entities/WorkflowEntity'; @@ -94,4 +94,24 @@ export class EEWorkflowsService extends WorkflowsService { 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'); + } + }); + }); + } } diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts index bbe813c94c..4f53c42828 100644 --- a/packages/cli/test/integration/shared/testDb.ts +++ b/packages/cli/test/integration/shared/testDb.ts @@ -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 // ---------------------------------- diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts index ba51197ef6..39c81799a6 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -10,6 +10,7 @@ import { ICredentialType, IDataObject, IExecuteFunctions, + INode, INodeExecutionData, INodeParameters, INodeTypeData, @@ -65,6 +66,8 @@ import type { InstalledPackagePayload, PostgresSchemaSection, } from './types'; +import { WorkflowEntity } from '../../../src/databases/entities/WorkflowEntity'; +import { v4 as uuid } from 'uuid'; /** * Initialize a test server. @@ -698,3 +701,45 @@ export const emptyPackage = () => { 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' } }] }; diff --git a/packages/cli/test/integration/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows.controller.ee.test.ts index 658f24a266..30d304d89c 100644 --- a/packages/cli/test/integration/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows.controller.ee.test.ts @@ -8,7 +8,9 @@ import { v4 as uuid } from 'uuid'; import type { Role } from '../../src/databases/entities/Role'; 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'); @@ -20,7 +22,9 @@ let testDbName = ''; let globalOwnerRole: Role; let globalMemberRole: Role; +let credentialOwnerRole: Role; let authAgent: AuthAgent; +let saveCredential: SaveCredentialFunction; beforeAll(async () => { app = await utils.initTestServer({ @@ -32,6 +36,9 @@ beforeAll(async () => { globalOwnerRole = await testDb.getGlobalOwnerRole(); globalMemberRole = await testDb.getGlobalMemberRole(); + credentialOwnerRole = await testDb.getCredentialOwnerRole(); + + saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole); authAgent = utils.createAuthAgent(app); @@ -123,21 +130,27 @@ describe('PUT /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 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 () => { const owner = await testDb.createUser({ globalRole: globalOwnerRole }); 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.body.data.ownedBy).toMatchObject({ @@ -154,10 +167,9 @@ describe('GET /workflows/:id', () => { const owner = await testDb.createUser({ globalRole: globalOwnerRole }); const member = await testDb.createUser({ globalRole: globalMemberRole }); const workflow = await createWorkflow({}, owner); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); 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.body.data.ownedBy).toMatchObject({ @@ -181,10 +193,9 @@ describe('GET /workflows/:id', () => { const member1 = await testDb.createUser({ globalRole: globalMemberRole }); const member2 = await testDb.createUser({ globalRole: globalMemberRole }); const workflow = await createWorkflow({}, owner); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); 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.body.data.ownedBy).toMatchObject({ @@ -197,3 +208,90 @@ describe('GET /workflows/:id', () => { 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); + }); +}); diff --git a/packages/cli/test/integration/workflows.controller.test.ts b/packages/cli/test/integration/workflows.controller.test.ts index 7b809fa949..4288de5c7e 100644 --- a/packages/cli/test/integration/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows.controller.test.ts @@ -7,6 +7,7 @@ import * as UserManagementHelpers from '../../src/UserManagement/UserManagementH import type { Role } from '../../src/databases/entities/Role'; import type { IPinData } from 'n8n-workflow'; +import { makeWorkflow, MOCK_PINDATA } from './shared/utils'; jest.mock('../../src/telemetry'); @@ -87,29 +88,3 @@ test('GET /workflows/:id should return pin data', async () => { 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' } }] };