From 97d3c6465cb1aa04fef13e44f8213fb6866a459a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Tue, 14 May 2024 14:08:37 +0200 Subject: [PATCH 01/58] ci: Do not hoist workspace packages in custom builds (no-changelog) (#9388) --- .npmrc | 1 + package.json | 3 --- pnpm-lock.yaml | 4 ---- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/.npmrc b/.npmrc index 0d9bdb6234..688ccc8857 100644 --- a/.npmrc +++ b/.npmrc @@ -7,4 +7,5 @@ prefer-workspace-packages = true link-workspace-packages = deep hoist = true shamefully-hoist = true +hoist-workspace-packages = false loglevel = warn diff --git a/package.json b/package.json index 7c30c3e250..7b99db0421 100644 --- a/package.json +++ b/package.json @@ -38,9 +38,6 @@ "test:e2e:dev": "scripts/run-e2e.js dev", "test:e2e:all": "scripts/run-e2e.js all" }, - "dependencies": { - "n8n": "workspace:*" - }, "devDependencies": { "@n8n_io/eslint-config": "workspace:*", "@ngneat/falso": "^6.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2888cfe80..644ea2c175 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,10 +44,6 @@ patchedDependencies: importers: .: - dependencies: - n8n: - specifier: workspace:* - version: link:packages/cli devDependencies: '@n8n_io/eslint-config': specifier: workspace:* From 82c8801f25446085bc8da5055d9932eed4321f47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Tue, 14 May 2024 14:08:51 +0200 Subject: [PATCH 02/58] fix(Code Node): Bind helper methods to the correct context (#9380) --- packages/nodes-base/nodes/Code/Sandbox.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/Code/Sandbox.ts b/packages/nodes-base/nodes/Code/Sandbox.ts index fb8aeb5ced..aab88f1b39 100644 --- a/packages/nodes-base/nodes/Code/Sandbox.ts +++ b/packages/nodes-base/nodes/Code/Sandbox.ts @@ -19,11 +19,16 @@ export interface SandboxContext extends IWorkflowDataProxyData { export const REQUIRED_N8N_ITEM_KEYS = new Set(['json', 'binary', 'pairedItem', 'error']); export function getSandboxContext(this: IExecuteFunctions, index: number): SandboxContext { + const helpers = { + ...this.helpers, + httpRequestWithAuthentication: this.helpers.httpRequestWithAuthentication.bind(this), + requestWithAuthenticationPaginated: this.helpers.requestWithAuthenticationPaginated.bind(this), + }; return { // from NodeExecuteFunctions $getNodeParameter: this.getNodeParameter, $getWorkflowStaticData: this.getWorkflowStaticData, - helpers: this.helpers, + helpers, // to bring in all $-prefixed vars and methods from WorkflowDataProxy // $node, $items(), $parameter, $json, $env, etc. From 78e7c7a9da96a293262cea5304509261ad10020c Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 14 May 2024 15:04:24 +0100 Subject: [PATCH 03/58] fix(Mattermost Node): Fix issue when fetching reactions (#9375) --- .../nodes/Mattermost/v1/actions/reaction/getAll/execute.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/getAll/execute.ts b/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/getAll/execute.ts index ae85f76b0e..ca81e76c1f 100644 --- a/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/getAll/execute.ts +++ b/packages/nodes-base/nodes/Mattermost/v1/actions/reaction/getAll/execute.ts @@ -15,6 +15,9 @@ export async function getAll( const body = {} as IDataObject; let responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + if (responseData === null) { + return []; + } if (limit > 0) { responseData = responseData.slice(0, limit); } From 52936633af9c71dff1957ee43a5eda48f7fc1bf1 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Tue, 14 May 2024 16:32:31 +0200 Subject: [PATCH 04/58] feat(editor): Add examples for object and array expression methods (#9360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Iván Ovejero --- .../completions/datatype.completions.ts | 57 ++- .../codemirror/completions/infoBoxRenderer.ts | 113 ++--- .../src/styles/plugins/_codemirror.scss | 5 + .../src/Extensions/ArrayExtensions.ts | 290 +++++++++--- .../workflow/src/Extensions/Extensions.ts | 2 + .../src/Extensions/NumberExtensions.ts | 2 +- .../src/Extensions/ObjectExtensions.ts | 137 +++++- .../src/Extensions/StringExtensions.ts | 31 +- .../src/NativeMethods/Array.methods.ts | 428 ++++++++++++++++-- .../ArrayExtensions.test.ts | 15 + .../StringExtensions.test.ts | 3 + 11 files changed, 890 insertions(+), 193 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index 62ab12645d..37e2d48abc 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -466,9 +466,20 @@ const stringOptions = (input: AutocompleteInput): Completion[] => { ]); if (resolved && validateFieldType('string', resolved, 'number').valid) { + const recommended = ['toNumber()']; + const timestampUnit = toTimestampUnit(Number(resolved)); + + if (timestampUnit) { + return applySections({ + options, + recommended: [...recommended, { label: 'toDateTime()', args: [`'${timestampUnit}'`] }], + sections: STRING_SECTIONS, + }); + } + return applySections({ options, - recommended: ['toNumber()'], + recommended, sections: STRING_SECTIONS, }); } @@ -541,6 +552,29 @@ const booleanOptions = (): Completion[] => { }); }; +const isWithinMargin = (ts: number, now: number, margin: number): boolean => { + return ts > now - margin && ts < now + margin; +}; + +const toTimestampUnit = (ts: number): null | 'ms' | 's' | 'us' => { + const nowMillis = Date.now(); + const marginMillis = 946_707_779_000; // 30y + + if (isWithinMargin(ts, nowMillis, marginMillis)) { + return 'ms'; + } + + if (isWithinMargin(ts, nowMillis / 1000, marginMillis / 1000)) { + return 's'; + } + + if (isWithinMargin(ts, nowMillis * 1000, marginMillis * 1000)) { + return 'us'; + } + + return null; +}; + const numberOptions = (input: AutocompleteInput): Completion[] => { const { resolved, transformLabel } = input; const options = sortCompletionsAlpha([ @@ -550,26 +584,11 @@ const numberOptions = (input: AutocompleteInput): Completion[] => { const ONLY_INTEGER = ['isEven()', 'isOdd()']; if (Number.isInteger(resolved)) { - const nowMillis = Date.now(); - const marginMillis = 946_707_779_000; // 30y - const isPlausableMillisDateTime = - resolved > nowMillis - marginMillis && resolved < nowMillis + marginMillis; - - if (isPlausableMillisDateTime) { + const timestampUnit = toTimestampUnit(resolved); + if (timestampUnit) { return applySections({ options, - recommended: [{ label: 'toDateTime()', args: ["'ms'"] }], - }); - } - - const nowSeconds = nowMillis / 1000; - const marginSeconds = marginMillis / 1000; - const isPlausableSecondsDateTime = - resolved > nowSeconds - marginSeconds && resolved < nowSeconds + marginSeconds; - if (isPlausableSecondsDateTime) { - return applySections({ - options, - recommended: [{ label: 'toDateTime()', args: ["'s'"] }], + recommended: [{ label: 'toDateTime()', args: [`'${timestampUnit}'`] }], }); } diff --git a/packages/editor-ui/src/plugins/codemirror/completions/infoBoxRenderer.ts b/packages/editor-ui/src/plugins/codemirror/completions/infoBoxRenderer.ts index 3449a106cb..7454a661bc 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/infoBoxRenderer.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/infoBoxRenderer.ts @@ -110,6 +110,66 @@ const renderDescription = ({ return descriptionBody; }; +const renderArg = (arg: DocMetadataArgument) => { + const argItem = document.createElement('li'); + const argName = document.createElement('span'); + argName.classList.add('autocomplete-info-arg-name'); + argName.textContent = arg.name.replaceAll('?', ''); + const tags = []; + + if (arg.type) { + tags.push(arg.type); + } + + if (!!arg.optional || arg.name.endsWith('?')) { + tags.push(i18n.baseText('codeNodeEditor.optional')); + } + + if (tags.length > 0) { + argName.textContent += ` (${tags.join(', ')})`; + } + + if (arg.description) { + argName.textContent += ':'; + } + argItem.appendChild(argName); + + if (arg.description) { + const argDescription = document.createElement('span'); + argDescription.classList.add('autocomplete-info-arg-description'); + + if (arg.default && arg.optional && !arg.description.toLowerCase().includes('default')) { + const separator = arg.description.endsWith('.') ? ' ' : '. '; + arg.description += + separator + + i18n.baseText('codeNodeEditor.defaultsTo', { + interpolate: { default: arg.default }, + }); + } + + argDescription.innerHTML = sanitizeHtml(arg.description.replace(/`(.*?)`/g, '$1')); + + argItem.appendChild(argDescription); + } + + if (Array.isArray(arg.args)) { + argItem.appendChild(renderArgList(arg.args)); + } + + return argItem; +}; + +const renderArgList = (args: DocMetadataArgument[]) => { + const argsList = document.createElement('ul'); + argsList.classList.add('autocomplete-info-args'); + + for (const arg of args) { + argsList.appendChild(renderArg(arg)); + } + + return argsList; +}; + const renderArgs = (args: DocMetadataArgument[]) => { const argsContainer = document.createElement('div'); argsContainer.classList.add('autocomplete-info-args-container'); @@ -118,58 +178,7 @@ const renderArgs = (args: DocMetadataArgument[]) => { argsTitle.classList.add('autocomplete-info-section-title'); argsTitle.textContent = i18n.baseText('codeNodeEditor.parameters'); argsContainer.appendChild(argsTitle); - - const argsList = document.createElement('ul'); - argsList.classList.add('autocomplete-info-args'); - - for (const arg of args.filter((a) => a.name !== '...')) { - const argItem = document.createElement('li'); - const argName = document.createElement('span'); - argName.classList.add('autocomplete-info-arg-name'); - argName.textContent = arg.name.replaceAll('?', ''); - const tags = []; - - if (arg.type) { - tags.push(arg.type); - } - - if (arg.optional || arg.name.endsWith('?')) { - tags.push(i18n.baseText('codeNodeEditor.optional')); - } - - if (args.length > 0) { - argName.textContent += ` (${tags.join(', ')})`; - } - - if (arg.description) { - argName.textContent += ':'; - } - argItem.appendChild(argName); - - if (arg.description) { - const argDescription = document.createElement('span'); - argDescription.classList.add('autocomplete-info-arg-description'); - - if (arg.default && !arg.description.toLowerCase().includes('default')) { - const separator = arg.description.endsWith('.') ? ' ' : '. '; - arg.description += - separator + - i18n.baseText('codeNodeEditor.defaultsTo', { - interpolate: { default: arg.default }, - }); - } - - argDescription.innerHTML = sanitizeHtml( - arg.description.replace(/`(.*?)`/g, '$1'), - ); - - argItem.appendChild(argDescription); - } - - argsList.appendChild(argItem); - } - - argsContainer.appendChild(argsList); + argsContainer.appendChild(renderArgList(args)); return argsContainer; }; diff --git a/packages/editor-ui/src/styles/plugins/_codemirror.scss b/packages/editor-ui/src/styles/plugins/_codemirror.scss index 53ab1e43b0..c9afaa7978 100644 --- a/packages/editor-ui/src/styles/plugins/_codemirror.scss +++ b/packages/editor-ui/src/styles/plugins/_codemirror.scss @@ -197,6 +197,11 @@ li + li { margin-top: var(--spacing-4xs); } + + .autocomplete-info-args { + margin-top: var(--spacing-4xs); + padding-left: var(--spacing-s); + } } .autocomplete-info-arg-name { diff --git a/packages/workflow/src/Extensions/ArrayExtensions.ts b/packages/workflow/src/Extensions/ArrayExtensions.ts index 8ed8a1a525..5cd3166b25 100644 --- a/packages/workflow/src/Extensions/ArrayExtensions.ts +++ b/packages/workflow/src/Extensions/ArrayExtensions.ts @@ -3,6 +3,7 @@ import { ExpressionExtensionError } from '../errors/expression-extension.error'; import type { Extension, ExtensionMap } from './Extensions'; import { compact as oCompact } from './ObjectExtensions'; import deepEqual from 'deep-equal'; +import uniqWith from 'lodash/uniqWith'; function first(value: unknown[]): unknown { return value[0]; @@ -52,31 +53,18 @@ function randomItem(value: unknown[]): unknown { } function unique(value: unknown[], extraArgs: string[]): unknown[] { - if (extraArgs.length) { - return value.reduce((l, v) => { - if (typeof v === 'object' && v !== null && extraArgs.every((i) => i in v)) { - const alreadySeen = l.find((i) => - extraArgs.every((j) => - deepEqual( - (i as Record)[j], - (v as Record, { strict: true })[j], - { strict: true }, - ), - ), - ); - if (!alreadySeen) { - l.push(v); - } - } - return l; - }, []); - } - return value.reduce((l, v) => { - if (l.findIndex((i) => deepEqual(i, v, { strict: true })) === -1) { - l.push(v); + const mapForEqualityCheck = (item: unknown): unknown => { + if (extraArgs.length > 0 && item && typeof item === 'object') { + return extraArgs.reduce>((acc, key) => { + acc[key] = (item as Record)[key]; + return acc; + }, {}); } - return l; - }, []); + return item; + }; + return uniqWith(value, (a, b) => + deepEqual(mapForEqualityCheck(a), mapForEqualityCheck(b), { strict: true }), + ); } const ensureNumberArray = (arr: unknown[], { fnName }: { fnName: string }) => { @@ -320,6 +308,10 @@ function intersection(value: unknown[], extraArgs: unknown[][]): unknown[] { return unique(newArr, []); } +function append(value: unknown[], extraArgs: unknown[][]): unknown[] { + return value.concat(extraArgs); +} + export function toJsonString(value: unknown[]) { return JSON.stringify(value); } @@ -342,97 +334,145 @@ export function toDateTime() { average.doc = { name: 'average', - description: 'Returns the mean average of all values in the array.', + description: + 'Returns the average of the numbers in the array. Throws an error if there are any non-numbers.', + examples: [{ example: '[12, 1, 5].average()', evaluated: '6' }], returnType: 'number', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-average', }; compact.doc = { name: 'compact', - description: 'Removes all empty values from the array.', + description: + 'Removes any empty values from the array. null, "" and undefined count as empty.', + examples: [{ example: '[2, null, 1, ""].compact()', evaluated: '[2, 1]' }], returnType: 'Array', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-compact', }; isEmpty.doc = { name: 'isEmpty', - description: 'Checks if the array doesn’t have any elements.', + description: 'Returns true if the array has no elements', + examples: [ + { example: '[].isEmpty()', evaluated: 'true' }, + { example: "['quick', 'brown', 'fox'].isEmpty()", evaluated: 'false' }, + ], returnType: 'boolean', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-isEmpty', }; isNotEmpty.doc = { name: 'isNotEmpty', - description: 'Checks if the array has elements.', + description: 'Returns true if the array has at least one element', + examples: [ + { example: "['quick', 'brown', 'fox'].isNotEmpty()", evaluated: 'true' }, + { example: '[].isNotEmpty()', evaluated: 'false' }, + ], returnType: 'boolean', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-isNotEmpty', }; first.doc = { name: 'first', - description: 'Returns the first element of the array.', - returnType: 'Element', + description: 'Returns the first element of the array', + examples: [{ example: "['quick', 'brown', 'fox'].first()", evaluated: "'quick'" }], + returnType: 'any', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-first', }; last.doc = { name: 'last', - description: 'Returns the last element of the array.', - returnType: 'Element', + description: 'Returns the last element of the array', + examples: [{ example: "['quick', 'brown', 'fox'].last()", evaluated: "'fox'" }], + returnType: 'any', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-last', }; max.doc = { name: 'max', - description: 'Gets the maximum value from a number-only array.', + description: + 'Returns the largest number in the array. Throws an error if there are any non-numbers.', + examples: [{ example: '[1, 12, 5].max()', evaluated: '12' }], returnType: 'number', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-max', }; min.doc = { name: 'min', - description: 'Gets the minimum value from a number-only array.', + description: + 'Returns the smallest number in the array. Throws an error if there are any non-numbers.', + examples: [{ example: '[12, 1, 5].min()', evaluated: '1' }], returnType: 'number', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-min', }; randomItem.doc = { name: 'randomItem', - description: 'Returns a random element from an array.', - returnType: 'Element', + description: 'Returns a randomly-chosen element from the array', + examples: [ + { example: "['quick', 'brown', 'fox'].randomItem()", evaluated: "'brown'" }, + { example: "['quick', 'brown', 'fox'].randomItem()", evaluated: "'quick'" }, + ], + returnType: 'any', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-randomItem', }; sum.doc = { name: 'sum', - description: 'Returns the total sum all the values in an array of parsable numbers.', + description: + 'Returns the total of all the numbers in the array. Throws an error if there are any non-numbers.', + examples: [{ example: '[12, 1, 5].sum()', evaluated: '18' }], returnType: 'number', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-sum', }; chunk.doc = { name: 'chunk', - description: 'Splits arrays into chunks with a length of `size`.', + description: 'Splits the array into an array of sub-arrays, each with the given length', + examples: [{ example: '[1, 2, 3, 4, 5, 6].chunk(2)', evaluated: '[[1,2],[3,4],[5,6]]' }], returnType: 'Array', - args: [{ name: 'size', type: 'number' }], + args: [ + { + name: 'length', + optional: false, + description: 'The number of elements in each chunk', + type: 'number', + }, + ], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-chunk', }; difference.doc = { name: 'difference', description: - 'Compares two arrays. Returns all elements in the base array that aren’t present in `arr`.', + "Compares two arrays. Returns all elements in the base array that aren't present\nin otherArray.", + examples: [{ example: '[1, 2, 3].difference([2, 3])', evaluated: '[1]' }], returnType: 'Array', - args: [{ name: 'arr', type: 'Array' }], + args: [ + { + name: 'otherArray', + optional: false, + description: 'The array to compare to the base array', + type: 'Array', + }, + ], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-difference', }; intersection.doc = { name: 'intersection', description: - 'Compares two arrays. Returns all elements in the base array that are present in `arr`.', + 'Compares two arrays. Returns all elements in the base array that are also present in the other array.', + examples: [{ example: '[1, 2].intersection([2, 3])', evaluated: '[2]' }], returnType: 'Array', - args: [{ name: 'arr', type: 'Array' }], + args: [ + { + name: 'otherArray', + optional: false, + description: 'The array to compare to the base array', + type: 'Array', + }, + ], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-intersection', }; @@ -440,37 +480,72 @@ intersection.doc = { merge.doc = { name: 'merge', description: - 'Merges two Object-arrays into one array by merging the key-value pairs of each element.', - returnType: 'array', - args: [{ name: 'arr', type: 'Array' }], + 'Merges two Object-arrays into one object by merging the key-value pairs of each element.', + examples: [ + { + example: + "[{ name: 'Nathan' }, { age: 42 }].merge([{ city: 'Berlin' }, { country: 'Germany' }])", + evaluated: "{ name: 'Nathan', age: 42, city: 'Berlin', country: 'Germany' }", + }, + ], + returnType: 'Object', + args: [ + { + name: 'otherArray', + optional: false, + description: 'The array to merge into the base array', + type: 'Array', + }, + ], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-merge', }; pluck.doc = { name: 'pluck', - description: 'Returns an array of Objects where the key is equal the given `fieldName`s.', + description: + 'Returns an array containing the values of the given field(s) in each Object of the array. Ignores any array elements that aren’t Objects or don’t have a key matching the field name(s) provided.', + examples: [ + { + example: "[{ name: 'Nathan', age: 42 },{ name: 'Jan', city: 'Berlin' }].pluck('name')", + evaluated: '["Nathan", "Jan"]', + }, + { + example: "[{ name: 'Nathan', age: 42 },{ name: 'Jan', city: 'Berlin' }].pluck('age')", + evaluated: '[42]', + }, + ], returnType: 'Array', args: [ - { name: 'fieldName1', type: 'string' }, - { name: 'fieldName1?', type: 'string' }, - { name: '...' }, - { name: 'fieldNameN?', type: 'string' }, + { + name: 'fieldNames', + optional: false, + variadic: true, + description: 'The keys to retrieve the value of', + type: 'string', + }, ], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-pluck', }; renameKeys.doc = { name: 'renameKeys', - description: 'Renames all matching keys in the array.', + description: + 'Changes all matching keys (field names) of any Objects in the array. Rename more than one key by\nadding extra arguments, i.e. from1, to1, from2, to2, ....', + examples: [ + { + example: "[{ name: 'bob' }, { name: 'meg' }].renameKeys('name', 'x')", + evaluated: "[{ x: 'bob' }, { x: 'meg' }]", + }, + ], returnType: 'Array', args: [ - { name: 'from1', type: 'string' }, - { name: 'to1', type: 'string' }, - { name: 'from2?', type: 'string' }, - { name: 'to2?', type: 'string' }, - { name: '...' }, - { name: 'fromN?', type: 'string' }, - { name: 'toN?', type: 'string' }, + { + name: 'from', + optional: false, + description: 'The key to rename', + type: 'string', + }, + { name: 'to', optional: false, description: 'The new key name', type: 'string' }, ], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-renameKeys', }; @@ -478,39 +553,117 @@ renameKeys.doc = { smartJoin.doc = { name: 'smartJoin', description: - 'Operates on an array of objects where each object contains key-value pairs. Creates a new object containing key-value pairs, where the key is the value of the first pair, and the value is the value of the second pair. Removes non-matching and empty values and trims any whitespace before joining.', - returnType: 'Array', + 'Creates a single Object from an array of Objects. Each Object in the array provides one field for the returned Object. Each Object in the array must contain a field with the key name and a field with the value.', + examples: [ + { + example: + "[{ field: 'age', value: 2 }, { field: 'city', value: 'Berlin' }].smartJoin('field', 'value')", + evaluated: "{ age: 2, city: 'Berlin' }", + }, + ], + returnType: 'Object', args: [ - { name: 'keyField', type: 'string' }, - { name: 'nameField', type: 'string' }, + { + name: 'keyField', + optional: false, + description: 'The field in each Object containing the key name', + type: 'string', + }, + { + name: 'nameField', + optional: false, + description: 'The field in each Object containing the value', + type: 'string', + }, ], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-smartJoin', }; union.doc = { name: 'union', - description: 'Concatenates two arrays and then removes duplicates.', + description: 'Concatenates two arrays and then removes any duplicates', + examples: [{ example: '[1, 2].union([2, 3])', evaluated: '[1, 2, 3]' }], returnType: 'Array', - args: [{ name: 'arr', type: 'Array' }], + args: [ + { + name: 'otherArray', + optional: false, + description: 'The array to union with the base array', + type: 'Array', + }, + ], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-union', }; unique.doc = { name: 'unique', - description: 'Remove duplicates from an array. ', - returnType: 'Element', + description: 'Removes any duplicate elements from the array', + examples: [ + { example: "['quick', 'brown', 'quick'].unique()", evaluated: "['quick', 'brown']" }, + { + example: "[{ name: 'Nathan', age: 42 }, { name: 'Nathan', age: 22 }].unique()", + evaluated: "[{ name: 'Nathan', age: 42 }, { name: 'Nathan', age: 22 }]", + }, + { + example: "[{ name: 'Nathan', age: 42 }, { name: 'Nathan', age: 22 }].unique('name')", + evaluated: "[{ name: 'Nathan', age: 42 }]", + }, + ], + returnType: 'any', aliases: ['removeDuplicates'], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-unique', + args: [ + { + name: 'fieldNames', + optional: false, + variadic: true, + description: 'The object keys to check for equality', + type: 'any', + }, + ], }; toJsonString.doc = { name: 'toJsonString', - description: 'Converts an array to a JSON string', + description: + "Converts the array to a JSON string. The same as JavaScript's JSON.stringify().", + examples: [ + { + example: "['quick', 'brown', 'fox'].toJsonString()", + evaluated: '\'["quick","brown","fox"]\'', + }, + ], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-toJsonString', returnType: 'string', }; +append.doc = { + name: 'append', + description: + 'Adds new elements to the end of the array. Similar to push(), but returns the modified array. Consider using spread syntax instead (see examples).', + examples: [ + { example: "['forget', 'me'].append('not')", evaluated: "['forget', 'me', 'not']" }, + { example: '[9, 0, 2].append(1, 0)', evaluated: '[9, 0, 2, 1, 0]' }, + { + example: '[...[9, 0, 2], 1, 0]', + evaluated: '[9, 0, 2, 1, 0]', + description: 'Consider using spread syntax instead', + }, + ], + docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-append', + returnType: 'Array', + args: [ + { + name: 'elements', + optional: false, + variadic: true, + description: 'The elements to append, in order', + type: 'any', + }, + ], +}; + const removeDuplicates: Extension = unique.bind({}); removeDuplicates.doc = { ...unique.doc, hidden: true }; @@ -537,6 +690,7 @@ export const arrayExtensions: ExtensionMap = { union, difference, intersection, + append, toJsonString, toInt, toFloat, diff --git a/packages/workflow/src/Extensions/Extensions.ts b/packages/workflow/src/Extensions/Extensions.ts index 9495c552cb..2270545f8a 100644 --- a/packages/workflow/src/Extensions/Extensions.ts +++ b/packages/workflow/src/Extensions/Extensions.ts @@ -19,6 +19,8 @@ export type DocMetadataArgument = { variadic?: boolean; description?: string; default?: string; + // Function arguments have nested arguments + args?: DocMetadataArgument[]; }; export type DocMetadataExample = { example: string; diff --git a/packages/workflow/src/Extensions/NumberExtensions.ts b/packages/workflow/src/Extensions/NumberExtensions.ts index 71b86c1373..b13520be4d 100644 --- a/packages/workflow/src/Extensions/NumberExtensions.ts +++ b/packages/workflow/src/Extensions/NumberExtensions.ts @@ -62,7 +62,7 @@ function toFloat(value: number) { } type DateTimeFormat = 'ms' | 's' | 'us' | 'excel'; -function toDateTime(value: number, extraArgs: [DateTimeFormat]) { +export function toDateTime(value: number, extraArgs: [DateTimeFormat]) { const [valueFormat = 'ms'] = extraArgs; if (!['ms', 's', 'us', 'excel'].includes(valueFormat)) { diff --git a/packages/workflow/src/Extensions/ObjectExtensions.ts b/packages/workflow/src/Extensions/ObjectExtensions.ts index 82da2f332d..be85ba52d7 100644 --- a/packages/workflow/src/Extensions/ObjectExtensions.ts +++ b/packages/workflow/src/Extensions/ObjectExtensions.ts @@ -110,14 +110,22 @@ export function toDateTime() { isEmpty.doc = { name: 'isEmpty', - description: 'Checks if the Object has no key-value pairs.', + description: 'Returns true if the Object has no keys (fields) set', + examples: [ + { example: "({'name': 'Nathan'}).isEmpty()", evaluated: 'false' }, + { example: '({}).isEmpty()', evaluated: 'true' }, + ], returnType: 'boolean', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-isEmpty', }; isNotEmpty.doc = { name: 'isNotEmpty', - description: 'Checks if the Object has key-value pairs.', + description: 'Returns true if the Object has at least one key (field) set', + examples: [ + { example: "({'name': 'Nathan'}).isNotEmpty()", evaluated: 'true' }, + { example: '({}).isNotEmpty()', evaluated: 'false' }, + ], returnType: 'boolean', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-isNotEmpty', @@ -125,14 +133,23 @@ isNotEmpty.doc = { compact.doc = { name: 'compact', - description: 'Removes empty values from an Object.', - returnType: 'boolean', + description: + 'Removes all fields that have empty values, i.e. are null, undefined, "nil" or ""', + examples: [{ example: "({ x: null, y: 2, z: '' }).compact()", evaluated: '{ y: 2 }' }], + returnType: 'Object', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-compact', }; urlEncode.doc = { name: 'urlEncode', - description: 'Transforms an Object into a URL parameter list. Only top-level keys are supported.', + description: + "Generates a URL parameter string from the Object's keys and values. Only top-level keys are supported.", + examples: [ + { + example: "({ name: 'Mr Nathan', city: 'hanoi' }).urlEncode()", + evaluated: "'name=Mr+Nathan&city=hanoi'", + }, + ], returnType: 'string', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-urlEncode', @@ -140,17 +157,43 @@ urlEncode.doc = { hasField.doc = { name: 'hasField', - description: 'Checks if the Object has a given field. Only top-level keys are supported.', + description: + 'Returns true if there is a field called name. Only checks top-level keys. Comparison is case-sensitive.', + examples: [ + { example: "({ name: 'Nathan', age: 42 }).hasField('name')", evaluated: 'true' }, + { example: "({ name: 'Nathan', age: 42 }).hasField('Name')", evaluated: 'false' }, + { example: "({ name: 'Nathan', age: 42 }).hasField('inventedField')", evaluated: 'false' }, + ], returnType: 'boolean', - args: [{ name: 'fieldName', type: 'string' }], + args: [ + { + name: 'name', + optional: false, + description: 'The name of the key to search for', + type: 'string', + }, + ], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-hasField', }; removeField.doc = { name: 'removeField', - description: 'Removes a given field from the Object. Only top-level fields are supported.', - returnType: 'object', - args: [{ name: 'key', type: 'string' }], + description: "Removes a field from the Object. The same as JavaScript's delete.", + examples: [ + { + example: "({ name: 'Nathan', city: 'hanoi' }).removeField('name')", + evaluated: "{ city: 'hanoi' }", + }, + ], + returnType: 'Object', + args: [ + { + name: 'key', + optional: false, + description: 'The name of the field to remove', + type: 'string', + }, + ], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-removeField', }; @@ -158,39 +201,95 @@ removeField.doc = { removeFieldsContaining.doc = { name: 'removeFieldsContaining', description: - 'Removes fields with a given value from the Object. Only top-level values are supported.', - returnType: 'object', - args: [{ name: 'value', type: 'string' }], + "Removes keys (fields) whose values at least partly match the given value. Comparison is case-sensitive. Fields that aren't strings are always kept.", + examples: [ + { + example: "({ name: 'Mr Nathan', city: 'hanoi', age: 42 }).removeFieldsContaining('Nathan')", + evaluated: "{ city: 'hanoi', age: 42 }", + }, + { + example: "({ name: 'Mr Nathan', city: 'hanoi', age: 42 }).removeFieldsContaining('Han')", + evaluated: '{ age: 42 }', + }, + { + example: "({ name: 'Mr Nathan', city: 'hanoi', age: 42 }).removeFieldsContaining('nathan')", + evaluated: "{ name: 'Mr Nathan', city: 'hanoi', age: 42 }", + }, + ], + returnType: 'Object', + args: [ + { + name: 'value', + optional: false, + description: 'The text that a value must contain in order to be removed', + type: 'string', + }, + ], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-removeFieldsContaining', }; keepFieldsContaining.doc = { name: 'keepFieldsContaining', - description: 'Removes fields that do not match the given value from the Object.', - returnType: 'object', - args: [{ name: 'value', type: 'string' }], + description: + "Removes any fields whose values don't at least partly match the given value. Comparison is case-sensitive. Fields that aren't strings will always be removed.", + examples: [ + { + example: "({ name: 'Mr Nathan', city: 'hanoi', age: 42 }).keepFieldsContaining('Nathan')", + evaluated: "{ name: 'Mr Nathan' }", + }, + { + example: "({ name: 'Mr Nathan', city: 'hanoi', age: 42 }).keepFieldsContaining('nathan')", + evaluated: '{}', + }, + { + example: "({ name: 'Mr Nathan', city: 'hanoi', age: 42 }).keepFieldsContaining('han')", + evaluated: "{ name: 'Mr Nathan', city: 'hanoi' }", + }, + ], + returnType: 'Object', + args: [ + { + name: 'value', + optional: false, + description: 'The text that a value must contain in order to be kept', + type: 'string', + }, + ], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-keepFieldsContaining', }; keys.doc = { name: 'keys', - description: "Returns an array of a given object's own enumerable string-keyed property names.", + description: + "Returns an array with all the field names (keys) the Object contains. The same as JavaScript's Object.keys(obj).", + examples: [{ example: "({ name: 'Mr Nathan', age: 42 }).keys()", evaluated: "['name', 'age']" }], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-keys', returnType: 'Array', }; values.doc = { name: 'values', - description: "Returns an array of a given object's own enumerable string-keyed property values.", + description: + "Returns an array with all the values of the fields the Object contains. The same as JavaScript's Object.values(obj).", + examples: [ + { example: "({ name: 'Mr Nathan', age: 42 }).values()", evaluated: "['Mr Nathan', 42]" }, + ], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-values', returnType: 'Array', }; toJsonString.doc = { name: 'toJsonString', - description: 'Converts an object to a JSON string', + description: + "Converts the Object to a JSON string. Similar to JavaScript's JSON.stringify().", + examples: [ + { + example: "({ name: 'Mr Nathan', age: 42 }).toJsonString()", + evaluated: '\'{"name":"Nathan","age":42}\'', + }, + ], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-toJsonString', returnType: 'string', diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index 17e5422c02..7062e2f632 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -5,8 +5,9 @@ import { titleCase } from 'title-case'; import type { Extension, ExtensionMap } from './Extensions'; import { transliterate } from 'transliteration'; import { ExpressionExtensionError } from '../errors/expression-extension.error'; -import type { DateTime } from 'luxon'; +import { DateTime } from 'luxon'; import { tryToParseDateTime } from '../TypeValidation'; +import { toDateTime as numberToDateTime } from './NumberExtensions'; export const SupportedHashAlgorithms = [ 'md5', @@ -216,8 +217,22 @@ function toDate(value: string): Date { return date; } -function toDateTime(value: string): DateTime { +function toDateTime(value: string, extraArgs: [string]): DateTime { try { + const [valueFormat] = extraArgs; + + if (valueFormat) { + if ( + valueFormat === 'ms' || + valueFormat === 's' || + valueFormat === 'us' || + valueFormat === 'excel' + ) { + return numberToDateTime(Number(value), [valueFormat]); + } + return DateTime.fromFormat(value, valueFormat); + } + return tryToParseDateTime(value); } catch (error) { throw new ExpressionExtensionError('cannot convert to Luxon DateTime'); @@ -454,7 +469,17 @@ toDateTime.doc = { { example: '"2024-03-29T18:06:31.798+01:00".toDateTime()' }, { example: '"Fri, 29 Mar 2024 18:08:01 +0100".toDateTime()' }, { example: '"20240329".toDateTime()' }, - { example: '"1711732132990".toDateTime()' }, + { example: '"1711732132990".toDateTime("ms")' }, + { example: '"31-01-2024".toDateTime("dd-MM-yyyy")' }, + ], + args: [ + { + name: 'format', + optional: true, + description: + 'The format of the date string. Options are ms (for Unix timestamp in milliseconds), s (for Unix timestamp in seconds), us (for Unix timestamp in microseconds) or excel (for days since 1900). Custom formats can be specified using Luxon tokens.', + type: 'string', + }, ], }; diff --git a/packages/workflow/src/NativeMethods/Array.methods.ts b/packages/workflow/src/NativeMethods/Array.methods.ts index 2fe75ee379..70bb3be59b 100644 --- a/packages/workflow/src/NativeMethods/Array.methods.ts +++ b/packages/workflow/src/NativeMethods/Array.methods.ts @@ -6,7 +6,8 @@ export const arrayMethods: NativeDoc = { length: { doc: { name: 'length', - description: 'Returns the number of elements in the Array.', + description: 'The number of elements in the array', + examples: [{ example: "['Bob', 'Bill', 'Nat'].length", evaluated: '3' }], docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/length', returnType: 'number', @@ -17,37 +18,159 @@ export const arrayMethods: NativeDoc = { concat: { doc: { name: 'concat', - description: 'Merges two or more arrays into one array.', + description: 'Joins one or more arrays onto the end of the base array', + examples: [ + { + example: "['Nathan', 'Jan'].concat(['Steve', 'Bill'])", + evaluated: "['Nathan', 'Jan', 'Steve', 'Bill']", + }, + { + example: "[5, 4].concat([100, 101], ['a', 'b'])", + evaluated: "[5, 4, 100, 101, 'a', 'b']", + }, + ], docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat', returnType: 'Array', args: [ - { name: 'arr1', type: 'Array' }, - { name: 'arr2', type: 'Array' }, - { name: '...' }, - { name: 'arrN', type: 'Array' }, + { + name: 'arrays', + variadic: true, + description: 'The arrays to be joined on the end of the base array, in order', + type: 'Array', + }, ], }, }, filter: { doc: { name: 'filter', - description: 'Returns an array only containing the elements that pass the test `fn`.', + description: + 'Returns an array with only the elements satisfying a condition. The condition is a function that returns true or false.', + examples: [ + { + example: '[12, 33, 16, 40].filter(age => age > 18)', + evaluated: '[33, 40]', + description: 'Keep ages over 18 (using arrow function notation)', + }, + { + example: "['Nathan', 'Bob', 'Sebastian'].filter(name => name.length < 5)", + evaluated: "['Bob']", + description: 'Keep names under 5 letters long (using arrow function notation)', + }, + { + example: + "['Nathan', 'Bob', 'Sebastian'].filter(function(name) { return name.length < 5 })", + evaluated: "['Bob']", + description: 'Or using traditional function notation', + }, + { + example: '[1, 7, 3, 10, 5].filter((num, index) => index % 2 !== 0)', + evaluated: '[7, 10]', + description: 'Keep numbers at odd indexes', + }, + ], docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter', returnType: 'Array', - args: [{ name: 'fn', type: 'Function' }], + args: [ + { + name: 'function', + description: + 'A function to run for each array element. If it returns true, the element will be kept. Consider using arrow function notation to save space.', + type: 'Function', + default: 'item => true', + args: [ + { + name: 'element', + description: 'The value of the current element', + type: 'any', + }, + { + name: 'index', + optional: true, + description: 'The position of the current element in the array (starting at 0)', + type: 'number', + }, + { + name: 'array', + optional: true, + description: 'The array being processed. Rarely needed.', + type: 'Array', + }, + { + name: 'thisValue', + optional: true, + description: + 'A value passed to the function as its this value. Rarely needed.', + type: 'any', + }, + ], + }, + ], }, }, find: { doc: { name: 'find', description: - 'Returns the first element in the provided array that passes the test `fn`. If no values satisfy the testing function, `undefined` is returned.', + 'Returns the first element from the array that satisfies the provided condition. The condition is a function that returns true or false. Returns undefined if no matches are found.\n\nIf you need all matching elements, use filter().', + examples: [ + { + example: '[12, 33, 16, 40].find(age => age > 18)', + evaluated: '33', + description: 'Find first age over 18 (using arrow function notation)', + }, + { + example: "['Nathan', 'Bob', 'Sebastian'].find(name => name.length < 5)", + evaluated: "'Bob'", + description: 'Find first name under 5 letters long (using arrow function notation)', + }, + { + example: + "['Nathan', 'Bob', 'Sebastian'].find(function(name) { return name.length < 5 })", + evaluated: "'Bob'", + description: 'Or using traditional function notation', + }, + ], docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find', - returnType: 'Array|undefined', - args: [{ name: 'fn', type: 'Function' }], + returnType: 'Array | undefined', + args: [ + { + name: 'function', + description: + 'A function to run for each array element. As soon as it returns true, that element will be returned. Consider using arrow function notation to save space.', + type: 'Function', + default: 'item => true', + args: [ + { + name: 'element', + description: 'The value of the current element', + type: 'any', + }, + { + name: 'index', + optional: true, + description: 'The position of the current element in the array (starting at 0)', + type: 'number', + }, + { + name: 'array', + optional: true, + description: 'The array being processed. Rarely needed.', + type: 'Array', + }, + { + name: 'thisValue', + optional: true, + description: + 'A value passed to the function as its this value. Rarely needed.', + type: 'any', + }, + ], + }, + ], }, }, findIndex: { @@ -69,7 +192,7 @@ export const arrayMethods: NativeDoc = { description: 'Returns the value of the last element that passes the test `fn`.', docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/findLast', - returnType: 'Element|undefined', + returnType: 'any | undefined', args: [{ name: 'fn', type: 'Function' }], }, }, @@ -89,26 +212,54 @@ export const arrayMethods: NativeDoc = { doc: { name: 'indexOf', description: - 'Returns the first index at which a given element can be found in the array, or -1 if it is not present.', + "Returns the position of the first matching element in the array, or -1 if the element isn't found. Positions start at 0.", + examples: [ + { example: "['Bob', 'Bill', 'Nat'].indexOf('Nat')", evaluated: '2' }, + { example: "['Bob', 'Bill', 'Nat'].indexOf('Nathan')", evaluated: '-1' }, + ], docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/indexOf', returnType: 'number', args: [ - { name: 'searchElement', type: 'string|number' }, - { name: 'fromIndex?', type: 'number' }, + { + name: 'element', + description: 'The value to look for', + type: 'any', + }, + { + name: 'start', + optional: true, + description: 'The index to start looking from', + default: '0', + type: 'number', + }, ], }, }, includes: { doc: { name: 'includes', - description: 'Checks if an array includes a certain value among its entries.', + description: 'Returns true if the array contains the specified element', + examples: [ + { example: "['Bob', 'Bill', 'Nat'].indexOf('Nat')", evaluated: 'true' }, + { example: "['Bob', 'Bill', 'Nat'].indexOf('Nathan')", evaluated: 'false' }, + ], docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/includes', returnType: 'boolean', args: [ - { name: 'searchElement', type: 'Element' }, - { name: 'fromIndex?', type: 'number' }, + { + name: 'element', + description: 'The value to search the array for', + type: 'any', + }, + { + name: 'start', + optional: true, + description: 'The index to start looking from', + default: '0', + type: 'number', + }, ], }, }, @@ -116,28 +267,95 @@ export const arrayMethods: NativeDoc = { doc: { name: 'join', description: - 'Returns a string that concatenates all of the elements in an array, separated by `separator`, which defaults to comma.', + 'Merges all elements of the array into a single string, with an optional separator between each element.\n\nThe opposite of String.split().', + examples: [ + { example: "['Wind', 'Water', 'Fire'].join(' + ')", evaluated: "'Wind + Water + Fire'" }, + { example: "['Wind', 'Water', 'Fire'].join()", evaluated: "'Wind,Water,Fire'" }, + { example: "['Wind', 'Water', 'Fire'].join('')", evaluated: "'WindWaterFire'" }, + ], docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/join', returnType: 'string', - args: [{ name: 'separator?', type: 'string' }], + args: [ + { + name: 'separator', + optional: true, + description: 'The character(s) to insert between each element', + default: "','", + type: 'string', + }, + ], }, }, map: { doc: { name: 'map', - description: 'Returns an array containing the results of calling `fn` on every element.', + description: + 'Creates a new array by applying a function to each element of the original array', + examples: [ + { + example: '[12, 33, 16].map(num => num * 2)', + evaluated: '[24, 66, 32]', + description: 'Double all numbers (using arrow function notation)', + }, + { + example: "['hello', 'old', 'chap'].map(word => word.toUpperCase())", + evaluated: "['HELLO', 'OLD', 'CHAP']]", + description: 'Convert elements to uppercase (using arrow function notation)', + }, + { + example: "['hello', 'old', 'chap'].map(function(word) { return word.toUpperCase() })", + evaluated: "['HELLO', 'OLD', 'CHAP']]", + description: 'Or using traditional function notation', + }, + ], docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map', returnType: 'Array', - args: [{ name: 'fn', type: 'Function' }], + args: [ + { + name: 'function', + description: + 'A function to run for each array element. In the new array, the output of this function takes the place of the element. Consider using arrow function notation to save space.', + type: 'Function', + default: 'item => item', + args: [ + { + name: 'element', + description: 'The value of the current element', + type: 'any', + }, + { + name: 'index', + optional: true, + description: 'The position of the current element in the array (starting at 0)', + type: 'number', + }, + { + name: 'array', + optional: true, + description: 'The array being processed. Rarely needed.', + type: 'Array', + }, + { + name: 'thisValue', + optional: true, + description: + 'A value passed to the function as its this value. Rarely needed.', + type: 'any', + }, + ], + }, + ], }, }, reverse: { doc: { name: 'reverse', - description: - 'Reverses an array and returns it. The first array element now becomes the last, and the last array element becomes the first.', + description: 'Reverses the order of the elements in the array', + examples: [ + { example: "['dog', 'bites', 'man'].reverse()", evaluated: "['man', 'bites', 'dog']" }, + ], docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reverse', returnType: 'Array', @@ -151,20 +369,78 @@ export const arrayMethods: NativeDoc = { docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce', returnType: 'any', - args: [{ name: 'fn', type: 'Function' }], + args: [ + { + name: 'function', + description: + 'A function to run for each array element. Takes the accumulated result and the current element, and returns a new accumulated result. Consider using arrow function notation to save space.', + type: 'Function', + default: 'item => item', + args: [ + { + name: 'prevResult', + description: + 'The accumulated result from applying the function to previous elements. When processing the first element, it’s set to initResult (or the first array element if not specified).', + type: 'any', + }, + { + name: 'currentElem', + description: 'The value in the array currently being processed', + type: 'any', + }, + { + name: 'index', + optional: true, + description: 'The position of the current element in the array (starting at 0)', + type: 'number', + }, + { + name: 'array', + optional: true, + description: 'The array being processed. Rarely needed.', + type: 'Array', + }, + ], + }, + { + name: 'initResult', + optional: true, + description: + "The initial value of the prevResult, used when calling the function on the first array element. When not specified it's set to the first array element, and the first function call is on the second array element instead of the first.", + type: 'any', + }, + ], }, }, slice: { doc: { name: 'slice', description: - 'Returns a section of an Array. `end` defaults to the length of the Array if not given.', + 'Returns a portion of the array, from the start index up to (but not including) the end index. Indexes start at 0.', + examples: [ + { example: '[1, 2, 3, 4, 5].slice(2, 4)', evaluated: '[3, 4]' }, + { example: '[1, 2, 3, 4, 5].slice(2)', evaluated: '[3, 4, 5]' }, + { example: '[1, 2, 3, 4, 5].slice(-2)', evaluated: '[4, 5]' }, + ], docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice', returnType: 'Array', args: [ - { name: 'start', type: 'number' }, - { name: 'end?', type: 'number' }, + { + name: 'start', + optional: true, + description: + 'The position to start from. Positions start at 0. Negative numbers count back from the end of the array.', + default: '0', + type: 'number', + }, + { + name: 'end', + optional: true, + description: + 'The position to select up to. The element at the end position is not included. Negative numbers select from the end of the array. If omitted, will extract to the end of the array.', + type: 'number', + }, ], }, }, @@ -172,11 +448,62 @@ export const arrayMethods: NativeDoc = { doc: { name: 'sort', description: - 'Returns a sorted array. The default sort order is ascending, built upon converting the elements into strings.', + 'Reorders the elements of the array. For sorting strings alphabetically, no parameter is required. For sorting numbers or Objects, see examples.', + examples: [ + { + example: "['d', 'a', 'c', 'b'].sort()", + evaluated: "['a', 'b', 'c', 'd']", + description: 'No need for a param when sorting strings', + }, + { + example: '[4, 2, 1, 3].sort((a, b) => (a - b))', + evaluated: '[1, 2, 3, 4]', + description: 'To sort numbers, you must use a function', + }, + { + example: '[4, 2, 1, 3].sort(function(a, b) { return a - b })', + evaluated: '[1, 2, 3, 4]', + description: 'Or using traditional function notation', + }, + { example: 'Sort in reverse alphabetical order' }, + { example: "arr = ['d', 'a', 'c', 'b']" }, + { + example: 'arr.sort((a, b) => b.localeCompare(a))', + evaluated: "['d', 'c', 'b', 'a']", + description: 'Sort in reverse alphabetical order', + }, + { + example: + "[{name:'Zak'}, {name:'Abe'}, {name:'Bob'}].sort((a, b) => a.name.localeCompare(b.name))", + evaluated: "[{name:'Abe'}, {name:'Bob'}, {name:'Zak'}]", + description: 'Sort array of objects by a property', + }, + ], docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort', returnType: 'Array', - args: [{ name: 'fn?', type: 'Function' }], + args: [ + { + name: 'compare', + optional: true, + description: + 'A function to compare two array elements and return a number indicating which one comes first:\nReturn < 0: a comes before b\nReturn 0: a and b are equal (leave order unchanged)\nReturn > 0: b comes before a\n\nIf no function is specified, converts all values to strings and compares their character codes.', + default: '""', + type: '(a, b) => number', + args: [ + { + name: 'a', + description: 'The first element to compare in the function', + type: 'any', + }, + { + name: 'b', + description: 'The second element to compare in the function', + type: 'any', + }, + ], + }, + ], }, }, splice: { @@ -210,10 +537,49 @@ export const arrayMethods: NativeDoc = { doc: { name: 'toSpliced', description: - 'Returns a new array with some elements removed and/or replaced at a given index. toSpliced() is the copying version of the splice() method', + 'Adds and/or removes array elements at a given position. \n\nSee also slice() and append().', + examples: [ + { + example: "['Jan', 'Mar'.toSpliced(1, 0, 'Feb')", + evaluated: "['Jan', 'Feb', 'Mar']", + description: 'Insert element at index 1', + }, + { + example: '["don\'t", "make", "me", "do", "this"].toSpliced(1, 2)', + evaluated: '["don\'t", "do", "this"]', + description: 'Delete 2 elements starting at index 1', + }, + { + example: '["don\'t", "be", "evil"].toSpliced(1, 2, "eat", "slugs")', + evaluated: '["don\'t", "eat", "slugs"]', + description: 'Replace 2 elements starting at index 1', + }, + ], docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/toSpliced', returnType: 'Array', + args: [ + { + name: 'start', + description: + 'The index (position) to add or remove elements at. New elements are inserted before the element at this index. A negative index counts back from the end of the array. ', + type: 'number', + }, + { + name: 'deleteCount', + optional: true, + description: + 'The number of elements to remove. If omitted, removes all elements from the start index onwards.', + type: 'number', + }, + { + name: 'elements', + optional: true, + variadic: true, + description: 'The elements to be added, in order', + type: 'any', + }, + ], }, }, }, diff --git a/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts index 299afed4c7..6759c9e02d 100644 --- a/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts @@ -92,6 +92,17 @@ describe('Data Transformation Functions', () => { ).toEqual([1, 2, 3, 'as', {}, [1, 2], '[sad]', null]); }); + test('.unique() should work on an arrays of objects', () => { + expect( + evaluate( + "={{ [{'name':'Nathan', age:42}, {'name':'Jan', age:16}, {'name':'Nathan', age:21}].unique('name') }}", + ), + ).toEqual([ + { name: 'Nathan', age: 42 }, + { name: 'Jan', age: 16 }, + ]); + }); + test('.isEmpty() should work correctly on an array', () => { expect(evaluate('={{ [].isEmpty() }}')).toEqual(true); }); @@ -242,6 +253,10 @@ describe('Data Transformation Functions', () => { ); }); + test('.append() should work on an array', () => { + expect(evaluate('={{ [1,2,3].append(4,5,"done") }}')).toEqual([1, 2, 3, 4, 5, 'done']); + }); + describe('Conversion methods', () => { test('should exist but return undefined (to not break expressions with mixed data)', () => { expect(evaluate('={{ numberList(1, 20).toInt() }}')).toBeUndefined(); diff --git a/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts index 7f437953c0..70ec97c915 100644 --- a/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts @@ -253,6 +253,9 @@ describe('Data Transformation Functions', () => { ); expect(evaluate('={{ "2008-11-11".toDateTime() }}')).toBeInstanceOf(DateTime); expect(evaluate('={{ "1-Feb-2024".toDateTime() }}')).toBeInstanceOf(DateTime); + expect(evaluate('={{ "1713976144063".toDateTime("ms") }}')).toBeInstanceOf(DateTime); + expect(evaluate('={{ "31-01-2024".toDateTime("dd-MM-yyyy") }}')).toBeInstanceOf(DateTime); + expect(() => evaluate('={{ "hi".toDateTime() }}')).toThrowError( new ExpressionExtensionError('cannot convert to Luxon DateTime'), ); From 6059722fbfeeca31addfc31ed287f79f40aaad18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Tue, 14 May 2024 17:20:21 +0200 Subject: [PATCH 05/58] feat(core): Allow using a custom certificates in docker containers (#8705) Co-authored-by: Jonathan Bennetts --- docker/images/n8n/docker-entrypoint.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docker/images/n8n/docker-entrypoint.sh b/docker/images/n8n/docker-entrypoint.sh index 63a7c1dca6..2205826e4c 100755 --- a/docker/images/n8n/docker-entrypoint.sh +++ b/docker/images/n8n/docker-entrypoint.sh @@ -1,4 +1,11 @@ #!/bin/sh +if [ -d /opt/custom-certificates ]; then + echo "Trusting custom certificates from /opt/custom-certificates." + export NODE_OPTIONS=--use-openssl-ca $NODE_OPTIONS + export SSL_CERT_DIR=/opt/custom-certificates + c_rehash /opt/custom-certificates +fi + if [ "$#" -gt 0 ]; then # Got started with arguments exec n8n "$@" From 1777f8cdb1ccccbe02bb9f5f344ca3f686fc7790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Tue, 14 May 2024 17:20:31 +0200 Subject: [PATCH 06/58] fix(editor): Temporarily disable tailwind (no-changelog) (#9394) --- packages/design-system/.storybook/preview.js | 2 +- packages/design-system/postcss.config.js | 2 +- packages/editor-ui/postcss.config.js | 2 +- packages/editor-ui/src/main.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/design-system/.storybook/preview.js b/packages/design-system/.storybook/preview.js index 991dcdccfb..bd329cfaea 100644 --- a/packages/design-system/.storybook/preview.js +++ b/packages/design-system/.storybook/preview.js @@ -2,7 +2,7 @@ import { setup } from '@storybook/vue3'; import { withThemeByDataAttribute } from '@storybook/addon-themes'; import './storybook.scss'; -import '../src/css/tailwind/index.css'; +// import '../src/css/tailwind/index.css'; import { library } from '@fortawesome/fontawesome-svg-core'; import { fas } from '@fortawesome/free-solid-svg-icons'; diff --git a/packages/design-system/postcss.config.js b/packages/design-system/postcss.config.js index e873f1a4f2..34e057badf 100644 --- a/packages/design-system/postcss.config.js +++ b/packages/design-system/postcss.config.js @@ -1,6 +1,6 @@ module.exports = { plugins: { - tailwindcss: {}, + // tailwindcss: {}, autoprefixer: {}, }, }; diff --git a/packages/editor-ui/postcss.config.js b/packages/editor-ui/postcss.config.js index e873f1a4f2..34e057badf 100644 --- a/packages/editor-ui/postcss.config.js +++ b/packages/editor-ui/postcss.config.js @@ -1,6 +1,6 @@ module.exports = { plugins: { - tailwindcss: {}, + // tailwindcss: {}, autoprefixer: {}, }, }; diff --git a/packages/editor-ui/src/main.ts b/packages/editor-ui/src/main.ts index 9169e4ee8a..92f696ef9e 100644 --- a/packages/editor-ui/src/main.ts +++ b/packages/editor-ui/src/main.ts @@ -3,7 +3,7 @@ import { createApp } from 'vue'; import 'vue-json-pretty/lib/styles.css'; import '@jsplumb/browser-ui/css/jsplumbtoolkit.css'; import 'n8n-design-system/css/index.scss'; -import 'n8n-design-system/css/tailwind/index.css'; +// import 'n8n-design-system/css/tailwind/index.css'; import './n8n-theme.scss'; From ed22dcd88ac7f8433b9ed5dc2139d8779b0e1d4c Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 15 May 2024 10:24:51 +0100 Subject: [PATCH 07/58] fix(Cortex Node): Fix issue with analyzer response not working for file observables (#9374) --- packages/nodes-base/nodes/Cortex/Cortex.node.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/nodes-base/nodes/Cortex/Cortex.node.ts b/packages/nodes-base/nodes/Cortex/Cortex.node.ts index 73b11a9050..765ef5b7f2 100644 --- a/packages/nodes-base/nodes/Cortex/Cortex.node.ts +++ b/packages/nodes-base/nodes/Cortex/Cortex.node.ts @@ -216,8 +216,6 @@ export class Cortex implements INodeType { '', options, )) as IJob; - - continue; } else { const observableValue = this.getNodeParameter('observableValue', i) as string; From 9e866591e1dd20a13bf6f96cac6eed79af65940f Mon Sep 17 00:00:00 2001 From: guangwu Date: Wed, 15 May 2024 18:00:44 +0800 Subject: [PATCH 08/58] fix: Small typo fix (no-changelog) (#8876) --- packages/nodes-base/nodes/FunctionItem/FunctionItem.node.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/FunctionItem/FunctionItem.node.ts b/packages/nodes-base/nodes/FunctionItem/FunctionItem.node.ts index 348f52aaef..44814cd625 100644 --- a/packages/nodes-base/nodes/FunctionItem/FunctionItem.node.ts +++ b/packages/nodes-base/nodes/FunctionItem/FunctionItem.node.ts @@ -126,7 +126,7 @@ return item;`, )?.toString('base64'); } } - // Retrun Data + // Return Data return item.binary; }, setBinaryDataAsync: async (data: IBinaryKeyData) => { From 1081429a4d0f7e2d1fc1841303448035b46e44d1 Mon Sep 17 00:00:00 2001 From: Mike Quinlan Date: Wed, 15 May 2024 04:01:16 -0600 Subject: [PATCH 09/58] feat(Slack Node): Add block support for message updates (#8925) --- .../nodes/Slack/V2/MessageDescription.ts | 67 +++++++++++++++++++ .../nodes-base/nodes/Slack/V2/SlackV2.node.ts | 26 ++----- 2 files changed, 72 insertions(+), 21 deletions(-) diff --git a/packages/nodes-base/nodes/Slack/V2/MessageDescription.ts b/packages/nodes-base/nodes/Slack/V2/MessageDescription.ts index 527e687af2..e3708889fb 100644 --- a/packages/nodes-base/nodes/Slack/V2/MessageDescription.ts +++ b/packages/nodes-base/nodes/Slack/V2/MessageDescription.ts @@ -859,6 +859,72 @@ export const messageFields: INodeProperties[] = [ description: 'Timestamp of the message to update', placeholder: '1663233118.856619', }, + { + displayName: 'Message Type', + name: 'messageType', + type: 'options', + displayOptions: { + show: { + operation: ['update'], + resource: ['message'], + }, + }, + description: + 'Whether to send a simple text message, or use Slack’s Blocks UI builder for more sophisticated messages that include form fields, sections and more', + options: [ + { + name: 'Simple Text Message', + value: 'text', + description: 'Supports basic Markdown', + }, + { + name: 'Blocks', + value: 'block', + description: + "Combine text, buttons, form elements, dividers and more in Slack 's visual builder", + }, + { + name: 'Attachments', + value: 'attachment', + }, + ], + default: 'text', + }, + { + displayName: 'Blocks', + name: 'blocksUi', + type: 'string', + required: true, + displayOptions: { + show: { + operation: ['update'], + resource: ['message'], + messageType: ['block'], + }, + }, + typeOptions: { + rows: 3, + }, + description: + "Enter the JSON output from Slack's visual Block Kit Builder here. You can then use expressions to add variable content to your blocks. To create blocks, use Slack's Block Kit Builder", + hint: "To create blocks, use Slack's Block Kit Builder", + default: '', + }, + { + displayName: 'Notification Text', + name: 'text', + type: 'string', + default: '', + displayOptions: { + show: { + operation: ['update'], + resource: ['message'], + messageType: ['block'], + }, + }, + description: + 'Fallback text to display in slack notifications. Supports markdown by default - this can be disabled in "Options".', + }, { displayName: 'Message Text', name: 'text', @@ -868,6 +934,7 @@ export const messageFields: INodeProperties[] = [ show: { resource: ['message'], operation: ['update'], + messageType: ['text'], }, }, description: diff --git a/packages/nodes-base/nodes/Slack/V2/SlackV2.node.ts b/packages/nodes-base/nodes/Slack/V2/SlackV2.node.ts index df0ac7b999..3029fea18d 100644 --- a/packages/nodes-base/nodes/Slack/V2/SlackV2.node.ts +++ b/packages/nodes-base/nodes/Slack/V2/SlackV2.node.ts @@ -25,12 +25,7 @@ import { fileFields, fileOperations } from './FileDescription'; import { reactionFields, reactionOperations } from './ReactionDescription'; import { userGroupFields, userGroupOperations } from './UserGroupDescription'; import { userFields, userOperations } from './UserDescription'; -import { - slackApiRequest, - slackApiRequestAllItems, - validateJSON, - getMessageContent, -} from './GenericFunctions'; +import { slackApiRequest, slackApiRequestAllItems, getMessageContent } from './GenericFunctions'; export class SlackV2 implements INodeType { description: INodeTypeDescription; @@ -779,6 +774,7 @@ export class SlackV2 implements INodeType { if (authentication === 'accessToken' && sendAsUser !== '' && sendAsUser !== undefined) { body.username = sendAsUser; } + // Add all the other options to the request const otherOptions = this.getNodeParameter('otherOptions', i) as IDataObject; let action = 'postMessage'; @@ -836,27 +832,15 @@ export class SlackV2 implements INodeType { {}, { extractValue: true }, ) as string; - const text = this.getNodeParameter('text', i) as string; const ts = this.getNodeParameter('ts', i)?.toString() as string; + const content = getMessageContent.call(this, i, nodeVersion, instanceId); + const body: IDataObject = { channel, - text, ts, + ...content, }; - const jsonParameters = this.getNodeParameter('jsonParameters', i, false); - if (jsonParameters) { - const blocksJson = this.getNodeParameter('blocksJson', i, []) as string; - - if (blocksJson !== '' && validateJSON(blocksJson) === undefined) { - throw new NodeOperationError(this.getNode(), 'Blocks it is not a valid json', { - itemIndex: i, - }); - } - if (blocksJson !== '') { - body.blocks = blocksJson; - } - } // Add all the other options to the request const updateFields = this.getNodeParameter('updateFields', i); Object.assign(body, updateFields); From 677f534661634c74340f50723e55e241570d5a56 Mon Sep 17 00:00:00 2001 From: oleg Date: Wed, 15 May 2024 12:02:21 +0200 Subject: [PATCH 10/58] feat(AI Agent Node): Implement Tool calling agent (#9339) Signed-off-by: Oleg Ivaniv --- .../nodes/agents/Agent/Agent.node.ts | 124 ++++++++---- .../Agent/agents/ToolsAgent/description.ts | 43 ++++ .../agents/Agent/agents/ToolsAgent/execute.ts | 189 ++++++++++++++++++ .../agents/Agent/agents/ToolsAgent/prompt.ts | 1 + .../@n8n/nodes-langchain/utils/logWrapper.ts | 62 +++--- 5 files changed, 344 insertions(+), 75 deletions(-) create mode 100644 packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/description.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/prompt.ts diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts index 9518728e35..f655ebd254 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts @@ -7,6 +7,7 @@ import type { INodeExecutionData, INodeType, INodeTypeDescription, + INodeProperties, } from 'n8n-workflow'; import { getTemplateNoticeField } from '../../../utils/sharedFields'; import { promptTypeOptions, textInput } from '../../../utils/descriptions'; @@ -20,11 +21,13 @@ import { reActAgentAgentProperties } from './agents/ReActAgent/description'; import { reActAgentAgentExecute } from './agents/ReActAgent/execute'; import { sqlAgentAgentProperties } from './agents/SqlAgent/description'; import { sqlAgentAgentExecute } from './agents/SqlAgent/execute'; +import { toolsAgentProperties } from './agents/ToolsAgent/description'; +import { toolsAgentExecute } from './agents/ToolsAgent/execute'; // Function used in the inputs expression to figure out which inputs to // display based on the agent type function getInputs( - agent: 'conversationalAgent' | 'openAiFunctionsAgent' | 'reActAgent' | 'sqlAgent', + agent: 'toolsAgent' | 'conversationalAgent' | 'openAiFunctionsAgent' | 'reActAgent' | 'sqlAgent', hasOutputParser?: boolean, ): Array { interface SpecialInput { @@ -92,6 +95,31 @@ function getInputs( type: NodeConnectionType.AiOutputParser, }, ]; + } else if (agent === 'toolsAgent') { + specialInputs = [ + { + type: NodeConnectionType.AiLanguageModel, + filter: { + nodes: [ + '@n8n/n8n-nodes-langchain.lmChatAnthropic', + '@n8n/n8n-nodes-langchain.lmChatAzureOpenAi', + '@n8n/n8n-nodes-langchain.lmChatMistralCloud', + '@n8n/n8n-nodes-langchain.lmChatOpenAi', + '@n8n/n8n-nodes-langchain.lmChatGroq', + ], + }, + }, + { + type: NodeConnectionType.AiMemory, + }, + { + type: NodeConnectionType.AiTool, + required: true, + }, + { + type: NodeConnectionType.AiOutputParser, + }, + ]; } else if (agent === 'openAiFunctionsAgent') { specialInputs = [ { @@ -157,16 +185,60 @@ function getInputs( return [NodeConnectionType.Main, ...getInputData(specialInputs)]; } +const agentTypeProperty: INodeProperties = { + displayName: 'Agent', + name: 'agent', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Conversational Agent', + value: 'conversationalAgent', + description: + 'Selects tools to accomplish its task and uses memory to recall previous conversations', + }, + { + name: 'OpenAI Functions Agent', + value: 'openAiFunctionsAgent', + description: + "Utilizes OpenAI's Function Calling feature to select the appropriate tool and arguments for execution", + }, + { + name: 'Plan and Execute Agent', + value: 'planAndExecuteAgent', + description: + 'Plan and execute agents accomplish an objective by first planning what to do, then executing the sub tasks', + }, + { + name: 'ReAct Agent', + value: 'reActAgent', + description: 'Strategically select tools to accomplish a given task', + }, + { + name: 'SQL Agent', + value: 'sqlAgent', + description: 'Answers questions about data in an SQL database', + }, + { + name: 'Tools Agent', + value: 'toolsAgent', + description: + 'Utilized unified Tool calling interface to select the appropriate tools and argument for execution', + }, + ], + default: '', +}; + export class Agent implements INodeType { description: INodeTypeDescription = { displayName: 'AI Agent', name: 'agent', icon: 'fa:robot', group: ['transform'], - version: [1, 1.1, 1.2, 1.3, 1.4, 1.5], + version: [1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6], description: 'Generates an action plan and executes it. Can use external tools.', subtitle: - "={{ { conversationalAgent: 'Conversational Agent', openAiFunctionsAgent: 'OpenAI Functions Agent', reActAgent: 'ReAct Agent', sqlAgent: 'SQL Agent', planAndExecuteAgent: 'Plan and Execute Agent' }[$parameter.agent] }}", + "={{ { toolsAgent: 'Tools Agent', conversationalAgent: 'Conversational Agent', openAiFunctionsAgent: 'OpenAI Functions Agent', reActAgent: 'ReAct Agent', sqlAgent: 'SQL Agent', planAndExecuteAgent: 'Plan and Execute Agent' }[$parameter.agent] }}", defaults: { name: 'AI Agent', color: '#404040', @@ -225,43 +297,18 @@ export class Agent implements INodeType { }, }, }, + // Make Conversational Agent the default agent for versions 1.5 and below { - displayName: 'Agent', - name: 'agent', - type: 'options', - noDataExpression: true, - options: [ - { - name: 'Conversational Agent', - value: 'conversationalAgent', - description: - 'Selects tools to accomplish its task and uses memory to recall previous conversations', - }, - { - name: 'OpenAI Functions Agent', - value: 'openAiFunctionsAgent', - description: - "Utilizes OpenAI's Function Calling feature to select the appropriate tool and arguments for execution", - }, - { - name: 'Plan and Execute Agent', - value: 'planAndExecuteAgent', - description: - 'Plan and execute agents accomplish an objective by first planning what to do, then executing the sub tasks', - }, - { - name: 'ReAct Agent', - value: 'reActAgent', - description: 'Strategically select tools to accomplish a given task', - }, - { - name: 'SQL Agent', - value: 'sqlAgent', - description: 'Answers questions about data in an SQL database', - }, - ], + ...agentTypeProperty, + displayOptions: { show: { '@version': [{ _cnd: { lte: 1.5 } }] } }, default: 'conversationalAgent', }, + // Make Tools Agent the default agent for versions 1.6 and above + { + ...agentTypeProperty, + displayOptions: { show: { '@version': [{ _cnd: { gte: 1.6 } }] } }, + default: 'toolsAgent', + }, { ...promptTypeOptions, displayOptions: { @@ -307,6 +354,7 @@ export class Agent implements INodeType { }, }, + ...toolsAgentProperties, ...conversationalAgentProperties, ...openAiFunctionsAgentProperties, ...reActAgentAgentProperties, @@ -321,6 +369,8 @@ export class Agent implements INodeType { if (agentType === 'conversationalAgent') { return await conversationalAgentExecute.call(this, nodeVersion); + } else if (agentType === 'toolsAgent') { + return await toolsAgentExecute.call(this, nodeVersion); } else if (agentType === 'openAiFunctionsAgent') { return await openAiFunctionsAgentExecute.call(this, nodeVersion); } else if (agentType === 'reActAgent') { diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/description.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/description.ts new file mode 100644 index 0000000000..4597909f7f --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/description.ts @@ -0,0 +1,43 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { SYSTEM_MESSAGE } from './prompt'; + +export const toolsAgentProperties: INodeProperties[] = [ + { + displayName: 'Options', + name: 'options', + type: 'collection', + displayOptions: { + show: { + agent: ['toolsAgent'], + }, + }, + default: {}, + placeholder: 'Add Option', + options: [ + { + displayName: 'System Message', + name: 'systemMessage', + type: 'string', + default: SYSTEM_MESSAGE, + description: 'The message that will be sent to the agent before the conversation starts', + typeOptions: { + rows: 6, + }, + }, + { + displayName: 'Max Iterations', + name: 'maxIterations', + type: 'number', + default: 10, + description: 'The maximum number of iterations the agent will run before stopping', + }, + { + displayName: 'Return Intermediate Steps', + name: 'returnIntermediateSteps', + type: 'boolean', + default: false, + description: 'Whether or not the output should include intermediate steps the agent took', + }, + ], + }, +]; diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts new file mode 100644 index 0000000000..65265f704e --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts @@ -0,0 +1,189 @@ +import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; + +import type { AgentAction, AgentFinish, AgentStep } from 'langchain/agents'; +import { AgentExecutor, createToolCallingAgent } from 'langchain/agents'; +import type { BaseChatMemory } from '@langchain/community/memory/chat_memory'; +import { ChatPromptTemplate } from '@langchain/core/prompts'; +import { omit } from 'lodash'; +import type { Tool } from '@langchain/core/tools'; +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { RunnableSequence } from '@langchain/core/runnables'; +import type { ZodObject } from 'zod'; +import { z } from 'zod'; +import type { BaseOutputParser, StructuredOutputParser } from '@langchain/core/output_parsers'; +import { OutputFixingParser } from 'langchain/output_parsers'; +import { + isChatInstance, + getPromptInputByType, + getOptionalOutputParsers, + getConnectedTools, +} from '../../../../../utils/helpers'; +import { SYSTEM_MESSAGE } from './prompt'; + +function getOutputParserSchema(outputParser: BaseOutputParser): ZodObject { + const parserType = outputParser.lc_namespace[outputParser.lc_namespace.length - 1]; + let schema: ZodObject; + + if (parserType === 'structured') { + // If the output parser is a structured output parser, we will use the schema from the parser + schema = (outputParser as StructuredOutputParser>).schema; + } else if (parserType === 'fix' && outputParser instanceof OutputFixingParser) { + // If the output parser is a fixing parser, we will use the schema from the connected structured output parser + schema = (outputParser.parser as StructuredOutputParser>).schema; + } else { + // If the output parser is not a structured output parser, we will use a fallback schema + schema = z.object({ text: z.string() }); + } + + return schema; +} + +export async function toolsAgentExecute( + this: IExecuteFunctions, + nodeVersion: number, +): Promise { + this.logger.verbose('Executing Tools Agent'); + const model = await this.getInputConnectionData(NodeConnectionType.AiLanguageModel, 0); + + if (!isChatInstance(model) || !model.bindTools) { + throw new NodeOperationError( + this.getNode(), + 'Tools Agent requires Chat Model which supports Tools calling', + ); + } + + const memory = (await this.getInputConnectionData(NodeConnectionType.AiMemory, 0)) as + | BaseChatMemory + | undefined; + + const tools = (await getConnectedTools(this, true)) as Array; + const outputParser = (await getOptionalOutputParsers(this))?.[0]; + let structuredOutputParserTool: DynamicStructuredTool | undefined; + + async function agentStepsParser( + steps: AgentFinish | AgentAction[], + ): Promise { + if (Array.isArray(steps)) { + const responseParserTool = steps.find((step) => step.tool === 'format_final_response'); + if (responseParserTool) { + const toolInput = responseParserTool?.toolInput; + const returnValues = (await outputParser.parse(toolInput as unknown as string)) as Record< + string, + unknown + >; + + return { + returnValues, + log: 'Final response formatted', + }; + } + } + + // If the steps are an AgentFinish and the outputParser is defined it must mean that the LLM didn't use `format_final_response` tool so we will parse the output manually + if (outputParser && typeof steps === 'object' && (steps as AgentFinish).returnValues) { + const finalResponse = (steps as AgentFinish).returnValues; + const returnValues = (await outputParser.parse(finalResponse as unknown as string)) as Record< + string, + unknown + >; + + return { + returnValues, + log: 'Final response formatted', + }; + } + return steps; + } + + if (outputParser) { + const schema = getOutputParserSchema(outputParser); + structuredOutputParserTool = new DynamicStructuredTool({ + schema, + name: 'format_final_response', + description: + 'Always use this tool for the final output to the user. It validates the output so only use it when you are sure the output is final.', + // We will not use the function here as we will use the parser to intercept & parse the output in the agentStepsParser + func: async () => '', + }); + + tools.push(structuredOutputParserTool); + } + + const options = this.getNodeParameter('options', 0, {}) as { + systemMessage?: string; + maxIterations?: number; + returnIntermediateSteps?: boolean; + }; + + const prompt = ChatPromptTemplate.fromMessages([ + ['system', `{system_message}${outputParser ? '\n\n{formatting_instructions}' : ''}`], + ['placeholder', '{chat_history}'], + ['human', '{input}'], + ['placeholder', '{agent_scratchpad}'], + ]); + + const agent = createToolCallingAgent({ + llm: model, + tools, + prompt, + streamRunnable: false, + }); + agent.streamRunnable = false; + + const runnableAgent = RunnableSequence.from<{ + steps: AgentStep[]; + }>([agent, agentStepsParser]); + + const executor = AgentExecutor.fromAgentAndTools({ + agent: runnableAgent, + memory, + tools, + returnIntermediateSteps: options.returnIntermediateSteps === true, + maxIterations: options.maxIterations ?? 10, + }); + const returnData: INodeExecutionData[] = []; + + const items = this.getInputData(); + for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { + try { + const input = getPromptInputByType({ + ctx: this, + i: itemIndex, + inputKey: 'text', + promptTypeKey: 'promptType', + }); + + if (input === undefined) { + throw new NodeOperationError(this.getNode(), 'The ‘text parameter is empty.'); + } + + const response = await executor.invoke({ + input, + system_message: options.systemMessage ?? SYSTEM_MESSAGE, + formatting_instructions: + 'IMPORTANT: Always call `format_final_response` to format your final response!', //outputParser?.getFormatInstructions(), + }); + + returnData.push({ + json: omit( + response, + 'system_message', + 'formatting_instructions', + 'input', + 'chat_history', + 'agent_scratchpad', + ), + }); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ json: { error: error.message }, pairedItem: { item: itemIndex } }); + continue; + } + + throw error; + } + } + + return await this.prepareOutputData(returnData); +} diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/prompt.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/prompt.ts new file mode 100644 index 0000000000..069a2629b5 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/prompt.ts @@ -0,0 +1 @@ +export const SYSTEM_MESSAGE = 'You are a helpful assistant'; diff --git a/packages/@n8n/nodes-langchain/utils/logWrapper.ts b/packages/@n8n/nodes-langchain/utils/logWrapper.ts index 1ce6924813..4cdec6fbfc 100644 --- a/packages/@n8n/nodes-langchain/utils/logWrapper.ts +++ b/packages/@n8n/nodes-langchain/utils/logWrapper.ts @@ -13,7 +13,6 @@ import type { Document } from '@langchain/core/documents'; import { TextSplitter } from 'langchain/text_splitter'; import { BaseChatMemory } from '@langchain/community/memory/chat_memory'; import { BaseRetriever } from '@langchain/core/retrievers'; -import type { FormatInstructionsOptions } from '@langchain/core/output_parsers'; import { BaseOutputParser, OutputParserException } from '@langchain/core/output_parsers'; import { isObject } from 'lodash'; import type { BaseDocumentLoader } from 'langchain/dist/document_loaders/base'; @@ -222,31 +221,7 @@ export function logWrapper( // ========== BaseOutputParser ========== if (originalInstance instanceof BaseOutputParser) { - if (prop === 'getFormatInstructions' && 'getFormatInstructions' in target) { - return (options?: FormatInstructionsOptions): string => { - connectionType = NodeConnectionType.AiOutputParser; - const { index } = executeFunctions.addInputData(connectionType, [ - [{ json: { action: 'getFormatInstructions' } }], - ]); - - // @ts-ignore - const response = callMethodSync.call(target, { - executeFunctions, - connectionType, - currentNodeRunIndex: index, - method: target[prop], - arguments: [options], - }) as string; - - executeFunctions.addOutputData(connectionType, index, [ - [{ json: { action: 'getFormatInstructions', response } }], - ]); - void logAiEvent(executeFunctions, 'n8n.ai.output.parser.get.instructions', { - response, - }); - return response; - }; - } else if (prop === 'parse' && 'parse' in target) { + if (prop === 'parse' && 'parse' in target) { return async (text: string | Record): Promise => { connectionType = NodeConnectionType.AiOutputParser; const stringifiedText = isObject(text) ? JSON.stringify(text) : text; @@ -254,19 +229,30 @@ export function logWrapper( [{ json: { action: 'parse', text: stringifiedText } }], ]); - const response = (await callMethodAsync.call(target, { - executeFunctions, - connectionType, - currentNodeRunIndex: index, - method: target[prop], - arguments: [stringifiedText], - })) as object; + try { + const response = (await callMethodAsync.call(target, { + executeFunctions, + connectionType, + currentNodeRunIndex: index, + method: target[prop], + arguments: [stringifiedText], + })) as object; - void logAiEvent(executeFunctions, 'n8n.ai.output.parser.parsed', { text, response }); - executeFunctions.addOutputData(connectionType, index, [ - [{ json: { action: 'parse', response } }], - ]); - return response; + void logAiEvent(executeFunctions, 'n8n.ai.output.parser.parsed', { text, response }); + executeFunctions.addOutputData(connectionType, index, [ + [{ json: { action: 'parse', response } }], + ]); + return response; + } catch (error) { + void logAiEvent(executeFunctions, 'n8n.ai.output.parser.parsed', { + text, + response: error.message ?? error, + }); + executeFunctions.addOutputData(connectionType, index, [ + [{ json: { action: 'parse', response: error.message ?? error } }], + ]); + throw error; + } }; } } From 8f254527e3da00b6a636e5b4a78383c296b7a6ee Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 15 May 2024 12:16:42 +0200 Subject: [PATCH 11/58] :rocket: Release 1.42.0 (#9405) Co-authored-by: netroy --- CHANGELOG.md | 31 ++++++++++++++++++++++ package.json | 2 +- packages/@n8n/nodes-langchain/package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/design-system/package.json | 2 +- packages/editor-ui/package.json | 2 +- packages/node-dev/package.json | 2 +- packages/nodes-base/package.json | 2 +- packages/workflow/package.json | 2 +- 10 files changed, 40 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33a58cc333..451497cb07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,34 @@ +# [1.42.0](https://github.com/n8n-io/n8n/compare/n8n@1.41.0...n8n@1.42.0) (2024-05-15) + + +### Bug Fixes + +* **Code Node:** Bind helper methods to the correct context ([#9380](https://github.com/n8n-io/n8n/issues/9380)) ([82c8801](https://github.com/n8n-io/n8n/commit/82c8801f25446085bc8da5055d9932eed4321f47)) +* **Cortex Node:** Fix issue with analyzer response not working for file observables ([#9374](https://github.com/n8n-io/n8n/issues/9374)) ([ed22dcd](https://github.com/n8n-io/n8n/commit/ed22dcd88ac7f8433b9ed5dc2139d8779b0e1d4c)) +* **editor:** Render backticks as code segments in error view ([#9352](https://github.com/n8n-io/n8n/issues/9352)) ([4ed5850](https://github.com/n8n-io/n8n/commit/4ed585040b20c50919e2ec2252216639c85194cb)) +* **Mattermost Node:** Fix issue when fetching reactions ([#9375](https://github.com/n8n-io/n8n/issues/9375)) ([78e7c7a](https://github.com/n8n-io/n8n/commit/78e7c7a9da96a293262cea5304509261ad10020c)) + + +### Features + +* **AI Agent Node:** Implement Tool calling agent ([#9339](https://github.com/n8n-io/n8n/issues/9339)) ([677f534](https://github.com/n8n-io/n8n/commit/677f534661634c74340f50723e55e241570d5a56)) +* **core:** Allow using a custom certificates in docker containers ([#8705](https://github.com/n8n-io/n8n/issues/8705)) ([6059722](https://github.com/n8n-io/n8n/commit/6059722fbfeeca31addfc31ed287f79f40aaad18)) +* **core:** Node hints(warnings) system ([#8954](https://github.com/n8n-io/n8n/issues/8954)) ([da6088d](https://github.com/n8n-io/n8n/commit/da6088d0bbb952fcdf595a650e1e01b7b02a2b7e)) +* **core:** Node version available in expression ([#9350](https://github.com/n8n-io/n8n/issues/9350)) ([a00467c](https://github.com/n8n-io/n8n/commit/a00467c9fa57d740de9eccfcd136267bc9e9559d)) +* **editor:** Add examples for number & boolean, add new methods ([#9358](https://github.com/n8n-io/n8n/issues/9358)) ([7b45dc3](https://github.com/n8n-io/n8n/commit/7b45dc313f42317f894469c6aa8abecc55704e3a)) +* **editor:** Add examples for object and array expression methods ([#9360](https://github.com/n8n-io/n8n/issues/9360)) ([5293663](https://github.com/n8n-io/n8n/commit/52936633af9c71dff1957ee43a5eda48f7fc1bf1)) +* **editor:** Add item selector to expression output ([#9281](https://github.com/n8n-io/n8n/issues/9281)) ([dc5994b](https://github.com/n8n-io/n8n/commit/dc5994b18580b9326574c5208d9beaf01c746f33)) +* **editor:** Autocomplete info box: improve structure and add examples ([#9019](https://github.com/n8n-io/n8n/issues/9019)) ([c92c870](https://github.com/n8n-io/n8n/commit/c92c870c7335f4e2af63fa1c6bcfd086b2957ef8)) +* **editor:** Remove AI Error Debugging ([#9337](https://github.com/n8n-io/n8n/issues/9337)) ([cda062b](https://github.com/n8n-io/n8n/commit/cda062bde63bcbfdd599d0662ddbe89c27a71686)) +* **Slack Node:** Add block support for message updates ([#8925](https://github.com/n8n-io/n8n/issues/8925)) ([1081429](https://github.com/n8n-io/n8n/commit/1081429a4d0f7e2d1fc1841303448035b46e44d1)) + + +### Performance Improvements + +* Add tailwind to editor and design system ([#9032](https://github.com/n8n-io/n8n/issues/9032)) ([1c1e444](https://github.com/n8n-io/n8n/commit/1c1e4443f41dd39da8d5fa3951c8dffb0fbfce10)) + + + # [1.41.0](https://github.com/n8n-io/n8n/compare/n8n@1.40.0...n8n@1.41.0) (2024-05-08) diff --git a/package.json b/package.json index 7b99db0421..7e5d9381cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-monorepo", - "version": "1.41.0", + "version": "1.42.0", "private": true, "homepage": "https://n8n.io", "engines": { diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index c51b714c2f..23854907f7 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-nodes-langchain", - "version": "1.41.0", + "version": "1.42.0", "description": "", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/cli/package.json b/packages/cli/package.json index 771203f194..59d4289982 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.41.0", + "version": "1.42.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/core/package.json b/packages/core/package.json index 1ee490d1a9..b0c53c9c1e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "1.41.0", + "version": "1.42.0", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 3aa3ee8fe3..09b3de3ab2 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -1,6 +1,6 @@ { "name": "n8n-design-system", - "version": "1.31.0", + "version": "1.32.0", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", "author": { diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index b2cdc81a99..e314346024 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "1.41.0", + "version": "1.42.0", "description": "Workflow Editor UI for n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index 9dd0e48ea8..bb7cfcb96c 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -1,6 +1,6 @@ { "name": "n8n-node-dev", - "version": "1.41.0", + "version": "1.42.0", "description": "CLI to simplify n8n credentials/node development", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 410f982c73..d58703f055 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "1.41.0", + "version": "1.42.0", "description": "Base nodes of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/workflow/package.json b/packages/workflow/package.json index d3839c66b7..a8f796bd2c 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -1,6 +1,6 @@ { "name": "n8n-workflow", - "version": "1.40.0", + "version": "1.41.0", "description": "Workflow base code of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From bf549301df541c43931fe4493b4bad7905fb0c8a Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 15 May 2024 13:54:32 +0100 Subject: [PATCH 12/58] feat: Add Slack trigger node (#9190) Co-authored-by: Giulio Andreini --- cypress/e2e/2-credentials.cy.ts | 5 +- .../nodes/Slack/SlackTrigger.node.ts | 414 ++++++++++++++++++ .../nodes/Slack/SlackTriggerHelpers.ts | 79 ++++ .../nodes/Slack/SlackTriggger.node.json | 18 + .../nodes/Slack/V2/GenericFunctions.ts | 4 +- packages/nodes-base/package.json | 1 + 6 files changed, 518 insertions(+), 3 deletions(-) create mode 100644 packages/nodes-base/nodes/Slack/SlackTrigger.node.ts create mode 100644 packages/nodes-base/nodes/Slack/SlackTriggerHelpers.ts create mode 100644 packages/nodes-base/nodes/Slack/SlackTriggger.node.json diff --git a/cypress/e2e/2-credentials.cy.ts b/cypress/e2e/2-credentials.cy.ts index 008758aef2..c4cdcb280b 100644 --- a/cypress/e2e/2-credentials.cy.ts +++ b/cypress/e2e/2-credentials.cy.ts @@ -254,8 +254,9 @@ describe('Credentials', () => { }); workflowPage.actions.visit(true); - workflowPage.actions.addNodeToCanvas('Slack'); - workflowPage.actions.openNode('Slack'); + workflowPage.actions.addNodeToCanvas('Manual'); + workflowPage.actions.addNodeToCanvas('Slack', true, true, 'Get a channel'); + workflowPage.getters.nodeCredentialsSelect().should('exist'); workflowPage.getters.nodeCredentialsSelect().click(); getVisibleSelect().find('li').last().click(); credentialsModal.getters.credentialAuthTypeRadioButtons().first().click(); diff --git a/packages/nodes-base/nodes/Slack/SlackTrigger.node.ts b/packages/nodes-base/nodes/Slack/SlackTrigger.node.ts new file mode 100644 index 0000000000..6f46b2b827 --- /dev/null +++ b/packages/nodes-base/nodes/Slack/SlackTrigger.node.ts @@ -0,0 +1,414 @@ +import type { + INodeListSearchItems, + ILoadOptionsFunctions, + INodeListSearchResult, + INodePropertyOptions, + IHookFunctions, + IWebhookFunctions, + IDataObject, + INodeType, + INodeTypeDescription, + IWebhookResponseData, + IBinaryKeyData, +} from 'n8n-workflow'; + +import { slackApiRequestAllItems } from './V2/GenericFunctions'; +import { downloadFile, getChannelInfo, getUserInfo } from './SlackTriggerHelpers'; + +export class SlackTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Slack Trigger', + name: 'slackTrigger', + icon: 'file:slack.svg', + group: ['trigger'], + version: 1, + subtitle: '={{$parameter["eventFilter"].join(", ")}}', + description: 'Handle Slack events via webhooks', + defaults: { + name: 'Slack Trigger', + }, + inputs: [], + outputs: ['main'], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + credentials: [ + { + name: 'slackApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden', + default: 'accessToken', + }, + { + displayName: + 'Set up a webhook in your Slack app to enable this node. More info', + name: 'notice', + type: 'notice', + default: '', + }, + { + displayName: 'Trigger On', + name: 'trigger', + type: 'multiOptions', + options: [ + { + name: 'Any Event', + value: 'any_event', + description: 'Triggers on any event', + }, + { + name: 'Bot / App Mention', + value: 'app_mention', + description: 'When your bot or app is mentioned in a channel the app is added to', + }, + { + name: 'File Made Public', + value: 'file_public', + description: 'When a file is made public', + }, + { + name: 'File Shared', + value: 'file_share', + description: 'When a file is shared in a channel the app is added to', + }, + { + name: 'New Message Posted to Channel', + value: 'message', + description: 'When a message is posted to a channel the app is added to', + }, + { + name: 'New Public Channel Created', + value: 'channel_created', + description: 'When a new public channel is created', + }, + { + name: 'New User', + value: 'team_join', + description: 'When a new user is added to Slack', + }, + { + name: 'Reaction Added', + value: 'reaction_added', + description: 'When a reaction is added to a message the app is added to', + }, + ], + default: [], + }, + { + displayName: 'Watch Whole Workspace', + name: 'watchWorkspace', + type: 'boolean', + default: false, + description: + 'Whether to watch for the event in the whole workspace, rather than a specific channel', + displayOptions: { + show: { + trigger: ['any_event', 'message', 'reaction_added', 'file_share', 'app_mention'], + }, + }, + }, + { + displayName: + 'This will use one execution for every event in any channel your bot is in, use with caution', + name: 'notice', + type: 'notice', + default: '', + displayOptions: { + show: { + trigger: ['any_event', 'message', 'reaction_added', 'file_share', 'app_mention'], + watchWorkspace: [true], + }, + }, + }, + { + displayName: 'Channel to Watch', + name: 'channelId', + type: 'resourceLocator', + required: true, + default: { mode: 'list', value: '' }, + placeholder: 'Select a channel...', + description: + 'The Slack channel to listen to events from. Applies to events: Bot/App mention, File Shared, New Message Posted on Channel, Reaction Added.', + displayOptions: { + show: { + watchWorkspace: [false], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a channel...', + typeOptions: { + searchListMethod: 'getChannels', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9]{2,}', + errorMessage: 'Not a valid Slack Channel ID', + }, + }, + ], + placeholder: 'C0122KQ70S7E', + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'https://app.slack.com/client/TS9594PZK/B0556F47Z3A', + validation: [ + { + type: 'regex', + properties: { + regex: 'http(s)?://app.slack.com/client/.*/([a-zA-Z0-9]{2,})', + errorMessage: 'Not a valid Slack Channel URL', + }, + }, + ], + extractValue: { + type: 'regex', + regex: 'https://app.slack.com/client/.*/([a-zA-Z0-9]{2,})', + }, + }, + ], + }, + { + displayName: 'Download Files', + name: 'downloadFiles', + type: 'boolean', + default: false, + description: 'Whether to download the files and add it to the output', + displayOptions: { + show: { + trigger: ['any_event', 'file_share'], + }, + }, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Field', + default: {}, + options: [ + { + displayName: 'Resolve IDs', + name: 'resolveIds', + type: 'boolean', + default: false, + description: 'Whether to resolve the IDs to their respective names and return them', + }, + { + displayName: 'Usernames or IDs to Ignore', + name: 'userIds', + type: 'multiOptions', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + default: [], + description: + 'A comma-separated string of encoded user IDs. Choose from the list, or specify IDs using an expression.', + }, + ], + }, + ], + }; + + methods = { + listSearch: { + async getChannels( + this: ILoadOptionsFunctions, + filter?: string, + ): Promise { + const qs = { types: 'public_channel,private_channel' }; + const channels = (await slackApiRequestAllItems.call( + this, + 'channels', + 'GET', + '/conversations.list', + {}, + qs, + )) as Array<{ id: string; name: string }>; + const results: INodeListSearchItems[] = channels + .map((c) => ({ + name: c.name, + value: c.id, + })) + .filter( + (c) => + !filter || + c.name.toLowerCase().includes(filter.toLowerCase()) || + c.value?.toString() === filter, + ) + .sort((a, b) => { + if (a.name.toLowerCase() < b.name.toLowerCase()) return -1; + if (a.name.toLowerCase() > b.name.toLowerCase()) return 1; + return 0; + }); + return { results }; + }, + }, + loadOptions: { + async getUsers(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const users = (await slackApiRequestAllItems.call( + this, + 'members', + 'GET', + '/users.list', + )) as Array<{ id: string; name: string }>; + for (const user of users) { + returnData.push({ + name: user.name, + value: user.id, + }); + } + + returnData.sort((a, b) => { + if (a.name < b.name) { + return -1; + } + if (a.name > b.name) { + return 1; + } + return 0; + }); + + return returnData; + }, + }, + }; + + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + return true; + }, + async create(this: IHookFunctions): Promise { + return true; + }, + async delete(this: IHookFunctions): Promise { + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const filters = this.getNodeParameter('trigger', []) as string[]; + const req = this.getRequestObject(); + const options = this.getNodeParameter('options', {}) as IDataObject; + const binaryData: IBinaryKeyData = {}; + const watchWorkspace = this.getNodeParameter('watchWorkspace', false) as boolean; + + // Check if the request is a challenge request + if (req.body.type === 'url_verification') { + const res = this.getResponseObject(); + res.status(200).json({ challenge: req.body.challenge }).end(); + + return { + noWebhookResponse: true, + }; + } + + // Check if the event type is in the filters + const eventType = req.body.event.type as string; + + if ( + !filters.includes('file_share') && + !filters.includes('any_event') && + !filters.includes(eventType) + ) { + return {}; + } + + const eventChannel = req.body.event.channel ?? req.body.event.item.channel; + + // Check for single channel + if (!watchWorkspace) { + if ( + eventChannel !== (this.getNodeParameter('channelId', {}, { extractValue: true }) as string) + ) { + return {}; + } + } + + // Check if user should be ignored + if (options.userIds) { + const userIds = options.userIds as string[]; + if (userIds.includes(req.body.event.user)) { + return {}; + } + } + + if (options.resolveIds) { + if (req.body.event.user) { + if (req.body.event.type === 'reaction_added') { + req.body.event.user_resolved = await getUserInfo.call(this, req.body.event.user); + req.body.event.item_user_resolved = await getUserInfo.call( + this, + req.body.event.item_user, + ); + } else { + req.body.event.user_resolved = await getUserInfo.call(this, req.body.event.user); + } + } + + if (eventChannel) { + const channel = await getChannelInfo.call(this, eventChannel); + const channelResolved = channel; + req.body.event.channel_resolved = channelResolved; + } + } + + if ( + req.body.event.subtype === 'file_share' && + (filters.includes('file_share') || filters.includes('any_event')) + ) { + if (this.getNodeParameter('downloadFiles', false) as boolean) { + for (let i = 0; i < req.body.event.files.length; i++) { + const file = (await downloadFile.call( + this, + req.body.event.files[i].url_private_download, + )) as Buffer; + + binaryData[`file_${i}`] = await this.helpers.prepareBinaryData( + file, + req.body.event.files[i].name, + req.body.event.files[i].mimetype, + ); + } + } + } + + return { + workflowData: [ + [ + { + json: req.body.event, + binary: Object.keys(binaryData).length ? binaryData : undefined, + }, + ], + ], + }; + } +} diff --git a/packages/nodes-base/nodes/Slack/SlackTriggerHelpers.ts b/packages/nodes-base/nodes/Slack/SlackTriggerHelpers.ts new file mode 100644 index 0000000000..a7f565c3f3 --- /dev/null +++ b/packages/nodes-base/nodes/Slack/SlackTriggerHelpers.ts @@ -0,0 +1,79 @@ +import type { IHttpRequestOptions, IWebhookFunctions } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; +import { slackApiRequest } from './V2/GenericFunctions'; + +export async function getUserInfo(this: IWebhookFunctions, userId: string): Promise { + const user = await slackApiRequest.call( + this, + 'GET', + '/users.info', + {}, + { + user: userId, + }, + ); + + return user.user.name; +} + +export async function getChannelInfo(this: IWebhookFunctions, channelId: string): Promise { + const channel = await slackApiRequest.call( + this, + 'GET', + '/conversations.info', + {}, + { + channel: channelId, + }, + ); + + return channel.channel.name; +} + +export async function downloadFile(this: IWebhookFunctions, url: string): Promise { + let options: IHttpRequestOptions = { + method: 'GET', + url, + }; + + const requestOptions = { + encoding: 'arraybuffer', + returnFullResponse: true, + json: false, + useStream: true, + }; + + options = Object.assign({}, options, requestOptions); + + const response = await this.helpers.requestWithAuthentication.call(this, 'slackApi', options); + + if (response.ok === false) { + if (response.error === 'paid_teams_only') { + throw new NodeOperationError( + this.getNode(), + `Your current Slack plan does not include the resource '${ + this.getNodeParameter('resource', 0) as string + }'`, + { + description: + 'Hint: Upgrade to a Slack plan that includes the functionality you want to use.', + level: 'warning', + }, + ); + } else if (response.error === 'missing_scope') { + throw new NodeOperationError( + this.getNode(), + 'Your Slack credential is missing required Oauth Scopes', + { + description: `Add the following scope(s) to your Slack App: ${response.needed}`, + level: 'warning', + }, + ); + } + throw new NodeOperationError( + this.getNode(), + 'Slack error response: ' + JSON.stringify(response.error), + ); + } + return response; +} diff --git a/packages/nodes-base/nodes/Slack/SlackTriggger.node.json b/packages/nodes-base/nodes/Slack/SlackTriggger.node.json new file mode 100644 index 0000000000..2d7fbb859e --- /dev/null +++ b/packages/nodes-base/nodes/Slack/SlackTriggger.node.json @@ -0,0 +1,18 @@ +{ + "node": "n8n-nodes-base.slackTrigger", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Communication"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/slack" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.slacktrigger/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Slack/V2/GenericFunctions.ts b/packages/nodes-base/nodes/Slack/V2/GenericFunctions.ts index 2554565239..f67d8beeb0 100644 --- a/packages/nodes-base/nodes/Slack/V2/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Slack/V2/GenericFunctions.ts @@ -5,6 +5,7 @@ import type { IOAuth2Options, IHttpRequestMethods, IRequestOptions, + IWebhookFunctions, } from 'n8n-workflow'; import { NodeOperationError, jsonParse } from 'n8n-workflow'; @@ -12,7 +13,7 @@ import { NodeOperationError, jsonParse } from 'n8n-workflow'; import get from 'lodash/get'; export async function slackApiRequest( - this: IExecuteFunctions | ILoadOptionsFunctions, + this: IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, method: IHttpRequestMethods, resource: string, body: object = {}, @@ -88,6 +89,7 @@ export async function slackApiRequest( Object.assign(response, { message_timestamp: response.ts }); delete response.ts; } + return response; } diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index d58703f055..5e481b1358 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -720,6 +720,7 @@ "dist/nodes/Simulate/Simulate.node.js", "dist/nodes/Simulate/SimulateTrigger.node.js", "dist/nodes/Slack/Slack.node.js", + "dist/nodes/Slack/SlackTrigger.node.js", "dist/nodes/Sms77/Sms77.node.js", "dist/nodes/Snowflake/Snowflake.node.js", "dist/nodes/SplitInBatches/SplitInBatches.node.js", From 68a6c8172973091e8474a9f173fa4a5e97284f18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Wed, 15 May 2024 15:50:53 +0200 Subject: [PATCH 13/58] fix(Email Trigger (IMAP) Node): Handle attachments correctly (#9410) --- package.json | 3 +- packages/@n8n/imap/package.json | 2 +- packages/@n8n/imap/src/ImapSimple.ts | 44 +--------- packages/@n8n/imap/src/PartData.ts | 84 ++++++++++++++++++ packages/@n8n/imap/test/PartData.test.ts | 88 +++++++++++++++++++ .../EmailReadImap/v1/EmailReadImapV1.node.ts | 10 ++- .../EmailReadImap/v2/EmailReadImapV2.node.ts | 14 ++- patches/@types__uuencode@0.0.3.patch | 10 +++ pnpm-lock.yaml | 14 +-- 9 files changed, 213 insertions(+), 56 deletions(-) create mode 100644 packages/@n8n/imap/src/PartData.ts create mode 100644 packages/@n8n/imap/test/PartData.test.ts create mode 100644 patches/@types__uuencode@0.0.3.patch diff --git a/package.json b/package.json index 7e5d9381cd..db1f0d70a9 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,8 @@ "pyodide@0.23.4": "patches/pyodide@0.23.4.patch", "@types/express-serve-static-core@4.17.43": "patches/@types__express-serve-static-core@4.17.43.patch", "@types/ws@8.5.4": "patches/@types__ws@8.5.4.patch", - "vite-plugin-checker@0.6.4": "patches/vite-plugin-checker@0.6.4.patch" + "vite-plugin-checker@0.6.4": "patches/vite-plugin-checker@0.6.4.patch", + "@types/uuencode@0.0.3": "patches/@types__uuencode@0.0.3.patch" } } } diff --git a/packages/@n8n/imap/package.json b/packages/@n8n/imap/package.json index 3773c5f3a1..66e1fcd491 100644 --- a/packages/@n8n/imap/package.json +++ b/packages/@n8n/imap/package.json @@ -10,7 +10,7 @@ "lint": "eslint . --quiet", "lintfix": "eslint . --fix", "watch": "tsc -p tsconfig.build.json --watch", - "test": "echo \"Error: no test created yet\"" + "test": "jest" }, "main": "dist/index.js", "module": "src/index.ts", diff --git a/packages/@n8n/imap/src/ImapSimple.ts b/packages/@n8n/imap/src/ImapSimple.ts index c495e8d1f4..eb6dc07722 100644 --- a/packages/@n8n/imap/src/ImapSimple.ts +++ b/packages/@n8n/imap/src/ImapSimple.ts @@ -2,13 +2,10 @@ import { EventEmitter } from 'events'; import type Imap from 'imap'; import { type ImapMessage } from 'imap'; -import * as qp from 'quoted-printable'; -import * as iconvlite from 'iconv-lite'; -import * as utf8 from 'utf8'; -import * as uuencode from 'uuencode'; import { getMessage } from './helpers/getMessage'; import type { Message, MessagePart } from './types'; +import { PartData } from './PartData'; const IMAP_EVENTS = ['alert', 'mail', 'expunge', 'uidvalidity', 'update', 'close', 'end'] as const; @@ -124,7 +121,7 @@ export class ImapSimple extends EventEmitter { /** The message part to be downloaded, from the `message.attributes.struct` Array */ part: MessagePart, ) { - return await new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { const fetch = this.imap.fetch(message.attributes.uid, { bodies: [part.partID], struct: true, @@ -138,43 +135,8 @@ export class ImapSimple extends EventEmitter { } const data = result.parts[0].body as string; - const encoding = part.encoding.toUpperCase(); - - if (encoding === 'BASE64') { - resolve(Buffer.from(data, 'base64').toString()); - return; - } - - if (encoding === 'QUOTED-PRINTABLE') { - if (part.params?.charset?.toUpperCase() === 'UTF-8') { - resolve(Buffer.from(utf8.decode(qp.decode(data))).toString()); - } else { - resolve(Buffer.from(qp.decode(data)).toString()); - } - return; - } - - if (encoding === '7BIT') { - resolve(Buffer.from(data).toString('ascii')); - return; - } - - if (encoding === '8BIT' || encoding === 'BINARY') { - const charset = part.params?.charset ?? 'utf-8'; - resolve(iconvlite.decode(Buffer.from(data), charset)); - return; - } - - if (encoding === 'UUENCODE') { - const parts = data.toString().split('\n'); // remove newline characters - const merged = parts.splice(1, parts.length - 4).join(''); // remove excess lines and join lines with empty string - resolve(uuencode.decode(merged)); - return; - } - - // if it gets here, the encoding is not currently supported - reject(new Error('Unknown encoding ' + part.encoding)); + resolve(PartData.fromData(data, encoding)); }; const fetchOnError = (error: Error) => { diff --git a/packages/@n8n/imap/src/PartData.ts b/packages/@n8n/imap/src/PartData.ts new file mode 100644 index 0000000000..d4ad353a97 --- /dev/null +++ b/packages/@n8n/imap/src/PartData.ts @@ -0,0 +1,84 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import * as qp from 'quoted-printable'; +import * as iconvlite from 'iconv-lite'; +import * as utf8 from 'utf8'; +import * as uuencode from 'uuencode'; + +export abstract class PartData { + constructor(readonly buffer: Buffer) {} + + toString() { + return this.buffer.toString(); + } + + static fromData(data: string, encoding: string, charset?: string): PartData { + if (encoding === 'BASE64') { + return new Base64PartData(data); + } + + if (encoding === 'QUOTED-PRINTABLE') { + return new QuotedPrintablePartData(data, charset); + } + + if (encoding === '7BIT') { + return new SevenBitPartData(data); + } + + if (encoding === '8BIT' || encoding === 'BINARY') { + return new BinaryPartData(data, charset); + } + + if (encoding === 'UUENCODE') { + return new UuencodedPartData(data); + } + + // if it gets here, the encoding is not currently supported + throw new Error('Unknown encoding ' + encoding); + } +} + +export class Base64PartData extends PartData { + constructor(data: string) { + super(Buffer.from(data, 'base64')); + } +} + +export class QuotedPrintablePartData extends PartData { + constructor(data: string, charset?: string) { + const decoded = + charset?.toUpperCase() === 'UTF-8' ? utf8.decode(qp.decode(data)) : qp.decode(data); + super(Buffer.from(decoded)); + } +} + +export class SevenBitPartData extends PartData { + constructor(data: string) { + super(Buffer.from(data)); + } + + toString() { + return this.buffer.toString('ascii'); + } +} + +export class BinaryPartData extends PartData { + constructor( + data: string, + readonly charset: string = 'utf-8', + ) { + super(Buffer.from(data)); + } + + toString() { + return iconvlite.decode(this.buffer, this.charset); + } +} + +export class UuencodedPartData extends PartData { + constructor(data: string) { + const parts = data.split('\n'); // remove newline characters + const merged = parts.splice(1, parts.length - 4).join(''); // remove excess lines and join lines with empty string + const decoded = uuencode.decode(merged); + super(decoded); + } +} diff --git a/packages/@n8n/imap/test/PartData.test.ts b/packages/@n8n/imap/test/PartData.test.ts new file mode 100644 index 0000000000..67d81718f3 --- /dev/null +++ b/packages/@n8n/imap/test/PartData.test.ts @@ -0,0 +1,88 @@ +import { + PartData, + Base64PartData, + QuotedPrintablePartData, + SevenBitPartData, + BinaryPartData, + UuencodedPartData, +} from '../src/PartData'; + +describe('PartData', () => { + describe('fromData', () => { + it('should return an instance of Base64PartData when encoding is BASE64', () => { + const result = PartData.fromData('data', 'BASE64'); + expect(result).toBeInstanceOf(Base64PartData); + }); + + it('should return an instance of QuotedPrintablePartData when encoding is QUOTED-PRINTABLE', () => { + const result = PartData.fromData('data', 'QUOTED-PRINTABLE'); + expect(result).toBeInstanceOf(QuotedPrintablePartData); + }); + + it('should return an instance of SevenBitPartData when encoding is 7BIT', () => { + const result = PartData.fromData('data', '7BIT'); + expect(result).toBeInstanceOf(SevenBitPartData); + }); + + it('should return an instance of BinaryPartData when encoding is 8BIT or BINARY', () => { + let result = PartData.fromData('data', '8BIT'); + expect(result).toBeInstanceOf(BinaryPartData); + result = PartData.fromData('data', 'BINARY'); + expect(result).toBeInstanceOf(BinaryPartData); + }); + + it('should return an instance of UuencodedPartData when encoding is UUENCODE', () => { + const result = PartData.fromData('data', 'UUENCODE'); + expect(result).toBeInstanceOf(UuencodedPartData); + }); + + it('should throw an error when encoding is not supported', () => { + expect(() => PartData.fromData('data', 'UNSUPPORTED')).toThrow( + 'Unknown encoding UNSUPPORTED', + ); + }); + }); +}); + +describe('Base64PartData', () => { + it('should correctly decode base64 data', () => { + const data = Buffer.from('Hello, world!', 'utf-8').toString('base64'); + const partData = new Base64PartData(data); + expect(partData.toString()).toBe('Hello, world!'); + }); +}); + +describe('QuotedPrintablePartData', () => { + it('should correctly decode quoted-printable data', () => { + const data = '=48=65=6C=6C=6F=2C=20=77=6F=72=6C=64=21'; // 'Hello, world!' in quoted-printable + const partData = new QuotedPrintablePartData(data); + expect(partData.toString()).toBe('Hello, world!'); + }); +}); + +describe('SevenBitPartData', () => { + it('should correctly decode 7bit data', () => { + const data = 'Hello, world!'; + const partData = new SevenBitPartData(data); + expect(partData.toString()).toBe('Hello, world!'); + }); +}); + +describe('BinaryPartData', () => { + it('should correctly decode binary data', () => { + const data = Buffer.from('Hello, world!', 'utf-8').toString(); + const partData = new BinaryPartData(data); + expect(partData.toString()).toBe('Hello, world!'); + }); +}); + +describe('UuencodedPartData', () => { + it('should correctly decode uuencoded data', () => { + const data = Buffer.from( + 'YmVnaW4gNjQ0IGRhdGEKLTImNUw7JlxMKCc9TzxGUUQoMGBgCmAKZW5kCg==', + 'base64', + ).toString('binary'); + const partData = new UuencodedPartData(data); + expect(partData.toString()).toBe('Hello, world!'); + }); +}); diff --git a/packages/nodes-base/nodes/EmailReadImap/v1/EmailReadImapV1.node.ts b/packages/nodes-base/nodes/EmailReadImap/v1/EmailReadImapV1.node.ts index 8df78cce8c..a7e321d1fd 100644 --- a/packages/nodes-base/nodes/EmailReadImap/v1/EmailReadImapV1.node.ts +++ b/packages/nodes-base/nodes/EmailReadImap/v1/EmailReadImapV1.node.ts @@ -285,7 +285,7 @@ export class EmailReadImapV1 implements INodeType { // Returns the email text - const getText = async (parts: any[], message: Message, subtype: string) => { + const getText = async (parts: any[], message: Message, subtype: string): Promise => { if (!message.attributes.struct) { return ''; } @@ -296,12 +296,14 @@ export class EmailReadImapV1 implements INodeType { ); }); - if (textParts.length === 0) { + const part = textParts[0]; + if (!part) { return ''; } try { - return await connection.getPartData(message, textParts[0]); + const partData = await connection.getPartData(message, part); + return partData.toString(); } catch { return ''; } @@ -330,7 +332,7 @@ export class EmailReadImapV1 implements INodeType { .then(async (partData) => { // Return it in the format n8n expects return await this.helpers.prepareBinaryData( - Buffer.from(partData), + partData.buffer, attachmentPart.disposition.params.filename as string, ); }); diff --git a/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts b/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts index 250615fb11..2d0cee5f5f 100644 --- a/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts +++ b/packages/nodes-base/nodes/EmailReadImap/v2/EmailReadImapV2.node.ts @@ -298,7 +298,11 @@ export class EmailReadImapV2 implements INodeType { // Returns the email text - const getText = async (parts: MessagePart[], message: Message, subtype: string) => { + const getText = async ( + parts: MessagePart[], + message: Message, + subtype: string, + ): Promise => { if (!message.attributes.struct) { return ''; } @@ -309,12 +313,14 @@ export class EmailReadImapV2 implements INodeType { ); }); - if (textParts.length === 0) { + const part = textParts[0]; + if (!part) { return ''; } try { - return await connection.getPartData(message, textParts[0]); + const partData = await connection.getPartData(message, part); + return partData.toString(); } catch { return ''; } @@ -355,7 +361,7 @@ export class EmailReadImapV2 implements INodeType { ?.filename as string, ); // Return it in the format n8n expects - return await this.helpers.prepareBinaryData(Buffer.from(partData), fileName); + return await this.helpers.prepareBinaryData(partData.buffer, fileName); }); attachmentPromises.push(attachmentPromise); diff --git a/patches/@types__uuencode@0.0.3.patch b/patches/@types__uuencode@0.0.3.patch new file mode 100644 index 0000000000..fbb1abfe43 --- /dev/null +++ b/patches/@types__uuencode@0.0.3.patch @@ -0,0 +1,10 @@ +diff --git a/index.d.ts b/index.d.ts +index f8f89c567f394a538018bfdf11c28dc15e9c9fdc..f3d1cd426711f1f714744474604bd7e321073983 100644 +--- a/index.d.ts ++++ b/index.d.ts +@@ -1,4 +1,4 @@ + /// + +-export function decode(str: string | Buffer): string; ++export function decode(str: string | Buffer): Buffer; + export function encode(str: string | Buffer): string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 644ea2c175..6b3427c96b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,9 @@ patchedDependencies: '@types/express-serve-static-core@4.17.43': hash: 5orrj4qleu2iko5t27vl44u4we path: patches/@types__express-serve-static-core@4.17.43.patch + '@types/uuencode@0.0.3': + hash: 3i7wecddkama6vhpu5o37g24u4 + path: patches/@types__uuencode@0.0.3.patch '@types/ws@8.5.4': hash: nbzuqaoyqbrfwipijj5qriqqju path: patches/@types__ws@8.5.4.patch @@ -227,7 +230,7 @@ importers: version: 3.0.3 '@types/uuencode': specifier: ^0.0.3 - version: 0.0.3 + version: 0.0.3(patch_hash=3i7wecddkama6vhpu5o37g24u4) packages/@n8n/nodes-langchain: dependencies: @@ -9258,7 +9261,7 @@ packages: ts-dedent: 2.2.0 type-fest: 2.19.0 vue: 3.4.21(typescript@5.4.2) - vue-component-type-helpers: 2.0.17 + vue-component-type-helpers: 2.0.18 transitivePeerDependencies: - encoding - supports-color @@ -10161,11 +10164,12 @@ packages: resolution: {integrity: sha512-+lqLGxWZsEe4Z6OrzBI7Ym4SMUTaMS5yOrHZ0/IL0bpIye1Qbs4PpobJL2mLDbftUXlPFZR7fu6d1yM+bHLX1w==} dev: true - /@types/uuencode@0.0.3: + /@types/uuencode@0.0.3(patch_hash=3i7wecddkama6vhpu5o37g24u4): resolution: {integrity: sha512-NaBWHPPQvcXqiSaMAGa2Ea/XaFcK/nHwGe2akwJBXRLkCNa2+izx/F1aKJrzFH+L68D88VLYIATTYP7B2k4zVA==} dependencies: '@types/node': 18.16.16 dev: true + patched: true /@types/uuid@8.3.4: resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} @@ -24340,8 +24344,8 @@ packages: resolution: {integrity: sha512-0vOfAtI67UjeO1G6UiX5Kd76CqaQ67wrRZiOe7UAb9Jm6GzlUr/fC7CV90XfwapJRjpCMaZFhv1V0ajWRmE9Dg==} dev: true - /vue-component-type-helpers@2.0.17: - resolution: {integrity: sha512-2car49m8ciqg/JjgMBkx7o/Fd2A7fHESxNqL/2vJYFLXm4VwYO4yH0rexOi4a35vwNgDyvt17B07Vj126l9rAQ==} + /vue-component-type-helpers@2.0.18: + resolution: {integrity: sha512-zi1QaDBhSb3oeHJh55aTCrosFNKEQsOL9j3XCAjpF9dwxDUUtd85RkJVzO+YpJqy1LNoCWLU8gwuZ7HW2iDN/A==} dev: true /vue-demi@0.14.5(vue@3.4.21): From 14fe9f268feeb0ca106ddaaa94c69cb356011524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 15 May 2024 17:35:30 +0200 Subject: [PATCH 14/58] fix(editor): Fix blank Public API page (#9409) --- packages/editor-ui/src/components/SettingsSidebar.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/editor-ui/src/components/SettingsSidebar.vue b/packages/editor-ui/src/components/SettingsSidebar.vue index 620f3e5e9a..1b341183a0 100644 --- a/packages/editor-ui/src/components/SettingsSidebar.vue +++ b/packages/editor-ui/src/components/SettingsSidebar.vue @@ -165,7 +165,9 @@ export default defineComponent({ return this.canUserAccessRouteByName(VIEWS.COMMUNITY_NODES); }, canAccessApiSettings(): boolean { - return this.canUserAccessRouteByName(VIEWS.API_SETTINGS); + return ( + this.settingsStore.isPublicApiEnabled && this.canUserAccessRouteByName(VIEWS.API_SETTINGS) + ); }, canAccessLdapSettings(): boolean { return this.canUserAccessRouteByName(VIEWS.LDAP_SETTINGS); From aad43d8cdcc9621fbd864fbe0235c9ff4ddbfe3e Mon Sep 17 00:00:00 2001 From: Giulio Andreini Date: Wed, 15 May 2024 17:57:21 +0200 Subject: [PATCH 15/58] fix(editor): Secondary button in dark mode (#9401) --- .../src/components/N8nButton/Button.scss | 12 +++++----- .../design-system/src/css/_primitives.scss | 19 ++++++++++++++++ .../design-system/src/css/_tokens.dark.scss | 22 ++++++++++++++++--- packages/design-system/src/css/_tokens.scss | 13 ++++++----- .../design-system/src/css/common/var.scss | 8 +++---- .../src/components/Node/NodeCreation.vue | 4 ++-- 6 files changed, 57 insertions(+), 21 deletions(-) diff --git a/packages/design-system/src/components/N8nButton/Button.scss b/packages/design-system/src/components/N8nButton/Button.scss index 7458dc6377..40ad0a7997 100644 --- a/packages/design-system/src/components/N8nButton/Button.scss +++ b/packages/design-system/src/components/N8nButton/Button.scss @@ -53,7 +53,7 @@ } } - &:focus { + &:focus:not(:active, .active) { color: $button-focus-font-color unquote($important); border-color: $button-focus-border-color unquote($important); background-color: $button-focus-background-color unquote($important); @@ -119,16 +119,16 @@ --button-background-color: var(--color-button-secondary-background); --button-hover-font-color: var(--color-button-secondary-hover-active-focus-font); - --button-hover-border-color: var(--color-button-secondary-hover-active-border); + --button-hover-border-color: var(--color-button-secondary-hover-active-focus-border); --button-hover-background-color: var(--color-button-secondary-hover-background); --button-active-font-color: var(--color-button-secondary-hover-active-focus-font); - --button-active-border-color: var(--color-button-secondary-hover-active-border); - --button-active-background-color: var(--color-button-secondary-active-background); + --button-active-border-color: var(--color-button-secondary-hover-active-focus-border); + --button-active-background-color: var(--color-button-secondary-active-focus-background); --button-focus-font-color: var(--color-button-secondary-hover-active-focus-font); - --button-focus-border-color: var(--color-button-secondary-border); - --button-focus-background-color: var(--color-button-secondary-background); + --button-focus-border-color: var(--color-button-secondary-hover-active-focus-border); + --button-focus-background-color: var(--color-button-secondary-active-focus-background); --button-focus-outline-color: var(--color-button-secondary-focus-outline); --button-disabled-font-color: var(--color-button-secondary-disabled-font); diff --git a/packages/design-system/src/css/_primitives.scss b/packages/design-system/src/css/_primitives.scss index fe02c267e3..57cc87f580 100644 --- a/packages/design-system/src/css/_primitives.scss +++ b/packages/design-system/src/css/_primitives.scss @@ -25,7 +25,9 @@ --prim-gray-25: hsl(var(--prim-gray-h), 50%, 97.5%); --prim-gray-10: hsl(var(--prim-gray-h), 50%, 99%); --prim-gray-0-alpha-075: hsla(var(--prim-gray-h), 50%, 100%, 0.75); + --prim-gray-0-alpha-030: hsla(var(--prim-gray-h), 50%, 100%, 0.3); --prim-gray-0-alpha-025: hsla(var(--prim-gray-h), 50%, 100%, 0.25); + --prim-gray-0-alpha-010: hsla(var(--prim-gray-h), 50%, 100%, 0.1); --prim-gray-0: hsl(var(--prim-gray-h), 50%, 100%); // Color Primary @@ -54,6 +56,18 @@ var(--prim-color-primary-l), 0.1 ); + --prim-color-primary-alpha-035: hsla( + var(--prim-color-primary-h), + var(--prim-color-primary-s), + var(--prim-color-primary-l), + 0.35 + ); + --prim-color-primary-alpha-050: hsla( + var(--prim-color-primary-h), + var(--prim-color-primary-s), + var(--prim-color-primary-l), + 0.5 + ); --prim-color-primary-tint-100: hsl( var(--prim-color-primary-h), var(--prim-color-primary-s), @@ -69,6 +83,11 @@ var(--prim-color-primary-s), calc(var(--prim-color-primary-l) + 25%) ); + --prim-color-primary-tint-270: hsl( + var(--prim-color-primary-h), + var(--prim-color-primary-s), + calc(var(--prim-color-primary-l) + 27%) + ); --prim-color-primary-tint-300: hsl( var(--prim-color-primary-h), var(--prim-color-primary-s), diff --git a/packages/design-system/src/css/_tokens.dark.scss b/packages/design-system/src/css/_tokens.dark.scss index 3380015000..cce44df6c6 100644 --- a/packages/design-system/src/css/_tokens.dark.scss +++ b/packages/design-system/src/css/_tokens.dark.scss @@ -22,6 +22,7 @@ --color-background-dark: var(--prim-gray-70); --color-background-medium: var(--prim-gray-540); --color-background-base: var(--prim-gray-670); + --color-background-light-base: var(--prim-gray-780); --color-background-light: var(--prim-gray-820); --color-background-xlight: var(--prim-gray-740); @@ -118,16 +119,31 @@ --color-variables-usage-syntax-bg: var(--prim-color-alt-a-alpha-025); // Button primary - --color-button-primary-disabled-font: var(--prim-gray-0-alpha-025); + --color-button-primary-focus-outline: var(--prim-color-primary-alpha-035); + --color-button-primary-disabled-font: var(--prim-gray-0-alpha-030); --color-button-primary-disabled-border: transparent; - --color-button-primary-disabled-background: var(--prim-color-primary-shade-300); + --color-button-primary-disabled-background: var(--prim-color-primary-alpha-050); // Button secondary - --color-button-secondary-border: var(--prim-gray-420); + --color-button-secondary-font: var(--prim-gray-70); + --color-button-secondary-border: var(--prim-gray-70); + --color-button-secondary-background: transparent; + --color-button-secondary-hover-active-focus-font: var(--prim-color-primary-tint-100); + --color-button-secondary-hover-background: transparent; + --color-button-secondary-active-focus-background: var(--prim-color-primary-alpha-010); + --color-button-secondary-focus-outline: var(--prim-color-primary-alpha-035); + --color-button-secondary-disabled-font: var(--prim-gray-0-alpha-030); + --color-button-secondary-disabled-border: var(--prim-gray-0-alpha-030); // Text button --color-text-button-secondary-font: var(--prim-gray-320); + // Node Creator Button + --color-button-node-creator-border-font: var(--color-button-secondary-font); + --color-button-node-creator-hover-font: var(--color-button-secondary-hover-active-focus-font); + --color-button-node-creator-hover-border: var(--prim-color-primary); + --color-button-node-creator-background: var(--prim-color-primary-alpha-010); + // Table --color-table-header-background: var(--prim-gray-740); --color-table-row-background: var(--prim-gray-820); diff --git a/packages/design-system/src/css/_tokens.scss b/packages/design-system/src/css/_tokens.scss index ab3797781d..7278517474 100644 --- a/packages/design-system/src/css/_tokens.scss +++ b/packages/design-system/src/css/_tokens.scss @@ -156,8 +156,8 @@ --color-button-primary-border: var(--prim-color-primary); --color-button-primary-background: var(--prim-color-primary); --color-button-primary-hover-active-border: var(--prim-color-primary-shade-100); - --color-button-primary-hover-active-background: var(--prim-color-primary-shade-100); - --color-button-primary-focus-outline: var(--prim-color-primary-tint-200); + --color-button-primary-hover-active-focus-background: var(--prim-color-primary-shade-100); + --color-button-primary-focus-outline: var(--prim-color-primary-alpha-035); --color-button-primary-disabled-font: var(--prim-gray-0-alpha-075); --color-button-primary-disabled-border: var(--prim-color-primary-tint-200); --color-button-primary-disabled-background: var(--prim-color-primary-tint-200); @@ -166,10 +166,10 @@ --color-button-secondary-font: var(--prim-gray-670); --color-button-secondary-border: var(--prim-gray-320); --color-button-secondary-background: var(--prim-gray-0); - --color-button-secondary-hover-active-focus-font: var(--prim-color-primary); - --color-button-secondary-hover-active-border: var(--prim-color-primary); + --color-button-secondary-hover-active-focus-font: var(--prim-color-primary-shade-100); + --color-button-secondary-hover-active-focus-border: var(--prim-color-primary); --color-button-secondary-hover-background: var(--prim-color-primary-tint-300); - --color-button-secondary-active-background: var(--prim-color-primary-tint-250); + --color-button-secondary-active-focus-background: var(--prim-color-primary-tint-270); --color-button-secondary-focus-outline: var(--prim-gray-120); --color-button-secondary-disabled-font: var(--prim-gray-200); --color-button-secondary-disabled-border: var(--prim-gray-200); @@ -187,7 +187,8 @@ // Node Creator Button --color-button-node-creator-border-font: var(--prim-gray-540); - --color-button-node-creator-hover-border-font: var(--prim-color-primary); + --color-button-node-creator-hover-font: var(--prim-color-primary); + --color-button-node-creator-hover-border: var(--prim-color-primary); --color-button-node-creator-background: var(--prim-gray-0); // Table diff --git a/packages/design-system/src/css/common/var.scss b/packages/design-system/src/css/common/var.scss index b138ec33b9..aa1fddd842 100644 --- a/packages/design-system/src/css/common/var.scss +++ b/packages/design-system/src/css/common/var.scss @@ -70,7 +70,7 @@ $border-radius-circle: 100%; /* Outline -------------------------- */ -$focus-outline-width: 2px; +$focus-outline-width: 3px; /* Box shadow -------------------------- */ @@ -551,7 +551,7 @@ $button-hover-border-color: var( ); $button-hover-background-color: var( --button-hover-background-color, - var(--color-button-primary-hover-active-background) + var(--color-button-primary-hover-active-focus-background) ); // Active @@ -562,7 +562,7 @@ $button-active-border-color: var( ); $button-active-background-color: var( --button-active-background-color, - var(--color-button-primary-hover-active-background) + var(--color-button-primary-hover-active-focus-background) ); // Focus @@ -570,7 +570,7 @@ $button-focus-font-color: var(--button-focus-font-color, var(--color-button-prim $button-focus-border-color: var(--button-focus-border-color, var(--color-button-primary-border)); $button-focus-background-color: var( --button-focus-background-color, - var(--color-button-primary-background) + var(--color-button-primary-hover-active-focus-background) ); $button-focus-outline-color: var( --button-focus-outline-color, diff --git a/packages/editor-ui/src/components/Node/NodeCreation.vue b/packages/editor-ui/src/components/Node/NodeCreation.vue index a30cd243de..88038a18f7 100644 --- a/packages/editor-ui/src/components/Node/NodeCreation.vue +++ b/packages/editor-ui/src/components/Node/NodeCreation.vue @@ -182,8 +182,8 @@ function nodeTypeSelected(nodeTypes: string[]) { color: var(--color-button-node-creator-border-font); &:hover { - border-color: var(--color-button-node-creator-hover-border-font); - color: var(--color-button-node-creator-hover-border-font); + color: var(--color-button-node-creator-hover-font); + border-color: var(--color-button-node-creator-hover-border); background: var(--color-button-node-creator-background); } } From 38b498e73a71a9ca8b10a89e498aa8330acf2626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 15 May 2024 18:04:17 +0200 Subject: [PATCH 16/58] fix(editor): Fix outdated roles in variables labels (#9411) --- packages/editor-ui/src/components/VariablesRow.vue | 4 ++-- packages/editor-ui/src/plugins/i18n/locales/en.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/editor-ui/src/components/VariablesRow.vue b/packages/editor-ui/src/components/VariablesRow.vue index e39ddcc014..4e66fbe96e 100644 --- a/packages/editor-ui/src/components/VariablesRow.vue +++ b/packages/editor-ui/src/components/VariablesRow.vue @@ -214,7 +214,7 @@ function focusFirstInput() { @@ -229,7 +229,7 @@ function focusFirstInput() { diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 0135bca4c1..43ef7c1efa 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -2196,9 +2196,9 @@ "variables.row.button.save": "Save", "variables.row.button.cancel": "Cancel", "variables.row.button.edit": "Edit", - "variables.row.button.edit.onlyOwnerCanSave": "Only owner can edit variables", + "variables.row.button.edit.onlyRoleCanEdit": "Only instance owner and admins can edit variables", "variables.row.button.delete": "Delete", - "variables.row.button.delete.onlyOwnerCanDelete": "Only owner can delete variables", + "variables.row.button.delete.onlyRoleCanDelete": "Only instance owner and can delete variables", "variables.row.usage.copiedToClipboard": "Copied to clipboard", "variables.row.usage.copyToClipboard": "Copy to clipboard", "variables.search.placeholder": "Search variables...", From 0d7358807b4244be574060726388bd49fc90dc64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Thu, 16 May 2024 10:45:58 +0200 Subject: [PATCH 17/58] fix(core): Add an option to disable STARTTLS for SMTP connections (#9415) --- packages/cli/src/UserManagement/email/NodeMailer.ts | 1 + packages/cli/src/config/schema.ts | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/packages/cli/src/UserManagement/email/NodeMailer.ts b/packages/cli/src/UserManagement/email/NodeMailer.ts index 824a6801f4..f6eedd80ac 100644 --- a/packages/cli/src/UserManagement/email/NodeMailer.ts +++ b/packages/cli/src/UserManagement/email/NodeMailer.ts @@ -19,6 +19,7 @@ export class NodeMailer { host: config.getEnv('userManagement.emails.smtp.host'), port: config.getEnv('userManagement.emails.smtp.port'), secure: config.getEnv('userManagement.emails.smtp.secure'), + ignoreTLS: !config.getEnv('userManagement.emails.smtp.startTLS'), }; if ( diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 15e746f2d9..1ea7c57f00 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -816,6 +816,12 @@ export const schema = { default: true, env: 'N8N_SMTP_SSL', }, + startTLS: { + doc: 'Whether or not to use STARTTLS for SMTP when SSL is disabled', + format: Boolean, + default: true, + env: 'N8N_SMTP_STARTTLS', + }, auth: { user: { doc: 'SMTP login username', From c9855e3dce42f8830636914458d1061668a466a8 Mon Sep 17 00:00:00 2001 From: Romain MARTINEAU Date: Thu, 16 May 2024 10:46:15 +0200 Subject: [PATCH 18/58] fix(core): Handle credential in body for oauth2 refresh token (#9179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ --- .../client-oauth2/src/ClientOAuth2Token.ts | 26 ++++++--- .../test/CredentialsFlow.test.ts | 58 ++++++++++++++++++- 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/packages/@n8n/client-oauth2/src/ClientOAuth2Token.ts b/packages/@n8n/client-oauth2/src/ClientOAuth2Token.ts index 9b696dff22..505bd7c982 100644 --- a/packages/@n8n/client-oauth2/src/ClientOAuth2Token.ts +++ b/packages/@n8n/client-oauth2/src/ClientOAuth2Token.ts @@ -10,6 +10,7 @@ export interface ClientOAuth2TokenData extends Record = { + refresh_token: this.refreshToken, + grant_type: 'refresh_token', + }; + + if (options.authentication === 'body') { + body.client_id = clientId; + body.client_secret = clientSecret; + } else { + headers.Authorization = auth(clientId, clientSecret); + } + const requestOptions = getRequestOptions( { url: options.accessTokenUri, method: 'POST', - headers: { - ...DEFAULT_HEADERS, - Authorization: auth(options.clientId, options.clientSecret), - }, - body: { - refresh_token: this.refreshToken, - grant_type: 'refresh_token', - }, + headers, + body, }, options, ); diff --git a/packages/@n8n/client-oauth2/test/CredentialsFlow.test.ts b/packages/@n8n/client-oauth2/test/CredentialsFlow.test.ts index 9e0749800d..39978c41f8 100644 --- a/packages/@n8n/client-oauth2/test/CredentialsFlow.test.ts +++ b/packages/@n8n/client-oauth2/test/CredentialsFlow.test.ts @@ -130,8 +130,8 @@ describe('CredentialsFlow', () => { }); describe('#refresh', () => { - const mockRefreshCall = () => - nock(config.baseUrl) + const mockRefreshCall = async () => { + const nockScope = nock(config.baseUrl) .post( '/login/oauth/access_token', ({ refresh_token, grant_type }) => @@ -142,6 +142,15 @@ describe('CredentialsFlow', () => { access_token: config.refreshedAccessToken, refresh_token: config.refreshedRefreshToken, }); + return await new Promise<{ headers: Headers; body: unknown }>((resolve) => { + nockScope.once('request', (req) => { + resolve({ + headers: req.headers, + body: req.requestBodyBuffers.toString('utf-8'), + }); + }); + }); + }; it('should make a request to get a new access token', async () => { const authClient = createAuthClient({ scopes: ['notifications'] }); @@ -150,12 +159,55 @@ describe('CredentialsFlow', () => { const token = await authClient.credentials.getToken(); expect(token.accessToken).toEqual(config.accessToken); - mockRefreshCall(); + const requestPromise = mockRefreshCall(); const token1 = await token.refresh(); + await requestPromise; + expect(token1).toBeInstanceOf(ClientOAuth2Token); expect(token1.accessToken).toEqual(config.refreshedAccessToken); expect(token1.tokenType).toEqual('bearer'); }); + + it('should make a request to get a new access token with authentication = "body"', async () => { + const authClient = createAuthClient({ scopes: ['notifications'], authentication: 'body' }); + void mockTokenCall({ requestedScope: 'notifications' }); + + const token = await authClient.credentials.getToken(); + expect(token.accessToken).toEqual(config.accessToken); + + const requestPromise = mockRefreshCall(); + const token1 = await token.refresh(); + const { headers, body } = await requestPromise; + + expect(token1).toBeInstanceOf(ClientOAuth2Token); + expect(token1.accessToken).toEqual(config.refreshedAccessToken); + expect(token1.tokenType).toEqual('bearer'); + expect(headers?.authorization).toBe(undefined); + expect(body).toEqual( + 'refresh_token=def456token&grant_type=refresh_token&client_id=abc&client_secret=123', + ); + }); + + it('should make a request to get a new access token with authentication = "header"', async () => { + const authClient = createAuthClient({ + scopes: ['notifications'], + authentication: 'header', + }); + void mockTokenCall({ requestedScope: 'notifications' }); + + const token = await authClient.credentials.getToken(); + expect(token.accessToken).toEqual(config.accessToken); + + const requestPromise = mockRefreshCall(); + const token1 = await token.refresh(); + const { headers, body } = await requestPromise; + + expect(token1).toBeInstanceOf(ClientOAuth2Token); + expect(token1.accessToken).toEqual(config.refreshedAccessToken); + expect(token1.tokenType).toEqual('bearer'); + expect(headers?.authorization).toBe('Basic YWJjOjEyMw=='); + expect(body).toEqual('refresh_token=def456token&grant_type=refresh_token'); + }); }); }); }); From 5a3122f2796f51b9d59724b75f9d424de0d0558a Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Thu, 16 May 2024 12:35:36 +0300 Subject: [PATCH 19/58] fix: PairedItems various fixes (no-changelog) (#9357) --- .../nodes/FileMaker/FileMaker.node.ts | 31 ++++++++++++------- .../v2/actions/sheet/append.operation.ts | 5 ++- .../actions/sheet/appendOrUpdate.operation.ts | 5 ++- .../v2/actions/sheet/update.operation.ts | 5 ++- .../nodes-base/nodes/Merge/v2/MergeV2.node.ts | 12 +++---- 5 files changed, 37 insertions(+), 21 deletions(-) diff --git a/packages/nodes-base/nodes/FileMaker/FileMaker.node.ts b/packages/nodes-base/nodes/FileMaker/FileMaker.node.ts index 29352a9425..245dba6b8b 100644 --- a/packages/nodes-base/nodes/FileMaker/FileMaker.node.ts +++ b/packages/nodes-base/nodes/FileMaker/FileMaker.node.ts @@ -692,8 +692,8 @@ export class FileMaker implements INodeType { const action = this.getNodeParameter('action', 0) as string; - try { - for (let i = 0; i < items.length; i++) { + for (let i = 0; i < items.length; i++) { + try { // Reset all values requestOptions = { uri: '', @@ -807,17 +807,24 @@ export class FileMaker implements INodeType { { itemIndex: i }, ); } - returnData.push({ json: response }); - } - } catch (error) { - if (error.node) { - throw error; - } + returnData.push({ json: response, pairedItem: { item: i } }); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: error.message }, + pairedItem: { item: i }, + }); + } else { + if (error.node) { + throw error; + } - throw new NodeOperationError( - this.getNode(), - `The action "${error.message}" is not implemented yet!`, - ); + throw new NodeOperationError( + this.getNode(), + `The action "${error.message}" is not implemented yet!`, + ); + } + } } await logout.call(this, token as string); diff --git a/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/append.operation.ts b/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/append.operation.ts index 5e1d0a810d..e5084f6ec0 100644 --- a/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/append.operation.ts +++ b/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/append.operation.ts @@ -260,7 +260,10 @@ export async function execute( } if (nodeVersion < 4 || dataMode === 'autoMapInputData') { - return items; + return items.map((item, index) => { + item.pairedItem = { item: index }; + return item; + }); } else { const returnData: INodeExecutionData[] = []; for (const [index, entry] of setData.entries()) { diff --git a/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/appendOrUpdate.operation.ts b/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/appendOrUpdate.operation.ts index ea68b5b954..b39dc758a8 100644 --- a/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/appendOrUpdate.operation.ts +++ b/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/appendOrUpdate.operation.ts @@ -430,7 +430,10 @@ export async function execute( } if (nodeVersion < 4 || dataMode === 'autoMapInputData') { - return items; + return items.map((item, index) => { + item.pairedItem = { item: index }; + return item; + }); } else { const returnData: INodeExecutionData[] = []; for (const [index, entry] of mappedValues.entries()) { diff --git a/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/update.operation.ts b/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/update.operation.ts index 658d8ffcdd..9799bd0c4f 100644 --- a/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/update.operation.ts +++ b/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/update.operation.ts @@ -404,7 +404,10 @@ export async function execute( } if (nodeVersion < 4 || dataMode === 'autoMapInputData') { - return items; + return items.map((item, index) => { + item.pairedItem = { item: index }; + return item; + }); } else { if (!updateData.length) { return []; diff --git a/packages/nodes-base/nodes/Merge/v2/MergeV2.node.ts b/packages/nodes-base/nodes/Merge/v2/MergeV2.node.ts index ffdfcfe545..1527691082 100644 --- a/packages/nodes-base/nodes/Merge/v2/MergeV2.node.ts +++ b/packages/nodes-base/nodes/Merge/v2/MergeV2.node.ts @@ -368,7 +368,7 @@ export class MergeV2 implements INodeType { let input1 = this.getInputData(0); let input2 = this.getInputData(1); - if (input1.length === 0 || input2.length === 0) { + if (input1?.length === 0 || input2?.length === 0) { // If data of any input is missing, return the data of // the input that contains data return [[...input1, ...input2]]; @@ -474,19 +474,19 @@ export class MergeV2 implements INodeType { if (!input1) return [returnData]; } - if (input1.length === 0 || input2.length === 0) { - if (!input1.length && joinMode === 'keepNonMatches' && outputDataFrom === 'input1') + if (input1?.length === 0 || input2?.length === 0) { + if (!input1?.length && joinMode === 'keepNonMatches' && outputDataFrom === 'input1') return [returnData]; - if (!input2.length && joinMode === 'keepNonMatches' && outputDataFrom === 'input2') + if (!input2?.length && joinMode === 'keepNonMatches' && outputDataFrom === 'input2') return [returnData]; if (joinMode === 'keepMatches') { // Stop the execution return [[]]; - } else if (joinMode === 'enrichInput1' && input1.length === 0) { + } else if (joinMode === 'enrichInput1' && input1?.length === 0) { // No data to enrich so stop return [[]]; - } else if (joinMode === 'enrichInput2' && input2.length === 0) { + } else if (joinMode === 'enrichInput2' && input2?.length === 0) { // No data to enrich so stop return [[]]; } else { From 92a1d65c4b00683cc334c70f183e5f8c99bfae65 Mon Sep 17 00:00:00 2001 From: Jon Date: Thu, 16 May 2024 11:33:35 +0100 Subject: [PATCH 20/58] fix(Microsoft OneDrive Trigger Node): Fix issue with test run failing (#9386) --- .../OneDrive/MicrosoftOneDriveTrigger.node.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/nodes-base/nodes/Microsoft/OneDrive/MicrosoftOneDriveTrigger.node.ts b/packages/nodes-base/nodes/Microsoft/OneDrive/MicrosoftOneDriveTrigger.node.ts index 111b7b5146..789447f213 100644 --- a/packages/nodes-base/nodes/Microsoft/OneDrive/MicrosoftOneDriveTrigger.node.ts +++ b/packages/nodes-base/nodes/Microsoft/OneDrive/MicrosoftOneDriveTrigger.node.ts @@ -123,9 +123,11 @@ export class MicrosoftOneDriveTrigger implements INodeType { }) as string ).replace('%21', '!'); const folderPath = await getPath.call(this, folderId); - responseData = responseData.filter((item: IDataObject) => - ((item.parentReference as IDataObject).path as string).startsWith(folderPath), - ); + + responseData = responseData.filter((item: IDataObject) => { + const path = (item.parentReference as IDataObject)?.path as string; + return typeof path === 'string' && path.startsWith(folderPath); + }); } responseData = responseData.filter((item: IDataObject) => item[eventResource]); if (!responseData?.length) { @@ -146,11 +148,7 @@ export class MicrosoftOneDriveTrigger implements INodeType { })); } - if (this.getMode() === 'manual') { - return [this.helpers.returnJsonArray(responseData[0])]; - } else { - return [this.helpers.returnJsonArray(responseData)]; - } + return [this.helpers.returnJsonArray(responseData)]; } catch (error) { if (this.getMode() === 'manual' || !workflowData.lastTimeChecked) { throw error; From 1377e212c709bc9ca6586c030ec083e89a3d8c37 Mon Sep 17 00:00:00 2001 From: Jon Date: Thu, 16 May 2024 13:35:03 +0100 Subject: [PATCH 21/58] fix(Mattermost Node): Change loadOptions to fetch all items (#9413) --- .../Mattermost/v1/methods/loadOptions.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/nodes-base/nodes/Mattermost/v1/methods/loadOptions.ts b/packages/nodes-base/nodes/Mattermost/v1/methods/loadOptions.ts index e52d0f2aef..e0f24e2537 100644 --- a/packages/nodes-base/nodes/Mattermost/v1/methods/loadOptions.ts +++ b/packages/nodes-base/nodes/Mattermost/v1/methods/loadOptions.ts @@ -1,12 +1,12 @@ import type { IDataObject, ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow'; -import { apiRequest } from '../transport'; +import { apiRequestAllItems } from '../transport'; // Get all the available channels export async function getChannels(this: ILoadOptionsFunctions): Promise { const endpoint = 'channels'; - const responseData = await apiRequest.call(this, 'GET', endpoint, {}); + const responseData = await apiRequestAllItems.call(this, 'GET', endpoint, {}); if (responseData === undefined) { throw new NodeOperationError(this.getNode(), 'No data got returned'); @@ -25,7 +25,7 @@ export async function getChannels(this: ILoadOptionsFunctions): Promise { const teamId = this.getCurrentNodeParameter('teamId'); const endpoint = `users/me/teams/${teamId}/channels`; - const responseData = await apiRequest.call(this, 'GET', endpoint, {}); + const responseData = await apiRequestAllItems.call(this, 'GET', endpoint, {}); if (responseData === undefined) { throw new NodeOperationError(this.getNode(), 'No data got returned'); @@ -72,7 +72,7 @@ export async function getChannelsInTeam( returnData.push({ name, - value: data.id, + value: data.id as string, }); } @@ -91,7 +91,7 @@ export async function getChannelsInTeam( export async function getTeams(this: ILoadOptionsFunctions): Promise { const endpoint = 'users/me/teams'; - const responseData = await apiRequest.call(this, 'GET', endpoint, {}); + const responseData = await apiRequestAllItems.call(this, 'GET', endpoint, {}); if (responseData === undefined) { throw new NodeOperationError(this.getNode(), 'No data got returned'); @@ -108,7 +108,7 @@ export async function getTeams(this: ILoadOptionsFunctions): Promise { const endpoint = 'users'; - const responseData = await apiRequest.call(this, 'GET', endpoint, {}); + const responseData = await apiRequestAllItems.call(this, 'GET', endpoint, {}); if (responseData === undefined) { throw new NodeOperationError(this.getNode(), 'No data got returned'); @@ -140,8 +140,8 @@ export async function getUsers(this: ILoadOptionsFunctions): Promise Date: Thu, 16 May 2024 13:38:15 +0100 Subject: [PATCH 22/58] fix(HubSpot Trigger Node): Fix issue with ticketId not being set (#9403) --- packages/nodes-base/nodes/Hubspot/HubspotTrigger.node.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/nodes-base/nodes/Hubspot/HubspotTrigger.node.ts b/packages/nodes-base/nodes/Hubspot/HubspotTrigger.node.ts index 63bd7905fa..aed2bc704d 100644 --- a/packages/nodes-base/nodes/Hubspot/HubspotTrigger.node.ts +++ b/packages/nodes-base/nodes/Hubspot/HubspotTrigger.node.ts @@ -447,6 +447,9 @@ export class HubspotTrigger implements INodeType { if (subscriptionType.includes('deal')) { bodyData[i].dealId = bodyData[i].objectId; } + if (subscriptionType.includes('ticket')) { + bodyData[i].ticketId = bodyData[i].objectId; + } delete bodyData[i].objectId; } return { From f13dbc9cc31fba20b4cb0bedf11e56e16079f946 Mon Sep 17 00:00:00 2001 From: Jon Date: Thu, 16 May 2024 13:38:40 +0100 Subject: [PATCH 23/58] feat(Extract from File Node): Add option to set encoding for CSV files (#9392) --- .../actions/spreadsheet.operation.ts | 4 +++- .../nodes/SpreadsheetFile/description.ts | 20 +++++++++++++++++++ .../SpreadsheetFile/v2/fromFile.operation.ts | 1 + 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/Files/ExtractFromFile/actions/spreadsheet.operation.ts b/packages/nodes-base/nodes/Files/ExtractFromFile/actions/spreadsheet.operation.ts index 6153037438..bebda26d16 100644 --- a/packages/nodes-base/nodes/Files/ExtractFromFile/actions/spreadsheet.operation.ts +++ b/packages/nodes-base/nodes/Files/ExtractFromFile/actions/spreadsheet.operation.ts @@ -17,7 +17,9 @@ export const description: INodeProperties[] = fromFile.description if (newProperty.name === 'options') { newProperty.options = (newProperty.options as INodeProperties[]).map((option) => { let newOption = option; - if (['delimiter', 'fromLine', 'maxRowCount', 'enableBOM'].includes(option.name)) { + if ( + ['delimiter', 'encoding', 'fromLine', 'maxRowCount', 'enableBOM'].includes(option.name) + ) { newOption = { ...option, displayOptions: { show: { '/operation': ['csv'] } } }; } if (option.name === 'sheetName') { diff --git a/packages/nodes-base/nodes/SpreadsheetFile/description.ts b/packages/nodes-base/nodes/SpreadsheetFile/description.ts index 78c8952b90..aac07f44d8 100644 --- a/packages/nodes-base/nodes/SpreadsheetFile/description.ts +++ b/packages/nodes-base/nodes/SpreadsheetFile/description.ts @@ -177,6 +177,26 @@ export const fromFileOptions: INodeProperties = { placeholder: 'e.g. ,', description: 'Set the field delimiter, usually a comma', }, + { + displayName: 'Encoding', + name: 'encoding', + type: 'options', + displayOptions: { + show: { + '/fileFormat': ['csv'], + }, + }, + options: [ + { name: 'ASCII', value: 'ascii' }, + { name: 'Latin1', value: 'latin1' }, + { name: 'UCS-2', value: 'ucs-2' }, + { name: 'UCS2', value: 'ucs2' }, + { name: 'UTF-8', value: 'utf-8' }, + { name: 'UTF16LE', value: 'utf16le' }, + { name: 'UTF8', value: 'utf8' }, + ], + default: 'utf-8', + }, { displayName: 'Exclude Byte Order Mark (BOM)', name: 'enableBOM', diff --git a/packages/nodes-base/nodes/SpreadsheetFile/v2/fromFile.operation.ts b/packages/nodes-base/nodes/SpreadsheetFile/v2/fromFile.operation.ts index ffafaa952d..719faeb478 100644 --- a/packages/nodes-base/nodes/SpreadsheetFile/v2/fromFile.operation.ts +++ b/packages/nodes-base/nodes/SpreadsheetFile/v2/fromFile.operation.ts @@ -92,6 +92,7 @@ export async function execute( const parser = createCSVParser({ delimiter: options.delimiter as string, fromLine: options.fromLine as number, + encoding: options.encoding as BufferEncoding, bom: options.enableBOM as boolean, to: maxRowCount > -1 ? maxRowCount : undefined, columns: options.headerRow !== false, From 211823650ba298aac899ff944819290f0bd4654a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 16 May 2024 14:42:47 +0200 Subject: [PATCH 24/58] feat(editor): Expand supported Unicode range for expressions (#9420) --- .../codemirror-lang/src/expressions/README.md | 6 +++++ .../src/expressions/expressions.grammar | 2 +- .../src/expressions/grammar.ts | 3 +-- .../test/expressions/cases.txt | 24 +++++++++++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/@n8n/codemirror-lang/src/expressions/README.md b/packages/@n8n/codemirror-lang/src/expressions/README.md index a758f62325..53c9e5c100 100644 --- a/packages/@n8n/codemirror-lang/src/expressions/README.md +++ b/packages/@n8n/codemirror-lang/src/expressions/README.md @@ -24,3 +24,9 @@ export function n8nExpressionLanguageSupport() { return new LanguageSupport(n8nLanguage); } ``` + +## Supported Unicode ranges + +- From `Basic Latin` up to and including `Currency Symbols` +- `Miscellaneous Symbols and Pictographs` +- `CJK Unified Ideographs` diff --git a/packages/@n8n/codemirror-lang/src/expressions/expressions.grammar b/packages/@n8n/codemirror-lang/src/expressions/expressions.grammar index f2cf200f35..9217f2c2fb 100644 --- a/packages/@n8n/codemirror-lang/src/expressions/expressions.grammar +++ b/packages/@n8n/codemirror-lang/src/expressions/expressions.grammar @@ -15,7 +15,7 @@ entity { Plaintext | Resolvable } resolvableChar { unicodeChar | "}" ![}] | "\\}}" } - unicodeChar { $[\u0000-\u007C] | $[\u007E-\u1FFF] | $[\u20A0-\u20CF] | $[\u{1F300}-\u{1F64F}] } + unicodeChar { $[\u0000-\u007C] | $[\u007E-\u20CF] | $[\u{1F300}-\u{1F64F}] | $[\u4E00-\u9FFF] } } @detectDelim diff --git a/packages/@n8n/codemirror-lang/src/expressions/grammar.ts b/packages/@n8n/codemirror-lang/src/expressions/grammar.ts index d1233ba922..bd081b4832 100644 --- a/packages/@n8n/codemirror-lang/src/expressions/grammar.ts +++ b/packages/@n8n/codemirror-lang/src/expressions/grammar.ts @@ -1,6 +1,5 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. import { LRParser } from '@lezer/lr'; - export const parser = LRParser.deserialize({ version: 14, states: "nQQOPOOOOOO'#Cb'#CbOOOO'#C`'#C`QQOPOOOOOO-E6^-E6^", @@ -11,7 +10,7 @@ export const parser = LRParser.deserialize({ skippedNodes: [0], repeatNodeCount: 1, tokenData: - "&U~RTO#ob#o#p!h#p;'Sb;'S;=`!]<%lOb~gTQ~O#ob#o#pv#p;'Sb;'S;=`!]<%lOb~yUO#ob#p;'Sb;'S;=`!]<%l~b~Ob~~!c~!`P;=`<%lb~!hOQ~~!kVO#ob#o#p#Q#p;'Sb;'S;=`!]<%l~b~Ob~~!c~#TWO#O#Q#O#P#m#P#q#Q#q#r%Z#r$IS#Q$Lj$Ml#Q;(b;(c%x;(c;(d&O~#pWO#O#Q#O#P#m#P#q#Q#q#r$Y#r$IS#Q$Lj$Ml#Q;(b;(c%x;(c;(d&O~$]TO#q#Q#q#r$l#r;'S#Q;'S;=`%r<%lO#Q~$qWR~O#O#Q#O#P#m#P#q#Q#q#r%Z#r$IS#Q$Lj$Ml#Q;(b;(c%x;(c;(d&O~%^TO#q#Q#q#r%m#r;'S#Q;'S;=`%r<%lO#Q~%rOR~~%uP;=`<%l#Q~%{P;NQ<%l#Q~&RP;=`;JY#Q", + "&U~RTO#ob#o#p!h#p;'Sb;'S;=`!]<%lOb~gTQ~O#ob#o#pv#p;'Sb;'S;=`!]<%lOb~yUO#ob#p;'Sb;'S;=`!]<%l~b~Ob~~!c~!`P;=`<%lb~!hOQ~~!kVO#ob#o#p#Q#p;'Sb;'S;=`!]<%l~b~Ob~~!c~#TWO#O#Q#O#P#m#P#q#Q#q#r%Z#r$Ml#Q*5S41d#Q;(b;(c%x;(c;(d&O~#pWO#O#Q#O#P#m#P#q#Q#q#r$Y#r$Ml#Q*5S41d#Q;(b;(c%x;(c;(d&O~$]TO#q#Q#q#r$l#r;'S#Q;'S;=`%r<%lO#Q~$qWR~O#O#Q#O#P#m#P#q#Q#q#r%Z#r$Ml#Q*5S41d#Q;(b;(c%x;(c;(d&O~%^TO#q#Q#q#r%m#r;'S#Q;'S;=`%r<%lO#Q~%rOR~~%uP;=`<%l#Q~%{P;NQ<%l#Q~&RP;=`;JY#Q", tokenizers: [0], topRules: { Program: [0, 1] }, tokenPrec: 0, diff --git a/packages/@n8n/codemirror-lang/test/expressions/cases.txt b/packages/@n8n/codemirror-lang/test/expressions/cases.txt index eeb42610a2..36f41ddccd 100644 --- a/packages/@n8n/codemirror-lang/test/expressions/cases.txt +++ b/packages/@n8n/codemirror-lang/test/expressions/cases.txt @@ -253,3 +253,27 @@ Program(Resolvable) ==> Program(Resolvable) + +# Resolvable with general punctuation char + +{{ '†' }} + +==> + +Program(Resolvable) + +# Resolvable with superscript char + +{{ '⁷' }} + +==> + +Program(Resolvable) + +# Resolvable with CJK char + +{{ '漢' }} + +==> + +Program(Resolvable) From 40bce7f44332042bf8dba0442044acd76cc9bf21 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Thu, 16 May 2024 14:53:22 +0200 Subject: [PATCH 25/58] feat(editor): Add examples for Luxon DateTime expression methods (#9361) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Iván Ovejero --- .../completions/luxon.completions.ts | 257 ++-------- .../codemirror/completions/constants.ts | 2 +- .../completions/datatype.completions.ts | 7 +- .../luxon.instance.docs.ts | 452 ++++++++++++++++-- .../luxon.static.docs.ts | 292 +++++++++-- packages/editor-ui/src/plugins/i18n/index.ts | 125 ----- .../src/plugins/i18n/locales/en.json | 93 ++-- packages/workflow/src/Expression.ts | 9 +- .../workflow/src/Extensions/DateExtensions.ts | 308 ++++++++++-- .../src/Extensions/StringExtensions.ts | 2 +- .../DateExtensions.test.ts | 16 + 11 files changed, 1029 insertions(+), 534 deletions(-) diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/luxon.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/luxon.completions.ts index 920a6234b5..4935f220b3 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completions/luxon.completions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/luxon.completions.ts @@ -1,6 +1,9 @@ import { escape } from '../utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import { defineComponent } from 'vue'; +import { createInfoBoxRenderer } from '@/plugins/codemirror/completions/infoBoxRenderer'; +import { luxonStaticDocs } from '@/plugins/codemirror/completions/nativesAutocompleteDocs/luxon.static.docs'; +import { luxonInstanceDocs } from '@/plugins/codemirror/completions/nativesAutocompleteDocs/luxon.instance.docs'; export const luxonCompletions = defineComponent({ methods: { @@ -14,27 +17,9 @@ export const luxonCompletions = defineComponent({ if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; - const options: Completion[] = this.luxonInstanceGetters().map(([getter, description]) => { - return { - label: `${matcher}.${getter}`, - type: 'function', - info: description, - }; - }); - - options.push( - ...this.luxonInstanceMethods().map(([method, description]) => { - return { - label: `${matcher}.${method}()`, - type: 'function', - info: description, - }; - }), - ); - return { from: preCursor.from, - options, + options: this.instanceCompletions(matcher), }; }, @@ -48,27 +33,9 @@ export const luxonCompletions = defineComponent({ if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; - const options: Completion[] = this.luxonInstanceGetters().map(([getter, description]) => { - return { - label: `${matcher}.${getter}`, - type: 'function', - info: description, - }; - }); - - options.push( - ...this.luxonInstanceMethods().map(([method, description]) => { - return { - label: `${matcher}.${method}()`, - type: 'function', - info: description, - }; - }), - ); - return { from: preCursor.from, - options, + options: this.instanceCompletions(matcher), }; }, @@ -82,206 +49,40 @@ export const luxonCompletions = defineComponent({ if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; - const options: Completion[] = this.luxonDateTimeStaticMethods().map( - ([method, description]) => { + const options: Completion[] = Object.entries(luxonStaticDocs.functions) + .filter(([_, { doc }]) => doc && !doc.hidden) + .map(([method, { doc }]) => { return { label: `DateTime.${method}()`, type: 'function', - info: description, + info: createInfoBoxRenderer(doc, true), }; - }, - ); + }); return { from: preCursor.from, options, }; }, - - luxonDateTimeStaticMethods() { - return Object.entries({ - now: this.$locale.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.now'), - local: this.$locale.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.local'), - utc: this.$locale.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.utc'), - fromJSDate: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromJSDate', - ), - fromMillis: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromMillis', - ), - fromSeconds: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSeconds', - ), - fromObject: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromObject', - ), - fromISO: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromISO', - ), - fromRFC2822: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromRFC2822', - ), - fromHTTP: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromHTTP', - ), - fromFormat: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromFormat', - ), - fromSQL: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSQL', - ), - invalid: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.invalid', - ), - isDateTime: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.isDateTime', - ), - }); - }, - - luxonInstanceGetters() { - return Object.entries({ - isValid: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.isValid'), - invalidReason: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.invalidReason', - ), - invalidExplanation: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.invalidExplanation', - ), - locale: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.locale'), - numberingSystem: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.numberingSystem', - ), - outputCalendar: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.outputCalendar', - ), - zone: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.zone'), - zoneName: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.zoneName'), - year: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.year'), - quarter: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.quarter'), - month: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.month'), - day: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.day'), - hour: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.hour'), - minute: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.minute'), - second: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.second'), - millisecond: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.millisecond', - ), - weekYear: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekYear'), - weekNumber: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.weekNumber', - ), - weekday: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekday'), - ordinal: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.ordinal'), - monthShort: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.monthShort', - ), - monthLong: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.monthLong', - ), - weekdayShort: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.weekdayShort', - ), - weekdayLong: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.weekdayLong', - ), - offset: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.offset'), - offsetNumber: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.offsetNumber', - ), - offsetNameShort: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.offsetNameShort', - ), - offsetNameLong: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.offsetNameLong', - ), - isOffsetFixed: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.isOffsetFixed', - ), - isInDST: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.isInDST'), - isInLeapYear: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.isInLeapYear', - ), - daysInMonth: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.daysInMonth', - ), - daysInYear: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.daysInYear', - ), - weeksInWeekYear: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.weeksInWeekYear', - ), - }); - }, - - luxonInstanceMethods() { - return Object.entries({ - toUTC: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toUTC'), - toLocal: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toLocal'), - setZone: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.setZone'), - setLocale: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.setLocale', - ), - set: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.set'), - plus: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.plus'), - minus: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.minus'), - startOf: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.startOf'), - endOf: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.endOf'), - toFormat: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toFormat'), - toLocaleString: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.toLocaleString', - ), - toLocaleParts: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.toLocaleParts', - ), - toISO: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toISO'), - toISODate: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.toISODate', - ), - toISOWeekDate: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.toISOWeekDate', - ), - toISOTime: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.toISOTime', - ), - toRFC2822: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.toRFC2822', - ), - toHTTP: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toHTTP'), - toSQLDate: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.toSQLDate', - ), - toSQLTime: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.toSQLTime', - ), - toSQL: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toSQL'), - toString: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toString'), - valueOf: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.valueOf'), - toMillis: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toMillis'), - toSeconds: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.toSeconds', - ), - toUnixInteger: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.toUnixInteger', - ), - toJSON: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toJSON'), - toBSON: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toBSON'), - toObject: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toObject'), - toJsDate: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toJsDate'), - diff: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.diff'), - diffNow: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.diffNow'), - until: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.until'), - hasSame: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.hasSame'), - equals: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.equals'), - toRelative: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.toRelative', - ), - toRelativeCalendar: this.$locale.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.toRelativeCalendar', - ), - min: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.min'), - max: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.max'), - }); + instanceCompletions(matcher: string): Completion[] { + return Object.entries(luxonInstanceDocs.properties) + .filter(([_, { doc }]) => doc && !doc.hidden) + .map(([getter, { doc }]) => { + return { + label: `${matcher}.${getter}`, + info: createInfoBoxRenderer(doc), + }; + }) + .concat( + Object.entries(luxonInstanceDocs.functions) + .filter(([_, { doc }]) => doc && !doc.hidden) + .map(([method, { doc }]) => { + return { + label: `${matcher}.${method}()`, + info: createInfoBoxRenderer(doc, true), + }; + }), + ); }, }, }); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/constants.ts b/packages/editor-ui/src/plugins/codemirror/completions/constants.ts index aad2c0a310..1db422594b 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/constants.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/constants.ts @@ -296,7 +296,7 @@ export const STRING_RECOMMENDED_OPTIONS = [ 'length', ]; -export const LUXON_RECOMMENDED_OPTIONS = ['format()', 'minus()', 'plus()', 'diff()', 'extract()']; +export const LUXON_RECOMMENDED_OPTIONS = ['format()', 'minus()', 'plus()', 'diffTo()', 'extract()']; export const OBJECT_RECOMMENDED_OPTIONS = ['keys()', 'values()', 'isEmpty()', 'hasField()']; export const ARRAY_RECOMMENDED_OPTIONS = ['length', 'last()', 'includes()', 'map()', 'filter()']; export const ARRAY_NUMBER_ONLY_METHODS = ['max()', 'min()', 'sum()', 'average()']; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index 37e2d48abc..733da5d7e0 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -756,7 +756,6 @@ export const luxonInstanceOptions = ({ name: key, isFunction, docs: luxonInstanceDocs, - translations: i18n.luxonInstance, includeHidden, transformLabel, }) as Completion; @@ -778,7 +777,6 @@ export const luxonStaticOptions = () => { name: key, isFunction: true, docs: luxonStaticDocs, - translations: i18n.luxonStatic, }) as Completion; }) .filter(Boolean), @@ -788,14 +786,12 @@ export const luxonStaticOptions = () => { const createLuxonAutocompleteOption = ({ name, docs, - translations, isFunction = false, includeHidden = false, transformLabel = (label) => label, }: { name: string; docs: NativeDoc; - translations: Record; isFunction?: boolean; includeHidden?: boolean; transformLabel?: (label: string) => string; @@ -835,8 +831,7 @@ const createLuxonAutocompleteOption = ({ option.info = createCompletionOption({ name, isFunction, - // Add translated description - doc: { ...doc, description: translations[name] } as DocMetadata, + doc, transformLabel, }).info; return option; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/nativesAutocompleteDocs/luxon.instance.docs.ts b/packages/editor-ui/src/plugins/codemirror/completions/nativesAutocompleteDocs/luxon.instance.docs.ts index ef4d7d0738..16d3d1befc 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/nativesAutocompleteDocs/luxon.instance.docs.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/nativesAutocompleteDocs/luxon.instance.docs.ts @@ -1,16 +1,18 @@ import type { NativeDoc } from 'n8n-workflow'; +import { i18n } from '@/plugins/i18n'; // Autocomplete documentation definition for DateTime instance props and methods -// Descriptions are added dynamically so they can be localized export const luxonInstanceDocs: Required = { typeName: 'DateTime', properties: { day: { doc: { name: 'day', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.day'), section: 'query', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeday', returnType: 'number', + examples: [{ example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.day", evaluated: '30' }], }, }, daysInMonth: { @@ -34,57 +36,99 @@ export const luxonInstanceDocs: Required = { hour: { doc: { name: 'hour', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.hour'), section: 'query', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimehour', returnType: 'number', + examples: [{ example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.hour", evaluated: '18' }], }, }, locale: { doc: { name: 'locale', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.locale'), section: 'query', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimelocale', returnType: 'string', + examples: [{ example: '$now.locale', evaluated: "'en-US'" }], }, }, millisecond: { doc: { name: 'millisecond', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.millisecond'), section: 'query', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimemillisecond', returnType: 'number', + examples: [ + { + example: "dt = '2024-03-30T18:49:07.234'.toDateTime()\ndt.millisecond", + evaluated: '234', + }, + ], }, }, minute: { doc: { name: 'minute', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.minute'), section: 'query', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeminute', returnType: 'number', + examples: [ + { example: "dt = '2024-03-30T18:49:07.234'.toDateTime()\ndt.minute", evaluated: '49' }, + ], }, }, month: { doc: { name: 'month', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.month'), section: 'query', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimemonth', returnType: 'number', + examples: [ + { example: "dt = '2024-03-30T18:49:07.234'.toDateTime()\ndt.month", evaluated: '3' }, + ], }, }, monthLong: { doc: { name: 'monthLong', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.monthLong'), section: 'query', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimemonthlong', returnType: 'string', + examples: [ + { + example: "dt = '2024-03-30T18:49:07.234'.toDateTime()\ndt.monthLong", + evaluated: "'March'", + }, + { + example: "dt = '2024-03-30T18:49:07.234'.toDateTime()\ndt.setLocale('de-DE').monthLong", + evaluated: "'März'", + }, + ], }, }, monthShort: { doc: { name: 'monthShort', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.monthShort'), section: 'query', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimemonthshort', returnType: 'string', + examples: [ + { + example: "dt = '2024-03-30T18:49:07.234'.toDateTime()\ndt.monthShort", + evaluated: "'Mar'", + }, + { + example: + "dt = '2024-03-30T18:49:07.234'.toDateTime()\ndt.setLocale('de-DE').monthShort", + evaluated: "'Mär'", + }, + ], }, }, numberingSystem: { @@ -144,49 +188,83 @@ export const luxonInstanceDocs: Required = { quarter: { doc: { name: 'quarter', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.quarter'), section: 'query', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimequarter', returnType: 'number', + examples: [ + { example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.quarter", evaluated: '1' }, + { example: "'2024-12-01T18:49'.toDateTime().quarter", evaluated: '4' }, + ], }, }, second: { doc: { name: 'second', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.second'), section: 'query', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimesecond', returnType: 'number', + examples: [ + { example: "dt = '2024-03-30T18:49:07.234'.toDateTime()\ndt.second", evaluated: '7' }, + ], }, }, weekday: { doc: { name: 'weekday', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekday'), section: 'query', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeweekday', returnType: 'number', + examples: [{ example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.weekday", evaluated: '6' }], }, }, weekdayLong: { doc: { name: 'weekdayLong', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekdayLong'), section: 'query', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeweekdaylong', returnType: 'string', + examples: [ + { + example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.weekdayLong", + evaluated: "'Saturday'", + }, + { + example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.setLocale('de-DE').weekdayLong", + evaluated: "'Samstag'", + }, + ], }, }, weekdayShort: { doc: { name: 'weekdayShort', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekdayShort'), section: 'query', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeweekdayshort', returnType: 'string', + examples: [ + { example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.weekdayShort", evaluated: "'Sat'" }, + { + example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.setLocale('fr-FR').weekdayShort", + evaluated: "'sam.'", + }, + ], }, }, weekNumber: { doc: { name: 'weekNumber', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekNumber'), section: 'query', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeweeknumber', returnType: 'number', + examples: [ + { example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.weekNumber", evaluated: '13' }, + ], }, }, weeksInWeekYear: { @@ -210,17 +288,23 @@ export const luxonInstanceDocs: Required = { year: { doc: { name: 'year', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.year'), section: 'query', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeyear', returnType: 'number', + examples: [{ example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.year", evaluated: '2024' }], }, }, zone: { doc: { name: 'zone', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.zone'), section: 'query', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimezone', returnType: 'Zone', + examples: [ + { example: '$now.zone', evaluated: '{ zoneName: "Europe/Berlin", valid: true }' }, + ], }, }, zoneName: { @@ -235,6 +319,7 @@ export const luxonInstanceDocs: Required = { isInDST: { doc: { name: 'isInDST', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.isInDST'), section: 'query', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeisindst', returnType: 'boolean', @@ -243,9 +328,14 @@ export const luxonInstanceDocs: Required = { isInLeapYear: { doc: { name: 'isInLeapYear', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.isInLeapYear'), section: 'query', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeisinleapyear', returnType: 'boolean', + examples: [ + { example: "'2024'.toDateTime().isInLeapYear", evaluated: 'true' }, + { example: "'2025'.toDateTime().isInLeapYear", evaluated: 'false' }, + ], }, }, isOffsetFixed: { @@ -271,74 +361,121 @@ export const luxonInstanceDocs: Required = { diff: { doc: { name: 'diff', + hidden: true, section: 'compare', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimediff', returnType: 'Duration', args: [ { name: 'other', type: 'DateTime' }, - { name: 'unit', type: 'string|string[]' }, - { name: 'opts', type: 'object' }, + { name: 'unit', type: 'string | string[]' }, + { name: 'options', type: 'Object' }, ], }, }, diffNow: { doc: { name: 'diffNow', + hidden: true, section: 'compare', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimediffnow', returnType: 'Duration', args: [ - { name: 'unit', type: 'string|string[]' }, - { name: 'opts', type: 'object' }, + { name: 'unit', type: 'string | string[]' }, + { name: 'options', type: 'Object' }, ], }, }, endOf: { doc: { name: 'endOf', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.endOf'), section: 'edit', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeendof', returnType: 'DateTime', - args: [{ name: 'unit', type: 'string', default: "'month'" }], + args: [ + { + name: 'unit', + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.instanceMethods.endOf.args.unit', + ), + type: 'string', + }, + { + name: 'options', + optional: true, + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.instanceMethods.endOf.args.opts', + ), + default: '{}', + type: 'Object', + }, + ], + examples: [ + { + example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.endOf('month')", + evaluated: '[DateTime: 2024-03-31T23:59:59.999Z]', + }, + ], }, }, equals: { doc: { name: 'equals', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.equals'), section: 'compare', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeequals', returnType: 'boolean', - args: [{ name: 'other', type: 'DateTime' }], + args: [ + { + name: 'other', + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.instanceMethods.equals.args.other', + ), + type: 'DateTime', + }, + ], + examples: [ + { + example: + "dt = '2024-03-20T18:49+02:00'.toDateTime()\ndt.equals('2024-03-20T19:49+02:00'.toDateTime())", + evaluated: 'false', + }, + ], }, }, hasSame: { doc: { name: 'hasSame', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.hasSame'), section: 'compare', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimehassame', returnType: 'boolean', args: [ - { name: 'other', type: 'DateTime' }, - { name: 'unit', type: 'string' }, + { + name: 'other', + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.instanceMethods.hasSame.args.other', + ), + type: 'DateTime', + }, + { + name: 'unit', + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.instanceMethods.hasSame.args.unit', + ), + type: 'string', + }, + ], + examples: [ + { + example: "'2024-03-20'.toDateTime().hasSame('2024-03-18'.toDateTime(), 'month')", + evaluated: 'true', + }, + { + example: "'1982-03-20'.toDateTime().hasSame('2024-03-18'.toDateTime(), 'month')", + evaluated: 'false', + }, ], - }, - }, - minus: { - doc: { - name: 'minus', - section: 'edit', - docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeminus', - returnType: 'DateTime', - args: [{ name: 'duration', type: 'Duration|object|number' }], - }, - }, - plus: { - doc: { - name: 'plus', - section: 'edit', - docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeplus', - returnType: 'DateTime', - args: [{ name: 'duration', type: 'Duration|object|number' }], }, }, reconfigure: { @@ -348,7 +485,7 @@ export const luxonInstanceDocs: Required = { hidden: true, docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimereconfigure', returnType: 'DateTime', - args: [{ name: 'properties', type: 'object' }], + args: [{ name: 'properties', type: 'Object' }], }, }, resolvedLocaleOptions: { @@ -357,47 +494,132 @@ export const luxonInstanceDocs: Required = { section: 'other', hidden: true, docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeresolvedlocaleoptions', - returnType: 'object', - args: [{ name: 'opts', type: 'object' }], + returnType: 'Object', + args: [{ name: 'options', type: 'Object' }], }, }, set: { doc: { name: 'set', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.set'), section: 'edit', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeset', returnType: 'DateTime', - args: [{ name: 'values', type: 'object' }], + args: [ + { + name: 'values', + optional: false, + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.instanceMethods.set.args.values', + ), + type: 'Object', + }, + ], + examples: [ + { + example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.set({ year:1982, month:10 })", + evaluated: '[DateTime: 1982-10-20T18:49:00.000Z]', + }, + ], }, }, setLocale: { doc: { name: 'setLocale', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.setLocale'), section: 'edit', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimesetlocale', returnType: 'DateTime', - args: [{ name: 'locale', type: 'any' }], + args: [ + { + name: 'locale', + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.instanceMethods.setLocale.args.locale', + ), + type: 'string', + }, + ], + examples: [ + { + example: "$now.setLocale('de-DE').toLocaleString({ dateStyle: 'long' })", + evaluated: "'5. Oktober 2024'", + }, + { + example: "$now.setLocale('fr-FR').toLocaleString({ dateStyle: 'long' })", + evaluated: "'5 octobre 2024'", + }, + ], }, }, setZone: { doc: { name: 'setZone', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.setZone'), section: 'edit', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimesetzone', returnType: 'DateTime', args: [ - { name: 'zone', type: 'string|Zone' }, - { name: 'opts', type: 'object' }, + { + name: 'zone', + optional: true, + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.instanceMethods.setZone.args.zone', + ), + default: '"local"', + type: 'string', + }, + { + name: 'options', + optional: true, + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.instanceMethods.setZone.args.opts', + ), + type: 'Object', + }, + ], + examples: [ + { + example: + "dt = '2024-01-01T00:00:00.000+02:00'.toDateTime()\ndt.setZone('America/Buenos_Aires')", + evaluated: '[DateTime: 2023-12-31T19:00:00.000-03:00]', + }, + { + example: "dt = '2024-01-01T00:00:00.000+02:00'.toDateTime()\ndt.setZone('UTC+7')", + evaluated: '[DateTime: 2024-01-01T05:00:00.000+07:00]', + }, ], }, }, startOf: { doc: { name: 'startOf', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.startOf'), section: 'edit', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimestartof', returnType: 'DateTime', - args: [{ name: 'unit', type: 'string', default: "'month'" }], + args: [ + { + name: 'unit', + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.instanceMethods.startOf.args.unit', + ), + type: 'string', + }, + { + name: 'options', + optional: true, + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.instanceMethods.startOf.args.opts', + ), + type: 'Object', + }, + ], + examples: [ + { + example: "'2024-03-20T18:49'.toDateTime().startOf('month')", + evaluated: '[DateTime: 2024-03-01T00:00:00.000Z]', + }, + ], }, }, toBSON: { @@ -418,7 +640,7 @@ export const luxonInstanceDocs: Required = { returnType: 'string', args: [ { name: 'fmt', type: 'string' }, - { name: 'opts', type: 'object' }, + { name: 'options', type: 'Object' }, ], }, }, @@ -434,10 +656,21 @@ export const luxonInstanceDocs: Required = { toISO: { doc: { name: 'toISO', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.toISO'), section: 'format', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetoiso', returnType: 'string', - args: [{ name: 'opts', type: 'object' }], + args: [ + { + name: 'options', + optional: true, + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.instanceMethods.toISO.args.opts', + ), + type: 'Object', + }, + ], + examples: [{ example: '$now.toISO()', evaluated: "'2024-04-05T18:44:55.525+02:00'" }], }, }, toISODate: { @@ -447,7 +680,7 @@ export const luxonInstanceDocs: Required = { hidden: true, docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetoisodate', returnType: 'string', - args: [{ name: 'opts', type: 'object' }], + args: [{ name: 'options', type: 'Object' }], }, }, toISOTime: { @@ -457,7 +690,7 @@ export const luxonInstanceDocs: Required = { hidden: true, docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetoisotime', returnType: 'string', - args: [{ name: 'opts', type: 'object' }], + args: [{ name: 'options', type: 'Object' }], }, }, toISOWeekDate: { @@ -490,9 +723,19 @@ export const luxonInstanceDocs: Required = { toLocal: { doc: { name: 'toLocal', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.toLocal'), section: 'edit', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetolocal', returnType: 'DateTime', + examples: [ + { + example: "dt = '2024-01-01T00:00:00.000Z'.toDateTime()\ndt.toLocal()", + evaluated: '[DateTime: 2024-01-01T01:00:00.000+01:00]', + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.instanceMethods.toLocal.example', + ), + }, + ], }, }, toLocaleParts: { @@ -504,28 +747,103 @@ export const luxonInstanceDocs: Required = { returnType: 'string', args: [ { name: 'formatOpts', type: 'any' }, - { name: 'opts', type: 'object' }, + { name: 'options', type: 'Object' }, ], }, }, toLocaleString: { doc: { name: 'toLocaleString', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.toLocaleString'), section: 'format', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetolocalestring', returnType: 'string', args: [ - { name: 'formatOpts', type: 'any' }, - { name: 'opts', type: 'object' }, + { + name: 'formatOpts', + optional: true, + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.instanceMethods.toLocaleString.args.opts', + ), + type: 'Object', + }, + ], + examples: [ + { example: '$now.toLocaleString()', evaluated: "'4/30/2024'" }, + { + example: "$now.toLocaleString({ dateStyle: 'medium', timeStyle: 'short' })", + evaluated: "'Apr 30, 2024, 10:00 PM'", + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.instanceMethods.toLocaleString.example', + ), + }, + { example: "$now.setLocale('de-DE').toLocaleString()", evaluated: "'30.4.2024'" }, + { example: "$now.toLocaleString({ dateStyle: 'short' })", evaluated: "'4/30/2024'" }, + { example: "$now.toLocaleString({ dateStyle: 'medium' })", evaluated: "'Apr 30, 2024'" }, + { example: "$now.toLocaleString({ dateStyle: 'long' })", evaluated: "'April 30, 2024'" }, + { + example: "$now.toLocaleString({ dateStyle: 'full' })", + evaluated: "'Tuesday, April 30, 2024'", + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.instanceMethods.toLocaleString.example', + ), + }, + { + example: "$now.toLocaleString({ year: 'numeric', month: 'numeric', day: 'numeric' })", + evaluated: "'4/30/2024'", + }, + { + example: "$now.toLocaleString({ year: '2-digit', month: '2-digit', day: '2-digit' })", + evaluated: "'04/30/24'", + }, + { + example: "$now.toLocaleString({ month: 'short', weekday: 'short', day: 'numeric' })", + evaluated: "'Tue, Apr 30'", + }, + { + example: "$now.toLocaleString({ month: 'long', weekday: 'long', day: 'numeric' })", + evaluated: "'Tuesday, April 30'", + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.instanceMethods.toLocaleString.example', + ), + }, + { example: "$now.toLocaleString({ timeStyle: 'short' })", evaluated: "'10:00 PM'" }, + { example: "$now.toLocaleString({ timeStyle: 'medium' })", evaluated: "'10:00:58 PM'" }, + { + example: "$now.toLocaleString({ timeStyle: 'long' })", + evaluated: "'10:00:58 PM GMT+2'", + }, + { + example: "$now.toLocaleString({ timeStyle: 'full' })", + evaluated: "'10:00:58 PM Central European Summer Time'", + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.instanceMethods.toLocaleString.example', + ), + }, + { + example: + "$now.toLocaleString({ hour: 'numeric', minute: 'numeric', hourCycle: 'h24' })", + evaluated: "'22:00'", + }, + { + example: + "$now.toLocaleString({ hour: '2-digit', minute: '2-digit', hourCycle: 'h12' })", + evaluated: "'10:00 PM'", + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.instanceMethods.toLocaleString.example', + ), + }, ], }, }, toMillis: { doc: { name: 'toMillis', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.toMillis'), section: 'format', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetomillis', returnType: 'number', + examples: [{ example: '$now.toMillis()', evaluated: '1712334324677' }], }, }, toObject: { @@ -534,17 +852,27 @@ export const luxonInstanceDocs: Required = { section: 'format', hidden: true, docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetoobject', - returnType: 'object', - args: [{ name: 'opts', type: 'any' }], + returnType: 'Object', + args: [{ name: 'options', type: 'any' }], }, }, toRelative: { doc: { name: 'toRelative', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.toRelative'), section: 'format', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetorelative', returnType: 'string', - args: [{ name: 'options', type: 'object' }], + args: [ + { + name: 'options', + optional: true, + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.instanceMethods.toRelative.args.opts', + ), + type: 'Object', + }, + ], }, }, toRelativeCalendar: { @@ -554,7 +882,7 @@ export const luxonInstanceDocs: Required = { hidden: true, docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetorelativecalendar', returnType: 'string', - args: [{ name: 'options', type: 'object' }], + args: [{ name: 'options', type: 'Object' }], }, }, toRFC2822: { @@ -569,9 +897,11 @@ export const luxonInstanceDocs: Required = { toSeconds: { doc: { name: 'toSeconds', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.toSeconds'), section: 'format', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetoseconds', returnType: 'number', + examples: [{ example: '$now.toSeconds()', evaluated: '1712334442.372' }], }, }, toSQL: { @@ -581,7 +911,7 @@ export const luxonInstanceDocs: Required = { docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetosql', returnType: 'string', hidden: true, - args: [{ name: 'options', type: 'object' }], + args: [{ name: 'options', type: 'Object' }], }, }, toSQLDate: { @@ -605,9 +935,11 @@ export const luxonInstanceDocs: Required = { toString: { doc: { name: 'toString', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.toString'), section: 'format', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetostring', returnType: 'string', + examples: [{ example: '$now.toString()', evaluated: "'2024-04-05T18:44:55.525+02:00'" }], }, }, toUnixInteger: { @@ -622,12 +954,34 @@ export const luxonInstanceDocs: Required = { toUTC: { doc: { name: 'toUTC', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.toUTC'), section: 'edit', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetoutc', returnType: 'DateTime', args: [ - { name: 'offset', type: 'number' }, - { name: 'opts', type: 'object' }, + { + name: 'zone', + optional: true, + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.instanceMethods.toUTC.args.zone', + ), + default: '"local"', + type: 'string', + }, + { + name: 'options', + optional: true, + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.instanceMethods.toUTC.args.opts', + ), + type: 'Object', + }, + ], + examples: [ + { + example: "dt = '2024-01-01T00:00:00.000+02:00'.toDateTime()\ndt.toUTC()", + evaluated: '[DateTime: 2023-12-31T22:00:00.000Z]', + }, ], }, }, diff --git a/packages/editor-ui/src/plugins/codemirror/completions/nativesAutocompleteDocs/luxon.static.docs.ts b/packages/editor-ui/src/plugins/codemirror/completions/nativesAutocompleteDocs/luxon.static.docs.ts index 2911e8fe0e..638da176eb 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/nativesAutocompleteDocs/luxon.static.docs.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/nativesAutocompleteDocs/luxon.static.docs.ts @@ -1,7 +1,7 @@ import type { NativeDoc } from 'n8n-workflow'; +import { i18n } from '@/plugins/i18n'; // Autocomplete documentation definition for DateTime class static props and methods -// Descriptions are added dynamically so they can be localized export const luxonStaticDocs: Required = { typeName: 'DateTime', properties: {}, @@ -9,6 +9,7 @@ export const luxonStaticDocs: Required = { now: { doc: { name: 'now', + description: i18n.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.now'), docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimenow', returnType: 'DateTime', }, @@ -16,65 +17,198 @@ export const luxonStaticDocs: Required = { local: { doc: { name: 'local', + description: i18n.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.local'), docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimelocal', returnType: 'DateTime', args: [ - { name: 'year?', type: 'number' }, - { name: 'month', type: 'number' }, - { name: 'day', type: 'number' }, - { name: 'hour', type: 'number' }, - { name: 'minute', type: 'number' }, - { name: 'second', type: 'number' }, - { name: 'millisecond', type: 'number' }, + { + name: 'year', + optional: true, + type: 'number', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.year'), + }, + { + name: 'month', + optional: true, + type: 'number', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.month'), + }, + { + name: 'day', + optional: true, + type: 'number', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.day'), + }, + { + name: 'hour', + optional: true, + type: 'number', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.hour'), + }, + { + name: 'minute', + optional: true, + type: 'number', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.minute'), + }, + { + name: 'second', + optional: true, + type: 'number', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.second'), + }, + { + name: 'millisecond', + optional: true, + type: 'number', + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.instanceMethods.millisecond', + ), + }, + ], + examples: [ + { + example: 'DateTime.local(1982, 12, 3)', + evaluated: '[DateTime: 1982-12-03T00:00:00.000-05:00]', + }, ], }, }, utc: { doc: { name: 'utc', + description: i18n.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.utc'), docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeutc', returnType: 'DateTime', args: [ - { name: 'year?', type: 'number' }, - { name: 'month', type: 'number' }, - { name: 'day', type: 'number' }, - { name: 'hour', type: 'number' }, - { name: 'minute', type: 'number' }, - { name: 'second', type: 'number' }, - { name: 'millisecond', type: 'number' }, + { + name: 'year', + optional: true, + type: 'number', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.year'), + }, + { + name: 'month', + optional: true, + type: 'number', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.month'), + }, + { + name: 'day', + optional: true, + type: 'number', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.day'), + }, + { + name: 'hour', + optional: true, + type: 'number', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.hour'), + }, + { + name: 'minute', + optional: true, + type: 'number', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.minute'), + }, + { + name: 'second', + optional: true, + type: 'number', + description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.second'), + }, + { + name: 'millisecond', + optional: true, + type: 'number', + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.instanceMethods.millisecond', + ), + }, + ], + examples: [ + { + example: 'DateTime.utc(1982, 12, 3)', + evaluated: '[DateTime: 1982-12-03T00:00:00.000Z]', + }, ], }, }, fromJSDate: { doc: { name: 'fromJSDate', + hidden: true, docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimefromjsdate', returnType: 'DateTime', args: [ { name: 'date', type: 'Date' }, - { name: 'options?', type: 'object' }, + { name: 'options', optional: true, type: 'Object' }, ], }, }, fromMillis: { doc: { name: 'fromMillis', + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromMillis', + ), docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimefrommillis', returnType: 'DateTime', args: [ - { name: 'milliseconds', type: 'number' }, - { name: 'options?', type: 'object' }, + { + name: 'milliseconds', + type: 'number', + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromMillis.args.milliseconds', + ), + }, + { + name: 'options', + optional: true, + type: 'Object', + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromMillis.args.opts', + ), + }, + ], + examples: [ + { + example: 'DateTime.fromMillis(1711838940000)', + evaluated: '[DateTime: 2024-03-30T18:49:00.000Z]', + }, ], }, }, fromSeconds: { doc: { name: 'fromSeconds', + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSeconds', + ), docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimefromseconds', returnType: 'DateTime', args: [ - { name: 'seconds', type: 'number' }, - { name: 'options?', type: 'object' }, + { + name: 'seconds', + type: 'number', + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSeconds.args.seconds', + ), + }, + { + name: 'options', + optional: true, + type: 'Object', + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSeconds.args.opts', + ), + }, + ], + examples: [ + { + example: 'DateTime.fromSeconds(1711838940)', + evaluated: '[DateTime: 2024-03-30T18:49:00.000Z]', + }, ], }, }, @@ -83,65 +217,90 @@ export const luxonStaticDocs: Required = { name: 'fromObject', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimefromobject', returnType: 'DateTime', + hidden: true, args: [ - { name: 'obj', type: 'object' }, - { name: 'options?', type: 'object' }, + { name: 'obj', type: 'Object' }, + { name: 'options', optional: true, type: 'Object' }, ], }, }, fromISO: { doc: { name: 'fromISO', + description: i18n.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromISO'), docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimefromiso', returnType: 'DateTime', args: [ - { name: 'text', type: 'string' }, - { name: 'options?', type: 'object' }, + { + name: 'isoString', + type: 'string', + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromISO.args.isoString', + ), + }, + { + name: 'options', + optional: true, + type: 'Object', + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromISO.args.opts', + ), + }, + ], + examples: [ + { + example: "DateTime.fromISO('2024-05-10T14:15:59.493Z')", + evaluated: '[DateTime: 2024-05-10T14:15:59.493Z]', + }, ], }, }, fromRFC2822: { doc: { name: 'fromRFC2822', + hidden: true, docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimefromrfc2822', returnType: 'DateTime', args: [ { name: 'text', type: 'string' }, - { name: 'options?', type: 'object' }, + { name: 'options', optional: true, type: 'Object' }, ], }, }, fromHTTP: { doc: { name: 'fromHTTP', + hidden: true, docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimefromhttp', returnType: 'DateTime', args: [ { name: 'text', type: 'string' }, - { name: 'options?', type: 'object' }, + { name: 'options', optional: true, type: 'Object' }, ], }, }, fromFormat: { doc: { name: 'fromFormat', + hidden: true, docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimefromformat', returnType: 'DateTime', args: [ { name: 'text', type: 'string' }, { name: 'fmt', type: 'string' }, - { name: 'options?', type: 'object' }, + { name: 'options', optional: true, type: 'Object' }, ], }, }, fromSQL: { doc: { name: 'fromSQL', + hidden: true, docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimefromsql', returnType: 'DateTime', args: [ { name: 'text', type: 'string' }, - { name: 'options?', type: 'object' }, + { name: 'options', optional: true, type: 'Object' }, ], }, }, @@ -150,18 +309,30 @@ export const luxonStaticDocs: Required = { name: 'invalid', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeinvalid', returnType: 'DateTime', + hidden: true, args: [ { name: 'reason', type: 'DateTime' }, - { name: 'explanation?', type: 'string' }, + { name: 'explanation', optional: true, type: 'string' }, ], }, }, isDateTime: { doc: { name: 'isDateTime', + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.isDateTime', + ), docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeisdatetime', returnType: 'boolean', - args: [{ name: 'o', type: 'object' }], + args: [ + { + name: 'maybeDateTime', + type: 'any', + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.isDateTime.args.maybeDateTime', + ), + }, + ], }, }, expandFormat: { @@ -169,9 +340,10 @@ export const luxonStaticDocs: Required = { name: 'expandFormat', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeexpandformat', returnType: 'string', + hidden: true, args: [ { name: 'fmt', type: 'any' }, - { name: 'localeOpts?', type: 'any' }, + { name: 'localeOpts', optional: true, type: 'any' }, ], }, }, @@ -179,11 +351,12 @@ export const luxonStaticDocs: Required = { doc: { name: 'fromFormatExplain', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimefromformatexplain', - returnType: 'object', + returnType: 'Object', + hidden: true, args: [ { name: 'text', type: 'string' }, { name: 'fmt', type: 'string' }, - { name: 'options?', type: 'object' }, + { name: 'options', optional: true, type: 'Object' }, ], }, }, @@ -192,10 +365,11 @@ export const luxonStaticDocs: Required = { name: 'fromString', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimefromstring', returnType: 'DateTime', + hidden: true, args: [ { name: 'text', type: 'string' }, { name: 'fmt', type: 'string' }, - { name: 'options?', type: 'object' }, + { name: 'options', optional: true, type: 'Object' }, ], }, }, @@ -203,35 +377,62 @@ export const luxonStaticDocs: Required = { doc: { name: 'fromStringExplain', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimefromstringexplain', - returnType: 'object', + returnType: 'Object', + hidden: true, args: [ { name: 'text', type: 'string' }, { name: 'fmt', type: 'string' }, - { name: 'options?', type: 'object' }, + { name: 'options', optional: true, type: 'Object' }, ], }, }, max: { doc: { name: 'max', + description: i18n.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.max'), docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimemax', - returnType: 'DateTime|undefined', + returnType: 'DateTime', args: [ - { name: 'dateTime1', type: 'DateTime' }, - { name: '...' }, - { name: 'dateTimeN', type: 'DateTime' }, + { + name: 'dateTimes', + variadic: true, + type: 'DateTime', + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.max.args.dateTimes', + ), + }, + ], + examples: [ + { + example: + "DateTime.max('2024-03-30T18:49'.toDateTime(), '2025-03-30T18:49'.toDateTime())", + evaluated: '[DateTime: 2025-03-30T18:49:00.000Z]', + }, ], }, }, min: { doc: { name: 'min', + description: i18n.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.min'), docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimemin', - returnType: 'DateTime|undefined', + returnType: 'DateTime', args: [ - { name: 'dateTime1', type: 'DateTime' }, - { name: '...' }, - { name: 'dateTimeN', type: 'DateTime' }, + { + name: 'dateTimes', + variadic: true, + type: 'DateTime', + description: i18n.baseText( + 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.min.args.dateTimes', + ), + }, + ], + examples: [ + { + example: + "DateTime.min('2024-03-30T18:49'.toDateTime(), '2025-03-30T18:49'.toDateTime())", + evaluated: '[DateTime: 2024-03-30T18:49:00.000Z]', + }, ], }, }, @@ -240,9 +441,10 @@ export const luxonStaticDocs: Required = { name: 'parseFormatForOpts', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeparseformatforopts', returnType: 'string', + hidden: true, args: [ { name: 'fmt', type: 'any' }, - { name: 'localeOpts?', type: 'any' }, + { name: 'localeOpts', optional: true, type: 'any' }, ], }, }, diff --git a/packages/editor-ui/src/plugins/i18n/index.ts b/packages/editor-ui/src/plugins/i18n/index.ts index c8c0bab310..a1a277d027 100644 --- a/packages/editor-ui/src/plugins/i18n/index.ts +++ b/packages/editor-ui/src/plugins/i18n/index.ts @@ -418,131 +418,6 @@ export class I18nClass { values: this.baseText('codeNodeEditor.completer.globalObject.values'), }; - luxonInstance: Record = { - // getters - isValid: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.isValid'), - invalidReason: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.invalidReason'), - invalidExplanation: this.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.invalidExplanation', - ), - locale: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.locale'), - numberingSystem: this.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.numberingSystem', - ), - outputCalendar: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.outputCalendar'), - zone: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.zone'), - zoneName: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.zoneName'), - year: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.year'), - quarter: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.quarter'), - month: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.month'), - day: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.day'), - hour: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.hour'), - minute: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.minute'), - second: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.second'), - millisecond: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.millisecond'), - weekYear: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekYear'), - weekNumber: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekNumber'), - weekday: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekday'), - ordinal: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.ordinal'), - monthShort: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.monthShort'), - monthLong: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.monthLong'), - weekdayShort: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekdayShort'), - weekdayLong: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekdayLong'), - offset: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.offset'), - offsetNumber: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.offsetNumber'), - offsetNameShort: this.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.offsetNameShort', - ), - offsetNameLong: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.offsetNameLong'), - isOffsetFixed: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.isOffsetFixed'), - isInDST: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.isInDST'), - isInLeapYear: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.isInLeapYear'), - daysInMonth: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.daysInMonth'), - daysInYear: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.daysInYear'), - weeksInWeekYear: this.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.weeksInWeekYear', - ), - - // methods - toUTC: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toUTC'), - toLocal: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toLocal'), - setZone: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.setZone'), - setLocale: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.setLocale'), - set: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.set'), - plus: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.plus'), - minus: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.minus'), - startOf: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.startOf'), - endOf: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.endOf'), - toFormat: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toFormat'), - toLocaleString: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toLocaleString'), - toLocaleParts: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toLocaleParts'), - toISO: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toISO'), - toISODate: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toISODate'), - toISOWeekDate: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toISOWeekDate'), - toISOTime: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toISOTime'), - toRFC2822: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toRFC2822'), - toHTTP: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toHTTP'), - toSQLDate: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toSQLDate'), - toSQLTime: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toSQLTime'), - toSQL: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toSQL'), - toString: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toString'), - valueOf: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.valueOf'), - toMillis: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toMillis'), - toSeconds: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toSeconds'), - toUnixInteger: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toUnixInteger'), - toJSON: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toJSON'), - toBSON: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toBSON'), - toObject: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toObject'), - toJSDate: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toJsDate'), - diff: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.diff'), - diffNow: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.diffNow'), - until: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.until'), - hasSame: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.hasSame'), - equals: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.equals'), - toRelative: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toRelative'), - toRelativeCalendar: this.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.toRelativeCalendar', - ), - min: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.min'), - max: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.max'), - reconfigure: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.reconfigure'), - resolvedLocaleOptions: this.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.resolvedLocaleOptions', - ), - }; - - luxonStatic: Record = { - now: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.now'), - local: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.local'), - utc: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.utc'), - fromJSDate: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromJSDate'), - fromMillis: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromMillis'), - fromSeconds: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSeconds'), - fromObject: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromObject'), - fromISO: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromISO'), - fromRFC2822: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromRFC2822'), - fromHTTP: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromHTTP'), - fromFormat: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromFormat'), - fromSQL: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSQL'), - invalid: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.invalid'), - isDateTime: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.isDateTime'), - expandFormat: this.baseText( - 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.expandFormat', - ), - fromFormatExplain: this.baseText( - 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromFormatExplain', - ), - fromString: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromString'), - fromStringExplain: this.baseText( - 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromStringExplain', - ), - max: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.max'), - min: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.min'), - parseFormatForOpts: this.baseText( - 'codeNodeEditor.completer.luxon.dateTimeStaticMethods.parseFormatForOpts', - ), - }; - autocompleteUIValues: Record = { docLinkLabel: this.baseText('expressionEdit.learnMore'), }; diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 43ef7c1efa..b9d7715da8 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -242,46 +242,60 @@ "codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromFormatExplain": "Explain how a string would be parsed by fromFormat().", "codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromHTTP": "Create a DateTime from an HTTP header date", "codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromISO": "Create a DateTime from an ISO 8601 string", + "codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromISO.args.isoString": "ISO 8601 string to convert to a DateTime", + "codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromISO.args.opts": "Configuration options. See See Luxon docs for more info.", "codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromJSDate": "Create a DateTime from a JavaScript Date object. Uses the default zone", "codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromMillis": "Create a DateTime from a number of milliseconds since the epoch (meaning since 1 January 1970 00:00:00 UTC). Uses the default zone", + "codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromMillis.args.milliseconds": "Number of milliseconds since the epoch (meaning since 1 January 1970 00:00:00 UTC)", + "codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromMillis.args.opts": "Configuration options. See See Luxon docs for more info.", "codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromObject": "Create a DateTime from a JavaScript object with keys like 'year' and 'hour' with reasonable defaults", "codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromRFC2822": "Create a DateTime from an RFC 2822 string", "codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromString": "Deprecated: use `fromFormat` instead.", "codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromStringExplain": "Deprecated: use `fromFormatExplain` instead.", "codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSQL": "Create a DateTime from a SQL date, time, or datetime", "codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSeconds": "Create a DateTime from a number of seconds since the epoch (meaning since 1 January 1970 00:00:00 UTC). Uses the default zone", + "codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSeconds.args.seconds": "Number of seconds since the epoch (meaning since 1 January 1970 00:00:00 UTC)", + "codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSeconds.args.opts": "Configuration options. See Luxon docs for more info.", "codeNodeEditor.completer.luxon.dateTimeStaticMethods.invalid": "Create an invalid DateTime.", "codeNodeEditor.completer.luxon.dateTimeStaticMethods.isDateTime": "Check if an object is a DateTime. Works across context boundaries", + "codeNodeEditor.completer.luxon.dateTimeStaticMethods.isDateTime.args.maybeDateTime": "Potential DateTime to check. Only instances of the Luxon DateTime class will return true.", "codeNodeEditor.completer.luxon.dateTimeStaticMethods.local": "Create a local DateTime", "codeNodeEditor.completer.luxon.dateTimeStaticMethods.max": "Return the max of several date times.", + "codeNodeEditor.completer.luxon.dateTimeStaticMethods.max.args.dateTimes": "DateTime objects to compare", "codeNodeEditor.completer.luxon.dateTimeStaticMethods.min": "Return the min of several date times.", - "codeNodeEditor.completer.luxon.dateTimeStaticMethods.now": "Create a DateTime for the current instant, in the system's time zone", + "codeNodeEditor.completer.luxon.dateTimeStaticMethods.min.args.dateTimes": "DateTime objects to compare", + "codeNodeEditor.completer.luxon.dateTimeStaticMethods.now": "Create a DateTime for the current instant, in the workflow's local time zone", "codeNodeEditor.completer.luxon.dateTimeStaticMethods.parseFormatForOpts": "Produce the the fully expanded format token for the locale Does NOT quote characters, so quoted tokens will not round trip correctly.", "codeNodeEditor.completer.luxon.dateTimeStaticMethods.utc": "Create a DateTime in UTC", - "codeNodeEditor.completer.luxon.instanceMethods.day": "Get the day of the month (1-30ish).", + "codeNodeEditor.completer.luxon.instanceMethods.day": "The day of the month (1-31).", "codeNodeEditor.completer.luxon.instanceMethods.daysInMonth": "Returns the number of days in this DateTime's month.", "codeNodeEditor.completer.luxon.instanceMethods.daysInYear": "Returns the number of days in this DateTime's year.", "codeNodeEditor.completer.luxon.instanceMethods.diff": "Return the difference between two DateTimes as a Duration.", "codeNodeEditor.completer.luxon.instanceMethods.diffNow": "Return the difference between this DateTime and right now.", - "codeNodeEditor.completer.luxon.instanceMethods.endOf": "Set this DateTime to the end (meaning the last millisecond) of a unit of time.", - "codeNodeEditor.completer.luxon.instanceMethods.equals": "Equality check.", - "codeNodeEditor.completer.luxon.instanceMethods.hasSame": "Return whether this DateTime is in the same unit of time as another DateTime.", - "codeNodeEditor.completer.luxon.instanceMethods.hour": "Get the hour of the day (0-23).", + "codeNodeEditor.completer.luxon.instanceMethods.endOf": "Rounds the DateTime up to the end of one of its units, e.g. the end of the month", + "codeNodeEditor.completer.luxon.instanceMethods.endOf.args.unit": "The unit to round to the end of. Can be year, quarter, month, week, day, hour, minute, second, or millisecond.", + "codeNodeEditor.completer.luxon.instanceMethods.endOf.args.opts": "Object with options that affect the output. Possible properties:\nuseLocaleWeeks (boolean): Whether to use the locale when calculating the start of the week. Defaults to false.", + "codeNodeEditor.completer.luxon.instanceMethods.equals": "Returns true if the two DateTimes represent exactly the same moment and are in the same time zone. For a less strict comparison, use hasSame().", + "codeNodeEditor.completer.luxon.instanceMethods.equals.args.other": "The other DateTime to compare", + "codeNodeEditor.completer.luxon.instanceMethods.hasSame": "Returns true if the two DateTimes are the same, down to the unit specified. Time zones are ignored (only local times are compared), so use toUTC() first if needed.", + "codeNodeEditor.completer.luxon.instanceMethods.hasSame.args.other": "The other DateTime to compare", + "codeNodeEditor.completer.luxon.instanceMethods.hasSame.args.unit": "The unit of time to check sameness down to. One of year, quarter, month, week, day, hour, minute, second, or millisecond.", + "codeNodeEditor.completer.luxon.instanceMethods.hour": "The hour of the day (0-23).", "codeNodeEditor.completer.luxon.instanceMethods.invalidExplanation": "Returns an explanation of why this DateTime became invalid, or null if the DateTime is valid.", "codeNodeEditor.completer.luxon.instanceMethods.invalidReason": "Returns an error code if this DateTime is invalid, or null if the DateTime is valid.", - "codeNodeEditor.completer.luxon.instanceMethods.isInDST": "Get whether the DateTime is in a DST.", - "codeNodeEditor.completer.luxon.instanceMethods.isInLeapYear": "Returns true if this DateTime is in a leap year, false otherwise.", + "codeNodeEditor.completer.luxon.instanceMethods.isInDST": "Whether the DateTime is in daylight saving time.", + "codeNodeEditor.completer.luxon.instanceMethods.isInLeapYear": "Whether the DateTime is in a leap year.", "codeNodeEditor.completer.luxon.instanceMethods.isOffsetFixed": "Get whether this zone's offset ever changes, as in a DST.", "codeNodeEditor.completer.luxon.instanceMethods.isValid": "Returns whether the DateTime is valid. Invalid DateTimes occur when The DateTime was created from invalid calendar information, such as the 13th month or February 30. The DateTime was created by an operation on another invalid date.", - "codeNodeEditor.completer.luxon.instanceMethods.locale": "Get the locale of a DateTime, such 'en-GB'. The locale is used when formatting the DateTime.", + "codeNodeEditor.completer.luxon.instanceMethods.locale": "The locale of a DateTime, such 'en-GB'. The locale is used when formatting the DateTime.", "codeNodeEditor.completer.luxon.instanceMethods.max": "Return the max of several date times.", - "codeNodeEditor.completer.luxon.instanceMethods.millisecond": "Get the millisecond of the second (0-999).", + "codeNodeEditor.completer.luxon.instanceMethods.millisecond": "The millisecond of the second (0-999).", "codeNodeEditor.completer.luxon.instanceMethods.min": "Return the min of several date times", "codeNodeEditor.completer.luxon.instanceMethods.minus": "Subtract hours, minutes, seconds, or milliseconds increases the timestamp by the right number of milliseconds.", - "codeNodeEditor.completer.luxon.instanceMethods.minute": "Get the minute of the hour (0-59).", - "codeNodeEditor.completer.luxon.instanceMethods.month": "Get the month (1-12).", - "codeNodeEditor.completer.luxon.instanceMethods.monthLong": "Get the human readable long month name, such as 'October'.", - "codeNodeEditor.completer.luxon.instanceMethods.monthShort": "Get the human readable short month name, such as 'Oct'.", + "codeNodeEditor.completer.luxon.instanceMethods.minute": "The minute of the hour (0-59).", + "codeNodeEditor.completer.luxon.instanceMethods.month": "The month (1-12).", + "codeNodeEditor.completer.luxon.instanceMethods.monthLong": "The textual long month name, e.g. 'October'. Defaults to the system's locale if unspecified.", + "codeNodeEditor.completer.luxon.instanceMethods.monthShort": "The textual abbreviated month name, e.g. 'Oct'. Defaults to the system's locale if unspecified.", "codeNodeEditor.completer.luxon.instanceMethods.numberingSystem": "Get the numbering system of a DateTime, such 'beng'. The numbering system is used when formatting the DateTime.", "codeNodeEditor.completer.luxon.instanceMethods.offset": "Get the UTC offset of this DateTime in minutes", "codeNodeEditor.completer.luxon.instanceMethods.offsetNameLong": "Get the long human name for the zone's current offset, for example \"Eastern Standard Time\" or \"Eastern Daylight Time\".", @@ -290,48 +304,61 @@ "codeNodeEditor.completer.luxon.instanceMethods.ordinal": "Get the ordinal (meaning the day of the year).", "codeNodeEditor.completer.luxon.instanceMethods.outputCalendar": "Get the output calendar of a DateTime, such 'islamic'. The output calendar is used when formatting the DateTime.", "codeNodeEditor.completer.luxon.instanceMethods.plus": "Add hours, minutes, seconds, or milliseconds increases the timestamp by the right number of milliseconds.", - "codeNodeEditor.completer.luxon.instanceMethods.quarter": "Get the quarter.", + "codeNodeEditor.completer.luxon.instanceMethods.quarter": "The quarter of the year (1-4).", "codeNodeEditor.completer.luxon.instanceMethods.reconfigure": "'Set' the locale, numberingSystem, or outputCalendar. Returns a newly-constructed DateTime.", "codeNodeEditor.completer.luxon.instanceMethods.resolvedLocaleOptions": "Returns the resolved Intl options for this DateTime. This is useful in understanding the behavior of formatting methods.", - "codeNodeEditor.completer.luxon.instanceMethods.second": "Get the second of the minute (0-59).", - "codeNodeEditor.completer.luxon.instanceMethods.set": "Set the values of specified units. Returns a newly-constructed DateTime.", - "codeNodeEditor.completer.luxon.instanceMethods.setLocale": "Set the locale. Returns a newly-constructed DateTime.", - "codeNodeEditor.completer.luxon.instanceMethods.setZone": "Set the DateTime's zone to specified zone. Returns a newly-constructed DateTime.", - "codeNodeEditor.completer.luxon.instanceMethods.startOf": "Set this DateTime to the beginning of a unit of time.", + "codeNodeEditor.completer.luxon.instanceMethods.second": "The second of the minute (0-59).", + "codeNodeEditor.completer.luxon.instanceMethods.set": "Assigns new values to specified units of the DateTime. To round a DateTime, see also startOf() and endOf().", + "codeNodeEditor.completer.luxon.instanceMethods.set.args.values": "An object containing the units to set and corresponding values to assign. Possible keys are year, month, day, hour, minute, second and millsecond.", + "codeNodeEditor.completer.luxon.instanceMethods.setLocale": "Sets the locale, which determines the language and formatting for the DateTime. Useful when generating a textual representation of the DateTime, e.g. with format() or toLocaleString().", + "codeNodeEditor.completer.luxon.instanceMethods.setLocale.args.locale": "The locale to set, e.g. 'en-GB' for British English or 'pt-BR' for Brazilian Portuguese. List (unofficial)", + "codeNodeEditor.completer.luxon.instanceMethods.setZone": "Converts the DateTime to the given time zone. The DateTime still represents the same moment unless specified in the options. See also toLocal() and toUTC().", + "codeNodeEditor.completer.luxon.instanceMethods.setZone.args.zone": "A zone identifier, either in the format 'America/New_York', 'UTC+3', or the strings 'local' or 'utc'. 'local' is the workflow's local time zone, this can be changed in workflow settings.", + "codeNodeEditor.completer.luxon.instanceMethods.setZone.args.opts": "Options that affect the output. Possible properties:\nkeepCalendarTime (boolean): Whether to keep the time the same and only change the offset. Defaults to false.", + "codeNodeEditor.completer.luxon.instanceMethods.startOf": "Rounds the DateTime down to the beginning of one of its units, e.g. the start of the month", + "codeNodeEditor.completer.luxon.instanceMethods.startOf.args.unit": "The unit to round to the beginning of. One of year, quarter, month, week, day, hour, minute, second, or millisecond.", + "codeNodeEditor.completer.luxon.instanceMethods.startOf.args.opts": "Object with options that affect the output. Possible properties:\nuseLocaleWeeks (boolean): Whether to use the locale when calculating the start of the week. Defaults to false.", "codeNodeEditor.completer.luxon.instanceMethods.toBSON": "Returns a BSON serializable equivalent to this DateTime.", "codeNodeEditor.completer.luxon.instanceMethods.toFormat": "Returns a string representation of this DateTime formatted according to the specified format string.", "codeNodeEditor.completer.luxon.instanceMethods.toHTTP": "Returns a string representation of this DateTime appropriate for use in HTTP headers.", "codeNodeEditor.completer.luxon.instanceMethods.toISO": "Returns an ISO 8601-compliant string representation of this DateTime.", + "codeNodeEditor.completer.luxon.instanceMethods.toISO.args.opts": "Configuration options. See Luxon docs for more info.", "codeNodeEditor.completer.luxon.instanceMethods.toISODate": "Returns an ISO 8601-compliant string representation of this DateTime's date component.", "codeNodeEditor.completer.luxon.instanceMethods.toISOTime": "Returns an ISO 8601-compliant string representation of this DateTime's time component.", "codeNodeEditor.completer.luxon.instanceMethods.toISOWeekDate": "Returns an ISO 8601-compliant string representation of this DateTime's week date.", "codeNodeEditor.completer.luxon.instanceMethods.toJSON": "Returns an ISO 8601 representation of this DateTime appropriate for use in JSON.", "codeNodeEditor.completer.luxon.instanceMethods.toJsDate": "Returns a JavaScript Date equivalent to this DateTime.", - "codeNodeEditor.completer.luxon.instanceMethods.toLocal": "Set the DateTime's zone to the host's local zone. Returns a newly-constructed DateTime.", + "codeNodeEditor.completer.luxon.instanceMethods.toLocal": "Converts a DateTime to the workflow's local time zone. The DateTime still represents the same moment unless specified in the parameters. The workflow's time zone can be set in the workflow settings.", + "codeNodeEditor.completer.luxon.instanceMethods.toLocal.example": "if time zone is Europe/Berlin", "codeNodeEditor.completer.luxon.instanceMethods.toLocaleParts": "Returns an array of format \"parts\", meaning individual tokens along with metadata.", - "codeNodeEditor.completer.luxon.instanceMethods.toLocaleString": "Returns a localized string representing this date. Accepts the same options as the Intl.DateTimeFormat constructor and any presets defined by Luxon.", - "codeNodeEditor.completer.luxon.instanceMethods.toMillis": "Returns the epoch milliseconds of this DateTime.", + "codeNodeEditor.completer.luxon.instanceMethods.toLocaleString": "Returns a localized string representing the DateTime, i.e. in the language and format corresponding to its locale. Defaults to the system's locale if none specified.", + "codeNodeEditor.completer.luxon.instanceMethods.toLocaleString.args.opts": "Configuration options for the rendering. See Intl.DateTimeFormat for a full list. Defaults to rendering a short date.", + "codeNodeEditor.completer.luxon.instanceMethods.toLocaleString.example": "Configuration options for the rendering. See Intl.DateTimeFormat for a full list. Defaults to rendering a short date.", + "codeNodeEditor.completer.luxon.instanceMethods.toMillis": "Returns a Unix timestamp in milliseconds (the number elapsed since 1st Jan 1970)", "codeNodeEditor.completer.luxon.instanceMethods.toObject": "Returns a JavaScript object with this DateTime's year, month, day, and so on.", "codeNodeEditor.completer.luxon.instanceMethods.toRFC2822": "Returns an RFC 2822-compatible string representation of this DateTime, always in UTC.", - "codeNodeEditor.completer.luxon.instanceMethods.toRelative": "Returns a string representation of a this time relative to now, such as 'in two days'.", + "codeNodeEditor.completer.luxon.instanceMethods.toRelative": "Returns a textual representation of the time relative to now, e.g. 'in two days'. Rounds down by default.", + "codeNodeEditor.completer.luxon.instanceMethods.toRelative.args.opts": "Options that affect the output. Possible properties:\nunit = the unit to default to (years, months, days, etc.).\nlocale = the language and formatting to use (e.g. de, fr)", "codeNodeEditor.completer.luxon.instanceMethods.toRelativeCalendar": "Returns a string representation of this date relative to today, such as '\"'yesterday' or 'next month'.", "codeNodeEditor.completer.luxon.instanceMethods.toSQL": "Returns a string representation of this DateTime appropriate for use in SQL DateTime.", "codeNodeEditor.completer.luxon.instanceMethods.toSQLDate": "Returns a string representation of this DateTime appropriate for use in SQL Date.", "codeNodeEditor.completer.luxon.instanceMethods.toSQLTime": "Returns a string representation of this DateTime appropriate for use in SQL Time.", - "codeNodeEditor.completer.luxon.instanceMethods.toSeconds": "Returns the epoch seconds of this DateTime.", - "codeNodeEditor.completer.luxon.instanceMethods.toString": "Returns a string representation of this DateTime appropriate for debugging.", - "codeNodeEditor.completer.luxon.instanceMethods.toUTC": "Set the DateTime's zone to UTC. Returns a newly-constructed DateTime.", + "codeNodeEditor.completer.luxon.instanceMethods.toSeconds": "Returns a Unix timestamp in seconds (the number elapsed since 1st Jan 1970)", + "codeNodeEditor.completer.luxon.instanceMethods.toString": "Returns a string representation of the DateTime. Similar to toISO(). For more formatting options, see format() or toLocaleString().", + "codeNodeEditor.completer.luxon.instanceMethods.toUTC": "Converts the DateTime to the given time zone. The DateTime still represents the same moment unless specified in the options. See also toLocal() and toUTC().", + "codeNodeEditor.completer.luxon.instanceMethods.toUTC.args.zone": "A zone identifier, either in the format 'America/New_York', 'UTC+3', or the strings 'local' or 'utc'", + "codeNodeEditor.completer.luxon.instanceMethods.toUTC.args.opts": "Options that affect the output. Possible properties:\nkeepCalendarTime (boolean): Whether to keep the time the same and only change the offset. Defaults to false.", "codeNodeEditor.completer.luxon.instanceMethods.toUnixInteger": "Returns the epoch seconds (as a whole number) of this DateTime.", "codeNodeEditor.completer.luxon.instanceMethods.until": "Return an Interval spanning between this DateTime and another DateTime.", "codeNodeEditor.completer.luxon.instanceMethods.valueOf": "Returns the epoch milliseconds of this DateTime.", - "codeNodeEditor.completer.luxon.instanceMethods.weekNumber": "Get the week number of the week year (1-52ish).", + "codeNodeEditor.completer.luxon.instanceMethods.weekNumber": "The week number of the year (1-52ish).", "codeNodeEditor.completer.luxon.instanceMethods.weekYear": "Get the week year.", - "codeNodeEditor.completer.luxon.instanceMethods.weekday": "Get the day of the week. 1 is Monday and 7 is Sunday.", - "codeNodeEditor.completer.luxon.instanceMethods.weekdayLong": "Get the human readable long weekday, such as 'Monday'.", - "codeNodeEditor.completer.luxon.instanceMethods.weekdayShort": "Get the human readable short weekday, such as 'Mon'.", + "codeNodeEditor.completer.luxon.instanceMethods.weekday": "The day of the week. 1 is Monday and 7 is Sunday.", + "codeNodeEditor.completer.luxon.instanceMethods.weekdayLong": "The textual long weekday name, e.g. 'Wednesday'. Defaults to the system's locale if unspecified.", + "codeNodeEditor.completer.luxon.instanceMethods.weekdayShort": "The textual abbreviated weekday name, e.g. 'Wed'. Defaults to the system's locale if unspecified.", "codeNodeEditor.completer.luxon.instanceMethods.weeksInWeekYear": "Returns the number of weeks in this DateTime's year.", - "codeNodeEditor.completer.luxon.instanceMethods.year": "Get the year.", - "codeNodeEditor.completer.luxon.instanceMethods.zone": "Get the time zone associated with this DateTime.", + "codeNodeEditor.completer.luxon.instanceMethods.year": "The year.", + "codeNodeEditor.completer.luxon.instanceMethods.zone": "The time zone associated with the DateTime", "codeNodeEditor.completer.luxon.instanceMethods.zoneName": "Get the name of the time zone.", "codeNodeEditor.completer.selector.all": "@:_reusableBaseText.codeNodeEditor.completer.all", "codeNodeEditor.completer.selector.context": "Extra data about the node", diff --git a/packages/workflow/src/Expression.ts b/packages/workflow/src/Expression.ts index 35f0ddca6c..8ec3e3a37e 100644 --- a/packages/workflow/src/Expression.ts +++ b/packages/workflow/src/Expression.ts @@ -73,18 +73,23 @@ export class Expression { * */ convertObjectValueToString(value: object): string { - const typeName = Array.isArray(value) ? 'Array' : 'Object'; - if (value instanceof DateTime && value.invalidReason !== null) { throw new ApplicationError('invalid DateTime'); } + let typeName = value.constructor.name ?? 'Object'; + if (DateTime.isDateTime(value)) { + typeName = 'DateTime'; + } + let result = ''; if (value instanceof Date) { // We don't want to use JSON.stringify for dates since it disregards workflow timezone result = DateTime.fromJSDate(value, { zone: this.workflow.settings?.timezone ?? getGlobalState().defaultTimezone, }).toISO(); + } else if (DateTime.isDateTime(value)) { + result = value.toString(); } else { result = JSON.stringify(value); } diff --git a/packages/workflow/src/Extensions/DateExtensions.ts b/packages/workflow/src/Extensions/DateExtensions.ts index 327c4ade3c..071f6ba850 100644 --- a/packages/workflow/src/Extensions/DateExtensions.ts +++ b/packages/workflow/src/Extensions/DateExtensions.ts @@ -10,29 +10,35 @@ import type { } from 'luxon'; import type { ExtensionMap } from './Extensions'; import { convertToDateTime } from './utils'; +import { toDateTime as stringToDateTime } from './StringExtensions'; -type DurationUnit = - | 'milliseconds' - | 'seconds' - | 'minutes' - | 'hours' - | 'days' - | 'weeks' - | 'months' - | 'quarter' - | 'years'; -type DatePart = - | 'day' - | 'week' - | 'month' - | 'year' - | 'hour' - | 'minute' - | 'second' - | 'millisecond' - | 'weekNumber' - | 'yearDayNumber' - | 'weekday'; +const durationUnits = [ + 'milliseconds', + 'seconds', + 'minutes', + 'hours', + 'days', + 'weeks', + 'months', + 'quarters', + 'years', +] as const; +type DurationUnit = (typeof durationUnits)[number]; + +const dateParts = [ + 'day', + 'week', + 'month', + 'year', + 'hour', + 'minute', + 'second', + 'millisecond', + 'weekNumber', + 'yearDayNumber', + 'weekday', +] as const; +type DatePart = (typeof dateParts)[number]; const DURATION_MAP: Record = { day: 'days', @@ -73,6 +79,16 @@ function isDateTime(date: unknown): date is DateTime { return date ? DateTime.isDateTime(date) : false; } +function toDateTime(date: string | Date | DateTime): DateTime { + if (isDateTime(date)) return date; + + if (typeof date === 'string') { + return stringToDateTime(date); + } + + return DateTime.fromJSDate(date); +} + function generateDurationObject(durationValue: number, unit: DurationUnit): DurationObjectUnits { const convertedUnit = DURATION_MAP[unit] || unit; return { [`${convertedUnit}`]: durationValue }; @@ -216,10 +232,37 @@ function plus( return DateTime.fromJSDate(date).plus(duration).toJSDate(); } -function toDateTime(date: Date | DateTime): DateTime { - if (isDateTime(date)) return date; +function diffTo(date: DateTime, args: [string | Date | DateTime, DurationUnit | DurationUnit[]]) { + const [otherDate, unit = 'days'] = args; + let units = Array.isArray(unit) ? unit : [unit]; - return DateTime.fromJSDate(date); + if (units.length === 0) { + units = ['days']; + } + + const allowedUnitSet = new Set([...dateParts, ...durationUnits]); + const errorUnit = units.find((u) => !allowedUnitSet.has(u)); + + if (errorUnit) { + throw new ExpressionExtensionError( + `Unsupported unit '${String(errorUnit)}'. Supported: ${durationUnits + .map((u) => `'${u}'`) + .join(', ')}.`, + ); + } + + const diffResult = date.diff(toDateTime(otherDate), units); + + if (units.length > 1) { + return diffResult.toObject(); + } + + return diffResult.as(units[0]); +} + +function diffToNow(date: DateTime, args: [DurationUnit | DurationUnit[]]) { + const [unit] = args; + return diffTo(date, [DateTime.now(), unit]); } function toInt(date: Date | DateTime): number { @@ -237,7 +280,7 @@ function toBoolean() { endOfMonth.doc = { name: 'endOfMonth', - returnType: 'Date', + returnType: 'DateTime', hidden: true, description: 'Transforms a date to the last possible moment that lies within the month.', section: 'edit', @@ -267,37 +310,98 @@ beginningOf.doc = { description: 'Transform a Date to the start of the given time period. Default unit is `week`.', section: 'edit', hidden: true, - returnType: 'Date', + returnType: 'DateTime', args: [{ name: 'unit?', type: 'DurationUnit' }], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-beginningOf', }; extract.doc = { name: 'extract', - description: 'Extracts the part defined in `datePart` from a Date. Default unit is `week`.', + description: + 'Extracts a part of the date or time, e.g. the month, as a number. To extract textual names instead, see format().', + examples: [ + { example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.extract('month')", evaluated: '3' }, + { example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.extract('hour')", evaluated: '18' }, + ], section: 'query', returnType: 'number', - args: [{ name: 'datePart?', type: 'DurationUnit' }], + args: [ + { + name: 'unit', + optional: true, + description: + 'The part of the date or time to return. One of: year, month, week, day, hour, minute, second', + default: '"week"', + type: 'string', + }, + ], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-extract', }; format.doc = { name: 'format', - description: 'Formats a Date in the given structure.', + description: + 'Converts the DateTime to a string, using the format specified. Formatting guide. For common formats, toLocaleString() may be easier.', + examples: [ + { + example: "dt = '2024-04-30T18:49'.toDateTime()\ndt.format('dd/LL/yyyy')", + evaluated: "'30/04/2024'", + }, + { + example: "dt = '2024-04-30T18:49'.toDateTime()\ndt.format('dd LLL yy')", + evaluated: "'30 Apr 24'", + }, + { + example: "dt = '2024-04-30T18:49'.toDateTime()\ndt.setLocale('fr').format('dd LLL yyyy')", + evaluated: "'30 avr. 2024'", + }, + { + example: "dt = '2024-04-30T18:49'.toDateTime()\ndt.format(\"HH 'hours and' mm 'minutes'\")", + evaluated: "'18 hours and 49 minutes'", + }, + ], returnType: 'string', section: 'format', - args: [{ name: 'fmt', default: "'yyyy-MM-dd'", type: 'TimeFormat' }], + args: [ + { + name: 'fmt', + description: + 'The format of the string to return ', + default: "'yyyy-MM-dd'", + type: 'string', + }, + ], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-format', }; isBetween.doc = { name: 'isBetween', - description: 'Checks if a Date is between two given dates.', - section: 'query', + description: 'Returns true if the DateTime lies between the two moments specified', + examples: [ + { + example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.isBetween('2020-06-01', '2025-06-01')", + evaluated: 'true', + }, + { + example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.isBetween('2020', '2025')", + evaluated: 'true', + }, + ], + section: 'compare', returnType: 'boolean', args: [ - { name: 'date1', type: 'Date|string' }, - { name: 'date2', type: 'Date|string' }, + { + name: 'date1', + description: + 'The moment that the base DateTime must be after. Can be an ISO date string or a Luxon DateTime.', + type: 'string | DateTime', + }, + { + name: 'date2', + description: + 'The moment that the base DateTime must be before. Can be an ISO date string or a Luxon DateTime.', + type: 'string | DateTime', + }, ], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-isBetween', }; @@ -317,7 +421,14 @@ isInLast.doc = { toDateTime.doc = { name: 'toDateTime', - description: 'Convert a JavaScript Date to a Luxon DateTime.', + description: + 'Converts a JavaScript Date to a Luxon DateTime. The DateTime contains the same information, but is easier to manipulate.', + examples: [ + { + example: "jsDate = new Date('2024-03-30T18:49')\njsDate.toDateTime().plus(5, 'days')", + evaluated: '[DateTime: 2024-05-05T18:49:00.000Z]', + }, + ], returnType: 'DateTime', hidden: true, docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-toDateTime', @@ -325,28 +436,135 @@ toDateTime.doc = { minus.doc = { name: 'minus', - description: 'Subtracts a given time period from a Date. Default unit is `milliseconds`.', + description: 'Subtracts a given period of time from the DateTime', + examples: [ + { + example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.minus(7, 'days')", + evaluated: '[DateTime: 2024-04-23T18:49:00.000Z]', + }, + { + example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.minus(4, 'years')", + evaluated: '[DateTime: 2020-04-30T18:49:00.000Z]', + }, + ], section: 'edit', - returnType: 'Date', + returnType: 'DateTime', args: [ - { name: 'n', type: 'number' }, - { name: 'unit?', type: 'DurationUnit' }, + { + name: 'n', + description: + 'The number of units to subtract. Or use a Luxon Duration object to subtract multiple units at once.', + type: 'number | object', + }, + { + name: 'unit', + optional: true, + description: + 'The units of the number. One of: years, months, weeks, days, hours, minutes, seconds, milliseconds', + default: '"milliseconds"', + type: 'string', + }, ], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-minus', }; plus.doc = { name: 'plus', - description: 'Adds a given time period to a Date. Default unit is `milliseconds`.', + description: 'Adds a given period of time to the DateTime', + examples: [ + { + example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.plus(7, 'days')", + evaluated: '[DateTime: 2024-04-07T18:49:00.000Z]', + }, + { + example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.plus(4, 'years')", + evaluated: '[DateTime: 2028-03-30T18:49:00.000Z]', + }, + ], section: 'edit', - returnType: 'Date', + returnType: 'DateTime', args: [ - { name: 'n', type: 'number' }, - { name: 'unit?', type: 'DurationUnit' }, + { + name: 'n', + description: + 'The number of units to add. Or use a Luxon Duration object to add multiple units at once.', + type: 'number | object', + }, + { + name: 'unit', + optional: true, + description: + 'The units of the number. One of: years, months, weeks, days, hours, minutes, seconds, milliseconds', + default: '"milliseconds"', + type: 'string', + }, ], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-plus', }; +diffTo.doc = { + name: 'diffTo', + description: 'Returns the difference between two DateTimes, in the given unit(s)', + examples: [ + { + example: "dt = '2025-01-01'.toDateTime()\ndt.diffTo('2024-03-30T18:49:07.234', 'days')", + evaluated: '276.21', + }, + { + example: + "dt1 = '2025-01-01T00:00:00.000'.toDateTime();\ndt2 = '2024-03-30T18:49:07.234'.toDateTime();\ndt1.diffTo(dt2, ['months', 'days'])", + evaluated: '{ months: 9, days: 1.21 }', + }, + ], + section: 'compare', + returnType: 'number | Record', + args: [ + { + name: 'otherDateTime', + default: '$now', + description: + 'The moment to subtract the base DateTime from. Can be an ISO date string or a Luxon DateTime.', + type: 'string | DateTime', + }, + { + name: 'unit', + default: "'days'", + description: + 'The unit, or array of units, to return the result in. Possible values: years, months, weeks, days, hours, minutes, seconds, milliseconds.', + type: 'string | string[]', + }, + ], + docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-diffTo', +}; + +diffToNow.doc = { + name: 'diffToNow', + description: + 'Returns the difference between the current moment and the DateTime, in the given unit(s). For a textual representation, use toRelative() instead.', + examples: [ + { + example: "dt = '2023-03-30T18:49:07.234'.toDateTime()\ndt.diffToNow('days')", + evaluated: '371.9', + }, + { + example: "dt = '2023-03-30T18:49:07.234.toDateTime()\ndt.diffToNow(['months', 'days'])", + evaluated: '{ months: 12, days: 5.9 }', + }, + ], + section: 'compare', + returnType: 'number | Record', + args: [ + { + name: 'unit', + description: + 'The unit, or array of units, to return the result in. Possible values: years, months, weeks, days, hours, minutes, seconds, milliseconds.', + default: "'days'", + type: 'string | string[]', + }, + ], + docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-diffToNow', +}; + export const dateExtensions: ExtensionMap = { typeName: 'Date', functions: { @@ -361,6 +579,8 @@ export const dateExtensions: ExtensionMap = { plus, format, toDateTime, + diffTo, + diffToNow, toInt, toFloat, toBoolean, diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index 7062e2f632..592aa8ac3c 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -217,7 +217,7 @@ function toDate(value: string): Date { return date; } -function toDateTime(value: string, extraArgs: [string]): DateTime { +export function toDateTime(value: string, extraArgs: [string] = ['']): DateTime { try { const [valueFormat] = extraArgs; diff --git a/packages/workflow/test/ExpressionExtensions/DateExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/DateExtensions.test.ts index e2fd830f11..2e542fc377 100644 --- a/packages/workflow/test/ExpressionExtensions/DateExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/DateExtensions.test.ts @@ -109,6 +109,22 @@ describe('Data Transformation Functions', () => { ).toThrow(); }); + test('.diffTo() should work with a single unit', () => { + expect( + evaluate( + "={{ '2025-01-01'.toDateTime().diffTo('2024-03-30T18:49:07.234', 'days').floor() }}", + ), + ).toEqual(276); + }); + + test('.diffTo() should work with an array of units', () => { + expect( + evaluate( + "={{ '2025-01-01T00:00:00.000'.toDateTime().diffTo('2024-03-30T18:49:07.234', ['months', 'days']) }}", + ), + ).toEqual({ months: 9, days: 1.2158884953703704 }); + }); + describe('toDateTime', () => { test('should return itself for DateTime', () => { const result = evaluate( From ce3eb12a6ba325d3785d54d90ff5a32152afd4c0 Mon Sep 17 00:00:00 2001 From: oleg Date: Thu, 16 May 2024 16:24:19 +0200 Subject: [PATCH 26/58] feat(OpenAI Node): Use v2 assistants API and add support for memory (#9406) Signed-off-by: Oleg Ivaniv --- .../actions/assistant/create.operation.ts | 41 ++++++++++-- .../assistant/deleteAssistant.operation.ts | 2 +- .../actions/assistant/list.operation.ts | 2 +- .../actions/assistant/message.operation.ts | 62 +++++++++++++++++-- .../actions/assistant/update.operation.ts | 55 +++++++++++++--- .../OpenAi/actions/versionDescription.ts | 1 + .../vendors/OpenAi/methods/listSearch.ts | 2 +- .../vendors/OpenAi/test/OpenAi.node.test.ts | 38 +++++++++--- 8 files changed, 175 insertions(+), 28 deletions(-) diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/create.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/create.operation.ts index 30664aebcc..341a17712a 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/create.operation.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/create.operation.ts @@ -133,6 +133,24 @@ const properties: INodeProperties[] = [ type: 'collection', default: {}, options: [ + { + displayName: 'Output Randomness (Temperature)', + name: 'temperature', + default: 1, + typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 }, + description: + 'Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive. We generally recommend altering this or temperature but not both.', + type: 'number', + }, + { + displayName: 'Output Randomness (Top P)', + name: 'topP', + default: 1, + typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 }, + description: + 'An alternative to sampling with temperature, controls diversity via nucleus sampling: 0.5 means half of all likelihood-weighted options are considered. We generally recommend altering this or temperature but not both.', + type: 'number', + }, { displayName: 'Fail if Assistant Already Exists', name: 'failIfExists', @@ -176,7 +194,7 @@ export async function execute(this: IExecuteFunctions, i: number): Promise ({ + role: message._getType() === 'ai' ? 'assistant' : 'user', + content: message.content.toString(), +}); export async function execute(this: IExecuteFunctions, i: number): Promise { const credentials = await this.getCredentials('openAiApi'); @@ -182,11 +196,47 @@ export async function execute(this: IExecuteFunctions, i: number): Promise tool.type !== 'code_interpreter'); } - if (knowledgeRetrieval && !tools.find((tool) => tool.type === 'retrieval')) { + if (knowledgeRetrieval && !tools.find((tool) => tool.type === 'file_search')) { tools.push({ - type: 'retrieval', + type: 'file_search', }); } - if (knowledgeRetrieval === false && tools.find((tool) => tool.type === 'retrieval')) { - tools = tools.filter((tool) => tool.type !== 'retrieval'); + if (knowledgeRetrieval === false && tools.find((tool) => tool.type === 'file_search')) { + tools = tools.filter((tool) => tool.type !== 'file_search'); } if (removeCustomTools) { @@ -185,7 +226,7 @@ export async function execute(this: IExecuteFunctions, i: number): Promise { const { data, has_more, last_id } = await apiRequest.call(this, 'GET', '/assistants', { headers: { - 'OpenAI-Beta': 'assistants=v1', + 'OpenAI-Beta': 'assistants=v2', }, qs: { limit: 100, diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/OpenAi.node.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/OpenAi.node.test.ts index 87e9754551..c6b7c9ddaa 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/OpenAi.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/OpenAi.node.test.ts @@ -84,13 +84,24 @@ describe('OpenAi, Assistant resource', () => { expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/assistants', { body: { description: 'description', - file_ids: [], instructions: 'some instructions', model: 'gpt-model', name: 'name', - tools: [{ type: 'code_interpreter' }, { type: 'retrieval' }], + tool_resources: { + code_interpreter: { + file_ids: [], + }, + file_search: { + vector_stores: [ + { + file_ids: [], + }, + ], + }, + }, + tools: [{ type: 'code_interpreter' }, { type: 'file_search' }], }, - headers: { 'OpenAI-Beta': 'assistants=v1' }, + headers: { 'OpenAI-Beta': 'assistants=v2' }, }); }); @@ -124,7 +135,7 @@ describe('OpenAi, Assistant resource', () => { ); expect(transport.apiRequest).toHaveBeenCalledWith('DELETE', '/assistants/assistant-id', { - headers: { 'OpenAI-Beta': 'assistants=v1' }, + headers: { 'OpenAI-Beta': 'assistants=v2' }, }); }); @@ -185,17 +196,28 @@ describe('OpenAi, Assistant resource', () => { expect(transport.apiRequest).toHaveBeenCalledTimes(2); expect(transport.apiRequest).toHaveBeenCalledWith('GET', '/assistants/assistant-id', { - headers: { 'OpenAI-Beta': 'assistants=v1' }, + headers: { 'OpenAI-Beta': 'assistants=v2' }, }); expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/assistants/assistant-id', { body: { - file_ids: [], instructions: 'some instructions', model: 'gpt-model', name: 'name', - tools: [{ type: 'existing_tool' }, { type: 'code_interpreter' }, { type: 'retrieval' }], + tool_resources: { + code_interpreter: { + file_ids: [], + }, + file_search: { + vector_stores: [ + { + file_ids: [], + }, + ], + }, + }, + tools: [{ type: 'existing_tool' }, { type: 'code_interpreter' }, { type: 'file_search' }], }, - headers: { 'OpenAI-Beta': 'assistants=v1' }, + headers: { 'OpenAI-Beta': 'assistants=v2' }, }); }); }); From 8069faa5fe63c0cf19b343db1bb7005d6c27118b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 17 May 2024 09:39:44 +0200 Subject: [PATCH 27/58] fix(core): Do not report to Sentry trigger activation errors from `ETIMEDOUT` or `ECONNREFUSED` (no-changelog) (#9379) --- packages/core/src/ActiveWorkflows.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/src/ActiveWorkflows.ts b/packages/core/src/ActiveWorkflows.ts index fb64f0fef7..cda49404f7 100644 --- a/packages/core/src/ActiveWorkflows.ts +++ b/packages/core/src/ActiveWorkflows.ts @@ -92,7 +92,13 @@ export class ActiveWorkflows { throw new WorkflowActivationError( `There was a problem activating the workflow: "${error.message}"`, - { cause: error, node: triggerNode }, + { + cause: error, + node: triggerNode, + level: ['ETIMEDOUT', 'ECONNREFUSED'].some((code) => error.message.includes(code)) + ? 'warning' + : 'error', + }, ); } } From 9b2ce819d42c4a541ae94956aaab608a989ec588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 17 May 2024 10:46:42 +0200 Subject: [PATCH 28/58] fix(core): Retry before continue on fail (#9395) --- packages/core/src/WorkflowExecute.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index a87f98b25d..c518eb2c1b 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -43,6 +43,7 @@ import { NodeConnectionType, ApplicationError, NodeExecutionOutput, + sleep, } from 'n8n-workflow'; import get from 'lodash/get'; import * as NodeExecuteFunctions from './NodeExecuteFunctions'; @@ -1054,7 +1055,7 @@ export class WorkflowExecute { workflowId: workflow.id, }); - const runNodeData = await workflow.runNode( + let runNodeData = await workflow.runNode( executionData, this.runExecutionData, runIndex, @@ -1066,6 +1067,24 @@ export class WorkflowExecute { nodeSuccessData = runNodeData.data; + const didContinueOnFail = nodeSuccessData?.at(0)?.at(0)?.json.error !== undefined; + + while (didContinueOnFail && tryIndex !== maxTries - 1) { + await sleep(waitBetweenTries); + + runNodeData = await workflow.runNode( + executionData, + this.runExecutionData, + runIndex, + this.additionalData, + NodeExecuteFunctions, + this.mode, + this.abortController.signal, + ); + + tryIndex++; + } + if (nodeSuccessData instanceof NodeExecutionOutput) { // eslint-disable-next-line @typescript-eslint/no-unsafe-call const hints: NodeExecutionHint[] = nodeSuccessData.getHints(); From b1f977ebd084ab3a8fb1d13109063de7d2a15296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 17 May 2024 10:47:03 +0200 Subject: [PATCH 29/58] fix(core): Remove excess args from routing error (#9377) --- packages/workflow/src/RoutingNode.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/packages/workflow/src/RoutingNode.ts b/packages/workflow/src/RoutingNode.ts index 99935ede32..abd77a5d5b 100644 --- a/packages/workflow/src/RoutingNode.ts +++ b/packages/workflow/src/RoutingNode.ts @@ -43,7 +43,6 @@ import type { import * as NodeHelpers from './NodeHelpers'; import type { Workflow } from './Workflow'; -import type { NodeError } from './errors/abstract/node.error'; import { NodeOperationError } from './errors/node-operation.error'; import { NodeApiError } from './errors/node-api.error'; @@ -233,23 +232,9 @@ export class RoutingNode { throw error; } - interface AxiosError extends NodeError { - isAxiosError: boolean; - description: string | undefined; - response?: { status: number }; - } - - const routingError = error as AxiosError; - throw new NodeApiError(this.node, error as JsonObject, { runIndex, itemIndex: i, - message: routingError?.message, - description: routingError?.description, - httpCode: - routingError.isAxiosError && routingError.response - ? String(routingError.response?.status) - : 'none', }); } } From 596c472ecc756bf934c51e7efae0075fb23313b4 Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Fri, 17 May 2024 10:53:15 +0200 Subject: [PATCH 30/58] feat: RBAC (#8922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Oleg Ivaniv Co-authored-by: Val <68596159+valya@users.noreply.github.com> Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ Co-authored-by: Valya Bullions Co-authored-by: Danny Martini Co-authored-by: Danny Martini Co-authored-by: Iván Ovejero Co-authored-by: Omar Ajoue Co-authored-by: oleg Co-authored-by: Michael Kret Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com> Co-authored-by: Elias Meire Co-authored-by: Giulio Andreini Co-authored-by: Giulio Andreini Co-authored-by: Ayato Hayashi --- cypress/composables/projects.ts | 18 + cypress/e2e/17-sharing.cy.ts | 6 +- cypress/e2e/19-execution.cy.ts | 10 +- cypress/e2e/23-variables.cy.ts | 7 +- cypress/e2e/28-debug.cy.ts | 2 +- cypress/e2e/29-templates.cy.ts | 33 +- .../e2e/30-editor-after-route-changes.cy.ts | 6 +- cypress/e2e/39-projects.cy.ts | 151 +++ cypress/e2e/5-ndv.cy.ts | 2 +- cypress/pages/credentials.ts | 2 +- cypress/pages/modals/credentials-modal.ts | 2 +- .../pages/modals/workflow-sharing-modal.ts | 2 +- cypress/pages/settings-users.ts | 4 +- cypress/pages/sidebar/main-sidebar.ts | 15 +- cypress/pages/sidebar/settings-sidebar.ts | 5 +- cypress/pages/variables.ts | 4 +- cypress/pages/workflow-executions-tab.ts | 2 +- cypress/pages/workflows.ts | 2 +- cypress/support/commands.ts | 9 +- cypress/support/index.ts | 1 + cypress/utils/executions.ts | 4 +- .../@n8n/permissions/src/combineScopes.ts | 23 + packages/@n8n/permissions/src/hasScope.ts | 8 +- packages/@n8n/permissions/src/index.ts | 1 + packages/@n8n/permissions/src/types.ts | 13 +- .../@n8n/permissions/test/hasScope.test.ts | 126 ++ packages/cli/.eslintrc.js | 16 + packages/cli/src/ActiveWebhooks.ts | 6 +- packages/cli/src/ActiveWorkflowManager.ts | 14 +- packages/cli/src/CredentialsHelper.ts | 65 +- packages/cli/src/Interfaces.ts | 3 +- packages/cli/src/InternalHooks.ts | 72 +- packages/cli/src/Ldap/helpers.ts | 38 +- packages/cli/src/Ldap/ldap.service.ts | 2 +- packages/cli/src/License.ts | 16 + packages/cli/src/Mfa/mfa.service.ts | 36 +- packages/cli/src/PublicApi/types.ts | 12 +- .../v1/handlers/audit/audit.handler.ts | 4 +- .../credentials/credentials.handler.ts | 9 +- .../credentials/credentials.service.ts | 9 +- .../handlers/executions/executions.handler.ts | 11 +- .../sourceControl/sourceControl.handler.ts | 4 +- .../v1/handlers/tags/tags.handler.ts | 12 +- .../v1/handlers/users/users.handler.ee.ts | 6 +- .../handlers/workflows/workflows.handler.ts | 128 +- .../handlers/workflows/workflows.service.ts | 26 +- .../shared/middlewares/global.middleware.ts | 35 +- packages/cli/src/Server.ts | 4 + .../src/UserManagement/PermissionChecker.ts | 65 +- packages/cli/src/WaitTracker.ts | 4 +- packages/cli/src/WaitingWebhooks.ts | 9 +- packages/cli/src/WebhookHelpers.ts | 24 +- .../cli/src/WorkflowExecuteAdditionalData.ts | 28 +- packages/cli/src/WorkflowRunner.ts | 2 +- packages/cli/src/auth/auth.service.ts | 4 +- .../cli/src/commands/import/credentials.ts | 102 +- packages/cli/src/commands/import/workflow.ts | 81 +- packages/cli/src/commands/ldap/reset.ts | 134 +- packages/cli/src/commands/mfa/disable.ts | 21 +- .../cli/src/commands/user-management/reset.ts | 10 +- packages/cli/src/commands/worker.ts | 5 +- packages/cli/src/constants.ts | 7 +- .../cli/src/controllers/auth.controller.ts | 4 +- .../cli/src/controllers/e2e.controller.ts | 90 +- .../src/controllers/invitation.controller.ts | 6 +- .../oauth/abstractOAuth.controller.ts | 1 + .../controllers/passwordReset.controller.ts | 8 +- .../cli/src/controllers/project.controller.ts | 221 +++ .../cli/src/controllers/role.controller.ts | 22 + .../cli/src/controllers/users.controller.ts | 182 ++- .../workflowStatistics.controller.ts | 8 +- .../src/credentials/credentials.controller.ts | 330 ++--- .../src/credentials/credentials.service.ee.ts | 127 +- .../src/credentials/credentials.service.ts | 304 ++++- packages/cli/src/databases/config.ts | 2 + packages/cli/src/databases/dsl/Column.ts | 6 +- packages/cli/src/databases/dsl/Table.ts | 9 +- .../cli/src/databases/entities/Project.ts | 25 + .../src/databases/entities/ProjectRelation.ts | 25 + .../databases/entities/SharedCredentials.ts | 14 +- .../src/databases/entities/SharedWorkflow.ts | 16 +- packages/cli/src/databases/entities/User.ts | 27 +- packages/cli/src/databases/entities/index.ts | 4 + .../1711390882123-MoveSshKeysToDatabase.ts | 5 + .../common/1714133768519-CreateProject.ts | 328 +++++ .../src/databases/migrations/mysqldb/index.ts | 2 + .../databases/migrations/postgresdb/index.ts | 2 + .../src/databases/migrations/sqlite/index.ts | 2 + .../repositories/credentials.repository.ts | 30 +- .../repositories/project.repository.ts | 45 + .../projectRelation.repository.ts | 55 + .../sharedCredentials.repository.ts | 137 +- .../repositories/sharedWorkflow.repository.ts | 249 ++-- .../databases/repositories/user.repository.ts | 47 +- .../repositories/workflow.repository.ts | 46 +- .../workflowStatistics.repository.ts | 30 +- .../databases/subscribers/UserSubscriber.ts | 73 + .../cli/src/databases/subscribers/index.ts | 5 + packages/cli/src/decorators/Scoped.ts | 60 + packages/cli/src/decorators/Scopes.ts | 14 - packages/cli/src/decorators/constants.ts | 2 +- packages/cli/src/decorators/index.ts | 2 +- .../cli/src/decorators/registerController.ts | 43 +- packages/cli/src/decorators/types.ts | 7 +- .../sourceControlExport.service.ee.ts | 57 +- .../sourceControlImport.service.ee.ts | 233 ++-- .../types/exportableCredential.ts | 3 +- .../sourceControl/types/exportableWorkflow.ts | 3 +- .../sourceControl/types/resourceOwner.ts | 11 + .../variables/variables.controller.ee.ts | 2 +- .../errors/response-errors/forbidden.error.ts | 7 + .../response-errors/unauthenticated.error.ts | 7 + .../response-errors/unauthorized.error.ts | 7 - .../src/executions/execution.service.ee.ts | 23 +- .../src/executions/executions.controller.ts | 24 +- packages/cli/src/jest.d.ts | 2 + .../cli/src/license/license.controller.ts | 2 +- .../listQuery/dtos/credentials.filter.dto.ts | 5 + .../listQuery/dtos/workflow.filter.dto.ts | 5 + packages/cli/src/permissions/checkAccess.ts | 87 ++ .../permissions/{roles.ts => global-roles.ts} | 14 +- packages/cli/src/permissions/project-roles.ts | 59 + .../cli/src/permissions/resource-roles.ts | 24 + packages/cli/src/requests.ts | 104 +- .../src/services/activeWorkflows.service.ts | 6 +- .../services/credentials-tester.service.ts | 2 +- packages/cli/src/services/events.service.ts | 37 +- packages/cli/src/services/frontend.service.ts | 7 + packages/cli/src/services/import.service.ts | 18 +- .../cli/src/services/ownership.service.ts | 74 +- packages/cli/src/services/project.service.ts | 343 +++++ packages/cli/src/services/role.service.ts | 239 ++++ packages/cli/src/services/user.service.ts | 26 +- .../src/services/userOnboarding.service.ts | 7 +- packages/cli/src/sso/saml/saml.service.ee.ts | 3 +- packages/cli/src/sso/saml/samlHelpers.ts | 43 +- packages/cli/src/telemetry/index.ts | 4 + packages/cli/src/utils.ts | 7 + .../cli/src/workflows/workflow.request.ts | 15 +- .../cli/src/workflows/workflow.service.ee.ts | 82 +- .../cli/src/workflows/workflow.service.ts | 178 ++- .../workflows/workflowExecution.service.ts | 5 +- .../workflowHistory.service.ee.ts | 30 +- .../src/workflows/workflowSharing.service.ts | 57 +- .../cli/src/workflows/workflows.controller.ts | 266 ++-- packages/cli/src/workflows/workflows.types.ts | 11 +- packages/cli/test/extend-expect.ts | 22 + .../integration/CredentialsHelper.test.ts | 152 +++ .../integration/PermissionChecker.test.ts | 270 ++-- .../cli/test/integration/auth.api.test.ts | 3 +- .../commands/credentials.cmd.test.ts | 110 +- .../integration/commands/import.cmd.test.ts | 117 +- .../integration/commands/ldap/reset.test.ts | 381 ++++++ .../integration/commands/reset.cmd.test.ts | 88 +- .../invitation.controller.integration.test.ts | 39 + .../cli/test/integration/credentials.test.ts | 270 +++- .../credentials.controller.test.ts | 142 +- .../{ => credentials}/credentials.ee.test.ts | 373 ++++-- .../credentials/credentials.service.test.ts | 55 + .../repositories/project.repository.test.ts | 155 +++ .../environments/SourceControl.test.ts | 5 + .../source-control-import.service.test.ts | 212 ++- .../integration/executions.controller.test.ts | 53 +- .../test/integration/import.service.test.ts | 31 +- .../test/integration/ldap/ldap.api.test.ts | 37 +- .../cli/test/integration/license.api.test.ts | 7 +- packages/cli/test/integration/me.api.test.ts | 44 +- .../cli/test/integration/project.api.test.ts | 1179 +++++++++++++++++ .../project.service.integration.test.ts | 116 ++ .../integration/publicApi/credentials.test.ts | 14 +- .../integration/publicApi/executions.test.ts | 2 + .../integration/publicApi/workflows.test.ts | 52 +- .../cli/test/integration/role.api.test.ts | 165 +++ .../test/integration/saml/samlHelpers.test.ts | 44 + .../services/project.service.test.ts | 202 +++ .../test/integration/shared/db/credentials.ts | 86 +- .../test/integration/shared/db/projects.ts | 63 + .../cli/test/integration/shared/db/users.ts | 21 +- .../test/integration/shared/db/workflows.ts | 73 +- packages/cli/test/integration/shared/ldap.ts | 33 + .../cli/test/integration/shared/testDb.ts | 6 +- packages/cli/test/integration/shared/types.ts | 7 +- .../integration/shared/utils/testServer.ts | 10 + .../test/integration/user.repository.test.ts | 23 + .../cli/test/integration/users.api.test.ts | 496 +++++-- .../workflowHistoryManager.test.ts | 4 + .../workflows/workflow.service.ee.test.ts | 1 + .../workflows/workflow.service.test.ts | 9 +- .../workflows/workflowSharing.service.test.ts | 117 ++ .../workflows/workflows.controller.ee.test.ts | 268 ++-- .../workflows/workflows.controller.test.ts | 489 ++++++- packages/cli/test/unit/InternalHooks.test.ts | 2 + packages/cli/test/unit/Ldap/helpers.test.ts | 40 + .../cli/test/unit/PermissionChecker.test.ts | 131 -- .../databases/entities/user.entity.test.ts | 18 + .../sharedCredentials.repository.test.ts | 47 +- .../services/activeWorkflows.service.test.ts | 13 +- .../test/unit/services/events.service.test.ts | 8 +- .../unit/services/ownership.service.test.ts | 165 ++- .../test/unit/services/user.service.test.ts | 29 +- packages/cli/test/unit/shared/mockObjects.ts | 9 + .../test/unit/sso/saml/samlHelpers.test.ts | 55 + .../src/components/N8nButton/Button.vue | 5 +- .../src/components/N8nMenu/Menu.vue | 9 +- .../src/components/N8nMenuItem/MenuItem.vue | 40 +- .../src/components/N8nTabs/Tabs.vue | 12 +- packages/design-system/src/types/button.ts | 4 + packages/design-system/src/types/menu.ts | 2 + packages/editor-ui/src/Interface.ts | 25 +- .../editor-ui/src/__tests__/data/projects.ts | 22 + .../editor-ui/src/__tests__/data/users.ts | 17 + packages/editor-ui/src/__tests__/mocks.ts | 14 +- .../src/__tests__/permissions.spec.ts | 154 ++- .../editor-ui/src/__tests__/router.test.ts | 1 + packages/editor-ui/src/__tests__/utils.ts | 16 + packages/editor-ui/src/api/credentials.ts | 16 +- packages/editor-ui/src/api/roles.api.ts | 7 + packages/editor-ui/src/api/workflows.ts | 11 +- .../src/components/CredentialCard.vue | 38 +- .../CredentialEdit/CredentialConfig.vue | 17 +- .../CredentialEdit/CredentialEdit.vue | 247 ++-- .../CredentialEdit/CredentialSharing.ee.vue | 256 ++-- .../src/components/CredentialsSelect.vue | 1 + .../src/components/DeleteUserModal.vue | 77 +- .../components/DuplicateWorkflowDialog.vue | 29 +- .../MainHeader/WorkflowDetails.spec.ts | 32 +- .../components/MainHeader/WorkflowDetails.vue | 54 +- .../editor-ui/src/components/MainSidebar.vue | 53 +- .../src/components/TagsContainer.vue | 4 +- .../editor-ui/src/components/Telemetry.vue | 4 +- .../editor-ui/src/components/VariablesRow.vue | 10 +- .../editor-ui/src/components/WorkflowCard.vue | 46 +- .../src/components/WorkflowSettings.vue | 25 +- .../src/components/WorkflowShareModal.ee.vue | 345 ++--- .../__tests__/CredentialCard.test.ts | 68 + .../__tests__/ParameterInput.test.ts | 1 + .../components/__tests__/WorkflowCard.test.ts | 54 +- .../__tests__/WorkflowSettings.spec.ts | 71 +- .../forms/ResourceFiltersDropdown.vue | 64 +- .../forms/ResourceOwnershipSelect.ee.vue | 62 - .../src/components/layouts/PageViewLayout.vue | 60 +- .../components/layouts/PageViewLayoutList.vue | 17 +- .../layouts/ResourcesListLayout.vue | 211 +-- .../src/composables/useNodeHelpers.ts | 11 +- .../src/composables/usePushConnection.spec.ts | 1 + .../src/composables/useWorkflowHelpers.ts | 46 +- packages/editor-ui/src/constants.ts | 4 + .../projects/__tests__/projects.utils.test.ts | 21 + .../projects/components/ProjectCardBadge.vue | 48 + .../components/ProjectDeleteDialog.vue | 117 ++ .../projects/components/ProjectNavigation.vue | 226 ++++ .../components/ProjectRoleUpgradeDialog.vue | 48 + .../projects/components/ProjectSettings.vue | 412 ++++++ .../projects/components/ProjectSharing.vue | 188 +++ .../components/ProjectSharingInfo.vue | 57 + .../projects/components/ProjectTabs.vue | 79 ++ .../__tests__/ProjectSettings.test.ts | 128 ++ .../__tests__/ProjectSharing.test.ts | 163 +++ .../components/__tests__/ProjectTabs.test.ts | 92 ++ .../__tests__/projects.utils.test.ts | 61 + .../src/features/projects/projects.api.ts | 59 + .../src/features/projects/projects.routes.ts | 108 ++ .../src/features/projects/projects.store.ts | 154 +++ .../src/features/projects/projects.types.ts | 29 + .../src/features/projects/projects.utils.ts | 28 + packages/editor-ui/src/init.ts | 10 + packages/editor-ui/src/permissions.ts | 195 +-- .../src/plugins/__tests__/telemetry.test.ts | 24 + .../src/plugins/i18n/locales/en.json | 87 +- packages/editor-ui/src/plugins/icons/index.ts | 2 + .../editor-ui/src/plugins/telemetry/index.ts | 11 +- packages/editor-ui/src/router.ts | 31 +- .../src/stores/__tests__/rbac.store.test.ts | 4 + .../editor-ui/src/stores/credentials.store.ts | 92 +- packages/editor-ui/src/stores/rbac.store.ts | 16 +- packages/editor-ui/src/stores/roles.store.ts | 50 + packages/editor-ui/src/stores/users.store.ts | 9 - .../src/stores/workflowHistory.store.ts | 7 +- .../src/stores/workflows.ee.store.ts | 51 +- .../editor-ui/src/stores/workflows.store.ts | 53 +- packages/editor-ui/src/types/roles.types.ts | 25 + packages/editor-ui/src/utils/apiUtils.ts | 9 + .../src/utils/templates/templateActions.ts | 2 +- .../editor-ui/src/views/CredentialsView.vue | 39 +- packages/editor-ui/src/views/NodeView.vue | 67 +- .../editor-ui/src/views/SettingsUsersView.vue | 10 +- .../__tests__/setupTemplate.store.testData.ts | 4 +- .../editor-ui/src/views/VariablesView.vue | 5 + .../editor-ui/src/views/WorkflowsView.vue | 72 +- .../views/__tests__/SettingsUsersView.test.ts | 137 ++ .../src/views/__tests__/WorkflowsView.test.ts | 7 + packages/workflow/src/Interfaces.ts | 19 +- 292 files changed, 14129 insertions(+), 3989 deletions(-) create mode 100644 cypress/composables/projects.ts create mode 100644 cypress/e2e/39-projects.cy.ts create mode 100644 packages/@n8n/permissions/src/combineScopes.ts create mode 100644 packages/cli/src/controllers/project.controller.ts create mode 100644 packages/cli/src/controllers/role.controller.ts create mode 100644 packages/cli/src/databases/entities/Project.ts create mode 100644 packages/cli/src/databases/entities/ProjectRelation.ts create mode 100644 packages/cli/src/databases/migrations/common/1714133768519-CreateProject.ts create mode 100644 packages/cli/src/databases/repositories/project.repository.ts create mode 100644 packages/cli/src/databases/repositories/projectRelation.repository.ts create mode 100644 packages/cli/src/databases/subscribers/UserSubscriber.ts create mode 100644 packages/cli/src/databases/subscribers/index.ts create mode 100644 packages/cli/src/decorators/Scoped.ts delete mode 100644 packages/cli/src/decorators/Scopes.ts create mode 100644 packages/cli/src/environments/sourceControl/types/resourceOwner.ts create mode 100644 packages/cli/src/errors/response-errors/forbidden.error.ts create mode 100644 packages/cli/src/errors/response-errors/unauthenticated.error.ts delete mode 100644 packages/cli/src/errors/response-errors/unauthorized.error.ts create mode 100644 packages/cli/src/permissions/checkAccess.ts rename packages/cli/src/permissions/{roles.ts => global-roles.ts} (86%) create mode 100644 packages/cli/src/permissions/project-roles.ts create mode 100644 packages/cli/src/permissions/resource-roles.ts create mode 100644 packages/cli/src/services/project.service.ts create mode 100644 packages/cli/src/services/role.service.ts create mode 100644 packages/cli/test/integration/CredentialsHelper.test.ts create mode 100644 packages/cli/test/integration/commands/ldap/reset.test.ts rename packages/cli/test/integration/{ => credentials}/credentials.controller.test.ts (61%) rename packages/cli/test/integration/{ => credentials}/credentials.ee.test.ts (57%) create mode 100644 packages/cli/test/integration/credentials/credentials.service.test.ts create mode 100644 packages/cli/test/integration/database/repositories/project.repository.test.ts create mode 100644 packages/cli/test/integration/project.api.test.ts create mode 100644 packages/cli/test/integration/project.service.integration.test.ts create mode 100644 packages/cli/test/integration/role.api.test.ts create mode 100644 packages/cli/test/integration/saml/samlHelpers.test.ts create mode 100644 packages/cli/test/integration/services/project.service.test.ts create mode 100644 packages/cli/test/integration/shared/db/projects.ts create mode 100644 packages/cli/test/integration/shared/ldap.ts create mode 100644 packages/cli/test/integration/workflows/workflowSharing.service.test.ts create mode 100644 packages/cli/test/unit/Ldap/helpers.test.ts delete mode 100644 packages/cli/test/unit/PermissionChecker.test.ts create mode 100644 packages/cli/test/unit/sso/saml/samlHelpers.test.ts create mode 100644 packages/editor-ui/src/__tests__/data/projects.ts create mode 100644 packages/editor-ui/src/__tests__/data/users.ts create mode 100644 packages/editor-ui/src/api/roles.api.ts create mode 100644 packages/editor-ui/src/components/__tests__/CredentialCard.test.ts delete mode 100644 packages/editor-ui/src/components/forms/ResourceOwnershipSelect.ee.vue create mode 100644 packages/editor-ui/src/features/projects/__tests__/projects.utils.test.ts create mode 100644 packages/editor-ui/src/features/projects/components/ProjectCardBadge.vue create mode 100644 packages/editor-ui/src/features/projects/components/ProjectDeleteDialog.vue create mode 100644 packages/editor-ui/src/features/projects/components/ProjectNavigation.vue create mode 100644 packages/editor-ui/src/features/projects/components/ProjectRoleUpgradeDialog.vue create mode 100644 packages/editor-ui/src/features/projects/components/ProjectSettings.vue create mode 100644 packages/editor-ui/src/features/projects/components/ProjectSharing.vue create mode 100644 packages/editor-ui/src/features/projects/components/ProjectSharingInfo.vue create mode 100644 packages/editor-ui/src/features/projects/components/ProjectTabs.vue create mode 100644 packages/editor-ui/src/features/projects/components/__tests__/ProjectSettings.test.ts create mode 100644 packages/editor-ui/src/features/projects/components/__tests__/ProjectSharing.test.ts create mode 100644 packages/editor-ui/src/features/projects/components/__tests__/ProjectTabs.test.ts create mode 100644 packages/editor-ui/src/features/projects/components/__tests__/projects.utils.test.ts create mode 100644 packages/editor-ui/src/features/projects/projects.api.ts create mode 100644 packages/editor-ui/src/features/projects/projects.routes.ts create mode 100644 packages/editor-ui/src/features/projects/projects.store.ts create mode 100644 packages/editor-ui/src/features/projects/projects.types.ts create mode 100644 packages/editor-ui/src/features/projects/projects.utils.ts create mode 100644 packages/editor-ui/src/stores/roles.store.ts create mode 100644 packages/editor-ui/src/types/roles.types.ts create mode 100644 packages/editor-ui/src/views/__tests__/SettingsUsersView.test.ts diff --git a/cypress/composables/projects.ts b/cypress/composables/projects.ts new file mode 100644 index 0000000000..dd25c3f20c --- /dev/null +++ b/cypress/composables/projects.ts @@ -0,0 +1,18 @@ +export const getHomeButton = () => cy.getByTestId('project-home-menu-item'); +export const getMenuItems = () => cy.getByTestId('project-menu-item'); +export const getAddProjectButton = () => cy.getByTestId('add-project-menu-item'); +export const getProjectTabs = () => cy.getByTestId('project-tabs').find('a'); +export const getProjectTabWorkflows = () => getProjectTabs().filter('a[href$="/workflows"]'); +export const getProjectTabCredentials = () => getProjectTabs().filter('a[href$="/credentials"]'); +export const getProjectTabSettings = () => getProjectTabs().filter('a[href$="/settings"]'); +export const getProjectSettingsSaveButton = () => cy.getByTestId('project-settings-save-button'); +export const getProjectSettingsCancelButton = () => + cy.getByTestId('project-settings-cancel-button'); +export const getProjectSettingsDeleteButton = () => + cy.getByTestId('project-settings-delete-button'); +export const getProjectMembersSelect = () => cy.getByTestId('project-members-select'); + +export const addProjectMember = (email: string) => { + getProjectMembersSelect().click(); + getProjectMembersSelect().get('.el-select-dropdown__item').contains(email.toLowerCase()).click(); +}; diff --git a/cypress/e2e/17-sharing.cy.ts b/cypress/e2e/17-sharing.cy.ts index 71f41250ec..7908e8d128 100644 --- a/cypress/e2e/17-sharing.cy.ts +++ b/cypress/e2e/17-sharing.cy.ts @@ -30,7 +30,7 @@ const workflowSharingModal = new WorkflowSharingModal(); const ndv = new NDV(); describe('Sharing', { disableAutoLogin: true }, () => { - before(() => cy.enableFeature('sharing', true)); + before(() => cy.enableFeature('sharing')); let workflowW2Url = ''; it('should create C1, W1, W2, share W1 with U3, as U2', () => { @@ -171,11 +171,11 @@ describe('Sharing', { disableAutoLogin: true }, () => { cy.get('input').should('not.have.length'); credentialsModal.actions.changeTab('Sharing'); cy.contains( - 'You can view this credential because you have permission to read and share', + 'Sharing a credential allows people to use it in their workflows. They cannot access credential details.', ).should('be.visible'); credentialsModal.getters.usersSelect().click(); - cy.getByTestId('user-email') + cy.getByTestId('project-sharing-info') .filter(':visible') .should('have.length', 3) .contains(INSTANCE_ADMIN.email) diff --git a/cypress/e2e/19-execution.cy.ts b/cypress/e2e/19-execution.cy.ts index 1855bdb43b..98c0909b4d 100644 --- a/cypress/e2e/19-execution.cy.ts +++ b/cypress/e2e/19-execution.cy.ts @@ -501,7 +501,7 @@ describe('Execution', () => { workflowPage.getters.clearExecutionDataButton().should('be.visible'); - cy.intercept('POST', '/rest/workflows/run').as('workflowRun'); + cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun'); workflowPage.getters .canvasNodeByName('do something with them') @@ -525,7 +525,7 @@ describe('Execution', () => { workflowPage.getters.zoomToFitButton().click(); - cy.intercept('POST', '/rest/workflows/run').as('workflowRun'); + cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun'); workflowPage.getters .canvasNodeByName('If') @@ -545,7 +545,7 @@ describe('Execution', () => { workflowPage.getters.clearExecutionDataButton().should('be.visible'); - cy.intercept('POST', '/rest/workflows/run').as('workflowRun'); + cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun'); workflowPage.getters .canvasNodeByName('NoOp2') @@ -576,7 +576,7 @@ describe('Execution', () => { 'My test workflow', ); - cy.intercept('POST', '/rest/workflows/run').as('workflowRun'); + cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun'); workflowPage.getters.zoomToFitButton().click(); workflowPage.getters.executeWorkflowButton().click(); @@ -599,7 +599,7 @@ describe('Execution', () => { 'My test workflow', ); - cy.intercept('POST', '/rest/workflows/run').as('workflowRun'); + cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun'); workflowPage.getters.zoomToFitButton().click(); workflowPage.getters.executeWorkflowButton().click(); diff --git a/cypress/e2e/23-variables.cy.ts b/cypress/e2e/23-variables.cy.ts index ce6a49fb99..c481f25128 100644 --- a/cypress/e2e/23-variables.cy.ts +++ b/cypress/e2e/23-variables.cy.ts @@ -4,7 +4,7 @@ const variablesPage = new VariablesPage(); describe('Variables', () => { it('should show the unlicensed action box when the feature is disabled', () => { - cy.disableFeature('variables', false); + cy.disableFeature('variables'); cy.visit(variablesPage.url); variablesPage.getters.unavailableResourcesList().should('be.visible'); @@ -18,14 +18,15 @@ describe('Variables', () => { beforeEach(() => { cy.intercept('GET', '/rest/variables').as('loadVariables'); + cy.intercept('GET', '/rest/login').as('login'); cy.visit(variablesPage.url); - cy.wait(['@loadVariables', '@loadSettings']); + cy.wait(['@loadVariables', '@loadSettings', '@login']); }); it('should show the licensed action box when the feature is enabled', () => { variablesPage.getters.emptyResourcesList().should('be.visible'); - variablesPage.getters.createVariableButton().should('be.visible'); + variablesPage.getters.emptyResourcesListNewVariableButton().should('be.visible'); }); it('should create a new variable using empty state row', () => { diff --git a/cypress/e2e/28-debug.cy.ts b/cypress/e2e/28-debug.cy.ts index 955d33ce28..71c733c254 100644 --- a/cypress/e2e/28-debug.cy.ts +++ b/cypress/e2e/28-debug.cy.ts @@ -19,7 +19,7 @@ describe('Debug', () => { it('should be able to debug executions', () => { cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions'); cy.intercept('GET', '/rest/executions/*').as('getExecution'); - cy.intercept('POST', '/rest/workflows/run').as('postWorkflowRun'); + cy.intercept('POST', '/rest/workflows/**/run').as('postWorkflowRun'); cy.signin({ email: INSTANCE_OWNER.email, password: INSTANCE_OWNER.password }); diff --git a/cypress/e2e/29-templates.cy.ts b/cypress/e2e/29-templates.cy.ts index 34762b12fc..d5f0a67f7e 100644 --- a/cypress/e2e/29-templates.cy.ts +++ b/cypress/e2e/29-templates.cy.ts @@ -10,7 +10,7 @@ describe('Workflow templates', () => { beforeEach(() => { cy.intercept('GET', '**/rest/settings', (req) => { // Disable cache - delete req.headers['if-none-match'] + delete req.headers['if-none-match']; req.reply((res) => { if (res.body.data) { // Disable custom templates host if it has been overridden by another intercept @@ -22,18 +22,27 @@ describe('Workflow templates', () => { it('Opens website when clicking templates sidebar link', () => { cy.visit(workflowsPage.url); - mainSidebar.getters.menuItem('Templates').should('be.visible'); + mainSidebar.getters.templates().should('be.visible'); // Templates should be a link to the website - mainSidebar.getters.templates().parent('a').should('have.attr', 'href').and('include', 'https://n8n.io/workflows'); + mainSidebar.getters + .templates() + .parent('a') + .should('have.attr', 'href') + .and('include', 'https://n8n.io/workflows'); // Link should contain instance address and n8n version - mainSidebar.getters.templates().parent('a').then(($a) => { - const href = $a.attr('href'); - const params = new URLSearchParams(href); - // Link should have all mandatory parameters expected on the website - expect(decodeURIComponent(`${params.get('utm_instance')}`)).to.include(window.location.origin); - expect(params.get('utm_n8n_version')).to.match(/[0-9]+\.[0-9]+\.[0-9]+/); - expect(params.get('utm_awc')).to.match(/[0-9]+/); - }); + mainSidebar.getters + .templates() + .parent('a') + .then(($a) => { + const href = $a.attr('href'); + const params = new URLSearchParams(href); + // Link should have all mandatory parameters expected on the website + expect(decodeURIComponent(`${params.get('utm_instance')}`)).to.include( + window.location.origin, + ); + expect(params.get('utm_n8n_version')).to.match(/[0-9]+\.[0-9]+\.[0-9]+/); + expect(params.get('utm_awc')).to.match(/[0-9]+/); + }); mainSidebar.getters.templates().parent('a').should('have.attr', 'target', '_blank'); }); @@ -41,6 +50,6 @@ describe('Workflow templates', () => { cy.visit(templatesPage.url); cy.origin('https://n8n.io', () => { cy.url().should('include', 'https://n8n.io/workflows'); - }) + }); }); }); diff --git a/cypress/e2e/30-editor-after-route-changes.cy.ts b/cypress/e2e/30-editor-after-route-changes.cy.ts index 727078e735..a502d3577c 100644 --- a/cypress/e2e/30-editor-after-route-changes.cy.ts +++ b/cypress/e2e/30-editor-after-route-changes.cy.ts @@ -148,7 +148,7 @@ describe('Editor actions should work', () => { it('after switching between Editor and Debug', () => { cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions'); cy.intercept('GET', '/rest/executions/*').as('getExecution'); - cy.intercept('POST', '/rest/workflows/run').as('postWorkflowRun'); + cy.intercept('POST', '/rest/workflows/**/run').as('postWorkflowRun'); editWorkflowAndDeactivate(); workflowPage.actions.executeWorkflow(); @@ -196,9 +196,9 @@ describe('Editor zoom should work after route changes', () => { cy.intercept('GET', '/rest/workflow-history/workflow/*/version/*').as('getVersion'); cy.intercept('GET', '/rest/workflow-history/workflow/*').as('getHistory'); cy.intercept('GET', '/rest/users').as('getUsers'); - cy.intercept('GET', '/rest/workflows').as('getWorkflows'); + cy.intercept('GET', '/rest/workflows?*').as('getWorkflows'); cy.intercept('GET', '/rest/active-workflows').as('getActiveWorkflows'); - cy.intercept('GET', '/rest/credentials').as('getCredentials'); + cy.intercept('GET', '/rest/credentials?*').as('getCredentials'); switchBetweenEditorAndHistory(); zoomInAndCheckNodes(); diff --git a/cypress/e2e/39-projects.cy.ts b/cypress/e2e/39-projects.cy.ts new file mode 100644 index 0000000000..5cf1ac1fdc --- /dev/null +++ b/cypress/e2e/39-projects.cy.ts @@ -0,0 +1,151 @@ +import { INSTANCE_ADMIN, INSTANCE_MEMBERS } from '../constants'; +import { WorkflowsPage, WorkflowPage, CredentialsModal, CredentialsPage } from '../pages'; +import * as projects from '../composables/projects'; + +const workflowsPage = new WorkflowsPage(); +const workflowPage = new WorkflowPage(); +const credentialsPage = new CredentialsPage(); +const credentialsModal = new CredentialsModal(); + +describe('Projects', () => { + beforeEach(() => { + cy.resetDatabase(); + cy.enableFeature('advancedPermissions'); + cy.enableFeature('projectRole:admin'); + cy.enableFeature('projectRole:editor'); + cy.changeQuota('maxTeamProjects', -1); + }); + + it('should handle workflows and credentials', () => { + cy.signin(INSTANCE_ADMIN); + cy.visit(workflowsPage.url); + workflowsPage.getters.workflowCards().should('not.have.length'); + + workflowsPage.getters.newWorkflowButtonCard().click(); + + cy.intercept('POST', '/rest/workflows').as('workflowSave'); + workflowPage.actions.saveWorkflowOnButtonClick(); + + cy.wait('@workflowSave').then((interception) => { + expect(interception.request.body).not.to.have.property('projectId'); + }); + + projects.getHomeButton().click(); + projects.getProjectTabs().should('have.length', 2); + + projects.getProjectTabCredentials().click(); + credentialsPage.getters.credentialCards().should('not.have.length'); + + credentialsPage.getters.emptyListCreateCredentialButton().click(); + credentialsModal.getters.newCredentialModal().should('be.visible'); + credentialsModal.getters.newCredentialTypeSelect().should('be.visible'); + credentialsModal.getters.newCredentialTypeOption('Notion API').click(); + credentialsModal.getters.newCredentialTypeButton().click(); + credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890'); + credentialsModal.actions.setName('My awesome Notion account'); + + cy.intercept('POST', '/rest/credentials').as('credentialSave'); + credentialsModal.actions.save(); + cy.wait('@credentialSave').then((interception) => { + expect(interception.request.body).not.to.have.property('projectId'); + }); + + credentialsModal.actions.close(); + credentialsPage.getters.credentialCards().should('have.length', 1); + + projects.getProjectTabWorkflows().click(); + workflowsPage.getters.workflowCards().should('have.length', 1); + + projects.getMenuItems().should('not.have.length'); + + cy.intercept('POST', '/rest/projects').as('projectCreate'); + projects.getAddProjectButton().click(); + cy.wait('@projectCreate'); + projects.getMenuItems().should('have.length', 1); + projects.getProjectTabs().should('have.length', 3); + + cy.get('input[name="name"]').type('Development'); + projects.addProjectMember(INSTANCE_MEMBERS[0].email); + + cy.intercept('PATCH', '/rest/projects/*').as('projectSettingsSave'); + projects.getProjectSettingsSaveButton().click(); + cy.wait('@projectSettingsSave').then((interception) => { + expect(interception.request.body).to.have.property('name').and.to.equal('Development'); + expect(interception.request.body).to.have.property('relations').to.have.lengthOf(2); + }); + + projects.getMenuItems().first().click(); + workflowsPage.getters.workflowCards().should('not.have.length'); + projects.getProjectTabs().should('have.length', 3); + + workflowsPage.getters.newWorkflowButtonCard().click(); + + cy.intercept('POST', '/rest/workflows').as('workflowSave'); + workflowPage.actions.saveWorkflowOnButtonClick(); + + cy.wait('@workflowSave').then((interception) => { + expect(interception.request.body).to.have.property('projectId'); + }); + + projects.getMenuItems().first().click(); + + projects.getProjectTabCredentials().click(); + credentialsPage.getters.credentialCards().should('not.have.length'); + + credentialsPage.getters.emptyListCreateCredentialButton().click(); + credentialsModal.getters.newCredentialModal().should('be.visible'); + credentialsModal.getters.newCredentialTypeSelect().should('be.visible'); + credentialsModal.getters.newCredentialTypeOption('Notion API').click(); + credentialsModal.getters.newCredentialTypeButton().click(); + credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890'); + credentialsModal.actions.setName('My awesome Notion account'); + + cy.intercept('POST', '/rest/credentials').as('credentialSave'); + credentialsModal.actions.save(); + cy.wait('@credentialSave').then((interception) => { + expect(interception.request.body).to.have.property('projectId'); + }); + credentialsModal.actions.close(); + + projects.getAddProjectButton().click(); + projects.getMenuItems().should('have.length', 2); + + let projectId: string; + projects.getMenuItems().first().click(); + cy.intercept('GET', '/rest/credentials*').as('credentialsList'); + projects.getProjectTabCredentials().click(); + cy.wait('@credentialsList').then((interception) => { + const url = new URL(interception.request.url); + const queryParams = new URLSearchParams(url.search); + const filter = queryParams.get('filter'); + expect(filter).to.be.a('string').and.to.contain('projectId'); + + if (filter) { + projectId = JSON.parse(filter).projectId; + } + }); + + projects.getMenuItems().last().click(); + cy.intercept('GET', '/rest/credentials*').as('credentialsList'); + projects.getProjectTabCredentials().click(); + cy.wait('@credentialsList').then((interception) => { + const url = new URL(interception.request.url); + const queryParams = new URLSearchParams(url.search); + const filter = queryParams.get('filter'); + expect(filter).to.be.a('string').and.to.contain('projectId'); + + if (filter) { + expect(JSON.parse(filter).projectId).not.to.equal(projectId); + } + }); + + projects.getHomeButton().click(); + workflowsPage.getters.workflowCards().should('have.length', 2); + + cy.intercept('GET', '/rest/credentials*').as('credentialsList'); + projects.getProjectTabCredentials().click(); + cy.wait('@credentialsList').then((interception) => { + expect(interception.request.url).not.to.contain('filter'); + }); + }); +}); diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index cdac202f48..6513a80cb6 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -697,7 +697,7 @@ describe('NDV', () => { }); it('Stop listening for trigger event from NDV', () => { - cy.intercept('POST', '/rest/workflows/run').as('workflowRun'); + cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun'); workflowPage.actions.addInitialNodeToCanvas('Local File Trigger', { keepNdvOpen: true, action: 'On Changes To A Specific File', diff --git a/cypress/pages/credentials.ts b/cypress/pages/credentials.ts index 24ec88565d..7ae2d0f3b4 100644 --- a/cypress/pages/credentials.ts +++ b/cypress/pages/credentials.ts @@ -1,7 +1,7 @@ import { BasePage } from './base'; export class CredentialsPage extends BasePage { - url = '/credentials'; + url = '/home/credentials'; getters = { emptyListCreateCredentialButton: () => cy.getByTestId('empty-resources-list').find('button'), createCredentialButton: () => cy.getByTestId('resources-list-add'), diff --git a/cypress/pages/modals/credentials-modal.ts b/cypress/pages/modals/credentials-modal.ts index 08a258a057..2275ea5e4c 100644 --- a/cypress/pages/modals/credentials-modal.ts +++ b/cypress/pages/modals/credentials-modal.ts @@ -25,7 +25,7 @@ export class CredentialsModal extends BasePage { credentialInputs: () => cy.getByTestId('credential-connection-parameter'), menu: () => this.getters.editCredentialModal().get('.menu-container'), menuItem: (name: string) => this.getters.menu().get('.n8n-menu-item').contains(name), - usersSelect: () => cy.getByTestId('credential-sharing-modal-users-select'), + usersSelect: () => cy.getByTestId('project-sharing-select').filter(':visible'), testSuccessTag: () => cy.getByTestId('credentials-config-container-test-success'), }; actions = { diff --git a/cypress/pages/modals/workflow-sharing-modal.ts b/cypress/pages/modals/workflow-sharing-modal.ts index c013093286..fc4ba8dada 100644 --- a/cypress/pages/modals/workflow-sharing-modal.ts +++ b/cypress/pages/modals/workflow-sharing-modal.ts @@ -3,7 +3,7 @@ import { BasePage } from '../base'; export class WorkflowSharingModal extends BasePage { getters = { modal: () => cy.getByTestId('workflowShare-modal', { timeout: 5000 }), - usersSelect: () => cy.getByTestId('workflow-sharing-modal-users-select'), + usersSelect: () => cy.getByTestId('project-sharing-select'), saveButton: () => cy.getByTestId('workflow-sharing-modal-save-button'), closeButton: () => this.getters.modal().find('.el-dialog__close').first(), }; diff --git a/cypress/pages/settings-users.ts b/cypress/pages/settings-users.ts index e3c80e5bcc..a16eb4ab6f 100644 --- a/cypress/pages/settings-users.ts +++ b/cypress/pages/settings-users.ts @@ -41,10 +41,10 @@ export class SettingsUsersPage extends BasePage { workflowPage.actions.visit(); mainSidebar.actions.goToSettings(); if (isOwner) { - settingsSidebar.getters.menuItem('Users').click(); + settingsSidebar.getters.users().click(); cy.url().should('match', new RegExp(this.url)); } else { - settingsSidebar.getters.menuItem('Users').should('not.exist'); + settingsSidebar.getters.users().should('not.exist'); // Should be redirected to workflows page if trying to access UM url cy.visit('/settings/users'); cy.url().should('match', new RegExp(workflowsPage.url)); diff --git a/cypress/pages/sidebar/main-sidebar.ts b/cypress/pages/sidebar/main-sidebar.ts index 5379b1f889..348d4aa148 100644 --- a/cypress/pages/sidebar/main-sidebar.ts +++ b/cypress/pages/sidebar/main-sidebar.ts @@ -5,14 +5,13 @@ const workflowsPage = new WorkflowsPage(); export class MainSidebar extends BasePage { getters = { - menuItem: (menuLabel: string) => - cy.getByTestId('menu-item').filter(`:contains("${menuLabel}")`), - settings: () => this.getters.menuItem('Settings'), - templates: () => this.getters.menuItem('Templates'), - workflows: () => this.getters.menuItem('Workflows'), - credentials: () => this.getters.menuItem('Credentials'), - executions: () => this.getters.menuItem('Executions'), - adminPanel: () => this.getters.menuItem('Admin Panel'), + menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id), + settings: () => this.getters.menuItem('settings'), + templates: () => this.getters.menuItem('templates'), + workflows: () => this.getters.menuItem('workflows'), + credentials: () => this.getters.menuItem('credentials'), + executions: () => this.getters.menuItem('executions'), + adminPanel: () => this.getters.menuItem('cloud-admin'), userMenu: () => cy.get('div[class="action-dropdown-container"]'), logo: () => cy.getByTestId('n8n-logo'), }; diff --git a/cypress/pages/sidebar/settings-sidebar.ts b/cypress/pages/sidebar/settings-sidebar.ts index 6d519d6c31..886a0a3c1e 100644 --- a/cypress/pages/sidebar/settings-sidebar.ts +++ b/cypress/pages/sidebar/settings-sidebar.ts @@ -2,9 +2,8 @@ import { BasePage } from '../base'; export class SettingsSidebar extends BasePage { getters = { - menuItem: (menuLabel: string) => - cy.getByTestId('menu-item').filter(`:contains("${menuLabel}")`), - users: () => this.getters.menuItem('Users'), + menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id), + users: () => this.getters.menuItem('settings-users'), back: () => cy.getByTestId('settings-back'), }; actions = { diff --git a/cypress/pages/variables.ts b/cypress/pages/variables.ts index 6091e5cf1b..6d9e9eb134 100644 --- a/cypress/pages/variables.ts +++ b/cypress/pages/variables.ts @@ -35,7 +35,7 @@ export class VariablesPage extends BasePage { deleteVariable: (key: string) => { const row = this.getters.variableRow(key); row.within(() => { - cy.getByTestId('variable-row-delete-button').click(); + cy.getByTestId('variable-row-delete-button').should('not.be.disabled').click(); }); const modal = cy.get('[role="dialog"]'); @@ -53,7 +53,7 @@ export class VariablesPage extends BasePage { editRow: (key: string) => { const row = this.getters.variableRow(key); row.within(() => { - cy.getByTestId('variable-row-edit-button').click(); + cy.getByTestId('variable-row-edit-button').should('not.be.disabled').click(); }); }, setRowValue: (row: Chainable>, field: 'key' | 'value', value: string) => { diff --git a/cypress/pages/workflow-executions-tab.ts b/cypress/pages/workflow-executions-tab.ts index eb855f026f..cf9665a8b8 100644 --- a/cypress/pages/workflow-executions-tab.ts +++ b/cypress/pages/workflow-executions-tab.ts @@ -32,7 +32,7 @@ export class WorkflowExecutionsTab extends BasePage { }, createManualExecutions: (count: number) => { for (let i = 0; i < count; i++) { - cy.intercept('POST', '/rest/workflows/run').as('workflowExecution'); + cy.intercept('POST', '/rest/workflows/**/run').as('workflowExecution'); workflowPage.actions.executeWorkflow(); cy.wait('@workflowExecution'); } diff --git a/cypress/pages/workflows.ts b/cypress/pages/workflows.ts index 56a3c44923..fd65a426a4 100644 --- a/cypress/pages/workflows.ts +++ b/cypress/pages/workflows.ts @@ -1,7 +1,7 @@ import { BasePage } from './base'; export class WorkflowsPage extends BasePage { - url = '/workflows'; + url = '/home/workflows'; getters = { newWorkflowButtonCard: () => cy.getByTestId('new-workflow-card'), newWorkflowTemplateCard: () => cy.getByTestId('new-workflow-template-card'), diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index a92dc2ce06..bd33a8f21f 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -65,7 +65,7 @@ Cypress.Commands.add('signout', () => { cy.request({ method: 'POST', url: `${BACKEND_BASE_URL}/rest/logout`, - headers: { 'browser-id': localStorage.getItem('n8n-browserId') } + headers: { 'browser-id': localStorage.getItem('n8n-browserId') }, }); cy.getCookie(N8N_AUTH_COOKIE).should('not.exist'); }); @@ -80,12 +80,19 @@ const setFeature = (feature: string, enabled: boolean) => enabled, }); +const setQuota = (feature: string, value: number) => + cy.request('PATCH', `${BACKEND_BASE_URL}/rest/e2e/quota`, { + feature: `quota:${feature}`, + value, + }); + const setQueueMode = (enabled: boolean) => cy.request('PATCH', `${BACKEND_BASE_URL}/rest/e2e/queue-mode`, { enabled, }); Cypress.Commands.add('enableFeature', (feature: string) => setFeature(feature, true)); +Cypress.Commands.add('changeQuota', (feature: string, value: number) => setQuota(feature, value)); Cypress.Commands.add('disableFeature', (feature: string) => setFeature(feature, false)); Cypress.Commands.add('enableQueueMode', () => setQueueMode(true)); Cypress.Commands.add('disableQueueMode', () => setQueueMode(false)); diff --git a/cypress/support/index.ts b/cypress/support/index.ts index f31e50c578..411b732250 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -30,6 +30,7 @@ declare global { disableFeature(feature: string): void; enableQueueMode(): void; disableQueueMode(): void; + changeQuota(feature: string, value: number): void; waitForLoad(waitForIntercepts?: boolean): void; grantBrowserPermissions(...permissions: string[]): void; readClipboard(): Chainable; diff --git a/cypress/utils/executions.ts b/cypress/utils/executions.ts index 81748af505..d88b58ea9b 100644 --- a/cypress/utils/executions.ts +++ b/cypress/utils/executions.ts @@ -29,7 +29,7 @@ export function createMockNodeExecutionData( ]; return acc; - }, {}) + }, {}) : data, source: [null], ...rest, @@ -88,7 +88,7 @@ export function runMockWorkflowExcution({ }) { const executionId = Math.random().toString(36).substring(4); - cy.intercept('POST', '/rest/workflows/run', { + cy.intercept('POST', '/rest/workflows/**/run', { statusCode: 201, body: { data: { diff --git a/packages/@n8n/permissions/src/combineScopes.ts b/packages/@n8n/permissions/src/combineScopes.ts new file mode 100644 index 0000000000..23da64d837 --- /dev/null +++ b/packages/@n8n/permissions/src/combineScopes.ts @@ -0,0 +1,23 @@ +import type { Scope, ScopeLevels, GlobalScopes, MaskLevels } from './types'; + +export function combineScopes(userScopes: GlobalScopes, masks?: MaskLevels): Set; +export function combineScopes(userScopes: ScopeLevels, masks?: MaskLevels): Set; +export function combineScopes( + userScopes: GlobalScopes | ScopeLevels, + masks?: MaskLevels, +): Set { + const maskedScopes: GlobalScopes | ScopeLevels = Object.fromEntries( + Object.entries(userScopes).map((e) => [e[0], [...e[1]]]), + ) as GlobalScopes | ScopeLevels; + + if (masks?.sharing) { + if ('project' in maskedScopes) { + maskedScopes.project = maskedScopes.project.filter((v) => masks.sharing.includes(v)); + } + if ('resource' in maskedScopes) { + maskedScopes.resource = maskedScopes.resource.filter((v) => masks.sharing.includes(v)); + } + } + + return new Set(Object.values(maskedScopes).flat()); +} diff --git a/packages/@n8n/permissions/src/hasScope.ts b/packages/@n8n/permissions/src/hasScope.ts index 76c22f7b19..d449283490 100644 --- a/packages/@n8n/permissions/src/hasScope.ts +++ b/packages/@n8n/permissions/src/hasScope.ts @@ -1,25 +1,29 @@ -import type { Scope, ScopeLevels, GlobalScopes, ScopeOptions } from './types'; +import { combineScopes } from './combineScopes'; +import type { Scope, ScopeLevels, GlobalScopes, ScopeOptions, MaskLevels } from './types'; export function hasScope( scope: Scope | Scope[], userScopes: GlobalScopes, + masks?: MaskLevels, options?: ScopeOptions, ): boolean; export function hasScope( scope: Scope | Scope[], userScopes: ScopeLevels, + masks?: MaskLevels, options?: ScopeOptions, ): boolean; export function hasScope( scope: Scope | Scope[], userScopes: GlobalScopes | ScopeLevels, + masks?: MaskLevels, options: ScopeOptions = { mode: 'oneOf' }, ): boolean { if (!Array.isArray(scope)) { scope = [scope]; } - const userScopeSet = new Set(Object.values(userScopes).flat()); + const userScopeSet = combineScopes(userScopes, masks); if (options.mode === 'allOf') { return !!scope.length && scope.every((s) => userScopeSet.has(s)); diff --git a/packages/@n8n/permissions/src/index.ts b/packages/@n8n/permissions/src/index.ts index 5934473ce7..0d3e510abe 100644 --- a/packages/@n8n/permissions/src/index.ts +++ b/packages/@n8n/permissions/src/index.ts @@ -1,2 +1,3 @@ export type * from './types'; export * from './hasScope'; +export * from './combineScopes'; diff --git a/packages/@n8n/permissions/src/types.ts b/packages/@n8n/permissions/src/types.ts index 1707d1c35e..817d6321a9 100644 --- a/packages/@n8n/permissions/src/types.ts +++ b/packages/@n8n/permissions/src/types.ts @@ -12,8 +12,10 @@ export type Resource = | 'license' | 'logStreaming' | 'orchestration' - | 'sourceControl' + | 'project' | 'saml' + | 'securityAudit' + | 'sourceControl' | 'tag' | 'user' | 'variable' @@ -48,7 +50,9 @@ export type LdapScope = ResourceScope<'ldap', 'manage' | 'sync'>; export type LicenseScope = ResourceScope<'license', 'manage'>; export type LogStreamingScope = ResourceScope<'logStreaming', 'manage'>; export type OrchestrationScope = ResourceScope<'orchestration', 'read' | 'list'>; +export type ProjectScope = ResourceScope<'project'>; export type SamlScope = ResourceScope<'saml', 'manage'>; +export type SecurityAuditScope = ResourceScope<'securityAudit', 'generate'>; export type SourceControlScope = ResourceScope<'sourceControl', 'pull' | 'push' | 'manage'>; export type TagScope = ResourceScope<'tag'>; export type UserScope = ResourceScope<'user', DefaultOperations | 'resetPassword' | 'changeRole'>; @@ -69,7 +73,9 @@ export type Scope = | LicenseScope | LogStreamingScope | OrchestrationScope + | ProjectScope | SamlScope + | SecurityAuditScope | SourceControlScope | TagScope | UserScope @@ -84,5 +90,10 @@ export type ProjectScopes = GetScopeLevel<'project'>; export type ResourceScopes = GetScopeLevel<'resource'>; export type ScopeLevels = GlobalScopes & (ProjectScopes | (ProjectScopes & ResourceScopes)); +export type MaskLevel = 'sharing'; +export type GetMaskLevel = Record; +export type SharingMasks = GetMaskLevel<'sharing'>; +export type MaskLevels = SharingMasks; + export type ScopeMode = 'oneOf' | 'allOf'; export type ScopeOptions = { mode: ScopeMode }; diff --git a/packages/@n8n/permissions/test/hasScope.test.ts b/packages/@n8n/permissions/test/hasScope.test.ts index 22137d6326..0e43bc8dc6 100644 --- a/packages/@n8n/permissions/test/hasScope.test.ts +++ b/packages/@n8n/permissions/test/hasScope.test.ts @@ -33,6 +33,7 @@ describe('hasScope', () => { { global: memberPermissions, }, + undefined, { mode: 'oneOf' }, ), ).toBe(true); @@ -43,6 +44,7 @@ describe('hasScope', () => { { global: memberPermissions, }, + undefined, { mode: 'allOf' }, ), ).toBe(true); @@ -53,6 +55,7 @@ describe('hasScope', () => { { global: memberPermissions, }, + undefined, { mode: 'oneOf' }, ), ).toBe(false); @@ -63,6 +66,7 @@ describe('hasScope', () => { { global: memberPermissions, }, + undefined, { mode: 'allOf' }, ), ).toBe(false); @@ -95,6 +99,7 @@ describe('hasScope', () => { { global: ownerPermissions, }, + undefined, { mode: 'allOf' }, ), ).toBe(true); @@ -105,6 +110,7 @@ describe('hasScope', () => { { global: memberPermissions, }, + undefined, { mode: 'allOf' }, ), ).toBe(false); @@ -115,6 +121,7 @@ describe('hasScope', () => { { global: memberPermissions, }, + undefined, { mode: 'allOf' }, ), ).toBe(false); @@ -125,8 +132,127 @@ describe('hasScope', () => { { global: memberPermissions, }, + undefined, { mode: 'allOf' }, ), ).toBe(false); }); }); + +describe('hasScope masking', () => { + test('should return true without mask when scopes present', () => { + expect( + hasScope('workflow:read', { + global: ['user:list'], + project: ['workflow:read'], + resource: [], + }), + ).toBe(true); + }); + + test('should return false without mask when scopes are not present', () => { + expect( + hasScope('workflow:update', { + global: ['user:list'], + project: ['workflow:read'], + resource: [], + }), + ).toBe(false); + }); + + test('should return false when mask does not include scope but scopes list does contain required scope', () => { + expect( + hasScope( + 'workflow:update', + { + global: ['user:list'], + project: ['workflow:read', 'workflow:update'], + resource: [], + }, + { + sharing: ['workflow:read'], + }, + ), + ).toBe(false); + }); + + test('should return true when mask does include scope and scope list includes scope', () => { + expect( + hasScope( + 'workflow:update', + { + global: ['user:list'], + project: ['workflow:read', 'workflow:update'], + resource: [], + }, + { + sharing: ['workflow:read', 'workflow:update'], + }, + ), + ).toBe(true); + }); + + test('should return true when mask does include scope and scopes list includes scope on multiple levels', () => { + expect( + hasScope( + 'workflow:update', + { + global: ['user:list'], + project: ['workflow:read', 'workflow:update'], + resource: ['workflow:update'], + }, + { + sharing: ['workflow:read', 'workflow:update'], + }, + ), + ).toBe(true); + }); + + test('should not mask out global scopes', () => { + expect( + hasScope( + 'workflow:update', + { + global: ['workflow:read', 'workflow:update'], + project: ['workflow:read'], + resource: ['workflow:read'], + }, + { + sharing: ['workflow:read'], + }, + ), + ).toBe(true); + }); + + test('should return false when scope is not in mask or scope list', () => { + expect( + hasScope( + 'workflow:update', + { + global: ['workflow:read'], + project: ['workflow:read'], + resource: ['workflow:read'], + }, + { + sharing: ['workflow:read'], + }, + ), + ).toBe(false); + }); + + test('should return false when scope is in mask or not scope list', () => { + expect( + hasScope( + 'workflow:update', + { + global: ['workflow:read'], + project: ['workflow:read'], + resource: ['workflow:read'], + }, + { + sharing: ['workflow:read', 'workflow:update'], + }, + ), + ).toBe(false); + }); +}); diff --git a/packages/cli/.eslintrc.js b/packages/cli/.eslintrc.js index 736003d7b8..9eaf0128f8 100644 --- a/packages/cli/.eslintrc.js +++ b/packages/cli/.eslintrc.js @@ -35,4 +35,20 @@ module.exports = { '@typescript-eslint/no-unsafe-enum-comparison': 'warn', '@typescript-eslint/no-unsafe-declaration-merging': 'warn', }, + + overrides: [ + { + files: ['./src/decorators/**/*.ts'], + rules: { + '@typescript-eslint/ban-types': [ + 'warn', + { + types: { + Function: false, + }, + }, + ], + }, + }, + ], }; diff --git a/packages/cli/src/ActiveWebhooks.ts b/packages/cli/src/ActiveWebhooks.ts index 6b9717341b..79626df025 100644 --- a/packages/cli/src/ActiveWebhooks.ts +++ b/packages/cli/src/ActiveWebhooks.ts @@ -84,7 +84,7 @@ export class ActiveWebhooks implements IWebhookManager { const workflowData = await this.workflowRepository.findOne({ where: { id: webhook.workflowId }, - relations: ['shared', 'shared.user'], + relations: { shared: { project: { projectRelations: true } } }, }); if (workflowData === null) { @@ -102,9 +102,7 @@ export class ActiveWebhooks implements IWebhookManager { settings: workflowData.settings, }); - const additionalData = await WorkflowExecuteAdditionalData.getBase( - workflowData.shared[0].user.id, - ); + const additionalData = await WorkflowExecuteAdditionalData.getBase(); const webhookData = NodeHelpers.getNodeWebhooks( workflow, diff --git a/packages/cli/src/ActiveWorkflowManager.ts b/packages/cli/src/ActiveWorkflowManager.ts index f0d1f2fbff..5e0bd66ed9 100644 --- a/packages/cli/src/ActiveWorkflowManager.ts +++ b/packages/cli/src/ActiveWorkflowManager.ts @@ -229,7 +229,6 @@ export class ActiveWorkflowManager { async clearWebhooks(workflowId: string) { const workflowData = await this.workflowRepository.findOne({ where: { id: workflowId }, - relations: ['shared', 'shared.user'], }); if (workflowData === null) { @@ -249,9 +248,7 @@ export class ActiveWorkflowManager { const mode = 'internal'; - const additionalData = await WorkflowExecuteAdditionalData.getBase( - workflowData.shared[0].user.id, - ); + const additionalData = await WorkflowExecuteAdditionalData.getBase(); const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData, undefined, true); @@ -570,13 +567,7 @@ export class ActiveWorkflowManager { ); } - const sharing = dbWorkflow.shared.find((shared) => shared.role === 'workflow:owner'); - - if (!sharing) { - throw new WorkflowActivationError(`Workflow ${dbWorkflow.display()} has no owner`); - } - - const additionalData = await WorkflowExecuteAdditionalData.getBase(sharing.user.id); + const additionalData = await WorkflowExecuteAdditionalData.getBase(); if (shouldAddWebhooks) { await this.addWebhooks(workflow, additionalData, 'trigger', activationMode); @@ -711,6 +702,7 @@ export class ActiveWorkflowManager { * @param {string} workflowId The id of the workflow to deactivate */ // TODO: this should happen in a transaction + // maybe, see: https://github.com/n8n-io/n8n/pull/8904#discussion_r1530150510 async remove(workflowId: string) { if (this.orchestrationService.isMultiMainSetupEnabled) { try { diff --git a/packages/cli/src/CredentialsHelper.ts b/packages/cli/src/CredentialsHelper.ts index c20ddd4798..670b944e49 100644 --- a/packages/cli/src/CredentialsHelper.ts +++ b/packages/cli/src/CredentialsHelper.ts @@ -30,15 +30,15 @@ import { ICredentialsHelper, NodeHelpers, Workflow, ApplicationError } from 'n8n import type { ICredentialsDb } from '@/Interfaces'; import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; -import { NodeTypes } from '@/NodeTypes'; import { CredentialTypes } from '@/CredentialTypes'; import { CredentialsOverwrites } from '@/CredentialsOverwrites'; import { RESPONSE_ERROR_MESSAGES } from './constants'; -import { Logger } from '@/Logger'; import { CredentialsRepository } from '@db/repositories/credentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { CredentialNotFoundError } from './errors/credential-not-found.error'; +import { In } from '@n8n/typeorm'; +import { CacheService } from './services/cache/cache.service'; const mockNode = { name: '', @@ -77,12 +77,11 @@ const mockNodeTypes: INodeTypes = { @Service() export class CredentialsHelper extends ICredentialsHelper { constructor( - private readonly logger: Logger, private readonly credentialTypes: CredentialTypes, - private readonly nodeTypes: NodeTypes, private readonly credentialsOverwrites: CredentialsOverwrites, private readonly credentialsRepository: CredentialsRepository, private readonly sharedCredentialsRepository: SharedCredentialsRepository, + private readonly cacheService: CacheService, ) { super(); } @@ -245,7 +244,6 @@ export class CredentialsHelper extends ICredentialsHelper { async getCredentials( nodeCredential: INodeCredentialsDetails, type: string, - userId?: string, ): Promise { if (!nodeCredential.id) { throw new ApplicationError('Found credential with no ID.', { @@ -257,14 +255,10 @@ export class CredentialsHelper extends ICredentialsHelper { let credential: CredentialsEntity; try { - credential = userId - ? await this.sharedCredentialsRepository - .findOneOrFail({ - relations: ['credentials'], - where: { credentials: { id: nodeCredential.id, type }, userId }, - }) - .then((shared) => shared.credentials) - : await this.credentialsRepository.findOneByOrFail({ id: nodeCredential.id, type }); + credential = await this.credentialsRepository.findOneByOrFail({ + id: nodeCredential.id, + type, + }); } catch (error) { throw new CredentialNotFoundError(nodeCredential.id, type); } @@ -338,7 +332,7 @@ export class CredentialsHelper extends ICredentialsHelper { await additionalData?.secretsHelpers?.waitForInit(); - const canUseSecrets = await this.credentialOwnedByOwner(nodeCredentials); + const canUseSecrets = await this.credentialCanUseExternalSecrets(nodeCredentials); return this.applyDefaultsAndOverwrites( additionalData, @@ -457,28 +451,39 @@ export class CredentialsHelper extends ICredentialsHelper { await this.credentialsRepository.update(findQuery, newCredentialsData); } - async credentialOwnedByOwner(nodeCredential: INodeCredentialsDetails): Promise { + async credentialCanUseExternalSecrets(nodeCredential: INodeCredentialsDetails): Promise { if (!nodeCredential.id) { return false; } - const credential = await this.sharedCredentialsRepository.findOne({ - where: { - role: 'credential:owner', - user: { - role: 'global:owner', - }, - credentials: { - id: nodeCredential.id, - }, - }, - }); + return ( + (await this.cacheService.get(`credential-can-use-secrets:${nodeCredential.id}`, { + refreshFn: async () => { + const credential = await this.sharedCredentialsRepository.findOne({ + where: { + role: 'credential:owner', + project: { + projectRelations: { + role: In(['project:personalOwner', 'project:admin']), + user: { + role: In(['global:owner', 'global:admin']), + }, + }, + }, + credentials: { + id: nodeCredential.id!, + }, + }, + }); - if (!credential) { - return false; - } + if (!credential) { + return false; + } - return true; + return true; + }, + })) ?? false + ); } } diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 9c56982c98..32825f2aba 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -535,7 +535,8 @@ export interface IWorkflowExecutionDataProcess { pushRef?: string; startNodes?: StartNodeData[]; workflowData: IWorkflowBase; - userId: string; + userId?: string; + projectId?: string; } export interface IWorkflowExecuteProcess { diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index 70b3030f23..61a119b9fe 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -34,6 +34,10 @@ import { License } from '@/License'; import { EventsService } from '@/services/events.service'; import { NodeTypes } from '@/NodeTypes'; import { Telemetry } from '@/telemetry'; +import type { Project } from '@db/entities/Project'; +import type { ProjectRole } from '@db/entities/ProjectRelation'; +import { ProjectRelationRepository } from './databases/repositories/projectRelation.repository'; +import { SharedCredentialsRepository } from './databases/repositories/sharedCredentials.repository'; function userToPayload(user: User): { userId: string; @@ -62,6 +66,8 @@ export class InternalHooks { private readonly instanceSettings: InstanceSettings, private readonly eventBus: MessageEventBus, private readonly license: License, + private readonly projectRelationRepository: ProjectRelationRepository, + private readonly sharedCredentialsRepository: SharedCredentialsRepository, ) { eventsService.on( 'telemetry.onFirstProductionWorkflowSuccess', @@ -164,7 +170,12 @@ export class InternalHooks { ); } - async onWorkflowCreated(user: User, workflow: IWorkflowBase, publicApi: boolean): Promise { + async onWorkflowCreated( + user: User, + workflow: IWorkflowBase, + project: Project, + publicApi: boolean, + ): Promise { const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); void Promise.all([ this.eventBus.sendAuditEvent({ @@ -180,6 +191,8 @@ export class InternalHooks { workflow_id: workflow.id, node_graph_string: JSON.stringify(nodeGraph), public_api: publicApi, + project_id: project.id, + project_type: project.type, }), ]); } @@ -208,19 +221,32 @@ export class InternalHooks { isCloudDeployment, }); + let userRole: 'owner' | 'sharee' | 'member' | undefined = undefined; + const role = await this.sharedWorkflowRepository.findSharingRole(user.id, workflow.id); + if (role) { + userRole = role === 'workflow:owner' ? 'owner' : 'sharee'; + } else { + const workflowOwner = await this.sharedWorkflowRepository.getWorkflowOwningProject( + workflow.id, + ); + + if (workflowOwner) { + const projectRole = await this.projectRelationRepository.findProjectRole({ + userId: user.id, + projectId: workflowOwner.id, + }); + + if (projectRole && projectRole !== 'project:personalOwner') { + userRole = 'member'; + } + } + } + const notesCount = Object.keys(nodeGraph.notes).length; const overlappingCount = Object.values(nodeGraph.notes).filter( (note) => note.overlapping, ).length; - let userRole: 'owner' | 'sharee' | undefined = undefined; - if (user.id && workflow.id) { - const role = await this.sharedWorkflowRepository.findSharingRole(user.id, workflow.id); - if (role) { - userRole = role === 'workflow:owner' ? 'owner' : 'sharee'; - } - } - void Promise.all([ this.eventBus.sendAuditEvent({ eventName: 'n8n.audit.workflow.updated', @@ -865,6 +891,9 @@ export class InternalHooks { credential_id: string; public_api: boolean; }): Promise { + const project = await this.sharedCredentialsRepository.findCredentialOwningProject( + userCreatedCredentialsData.credential_id, + ); void Promise.all([ this.eventBus.sendAuditEvent({ eventName: 'n8n.audit.user.credentials.created', @@ -880,6 +909,8 @@ export class InternalHooks { credential_type: userCreatedCredentialsData.credential_type, credential_id: userCreatedCredentialsData.credential_id, instance_id: this.instanceSettings.instanceId, + project_id: project?.id, + project_type: project?.type, }), ]); } @@ -1207,4 +1238,27 @@ export class InternalHooks { }): Promise { return await this.telemetry.track('User updated external secrets settings', saveData); } + + async onTeamProjectCreated(data: { user_id: string; role: GlobalRole }) { + return await this.telemetry.track('User created project', data); + } + + async onTeamProjectDeleted(data: { + user_id: string; + role: GlobalRole; + project_id: string; + removal_type: 'delete' | 'transfer'; + target_project_id?: string; + }) { + return await this.telemetry.track('User deleted project', data); + } + + async onTeamProjectUpdated(data: { + user_id: string; + role: GlobalRole; + project_id: string; + members: Array<{ user_id: string; role: ProjectRole }>; + }) { + return await this.telemetry.track('Project settings updated', data); + } } diff --git a/packages/cli/src/Ldap/helpers.ts b/packages/cli/src/Ldap/helpers.ts index 12f1b402e6..567031d01d 100644 --- a/packages/cli/src/Ldap/helpers.ts +++ b/packages/cli/src/Ldap/helpers.ts @@ -93,7 +93,7 @@ export const getAuthIdentityByLdapId = async ( idAttributeValue: string, ): Promise => { return await Container.get(AuthIdentityRepository).findOne({ - relations: ['user'], + relations: { user: true }, where: { providerId: idAttributeValue, providerType: 'ldap', @@ -140,7 +140,7 @@ export const getLdapIds = async (): Promise => { export const getLdapUsers = async (): Promise => { const identities = await Container.get(AuthIdentityRepository).find({ - relations: ['user'], + relations: { user: true }, where: { providerType: 'ldap', }, @@ -179,10 +179,15 @@ export const processUsers = async ( toUpdateUsers: Array<[string, User]>, toDisableUsers: string[], ): Promise => { + const userRepository = Container.get(UserRepository); await Db.transaction(async (transactionManager) => { return await Promise.all([ ...toCreateUsers.map(async ([ldapId, user]) => { - const authIdentity = AuthIdentity.create(await transactionManager.save(user), ldapId); + const { user: savedUser } = await userRepository.createUserWithProject( + user, + transactionManager, + ); + const authIdentity = AuthIdentity.create(savedUser, ldapId); return await transactionManager.save(authIdentity); }), ...toUpdateUsers.map(async ([ldapId, user]) => { @@ -202,7 +207,13 @@ export const processUsers = async ( providerId: ldapId, }); if (authIdentity?.userId) { - await transactionManager.update(User, { id: authIdentity?.userId }, { disabled: true }); + const user = await transactionManager.findOneBy(User, { id: authIdentity.userId }); + + if (user) { + user.disabled = true; + await transactionManager.save(user); + } + await transactionManager.delete(AuthIdentity, { userId: authIdentity?.userId }); } }), @@ -266,14 +277,11 @@ export const createLdapAuthIdentity = async (user: User, ldapId: string) => { }; export const createLdapUserOnLocalDb = async (data: Partial, ldapId: string) => { - const user = await Container.get(UserRepository).save( - { - password: randomPassword(), - role: 'global:member', - ...data, - }, - { transaction: false }, - ); + const { user } = await Container.get(UserRepository).createUserWithProject({ + password: randomPassword(), + role: 'global:member', + ...data, + }); await createLdapAuthIdentity(user, ldapId); return user; }; @@ -281,7 +289,11 @@ export const createLdapUserOnLocalDb = async (data: Partial, ldapId: strin export const updateLdapUserOnLocalDb = async (identity: AuthIdentity, data: Partial) => { const userId = identity?.user?.id; if (userId) { - await Container.get(UserRepository).update({ id: userId }, data); + const user = await Container.get(UserRepository).findOneBy({ id: userId }); + + if (user) { + await Container.get(UserRepository).save({ id: userId, ...data }, { transaction: true }); + } } }; diff --git a/packages/cli/src/Ldap/ldap.service.ts b/packages/cli/src/Ldap/ldap.service.ts index 0d7f45e58d..c13a31ecca 100644 --- a/packages/cli/src/Ldap/ldap.service.ts +++ b/packages/cli/src/Ldap/ldap.service.ts @@ -349,7 +349,7 @@ export class LdapService { localAdUsers, ); - this.logger.debug('LDAP - Users processed', { + this.logger.debug('LDAP - Users to process', { created: usersToCreate.length, updated: usersToUpdate.length, disabled: usersToDisable.length, diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index 1e31222571..979320404a 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -289,6 +289,18 @@ export class License { return this.isFeatureEnabled(LICENSE_FEATURES.WORKER_VIEW); } + isProjectRoleAdminLicensed() { + return this.isFeatureEnabled(LICENSE_FEATURES.PROJECT_ROLE_ADMIN); + } + + isProjectRoleEditorLicensed() { + return this.isFeatureEnabled(LICENSE_FEATURES.PROJECT_ROLE_EDITOR); + } + + isProjectRoleViewerLicensed() { + return this.isFeatureEnabled(LICENSE_FEATURES.PROJECT_ROLE_VIEWER); + } + getCurrentEntitlements() { return this.manager?.getCurrentEntitlements() ?? []; } @@ -341,6 +353,10 @@ export class License { ); } + getTeamProjectLimit() { + return this.getFeatureValue(LICENSE_QUOTAS.TEAM_PROJECT_LIMIT) ?? 0; + } + getPlanName(): string { return this.getFeatureValue('planName') ?? 'Community'; } diff --git a/packages/cli/src/Mfa/mfa.service.ts b/packages/cli/src/Mfa/mfa.service.ts index 019005054d..53f1229a4d 100644 --- a/packages/cli/src/Mfa/mfa.service.ts +++ b/packages/cli/src/Mfa/mfa.service.ts @@ -25,10 +25,16 @@ export class MfaService { secret, recoveryCodes, ); - return await this.userRepository.update(userId, { - mfaSecret: encryptedSecret, - mfaRecoveryCodes: encryptedRecoveryCodes, - }); + + const user = await this.userRepository.findOneBy({ id: userId }); + if (user) { + Object.assign(user, { + mfaSecret: encryptedSecret, + mfaRecoveryCodes: encryptedRecoveryCodes, + }); + + await this.userRepository.save(user); + } } public encryptSecretAndRecoveryCodes(rawSecret: string, rawRecoveryCodes: string[]) { @@ -56,7 +62,12 @@ export class MfaService { } public async enableMfa(userId: string) { - await this.userRepository.update(userId, { mfaEnabled: true }); + const user = await this.userRepository.findOneBy({ id: userId }); + if (user) { + user.mfaEnabled = true; + + await this.userRepository.save(user); + } } public encryptRecoveryCodes(mfaRecoveryCodes: string[]) { @@ -64,10 +75,15 @@ export class MfaService { } public async disableMfa(userId: string) { - await this.userRepository.update(userId, { - mfaEnabled: false, - mfaSecret: null, - mfaRecoveryCodes: [], - }); + const user = await this.userRepository.findOneBy({ id: userId }); + + if (user) { + Object.assign(user, { + mfaEnabled: false, + mfaSecret: null, + mfaRecoveryCodes: [], + }); + await this.userRepository.save(user); + } } } diff --git a/packages/cli/src/PublicApi/types.ts b/packages/cli/src/PublicApi/types.ts index c7e1bddadf..2e80deb1af 100644 --- a/packages/cli/src/PublicApi/types.ts +++ b/packages/cli/src/PublicApi/types.ts @@ -1,4 +1,5 @@ -import type { IDataObject, ExecutionStatus } from 'n8n-workflow'; +import type { ExecutionStatus, ICredentialDataDecryptedObject } from 'n8n-workflow'; + import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { TagEntity } from '@db/entities/TagEntity'; import type { Risk } from '@/security-audit/types'; @@ -127,7 +128,14 @@ export declare namespace UserRequest { } export declare namespace CredentialRequest { - type Create = AuthenticatedRequest<{}, {}, { type: string; name: string; data: IDataObject }, {}>; + type Create = AuthenticatedRequest< + {}, + {}, + { type: string; name: string; data: ICredentialDataDecryptedObject }, + {} + >; + + type Delete = AuthenticatedRequest<{ id: string }, {}, {}, Record>; } export type OperationID = 'getUsers' | 'getUser'; diff --git a/packages/cli/src/PublicApi/v1/handlers/audit/audit.handler.ts b/packages/cli/src/PublicApi/v1/handlers/audit/audit.handler.ts index 9fd2d028ff..caf3750ad4 100644 --- a/packages/cli/src/PublicApi/v1/handlers/audit/audit.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/audit/audit.handler.ts @@ -1,11 +1,11 @@ -import { authorize } from '@/PublicApi/v1/shared/middlewares/global.middleware'; +import { globalScope } from '@/PublicApi/v1/shared/middlewares/global.middleware'; import type { Response } from 'express'; import type { AuditRequest } from '@/PublicApi/types'; import Container from 'typedi'; export = { generateAudit: [ - authorize(['global:owner', 'global:admin']), + globalScope('securityAudit:generate'), async (req: AuditRequest.Generate, res: Response): Promise => { try { const { SecurityAuditService } = await import('@/security-audit/SecurityAudit.service'); diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts index 165c7f9116..4da7635831 100644 --- a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts @@ -4,9 +4,8 @@ import type express from 'express'; import { CredentialsHelper } from '@/CredentialsHelper'; import { CredentialTypes } from '@/CredentialTypes'; import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; -import type { CredentialRequest } from '@/requests'; -import type { CredentialTypeRequest } from '../../../types'; -import { authorize } from '../../shared/middlewares/global.middleware'; +import type { CredentialTypeRequest, CredentialRequest } from '../../../types'; +import { projectScope } from '../../shared/middlewares/global.middleware'; import { validCredentialsProperties, validCredentialType } from './credentials.middleware'; import { @@ -23,7 +22,6 @@ import { Container } from 'typedi'; export = { createCredential: [ - authorize(['global:owner', 'global:admin', 'global:member']), validCredentialType, validCredentialsProperties, async ( @@ -47,7 +45,7 @@ export = { }, ], deleteCredential: [ - authorize(['global:owner', 'global:admin', 'global:member']), + projectScope('credential:delete', 'credential'), async ( req: CredentialRequest.Delete, res: express.Response, @@ -75,7 +73,6 @@ export = { ], getCredentialType: [ - authorize(['global:owner', 'global:admin', 'global:member']), async (req: CredentialTypeRequest.Get, res: express.Response): Promise => { const { credentialTypeName } = req.params; diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts index 935956df2e..6a7cfa208d 100644 --- a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts @@ -16,6 +16,7 @@ import type { CredentialRequest } from '@/requests'; import { Container } from 'typedi'; import { CredentialsRepository } from '@db/repositories/credentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; import { InternalHooks } from '@/InternalHooks'; export async function getCredentials(credentialId: string): Promise { @@ -28,7 +29,7 @@ export async function getSharedCredentials( ): Promise { return await Container.get(SharedCredentialsRepository).findOne({ where: { - userId, + project: { projectRelations: { userId } }, credentialsId: credentialId, }, relations: ['credentials'], @@ -66,10 +67,14 @@ export async function saveCredential( const newSharedCredential = new SharedCredentials(); + const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + user.id, + ); + Object.assign(newSharedCredential, { role: 'credential:owner', - user, credentials: savedCredential, + projectId: personalProject.id, }); await transactionManager.save(newSharedCredential); diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts index 3a63fb9aa0..d7b9e1cb2f 100644 --- a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts @@ -3,7 +3,7 @@ import { Container } from 'typedi'; import { replaceCircularReferences } from 'n8n-workflow'; import { ActiveExecutions } from '@/ActiveExecutions'; -import { authorize, validCursor } from '../../shared/middlewares/global.middleware'; +import { validCursor } from '../../shared/middlewares/global.middleware'; import type { ExecutionRequest } from '../../../types'; import { getSharedWorkflowIds } from '../workflows/workflows.service'; import { encodeNextCursor } from '../../shared/services/pagination.service'; @@ -12,9 +12,8 @@ import { ExecutionRepository } from '@db/repositories/execution.repository'; export = { deleteExecution: [ - authorize(['global:owner', 'global:admin', 'global:member']), async (req: ExecutionRequest.Delete, res: express.Response): Promise => { - const sharedWorkflowsIds = await getSharedWorkflowIds(req.user); + const sharedWorkflowsIds = await getSharedWorkflowIds(req.user, ['workflow:delete']); // user does not have workflows hence no executions // or the execution they are trying to access belongs to a workflow they do not own @@ -44,9 +43,8 @@ export = { }, ], getExecution: [ - authorize(['global:owner', 'global:admin', 'global:member']), async (req: ExecutionRequest.Get, res: express.Response): Promise => { - const sharedWorkflowsIds = await getSharedWorkflowIds(req.user); + const sharedWorkflowsIds = await getSharedWorkflowIds(req.user, ['workflow:read']); // user does not have workflows hence no executions // or the execution they are trying to access belongs to a workflow they do not own @@ -75,7 +73,6 @@ export = { }, ], getExecutions: [ - authorize(['global:owner', 'global:admin', 'global:member']), validCursor, async (req: ExecutionRequest.GetAll, res: express.Response): Promise => { const { @@ -86,7 +83,7 @@ export = { workflowId = undefined, } = req.query; - const sharedWorkflowsIds = await getSharedWorkflowIds(req.user); + const sharedWorkflowsIds = await getSharedWorkflowIds(req.user, ['workflow:read']); // user does not have workflows hence no executions // or the execution they are trying to access belongs to a workflow they do not own diff --git a/packages/cli/src/PublicApi/v1/handlers/sourceControl/sourceControl.handler.ts b/packages/cli/src/PublicApi/v1/handlers/sourceControl/sourceControl.handler.ts index 66233867de..a413290c56 100644 --- a/packages/cli/src/PublicApi/v1/handlers/sourceControl/sourceControl.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/sourceControl/sourceControl.handler.ts @@ -2,7 +2,7 @@ import type express from 'express'; import { Container } from 'typedi'; import type { StatusResult } from 'simple-git'; import type { PublicSourceControlRequest } from '../../../types'; -import { authorize } from '../../shared/middlewares/global.middleware'; +import { globalScope } from '../../shared/middlewares/global.middleware'; import type { ImportResult } from '@/environments/sourceControl/types/importResult'; import { SourceControlService } from '@/environments/sourceControl/sourceControl.service.ee'; import { SourceControlPreferencesService } from '@/environments/sourceControl/sourceControlPreferences.service.ee'; @@ -14,7 +14,7 @@ import { InternalHooks } from '@/InternalHooks'; export = { pull: [ - authorize(['global:owner', 'global:admin']), + globalScope('sourceControl:pull'), async ( req: PublicSourceControlRequest.Pull, res: express.Response, diff --git a/packages/cli/src/PublicApi/v1/handlers/tags/tags.handler.ts b/packages/cli/src/PublicApi/v1/handlers/tags/tags.handler.ts index 56e8f3b4b3..3711aa36e8 100644 --- a/packages/cli/src/PublicApi/v1/handlers/tags/tags.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/tags/tags.handler.ts @@ -1,7 +1,7 @@ import type express from 'express'; import type { TagEntity } from '@db/entities/TagEntity'; -import { authorize, validCursor } from '../../shared/middlewares/global.middleware'; +import { globalScope, validCursor } from '../../shared/middlewares/global.middleware'; import type { TagRequest } from '../../../types'; import { encodeNextCursor } from '../../shared/services/pagination.service'; @@ -12,7 +12,7 @@ import { TagService } from '@/services/tag.service'; export = { createTag: [ - authorize(['global:owner', 'global:admin', 'global:member']), + globalScope('tag:create'), async (req: TagRequest.Create, res: express.Response): Promise => { const { name } = req.body; @@ -27,7 +27,7 @@ export = { }, ], updateTag: [ - authorize(['global:owner', 'global:admin', 'global:member']), + globalScope('tag:update'), async (req: TagRequest.Update, res: express.Response): Promise => { const { id } = req.params; const { name } = req.body; @@ -49,7 +49,7 @@ export = { }, ], deleteTag: [ - authorize(['global:owner', 'global:admin']), + globalScope('tag:delete'), async (req: TagRequest.Delete, res: express.Response): Promise => { const { id } = req.params; @@ -65,7 +65,7 @@ export = { }, ], getTags: [ - authorize(['global:owner', 'global:admin', 'global:member']), + globalScope('tag:read'), validCursor, async (req: TagRequest.GetAll, res: express.Response): Promise => { const { offset = 0, limit = 100 } = req.query; @@ -88,7 +88,7 @@ export = { }, ], getTag: [ - authorize(['global:owner', 'global:admin', 'global:member']), + globalScope('tag:read'), async (req: TagRequest.Get, res: express.Response): Promise => { const { id } = req.params; diff --git a/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts b/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts index 8fd36b1dbb..96b2d57239 100644 --- a/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts +++ b/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts @@ -5,7 +5,7 @@ import { clean, getAllUsersAndCount, getUser } from './users.service.ee'; import { encodeNextCursor } from '../../shared/services/pagination.service'; import { - authorize, + globalScope, validCursor, validLicenseWithUserQuota, } from '../../shared/middlewares/global.middleware'; @@ -15,7 +15,7 @@ import { InternalHooks } from '@/InternalHooks'; export = { getUser: [ validLicenseWithUserQuota, - authorize(['global:owner', 'global:admin']), + globalScope('user:read'), async (req: UserRequest.Get, res: express.Response) => { const { includeRole = false } = req.query; const { id } = req.params; @@ -41,7 +41,7 @@ export = { getUsers: [ validLicenseWithUserQuota, validCursor, - authorize(['global:owner', 'global:admin']), + globalScope(['user:list', 'user:read']), async (req: UserRequest.Get, res: express.Response) => { const { offset = 0, limit = 100, includeRole = false } = req.query; diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts index 1083d33edf..6daab565c9 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts @@ -11,11 +11,10 @@ import { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { ExternalHooks } from '@/ExternalHooks'; import { addNodeIds, replaceInvalidCredentials } from '@/WorkflowHelpers'; import type { WorkflowRequest } from '../../../types'; -import { authorize, validCursor } from '../../shared/middlewares/global.middleware'; +import { projectScope, validCursor } from '../../shared/middlewares/global.middleware'; import { encodeNextCursor } from '../../shared/services/pagination.service'; import { getWorkflowById, - getSharedWorkflow, setWorkflowAsActive, setWorkflowAsInactive, updateWorkflow, @@ -30,10 +29,10 @@ import { WorkflowHistoryService } from '@/workflows/workflowHistory/workflowHist import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; import { TagRepository } from '@/databases/repositories/tag.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; export = { createWorkflow: [ - authorize(['global:owner', 'global:admin', 'global:member']), async (req: WorkflowRequest.Create, res: express.Response): Promise => { const workflow = req.body; @@ -44,7 +43,10 @@ export = { addNodeIds(workflow); - const createdWorkflow = await createWorkflow(workflow, req.user, 'workflow:owner'); + const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + req.user.id, + ); + const createdWorkflow = await createWorkflow(workflow, req.user, project, 'workflow:owner'); await Container.get(WorkflowHistoryService).saveVersion( req.user, @@ -53,13 +55,13 @@ export = { ); await Container.get(ExternalHooks).run('workflow.afterCreate', [createdWorkflow]); - void Container.get(InternalHooks).onWorkflowCreated(req.user, createdWorkflow, true); + void Container.get(InternalHooks).onWorkflowCreated(req.user, createdWorkflow, project, true); return res.json(createdWorkflow); }, ], deleteWorkflow: [ - authorize(['global:owner', 'global:admin', 'global:member']), + projectScope('workflow:delete', 'workflow'), async (req: WorkflowRequest.Get, res: express.Response): Promise => { const { id: workflowId } = req.params; @@ -74,15 +76,21 @@ export = { }, ], getWorkflow: [ - authorize(['global:owner', 'global:admin', 'global:member']), + projectScope('workflow:read', 'workflow'), async (req: WorkflowRequest.Get, res: express.Response): Promise => { const { id } = req.params; - const sharedWorkflow = await getSharedWorkflow(req.user, id); + const workflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser( + id, + req.user, + ['workflow:read'], + { includeTags: !config.getEnv('workflowTagsDisabled') }, + ); - if (!sharedWorkflow) { + if (!workflow) { // user trying to access a workflow they do not own - // or workflow does not exist + // and was not shared to them + // Or does not exist. return res.status(404).json({ message: 'Not Found' }); } @@ -91,11 +99,10 @@ export = { public_api: true, }); - return res.json(sharedWorkflow.workflow); + return res.json(workflow); }, ], getWorkflows: [ - authorize(['global:owner', 'global:admin', 'global:member']), validCursor, async (req: WorkflowRequest.GetAll, res: express.Response): Promise => { const { offset = 0, limit = 100, active, tags, name } = req.query; @@ -121,19 +128,24 @@ export = { ); } - const sharedWorkflows = await Container.get(SharedWorkflowRepository).getSharedWorkflows( + let workflows = await Container.get(SharedWorkflowRepository).findAllWorkflowsForUser( req.user, - options, + ['workflow:read'], ); - if (!sharedWorkflows.length) { + if (options.workflowIds) { + const workflowIds = options.workflowIds; + workflows = workflows.filter((wf) => workflowIds.includes(wf.id)); + } + + if (!workflows.length) { return res.status(200).json({ data: [], nextCursor: null, }); } - const workflowsIds = sharedWorkflows.map((shareWorkflow) => shareWorkflow.workflowId); + const workflowsIds = workflows.map((wf) => wf.id); where.id = In(workflowsIds); } @@ -160,7 +172,7 @@ export = { }, ], updateWorkflow: [ - authorize(['global:owner', 'global:admin', 'global:member']), + projectScope('workflow:update', 'workflow'), async (req: WorkflowRequest.Update, res: express.Response): Promise => { const { id } = req.params; const updateData = new WorkflowEntity(); @@ -168,9 +180,13 @@ export = { updateData.id = id; updateData.versionId = uuid(); - const sharedWorkflow = await getSharedWorkflow(req.user, id); + const workflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser( + id, + req.user, + ['workflow:update'], + ); - if (!sharedWorkflow) { + if (!workflow) { // user trying to access a workflow they do not own // or workflow does not exist return res.status(404).json({ message: 'Not Found' }); @@ -181,23 +197,23 @@ export = { const workflowManager = Container.get(ActiveWorkflowManager); - if (sharedWorkflow.workflow.active) { + if (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 workflowManager.remove(id); } try { - await updateWorkflow(sharedWorkflow.workflowId, updateData); + await updateWorkflow(workflow.id, updateData); } catch (error) { if (error instanceof Error) { return res.status(400).json({ message: error.message }); } } - if (sharedWorkflow.workflow.active) { + if (workflow.active) { try { - await workflowManager.add(sharedWorkflow.workflowId, 'update'); + await workflowManager.add(workflow.id, 'update'); } catch (error) { if (error instanceof Error) { return res.status(400).json({ message: error.message }); @@ -205,13 +221,13 @@ export = { } } - const updatedWorkflow = await getWorkflowById(sharedWorkflow.workflowId); + const updatedWorkflow = await getWorkflowById(workflow.id); if (updatedWorkflow) { await Container.get(WorkflowHistoryService).saveVersion( req.user, updatedWorkflow, - sharedWorkflow.workflowId, + workflow.id, ); } @@ -222,21 +238,25 @@ export = { }, ], activateWorkflow: [ - authorize(['global:owner', 'global:admin', 'global:member']), + projectScope('workflow:update', 'workflow'), async (req: WorkflowRequest.Activate, res: express.Response): Promise => { const { id } = req.params; - const sharedWorkflow = await getSharedWorkflow(req.user, id); + const workflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser( + id, + req.user, + ['workflow:update'], + ); - if (!sharedWorkflow) { + if (!workflow) { // user trying to access a workflow they do not own // or workflow does not exist return res.status(404).json({ message: 'Not Found' }); } - if (!sharedWorkflow.workflow.active) { + if (!workflow.active) { try { - await Container.get(ActiveWorkflowManager).add(sharedWorkflow.workflowId, 'activate'); + await Container.get(ActiveWorkflowManager).add(workflow.id, 'activate'); } catch (error) { if (error instanceof Error) { return res.status(400).json({ message: error.message }); @@ -244,25 +264,29 @@ export = { } // change the status to active in the DB - await setWorkflowAsActive(sharedWorkflow.workflow); + await setWorkflowAsActive(workflow); - sharedWorkflow.workflow.active = true; + workflow.active = true; - return res.json(sharedWorkflow.workflow); + return res.json(workflow); } // nothing to do as the workflow is already active - return res.json(sharedWorkflow.workflow); + return res.json(workflow); }, ], deactivateWorkflow: [ - authorize(['global:owner', 'global:admin', 'global:member']), + projectScope('workflow:update', 'workflow'), async (req: WorkflowRequest.Activate, res: express.Response): Promise => { const { id } = req.params; - const sharedWorkflow = await getSharedWorkflow(req.user, id); + const workflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser( + id, + req.user, + ['workflow:update'], + ); - if (!sharedWorkflow) { + if (!workflow) { // user trying to access a workflow they do not own // or workflow does not exist return res.status(404).json({ message: 'Not Found' }); @@ -270,22 +294,22 @@ export = { const activeWorkflowManager = Container.get(ActiveWorkflowManager); - if (sharedWorkflow.workflow.active) { - await activeWorkflowManager.remove(sharedWorkflow.workflowId); + if (workflow.active) { + await activeWorkflowManager.remove(workflow.id); - await setWorkflowAsInactive(sharedWorkflow.workflow); + await setWorkflowAsInactive(workflow); - sharedWorkflow.workflow.active = false; + workflow.active = false; - return res.json(sharedWorkflow.workflow); + return res.json(workflow); } // nothing to do as the workflow is already inactive - return res.json(sharedWorkflow.workflow); + return res.json(workflow); }, ], getWorkflowTags: [ - authorize(['global:owner', 'global:admin', 'global:member']), + projectScope('workflow:read', 'workflow'), async (req: WorkflowRequest.GetTags, res: express.Response): Promise => { const { id } = req.params; @@ -293,9 +317,13 @@ export = { return res.status(400).json({ message: 'Workflow Tags Disabled' }); } - const sharedWorkflow = await getSharedWorkflow(req.user, id); + const workflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser( + id, + req.user, + ['workflow:read'], + ); - if (!sharedWorkflow) { + if (!workflow) { // user trying to access a workflow he does not own // or workflow does not exist return res.status(404).json({ message: 'Not Found' }); @@ -307,7 +335,7 @@ export = { }, ], updateWorkflowTags: [ - authorize(['global:owner', 'global:admin', 'global:member']), + projectScope('workflow:update', 'workflow'), async (req: WorkflowRequest.UpdateTags, res: express.Response): Promise => { const { id } = req.params; const newTags = req.body.map((newTag) => newTag.id); @@ -316,7 +344,11 @@ export = { return res.status(400).json({ message: 'Workflow Tags Disabled' }); } - const sharedWorkflow = await getSharedWorkflow(req.user, id); + const sharedWorkflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser( + id, + req.user, + ['workflow:update'], + ); if (!sharedWorkflow) { // user trying to access a workflow he does not own diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts index 39116eb7a1..bb7b8bebd2 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts @@ -4,23 +4,31 @@ import type { User } from '@db/entities/User'; import { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { WorkflowTagMapping } from '@db/entities/WorkflowTagMapping'; import { SharedWorkflow, type WorkflowSharingRole } from '@db/entities/SharedWorkflow'; -import config from '@/config'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; +import type { Project } from '@/databases/entities/Project'; import { WorkflowTagMappingRepository } from '@db/repositories/workflowTagMapping.repository'; import { TagRepository } from '@db/repositories/tag.repository'; +import { License } from '@/License'; +import { WorkflowSharingService } from '@/workflows/workflowSharing.service'; +import type { Scope } from '@n8n/permissions'; +import config from '@/config'; function insertIf(condition: boolean, elements: string[]): string[] { return condition ? elements : []; } -export async function getSharedWorkflowIds(user: User): Promise { - const where = ['global:owner', 'global:admin'].includes(user.role) ? {} : { userId: user.id }; - const sharedWorkflows = await Container.get(SharedWorkflowRepository).find({ - where, - select: ['workflowId'], - }); - return sharedWorkflows.map(({ workflowId }) => workflowId); +export async function getSharedWorkflowIds(user: User, scopes: Scope[]): Promise { + if (Container.get(License).isSharingEnabled()) { + return await Container.get(WorkflowSharingService).getSharedWorkflowIds(user, { + scopes, + }); + } else { + return await Container.get(WorkflowSharingService).getSharedWorkflowIds(user, { + workflowRoles: ['workflow:owner'], + projectRoles: ['project:personalOwner'], + }); + } } export async function getSharedWorkflow( @@ -45,6 +53,7 @@ export async function getWorkflowById(id: string): Promise { return await Db.transaction(async (transactionManager) => { @@ -56,6 +65,7 @@ export async function createWorkflow( Object.assign(newSharedWorkflow, { role, user, + project: personalProject, workflow: savedWorkflow, }); await transactionManager.save(newSharedWorkflow); diff --git a/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts b/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts index 6baf96607e..6fa9bed113 100644 --- a/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts +++ b/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts @@ -3,27 +3,48 @@ import type express from 'express'; import { Container } from 'typedi'; import { License } from '@/License'; -import type { GlobalRole } from '@db/entities/User'; import type { AuthenticatedRequest } from '@/requests'; import type { PaginatedRequest } from '../../../types'; import { decodeCursor } from '../services/pagination.service'; +import type { Scope } from '@n8n/permissions'; +import { userHasScope } from '@/permissions/checkAccess'; const UNLIMITED_USERS_QUOTA = -1; -export const authorize = - (authorizedRoles: readonly GlobalRole[]) => - ( - req: AuthenticatedRequest, +export type ProjectScopeResource = 'workflow' | 'credential'; + +const buildScopeMiddleware = ( + scopes: Scope[], + resource?: ProjectScopeResource, + { globalOnly } = { globalOnly: false }, +) => { + return async ( + req: AuthenticatedRequest<{ id?: string }>, res: express.Response, next: express.NextFunction, - ): express.Response | void => { - if (!authorizedRoles.includes(req.user.role)) { + ): Promise => { + const params: { credentialId?: string; workflowId?: string } = {}; + if (req.params.id) { + if (resource === 'workflow') { + params.workflowId = req.params.id; + } else if (resource === 'credential') { + params.credentialId = req.params.id; + } + } + if (!(await userHasScope(req.user, scopes, globalOnly, params))) { return res.status(403).json({ message: 'Forbidden' }); } return next(); }; +}; + +export const globalScope = (scopes: Scope | Scope[]) => + buildScopeMiddleware(Array.isArray(scopes) ? scopes : [scopes], undefined, { globalOnly: true }); + +export const projectScope = (scopes: Scope | Scope[], resource: ProjectScopeResource) => + buildScopeMiddleware(Array.isArray(scopes) ? scopes : [scopes], resource, { globalOnly: false }); export const validCursor = ( req: PaginatedRequest, diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 4b2f3ce194..c8054a78b3 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -71,6 +71,8 @@ import { InvitationController } from './controllers/invitation.controller'; // import { CollaborationService } from './collaboration/collaboration.service'; import { BadRequestError } from './errors/response-errors/bad-request.error'; import { OrchestrationService } from '@/services/orchestration.service'; +import { ProjectController } from './controllers/project.controller'; +import { RoleController } from './controllers/role.controller'; const exec = promisify(callbackExec); @@ -146,6 +148,8 @@ export class Server extends AbstractServer { ExecutionsController, CredentialsController, AIController, + ProjectController, + RoleController, ]; if ( diff --git a/packages/cli/src/UserManagement/PermissionChecker.ts b/packages/cli/src/UserManagement/PermissionChecker.ts index 9cd9590ecb..6d2a8e04ba 100644 --- a/packages/cli/src/UserManagement/PermissionChecker.ts +++ b/packages/cli/src/UserManagement/PermissionChecker.ts @@ -5,64 +5,47 @@ import { CredentialAccessError, NodeOperationError, WorkflowOperationError } fro import config from '@/config'; import { License } from '@/License'; import { OwnershipService } from '@/services/ownership.service'; -import { UserRepository } from '@db/repositories/user.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; -import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; +import { ProjectService } from '@/services/project.service'; @Service() export class PermissionChecker { constructor( - private readonly userRepository: UserRepository, private readonly sharedCredentialsRepository: SharedCredentialsRepository, - private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly ownershipService: OwnershipService, private readonly license: License, + private readonly projectService: ProjectService, ) {} /** - * Check if a user is permitted to execute a workflow. + * Check if a workflow has the ability to execute based on the projects it's apart of. */ - async check(workflowId: string, userId: string, nodes: INode[]) { - // allow if no nodes in this workflow use creds - + async check(workflowId: string, nodes: INode[]) { + const homeProject = await this.ownershipService.getWorkflowProjectCached(workflowId); + const homeProjectOwner = await this.ownershipService.getProjectOwnerCached(homeProject.id); + if (homeProject.type === 'personal' && homeProjectOwner?.hasGlobalScope('credential:list')) { + // Workflow belongs to a project by a user with privileges + // so all credentials are usable. Skip credential checks. + return; + } + const projectIds = await this.projectService.findProjectsWorkflowIsIn(workflowId); const credIdsToNodes = this.mapCredIdsToNodes(nodes); const workflowCredIds = Object.keys(credIdsToNodes); if (workflowCredIds.length === 0) return; - // allow if requesting user is instance owner + const accessible = await this.sharedCredentialsRepository.getFilteredAccessibleCredentials( + projectIds, + workflowCredIds, + ); - const user = await this.userRepository.findOneOrFail({ - where: { id: userId }, - }); - - if (user.hasGlobalScope('workflow:execute')) return; - - const isSharingEnabled = this.license.isSharingEnabled(); - - // allow if all creds used in this workflow are a subset of - // all creds accessible to users who have access to this workflow - - let workflowUserIds = [userId]; - - if (workflowId && isSharingEnabled) { - workflowUserIds = await this.sharedWorkflowRepository.getSharedUserIds(workflowId); + for (const credentialsId of workflowCredIds) { + if (!accessible.includes(credentialsId)) { + const nodeToFlag = credIdsToNodes[credentialsId][0]; + throw new CredentialAccessError(nodeToFlag, credentialsId, workflowId); + } } - - const accessibleCredIds = isSharingEnabled - ? await this.sharedCredentialsRepository.getAccessibleCredentialIds(workflowUserIds) - : await this.sharedCredentialsRepository.getOwnedCredentialIds(workflowUserIds); - - const inaccessibleCredIds = workflowCredIds.filter((id) => !accessibleCredIds.includes(id)); - - if (inaccessibleCredIds.length === 0) return; - - // if disallowed, flag only first node using first inaccessible cred - const inaccessibleCredId = inaccessibleCredIds[0]; - const nodeToFlag = credIdsToNodes[inaccessibleCredId][0]; - - throw new CredentialAccessError(nodeToFlag, inaccessibleCredId, workflowId); } async checkSubworkflowExecutePolicy( @@ -91,14 +74,14 @@ export class PermissionChecker { } const parentWorkflowOwner = - await this.ownershipService.getWorkflowOwnerCached(parentWorkflowId); + await this.ownershipService.getWorkflowProjectCached(parentWorkflowId); - const subworkflowOwner = await this.ownershipService.getWorkflowOwnerCached(subworkflow.id); + const subworkflowOwner = await this.ownershipService.getWorkflowProjectCached(subworkflow.id); const description = subworkflowOwner.id === parentWorkflowOwner.id ? 'Change the settings of the sub-workflow so it can be called by this one.' - : `${subworkflowOwner.firstName} (${subworkflowOwner.email}) can make this change. You may need to tell them the ID of the sub-workflow, which is ${subworkflow.id}`; + : `An admin for the ${subworkflowOwner.name} project can make this change. You may need to tell them the ID of the sub-workflow, which is ${subworkflow.id}`; const errorToThrow = new WorkflowOperationError( `Target workflow ID ${subworkflow.id} may not be called`, diff --git a/packages/cli/src/WaitTracker.ts b/packages/cli/src/WaitTracker.ts index 6b79d10a9b..ae6dbf9a62 100644 --- a/packages/cli/src/WaitTracker.ts +++ b/packages/cli/src/WaitTracker.ts @@ -173,13 +173,13 @@ export class WaitTracker { throw new ApplicationError('Only saved workflows can be resumed.'); } const workflowId = fullExecutionData.workflowData.id; - const user = await this.ownershipService.getWorkflowOwnerCached(workflowId); + const project = await this.ownershipService.getWorkflowProjectCached(workflowId); const data: IWorkflowExecutionDataProcess = { executionMode: fullExecutionData.mode, executionData: fullExecutionData.data, workflowData: fullExecutionData.workflowData, - userId: user.id, + projectId: project.id, }; // Start the execution again diff --git a/packages/cli/src/WaitingWebhooks.ts b/packages/cli/src/WaitingWebhooks.ts index c745644dbe..cf16569b99 100644 --- a/packages/cli/src/WaitingWebhooks.ts +++ b/packages/cli/src/WaitingWebhooks.ts @@ -88,19 +88,12 @@ export class WaitingWebhooks implements IWebhookManager { settings: workflowData.settings, }); - let workflowOwner; - try { - workflowOwner = await this.ownershipService.getWorkflowOwnerCached(workflowData.id); - } catch (error) { - throw new NotFoundError('Could not find workflow'); - } - const workflowStartNode = workflow.getNode(lastNodeExecuted); if (workflowStartNode === null) { throw new NotFoundError('Could not find node to process webhook.'); } - const additionalData = await WorkflowExecuteAdditionalData.getBase(workflowOwner.id); + const additionalData = await WorkflowExecuteAdditionalData.getBase(); const webhookData = NodeHelpers.getNodeWebhooks( workflow, workflowStartNode, diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index 2682e928ed..ec3d181577 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -56,8 +56,6 @@ import * as WorkflowHelpers from '@/WorkflowHelpers'; import { WorkflowRunner } from '@/WorkflowRunner'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; import { ActiveExecutions } from '@/ActiveExecutions'; -import type { User } from '@db/entities/User'; -import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { EventsService } from '@/services/events.service'; import { OwnershipService } from './services/ownership.service'; import { parseBody } from './middlewares'; @@ -65,6 +63,7 @@ import { Logger } from './Logger'; import { NotFoundError } from './errors/response-errors/not-found.error'; import { InternalServerError } from './errors/response-errors/internal-server.error'; import { UnprocessableRequestError } from './errors/response-errors/unprocessable.error'; +import type { Project } from './databases/entities/Project'; export const WEBHOOK_METHODS: IHttpRequestMethods[] = [ 'DELETE', @@ -248,22 +247,15 @@ export async function executeWebhook( $executionId: executionId, }; - let user: User; - if ( - (workflowData as WorkflowEntity).shared?.length && - (workflowData as WorkflowEntity).shared[0].user - ) { - user = (workflowData as WorkflowEntity).shared[0].user; - } else { - try { - user = await Container.get(OwnershipService).getWorkflowOwnerCached(workflowData.id); - } catch (error) { - throw new NotFoundError('Cannot find workflow'); - } + let project: Project | undefined = undefined; + try { + project = await Container.get(OwnershipService).getWorkflowProjectCached(workflowData.id); + } catch (error) { + throw new NotFoundError('Cannot find workflow'); } // Prepare everything that is needed to run the workflow - const additionalData = await WorkflowExecuteAdditionalData.getBase(user.id); + const additionalData = await WorkflowExecuteAdditionalData.getBase(); // Get the responseMode const responseMode = workflow.expression.getSimpleParameterValue( @@ -546,7 +538,7 @@ export async function executeWebhook( pushRef, workflowData, pinData, - userId: user.id, + projectId: project?.id, }; let responsePromise: IDeferredPromise | undefined; diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 88374f0c65..5fb6a3de06 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -195,12 +195,12 @@ export function executeErrorWorkflow( } Container.get(OwnershipService) - .getWorkflowOwnerCached(workflowId) - .then((user) => { + .getWorkflowProjectCached(workflowId) + .then((project) => { void Container.get(WorkflowExecutionService).executeErrorWorkflow( errorWorkflow, workflowErrorData, - user, + project, ); }) .catch((error: Error) => { @@ -223,12 +223,12 @@ export function executeErrorWorkflow( ) { logger.verbose('Start internal error workflow', { executionId, workflowId }); void Container.get(OwnershipService) - .getWorkflowOwnerCached(workflowId) - .then((user) => { + .getWorkflowProjectCached(workflowId) + .then((project) => { void Container.get(WorkflowExecutionService).executeErrorWorkflow( workflowId, workflowErrorData, - user, + project, ); }); } @@ -655,7 +655,6 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { export async function getRunData( workflowData: IWorkflowBase, - userId: string, inputData?: INodeExecutionData[], ): Promise { const mode = 'integrated'; @@ -698,7 +697,6 @@ export async function getRunData( executionData: runExecutionData, // @ts-ignore workflowData, - userId, }; return runData; @@ -784,9 +782,7 @@ async function executeWorkflow( settings: workflowData.settings, }); - const runData = - options.loadedRunData ?? - (await getRunData(workflowData, additionalData.userId, options.inputData)); + const runData = options.loadedRunData ?? (await getRunData(workflowData, options.inputData)); let executionId; @@ -800,11 +796,7 @@ async function executeWorkflow( let data; try { - await Container.get(PermissionChecker).check( - workflowData.id, - additionalData.userId, - workflowData.nodes, - ); + await Container.get(PermissionChecker).check(workflowData.id, workflowData.nodes); await Container.get(PermissionChecker).checkSubworkflowExecutePolicy( workflow, options.parentWorkflowId, @@ -813,7 +805,7 @@ async function executeWorkflow( // Create new additionalData to have different workflow loaded and to call // different webhooks - const additionalDataIntegrated = await getBase(additionalData.userId); + const additionalDataIntegrated = await getBase(); additionalDataIntegrated.hooks = getWorkflowHooksIntegrated( runData.executionMode, executionId, @@ -966,7 +958,7 @@ export function sendDataToUI(type: string, data: IDataObject | IDataObject[]) { * Returns the base additional data without webhooks */ export async function getBase( - userId: string, + userId?: string, currentNodeParameters?: INodeParameters, executionTimeoutTimestamp?: number, ): Promise { diff --git a/packages/cli/src/WorkflowRunner.ts b/packages/cli/src/WorkflowRunner.ts index 10ca743fae..7034747246 100644 --- a/packages/cli/src/WorkflowRunner.ts +++ b/packages/cli/src/WorkflowRunner.ts @@ -161,7 +161,7 @@ export class WorkflowRunner { const { id: workflowId, nodes } = data.workflowData; try { - await this.permissionChecker.check(workflowId, data.userId, nodes); + await this.permissionChecker.check(workflowId, nodes); } catch (error) { // Create a failed execution with the data for the node, save it and abort execution const runData = generateFailedExecutionFromError(data.executionMode, error, error.node); diff --git a/packages/cli/src/auth/auth.service.ts b/packages/cli/src/auth/auth.service.ts index 9032b446de..ccf562e27e 100644 --- a/packages/cli/src/auth/auth.service.ts +++ b/packages/cli/src/auth/auth.service.ts @@ -8,7 +8,7 @@ import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES, Time } from '@/constants'; import type { User } from '@db/entities/User'; import { UserRepository } from '@db/repositories/user.repository'; import { AuthError } from '@/errors/response-errors/auth.error'; -import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { License } from '@/License'; import { Logger } from '@/Logger'; import type { AuthenticatedRequest } from '@/requests'; @@ -92,7 +92,7 @@ export class AuthService { !user.isOwner && !isWithinUsersLimit ) { - throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); + throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); } const token = this.issueJWT(user, browserId); diff --git a/packages/cli/src/commands/import/credentials.ts b/packages/cli/src/commands/import/credentials.ts index 52440e1149..03e96aa646 100644 --- a/packages/cli/src/commands/import/credentials.ts +++ b/packages/cli/src/commands/import/credentials.ts @@ -6,7 +6,6 @@ import glob from 'fast-glob'; import type { EntityManager } from '@n8n/typeorm'; import * as Db from '@/Db'; -import type { User } from '@db/entities/User'; import { SharedCredentials } from '@db/entities/SharedCredentials'; import { CredentialsEntity } from '@db/entities/CredentialsEntity'; import { disableAutoGeneratedIds } from '@db/utils/commandHelpers'; @@ -15,6 +14,8 @@ import type { ICredentialsEncrypted } from 'n8n-workflow'; import { ApplicationError, jsonParse } from 'n8n-workflow'; import { UM_FIX_INSTRUCTION } from '@/constants'; import { UserRepository } from '@db/repositories/user.repository'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import type { Project } from '@/databases/entities/Project'; export class ImportCredentialsCommand extends BaseCommand { static description = 'Import credentials'; @@ -23,6 +24,7 @@ export class ImportCredentialsCommand extends BaseCommand { '$ n8n import:credentials --input=file.json', '$ n8n import:credentials --separate --input=backups/latest/', '$ n8n import:credentials --input=file.json --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', + '$ n8n import:credentials --input=file.json --projectId=Ox8O54VQrmBrb4qL', '$ n8n import:credentials --separate --input=backups/latest/ --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', ]; @@ -38,6 +40,9 @@ export class ImportCredentialsCommand extends BaseCommand { userId: Flags.string({ description: 'The ID of the user to assign the imported credentials to', }), + projectId: Flags.string({ + description: 'The ID of the project to assign the imported credential to', + }), }; private transactionManager: EntityManager; @@ -64,21 +69,27 @@ export class ImportCredentialsCommand extends BaseCommand { } } - const user = flags.userId ? await this.getAssignee(flags.userId) : await this.getOwner(); + if (flags.projectId && flags.userId) { + throw new ApplicationError( + 'You cannot use `--userId` and `--projectId` together. Use one or the other.', + ); + } + + const project = await this.getProject(flags.userId, flags.projectId); const credentials = await this.readCredentials(flags.input, flags.separate); await Db.getConnection().transaction(async (transactionManager) => { this.transactionManager = transactionManager; - const result = await this.checkRelations(credentials, flags.userId); + const result = await this.checkRelations(credentials, flags.projectId, flags.userId); if (!result.success) { throw new ApplicationError(result.message); } for (const credential of credentials) { - await this.storeCredential(credential, user); + await this.storeCredential(credential, project); } }); @@ -98,7 +109,7 @@ export class ImportCredentialsCommand extends BaseCommand { ); } - private async storeCredential(credential: Partial, user: User) { + private async storeCredential(credential: Partial, project: Project) { const result = await this.transactionManager.upsert(CredentialsEntity, credential, ['id']); const sharingExists = await this.transactionManager.existsBy(SharedCredentials, { @@ -111,25 +122,34 @@ export class ImportCredentialsCommand extends BaseCommand { SharedCredentials, { credentialsId: result.identifiers[0].id as string, - userId: user.id, role: 'credential:owner', + projectId: project.id, }, - ['credentialsId', 'userId'], + ['credentialsId', 'projectId'], ); } } - private async getOwner() { + private async getOwnerProject() { const owner = await Container.get(UserRepository).findOneBy({ role: 'global:owner' }); if (!owner) { throw new ApplicationError(`Failed to find owner. ${UM_FIX_INSTRUCTION}`); } - return owner; + const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + owner.id, + ); + + return project; } - private async checkRelations(credentials: ICredentialsEncrypted[], userId?: string) { - if (!userId) { + private async checkRelations( + credentials: ICredentialsEncrypted[], + projectId?: string, + userId?: string, + ) { + // The credential is not supposed to be re-owned. + if (!projectId && !userId) { return { success: true as const, message: undefined, @@ -145,15 +165,26 @@ export class ImportCredentialsCommand extends BaseCommand { continue; } - const ownerId = await this.getCredentialOwner(credential.id); - if (!ownerId) { + const { user, project: ownerProject } = await this.getCredentialOwner(credential.id); + + if (!ownerProject) { continue; } - if (ownerId !== userId) { + if (ownerProject.id !== projectId) { + const currentOwner = + ownerProject.type === 'personal' + ? `the user with the ID "${user.id}"` + : `the project with the ID "${ownerProject.id}"`; + const newOwner = userId + ? // The user passed in `--userId`, so let's use the user ID in the error + // message as opposed to the project ID. + `the user with the ID "${userId}"` + : `the project with the ID "${projectId}"`; + return { success: false as const, - message: `The credential with id "${credential.id}" is already owned by the user with the id "${ownerId}". It can't be re-owned by the user with the id "${userId}"`, + message: `The credential with ID "${credential.id}" is already owned by ${currentOwner}. It can't be re-owned by ${newOwner}.`, }; } } @@ -206,26 +237,39 @@ export class ImportCredentialsCommand extends BaseCommand { }); } - private async getAssignee(userId: string) { - const user = await Container.get(UserRepository).findOneBy({ id: userId }); - - if (!user) { - throw new ApplicationError('Failed to find user', { extra: { userId } }); - } - - return user; - } - private async getCredentialOwner(credentialsId: string) { - const sharedCredential = await this.transactionManager.findOneBy(SharedCredentials, { - credentialsId, - role: 'credential:owner', + const sharedCredential = await this.transactionManager.findOne(SharedCredentials, { + where: { credentialsId, role: 'credential:owner' }, + relations: { project: true }, }); - return sharedCredential?.userId; + if (sharedCredential && sharedCredential.project.type === 'personal') { + const user = await Container.get(UserRepository).findOneByOrFail({ + projectRelations: { + role: 'project:personalOwner', + projectId: sharedCredential.projectId, + }, + }); + + return { user, project: sharedCredential.project }; + } + + return {}; } private async credentialExists(credentialId: string) { return await this.transactionManager.existsBy(CredentialsEntity, { id: credentialId }); } + + private async getProject(userId?: string, projectId?: string) { + if (projectId) { + return await Container.get(ProjectRepository).findOneByOrFail({ id: projectId }); + } + + if (userId) { + return await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId); + } + + return await this.getOwnerProject(); + } } diff --git a/packages/cli/src/commands/import/workflow.ts b/packages/cli/src/commands/import/workflow.ts index 40404482fb..7a6b7c38f2 100644 --- a/packages/cli/src/commands/import/workflow.ts +++ b/packages/cli/src/commands/import/workflow.ts @@ -14,6 +14,7 @@ import type { IWorkflowToImport } from '@/Interfaces'; import { ImportService } from '@/services/import.service'; import { BaseCommand } from '../BaseCommand'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; function assertHasWorkflowsToImport(workflows: unknown): asserts workflows is IWorkflowToImport[] { if (!Array.isArray(workflows)) { @@ -40,6 +41,7 @@ export class ImportWorkflowsCommand extends BaseCommand { '$ n8n import:workflow --input=file.json', '$ n8n import:workflow --separate --input=backups/latest/', '$ n8n import:workflow --input=file.json --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', + '$ n8n import:workflow --input=file.json --projectId=Ox8O54VQrmBrb4qL', '$ n8n import:workflow --separate --input=backups/latest/ --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', ]; @@ -55,6 +57,9 @@ export class ImportWorkflowsCommand extends BaseCommand { userId: Flags.string({ description: 'The ID of the user to assign the imported workflows to', }), + projectId: Flags.string({ + description: 'The ID of the project to assign the imported workflows to', + }), }; async init() { @@ -79,24 +84,32 @@ export class ImportWorkflowsCommand extends BaseCommand { } } - const owner = await this.getOwner(); + if (flags.projectId && flags.userId) { + throw new ApplicationError( + 'You cannot use `--userId` and `--projectId` together. Use one or the other.', + ); + } + + const project = await this.getProject(flags.userId, flags.projectId); const workflows = await this.readWorkflows(flags.input, flags.separate); - const result = await this.checkRelations(workflows, flags.userId); + const result = await this.checkRelations(workflows, flags.projectId, flags.userId); + if (!result.success) { throw new ApplicationError(result.message); } this.logger.info(`Importing ${workflows.length} workflows...`); - await Container.get(ImportService).importWorkflows(workflows, flags.userId ?? owner.id); + await Container.get(ImportService).importWorkflows(workflows, project.id); this.reportSuccess(workflows.length); } - private async checkRelations(workflows: WorkflowEntity[], userId: string | undefined) { - if (!userId) { + private async checkRelations(workflows: WorkflowEntity[], projectId?: string, userId?: string) { + // The credential is not supposed to be re-owned. + if (!userId && !projectId) { return { success: true as const, message: undefined, @@ -108,15 +121,26 @@ export class ImportWorkflowsCommand extends BaseCommand { continue; } - const ownerId = await this.getWorkflowOwner(workflow); - if (!ownerId) { + const { user, project: ownerProject } = await this.getWorkflowOwner(workflow); + + if (!ownerProject) { continue; } - if (ownerId !== userId) { + if (ownerProject.id !== projectId) { + const currentOwner = + ownerProject.type === 'personal' + ? `the user with the ID "${user.id}"` + : `the project with the ID "${ownerProject.id}"`; + const newOwner = userId + ? // The user passed in `--userId`, so let's use the user ID in the error + // message as opposed to the project ID. + `the user with the ID "${userId}"` + : `the project with the ID "${projectId}"`; + return { success: false as const, - message: `The credential with id "${workflow.id}" is already owned by the user with the id "${ownerId}". It can't be re-owned by the user with the id "${userId}"`, + message: `The credential with ID "${workflow.id}" is already owned by ${currentOwner}. It can't be re-owned by ${newOwner}.`, }; } } @@ -136,22 +160,37 @@ export class ImportWorkflowsCommand extends BaseCommand { this.logger.info(`Successfully imported ${total} ${total === 1 ? 'workflow.' : 'workflows.'}`); } - private async getOwner() { + private async getOwnerProject() { const owner = await Container.get(UserRepository).findOneBy({ role: 'global:owner' }); if (!owner) { throw new ApplicationError(`Failed to find owner. ${UM_FIX_INSTRUCTION}`); } - return owner; + const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + owner.id, + ); + + return project; } private async getWorkflowOwner(workflow: WorkflowEntity) { - const sharing = await Container.get(SharedWorkflowRepository).findOneBy({ - workflowId: workflow.id, - role: 'workflow:owner', + const sharing = await Container.get(SharedWorkflowRepository).findOne({ + where: { workflowId: workflow.id, role: 'workflow:owner' }, + relations: { project: true }, }); - return sharing?.userId; + if (sharing && sharing.project.type === 'personal') { + const user = await Container.get(UserRepository).findOneByOrFail({ + projectRelations: { + role: 'project:personalOwner', + projectId: sharing.projectId, + }, + }); + + return { user, project: sharing.project }; + } + + return {}; } private async workflowExists(workflow: WorkflowEntity) { @@ -189,4 +228,16 @@ export class ImportWorkflowsCommand extends BaseCommand { return workflowInstances; } } + + private async getProject(userId?: string, projectId?: string) { + if (projectId) { + return await Container.get(ProjectRepository).findOneByOrFail({ id: projectId }); + } + + if (userId) { + return await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId); + } + + return await this.getOwnerProject(); + } } diff --git a/packages/cli/src/commands/ldap/reset.ts b/packages/cli/src/commands/ldap/reset.ts index 2693465239..39dea43adc 100644 --- a/packages/cli/src/commands/ldap/reset.ts +++ b/packages/cli/src/commands/ldap/reset.ts @@ -5,18 +5,115 @@ import { AuthProviderSyncHistoryRepository } from '@db/repositories/authProvider import { SettingsRepository } from '@db/repositories/settings.repository'; import { UserRepository } from '@db/repositories/user.repository'; import { BaseCommand } from '../BaseCommand'; +import { Flags } from '@oclif/core'; +import { ApplicationError } from 'n8n-workflow'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { WorkflowService } from '@/workflows/workflow.service'; +import { In } from '@n8n/typeorm'; +import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; +import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; +import { CredentialsService } from '@/credentials/credentials.service'; +import { UM_FIX_INSTRUCTION } from '@/constants'; + +const wrongFlagsError = + 'You must use exactly one of `--userId`, `--projectId` or `--deleteWorkflowsAndCredentials`.'; export class Reset extends BaseCommand { - static description = '\nResets the database to the default ldap state'; + static description = + '\nResets the database to the default ldap state.\n\nTHIS DELETES ALL LDAP MANAGED USERS.'; + + static examples = [ + '$ n8n ldap:reset --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', + '$ n8n ldap:reset --projectId=Ox8O54VQrmBrb4qL', + '$ n8n ldap:reset --deleteWorkflowsAndCredentials', + ]; + + static flags = { + help: Flags.help({ char: 'h' }), + userId: Flags.string({ + description: + 'The ID of the user to assign the workflows and credentials owned by the deleted LDAP users to', + }), + projectId: Flags.string({ + description: + 'The ID of the project to assign the workflows and credentials owned by the deleted LDAP users to', + }), + deleteWorkflowsAndCredentials: Flags.boolean({ + description: + 'Delete all workflows and credentials owned by the users that were created by the users managed via LDAP.', + }), + }; async run(): Promise { + const { flags } = await this.parse(Reset); + const numberOfOptions = + Number(!!flags.userId) + + Number(!!flags.projectId) + + Number(!!flags.deleteWorkflowsAndCredentials); + + if (numberOfOptions !== 1) { + throw new ApplicationError(wrongFlagsError); + } + + const owner = await this.getOwner(); const ldapIdentities = await Container.get(AuthIdentityRepository).find({ where: { providerType: 'ldap' }, select: ['userId'], }); + const personalProjectIds = await Container.get( + ProjectRelationRepository, + ).getPersonalProjectsForUsers(ldapIdentities.map((i) => i.userId)); + + // Migrate all workflows and credentials to another project. + if (flags.projectId ?? flags.userId) { + if (flags.userId && ldapIdentities.some((i) => i.userId === flags.userId)) { + throw new ApplicationError( + `Can't migrate workflows and credentials to the user with the ID ${flags.userId}. That user was created via LDAP and will be deleted as well.`, + ); + } + + if (flags.projectId && personalProjectIds.includes(flags.projectId)) { + throw new ApplicationError( + `Can't migrate workflows and credentials to the project with the ID ${flags.projectId}. That project is a personal project belonging to a user that was created via LDAP and will be deleted as well.`, + ); + } + + const project = await this.getProject(flags.userId, flags.projectId); + + await Container.get(UserRepository).manager.transaction(async (trx) => { + for (const projectId of personalProjectIds) { + await Container.get(WorkflowService).transferAll(projectId, project.id, trx); + await Container.get(CredentialsService).transferAll(projectId, project.id, trx); + } + }); + } + + const [ownedSharedWorkflows, ownedSharedCredentials] = await Promise.all([ + Container.get(SharedWorkflowRepository).find({ + select: { workflowId: true }, + where: { projectId: In(personalProjectIds), role: 'workflow:owner' }, + }), + Container.get(SharedCredentialsRepository).find({ + relations: { credentials: true }, + where: { projectId: In(personalProjectIds), role: 'credential:owner' }, + }), + ]); + + const ownedCredentials = ownedSharedCredentials.map(({ credentials }) => credentials); + + for (const { workflowId } of ownedSharedWorkflows) { + await Container.get(WorkflowService).delete(owner, workflowId); + } + + for (const credential of ownedCredentials) { + await Container.get(CredentialsService).delete(credential); + } + await Container.get(AuthProviderSyncHistoryRepository).delete({ providerType: 'ldap' }); await Container.get(AuthIdentityRepository).delete({ providerType: 'ldap' }); await Container.get(UserRepository).deleteMany(ldapIdentities.map((i) => i.userId)); + await Container.get(ProjectRepository).delete({ id: In(personalProjectIds) }); await Container.get(SettingsRepository).delete({ key: LDAP_FEATURE_NAME }); await Container.get(SettingsRepository).insert({ key: LDAP_FEATURE_NAME, @@ -27,8 +124,43 @@ export class Reset extends BaseCommand { this.logger.info('Successfully reset the database to default ldap state.'); } + async getProject(userId?: string, projectId?: string) { + if (projectId) { + const project = await Container.get(ProjectRepository).findOneBy({ id: projectId }); + + if (project === null) { + throw new ApplicationError(`Could not find the project with the ID ${projectId}.`); + } + + return project; + } + + if (userId) { + const project = await Container.get(ProjectRepository).getPersonalProjectForUser(userId); + + if (project === null) { + throw new ApplicationError( + `Could not find the user with the ID ${userId} or their personalProject.`, + ); + } + + return project; + } + + throw new ApplicationError(wrongFlagsError); + } + async catch(error: Error): Promise { this.logger.error('Error resetting database. See log messages for details.'); this.logger.error(error.message); } + + private async getOwner() { + const owner = await Container.get(UserRepository).findOneBy({ role: 'global:owner' }); + if (!owner) { + throw new ApplicationError(`Failed to find owner. ${UM_FIX_INSTRUCTION}`); + } + + return owner; + } } diff --git a/packages/cli/src/commands/mfa/disable.ts b/packages/cli/src/commands/mfa/disable.ts index fb39aed795..a839b56e0d 100644 --- a/packages/cli/src/commands/mfa/disable.ts +++ b/packages/cli/src/commands/mfa/disable.ts @@ -27,16 +27,27 @@ export class DisableMFACommand extends BaseCommand { return; } - const updateOperationResult = await Container.get(UserRepository).update( - { email: flags.email }, - { mfaSecret: null, mfaRecoveryCodes: [], mfaEnabled: false }, - ); + const user = await Container.get(UserRepository).findOneBy({ email: flags.email }); - if (!updateOperationResult.affected) { + if (!user) { this.reportUserDoesNotExistError(flags.email); return; } + if ( + user.mfaSecret === null && + Array.isArray(user.mfaRecoveryCodes) && + user.mfaRecoveryCodes.length === 0 && + !user.mfaEnabled + ) { + this.reportUserDoesNotExistError(flags.email); + return; + } + + Object.assign(user, { mfaSecret: null, mfaRecoveryCodes: [], mfaEnabled: false }); + + await Container.get(UserRepository).save(user); + this.reportSuccess(flags.email); } diff --git a/packages/cli/src/commands/user-management/reset.ts b/packages/cli/src/commands/user-management/reset.ts index 188183e7d4..30f60af0a8 100644 --- a/packages/cli/src/commands/user-management/reset.ts +++ b/packages/cli/src/commands/user-management/reset.ts @@ -7,6 +7,7 @@ import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials. import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { UserRepository } from '@db/repositories/user.repository'; import { BaseCommand } from '../BaseCommand'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; const defaultUserProps = { firstName: null, @@ -23,9 +24,12 @@ export class Reset extends BaseCommand { async run(): Promise { const owner = await this.getInstanceOwner(); + const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + owner.id, + ); - await Container.get(SharedWorkflowRepository).makeOwnerOfAllWorkflows(owner); - await Container.get(SharedCredentialsRepository).makeOwnerOfAllCredentials(owner); + await Container.get(SharedWorkflowRepository).makeOwnerOfAllWorkflows(personalProject); + await Container.get(SharedCredentialsRepository).makeOwnerOfAllCredentials(personalProject); await Container.get(UserRepository).deleteAllExcept(owner); await Container.get(UserRepository).save(Object.assign(owner, defaultUserProps)); @@ -38,7 +42,7 @@ export class Reset extends BaseCommand { const newSharedCredentials = danglingCredentials.map((credentials) => Container.get(SharedCredentialsRepository).create({ credentials, - user: owner, + projectId: personalProject.id, role: 'credential:owner', }), ); diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index 965a6ac289..28beddb49f 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -17,7 +17,6 @@ import { Queue } from '@/Queue'; import { N8N_VERSION } from '@/constants'; import { ExecutionRepository } from '@db/repositories/execution.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; -import { OwnershipService } from '@/services/ownership.service'; import type { ICredentialsOverwrite } from '@/Interfaces'; import { CredentialsOverwrites } from '@/CredentialsOverwrites'; import { rawBodyReader, bodyParser } from '@/middlewares'; @@ -118,8 +117,6 @@ export class Worker extends BaseCommand { ); await executionRepository.updateStatus(executionId, 'running'); - const workflowOwner = await Container.get(OwnershipService).getWorkflowOwnerCached(workflowId); - let { staticData } = fullExecutionData.workflowData; if (loadStaticData) { const workflowData = await Container.get(WorkflowRepository).findOne({ @@ -160,7 +157,7 @@ export class Worker extends BaseCommand { }); const additionalData = await WorkflowExecuteAdditionalData.getBase( - workflowOwner.id, + undefined, undefined, executionTimeoutTimestamp, ); diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 2253ae8322..0dc0ee1fdf 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -48,7 +48,8 @@ export const RESPONSE_ERROR_MESSAGES = { USERS_QUOTA_REACHED: 'Maximum number of users reached', OAUTH2_CREDENTIAL_TEST_SUCCEEDED: 'Connection Successful!', OAUTH2_CREDENTIAL_TEST_FAILED: 'This OAuth2 credential was not connected to an account.', -}; + MISSING_SCOPE: 'User is missing a scope required to perform this action', +} as const; export const AUTH_COOKIE_NAME = 'n8n-auth'; @@ -86,6 +87,9 @@ export const LICENSE_FEATURES = { MULTIPLE_MAIN_INSTANCES: 'feat:multipleMainInstances', WORKER_VIEW: 'feat:workerView', ADVANCED_PERMISSIONS: 'feat:advancedPermissions', + PROJECT_ROLE_ADMIN: 'feat:projectRole:admin', + PROJECT_ROLE_EDITOR: 'feat:projectRole:editor', + PROJECT_ROLE_VIEWER: 'feat:projectRole:viewer', } as const; export const LICENSE_QUOTAS = { @@ -93,6 +97,7 @@ export const LICENSE_QUOTAS = { VARIABLES_LIMIT: 'quota:maxVariables', USERS_LIMIT: 'quota:users', WORKFLOW_HISTORY_PRUNE_LIMIT: 'quota:workflowHistoryPrune', + TEAM_PROJECT_LIMIT: 'quota:maxTeamProjects', } as const; export const UNLIMITED_LICENSE_QUOTA = -1; diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 456937bbaa..97de4c8d8e 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -21,7 +21,7 @@ import { MfaService } from '@/Mfa/mfa.service'; import { Logger } from '@/Logger'; import { AuthError } from '@/errors/response-errors/auth.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { ApplicationError } from 'n8n-workflow'; import { UserRepository } from '@/databases/repositories/user.repository'; @@ -130,7 +130,7 @@ export class AuthController { inviterId, inviteeId, }); - throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); + throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); } if (!inviterId || !inviteeId) { diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index 2f431de083..e8fc7c5ca9 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -6,10 +6,10 @@ import { UserRepository } from '@db/repositories/user.repository'; import { ActiveWorkflowManager } from '@/ActiveWorkflowManager'; import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; import { License } from '@/License'; -import { LICENSE_FEATURES, inE2ETests } from '@/constants'; +import { LICENSE_FEATURES, LICENSE_QUOTAS, UNLIMITED_LICENSE_QUOTA, inE2ETests } from '@/constants'; import { Patch, Post, RestController } from '@/decorators'; import type { UserSetupPayload } from '@/requests'; -import type { BooleanLicenseFeature, IPushDataType } from '@/Interfaces'; +import type { BooleanLicenseFeature, IPushDataType, NumericLicenseFeature } from '@/Interfaces'; import { MfaService } from '@/Mfa/mfa.service'; import { Push } from '@/push'; import { CacheService } from '@/services/cache/cache.service'; @@ -25,21 +25,23 @@ if (!inE2ETests) { const tablesToTruncate = [ 'auth_identity', 'auth_provider_sync_history', - 'event_destinations', - 'shared_workflow', - 'shared_credentials', - 'webhook_entity', - 'workflows_tags', 'credentials_entity', - 'tag_entity', - 'workflow_statistics', - 'workflow_entity', + 'event_destinations', 'execution_entity', - 'settings', - 'installed_packages', 'installed_nodes', + 'installed_packages', + 'project', + 'project_relation', + 'settings', + 'shared_credentials', + 'shared_workflow', + 'tag_entity', 'user', 'variables', + 'webhook_entity', + 'workflow_entity', + 'workflow_statistics', + 'workflows_tags', ]; type ResetRequest = Request< @@ -81,21 +83,35 @@ export class E2EController { [LICENSE_FEATURES.MULTIPLE_MAIN_INSTANCES]: false, [LICENSE_FEATURES.WORKER_VIEW]: false, [LICENSE_FEATURES.ADVANCED_PERMISSIONS]: false, + [LICENSE_FEATURES.PROJECT_ROLE_ADMIN]: false, + [LICENSE_FEATURES.PROJECT_ROLE_EDITOR]: false, + [LICENSE_FEATURES.PROJECT_ROLE_VIEWER]: false, + }; + + private numericFeatures: Record = { + [LICENSE_QUOTAS.TRIGGER_LIMIT]: -1, + [LICENSE_QUOTAS.VARIABLES_LIMIT]: -1, + [LICENSE_QUOTAS.USERS_LIMIT]: -1, + [LICENSE_QUOTAS.WORKFLOW_HISTORY_PRUNE_LIMIT]: -1, + [LICENSE_QUOTAS.TEAM_PROJECT_LIMIT]: 0, }; constructor( license: License, private readonly settingsRepo: SettingsRepository, - private readonly userRepo: UserRepository, private readonly workflowRunner: ActiveWorkflowManager, private readonly mfaService: MfaService, private readonly cacheService: CacheService, private readonly push: Push, private readonly passwordUtility: PasswordUtility, private readonly eventBus: MessageEventBus, + private readonly userRepository: UserRepository, ) { license.isFeatureEnabled = (feature: BooleanLicenseFeature) => this.enabledFeatures[feature] ?? false; + // eslint-disable-next-line @typescript-eslint/unbound-method + license.getFeatureValue = (feature: NumericLicenseFeature) => + this.numericFeatures[feature] ?? UNLIMITED_LICENSE_QUOTA; } @Post('/reset', { skipAuth: true }) @@ -119,6 +135,12 @@ export class E2EController { this.enabledFeatures[feature] = enabled; } + @Patch('/quota', { skipAuth: true }) + setQuota(req: Request<{}, {}, { feature: NumericLicenseFeature; value: number }>) { + const { value, feature } = req.body; + this.numericFeatures[feature] = value; + } + @Patch('/queue-mode', { skipAuth: true }) async setQueueMode(req: Request<{}, {}, { enabled: boolean }>) { const { enabled } = req.body; @@ -163,34 +185,34 @@ export class E2EController { members: UserSetupPayload[], admin: UserSetupPayload, ) { - const instanceOwner = this.userRepo.create({ - id: uuid(), - ...owner, - password: await this.passwordUtility.hash(owner.password), - role: 'global:owner', - }); - if (owner?.mfaSecret && owner.mfaRecoveryCodes?.length) { const { encryptedRecoveryCodes, encryptedSecret } = this.mfaService.encryptSecretAndRecoveryCodes(owner.mfaSecret, owner.mfaRecoveryCodes); - instanceOwner.mfaSecret = encryptedSecret; - instanceOwner.mfaRecoveryCodes = encryptedRecoveryCodes; + owner.mfaSecret = encryptedSecret; + owner.mfaRecoveryCodes = encryptedRecoveryCodes; } - const adminUser = this.userRepo.create({ - id: uuid(), - ...admin, - password: await this.passwordUtility.hash(admin.password), - role: 'global:admin', - }); + const userCreatePromises = [ + this.userRepository.createUserWithProject({ + id: uuid(), + ...owner, + password: await this.passwordUtility.hash(owner.password), + role: 'global:owner', + }), + ]; - const users = []; - - users.push(instanceOwner, adminUser); + userCreatePromises.push( + this.userRepository.createUserWithProject({ + id: uuid(), + ...admin, + password: await this.passwordUtility.hash(admin.password), + role: 'global:admin', + }), + ); for (const { password, ...payload } of members) { - users.push( - this.userRepo.create({ + userCreatePromises.push( + this.userRepository.createUserWithProject({ id: uuid(), ...payload, password: await this.passwordUtility.hash(password), @@ -199,7 +221,7 @@ export class E2EController { ); } - await this.userRepo.insert(users); + await Promise.all(userCreatePromises); await this.settingsRepo.update( { key: 'userManagement.isInstanceOwnerSetUp' }, diff --git a/packages/cli/src/controllers/invitation.controller.ts b/packages/cli/src/controllers/invitation.controller.ts index e1ca3dcc18..bb5f006a5c 100644 --- a/packages/cli/src/controllers/invitation.controller.ts +++ b/packages/cli/src/controllers/invitation.controller.ts @@ -15,7 +15,7 @@ import { PostHogClient } from '@/posthog'; import type { User } from '@/databases/entities/User'; import { UserRepository } from '@db/repositories/user.repository'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { InternalHooks } from '@/InternalHooks'; import { ExternalHooks } from '@/ExternalHooks'; @@ -55,7 +55,7 @@ export class InvitationController { this.logger.debug( 'Request to send email invite(s) to user(s) failed because the user limit quota has been reached', ); - throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); + throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); } if (!config.getEnv('userManagement.isInstanceOwnerSetUp')) { @@ -98,7 +98,7 @@ export class InvitationController { } if (invite.role === 'global:admin' && !this.license.isAdvancedPermissionsLicensed()) { - throw new UnauthorizedError( + throw new ForbiddenError( 'Cannot invite admin user without advanced permissions. Please upgrade to a license that includes this feature.', ); } diff --git a/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts b/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts index 6f21069150..b778216a60 100644 --- a/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts +++ b/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts @@ -47,6 +47,7 @@ export abstract class AbstractOAuthController { const credential = await this.sharedCredentialsRepository.findCredentialForUser( credentialId, req.user, + ['credential:read'], ); if (!credential) { diff --git a/packages/cli/src/controllers/passwordReset.controller.ts b/packages/cli/src/controllers/passwordReset.controller.ts index bf8034a0d7..fdf9e49135 100644 --- a/packages/cli/src/controllers/passwordReset.controller.ts +++ b/packages/cli/src/controllers/passwordReset.controller.ts @@ -17,7 +17,7 @@ import { InternalHooks } from '@/InternalHooks'; import { UrlService } from '@/services/url.service'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; import { UserRepository } from '@/databases/repositories/user.repository'; @@ -76,7 +76,7 @@ export class PasswordResetController { this.logger.debug( 'Request to send password reset email failed because the user limit was reached', ); - throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); + throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); } if ( isSamlCurrentAuthenticationMethod() && @@ -88,7 +88,7 @@ export class PasswordResetController { this.logger.debug( 'Request to send password reset email failed because login is handled by SAML', ); - throw new UnauthorizedError( + throw new ForbiddenError( 'Login is handled by SAML. Please contact your Identity Provider to reset your password.', ); } @@ -163,7 +163,7 @@ export class PasswordResetController { 'Request to resolve password token failed because the user limit was reached', { userId: user.id }, ); - throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); + throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); } this.logger.info('Reset-password token resolved successfully', { userId: user.id }); diff --git a/packages/cli/src/controllers/project.controller.ts b/packages/cli/src/controllers/project.controller.ts new file mode 100644 index 0000000000..d9fa5d6ff5 --- /dev/null +++ b/packages/cli/src/controllers/project.controller.ts @@ -0,0 +1,221 @@ +import type { Project } from '@db/entities/Project'; +import { + Get, + Post, + GlobalScope, + RestController, + Licensed, + Patch, + ProjectScope, + Delete, +} from '@/decorators'; +import { ProjectRequest } from '@/requests'; +import { + ProjectService, + TeamProjectOverQuotaError, + UnlicensedProjectRoleError, +} from '@/services/project.service'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { combineScopes } from '@n8n/permissions'; +import type { Scope } from '@n8n/permissions'; +import { RoleService } from '@/services/role.service'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { In, Not } from '@n8n/typeorm'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { InternalHooks } from '@/InternalHooks'; + +@RestController('/projects') +export class ProjectController { + constructor( + private readonly projectsService: ProjectService, + private readonly roleService: RoleService, + private readonly projectRepository: ProjectRepository, + private readonly internalHooks: InternalHooks, + ) {} + + @Get('/') + async getAllProjects(req: ProjectRequest.GetAll): Promise { + return await this.projectsService.getAccessibleProjects(req.user); + } + + @Get('/count') + async getProjectCounts() { + return await this.projectsService.getProjectCounts(); + } + + @Post('/') + @GlobalScope('project:create') + // Using admin as all plans that contain projects should allow admins at the very least + @Licensed('feat:projectRole:admin') + async createProject(req: ProjectRequest.Create): Promise { + try { + const project = await this.projectsService.createTeamProject(req.body.name, req.user); + + void this.internalHooks.onTeamProjectCreated({ + user_id: req.user.id, + role: req.user.role, + }); + + return project; + } catch (e) { + if (e instanceof TeamProjectOverQuotaError) { + throw new BadRequestError(e.message); + } + throw e; + } + } + + @Get('/my-projects') + async getMyProjects( + req: ProjectRequest.GetMyProjects, + ): Promise { + const relations = await this.projectsService.getProjectRelationsForUser(req.user); + const otherTeamProject = req.user.hasGlobalScope('project:read') + ? await this.projectRepository.findBy({ + type: 'team', + id: Not(In(relations.map((pr) => pr.projectId))), + }) + : []; + + const results: ProjectRequest.GetMyProjectsResponse = []; + + for (const pr of relations) { + const result: ProjectRequest.GetMyProjectsResponse[number] = Object.assign( + this.projectRepository.create(pr.project), + { + role: pr.role, + scopes: req.query.includeScopes ? ([] as Scope[]) : undefined, + }, + ); + + if (result.scopes) { + result.scopes.push( + ...combineScopes({ + global: this.roleService.getRoleScopes(req.user.role), + project: this.roleService.getRoleScopes(pr.role), + }), + ); + } + + results.push(result); + } + + for (const project of otherTeamProject) { + const result: ProjectRequest.GetMyProjectsResponse[number] = Object.assign( + this.projectRepository.create(project), + { + // If the user has the global `project:read` scope then they may not + // own this relationship in that case we use the global user role + // instead of the relation role, which is for another user. + role: req.user.role, + scopes: req.query.includeScopes ? [] : undefined, + }, + ); + + if (result.scopes) { + result.scopes.push( + ...combineScopes({ global: this.roleService.getRoleScopes(req.user.role) }), + ); + } + + results.push(result); + } + + // Deduplicate and sort scopes + for (const result of results) { + if (result.scopes) { + result.scopes = [...new Set(result.scopes)].sort(); + } + } + + return results; + } + + @Get('/personal') + async getPersonalProject(req: ProjectRequest.GetPersonalProject) { + const project = await this.projectsService.getPersonalProject(req.user); + if (!project) { + throw new NotFoundError('Could not find a personal project for this user'); + } + const scopes: Scope[] = [ + ...combineScopes({ + global: this.roleService.getRoleScopes(req.user.role), + project: this.roleService.getRoleScopes('project:personalOwner'), + }), + ]; + return { + ...project, + scopes, + }; + } + + @Get('/:projectId') + @ProjectScope('project:read') + async getProject(req: ProjectRequest.Get): Promise { + const [{ id, name, type }, relations] = await Promise.all([ + this.projectsService.getProject(req.params.projectId), + this.projectsService.getProjectRelations(req.params.projectId), + ]); + const myRelation = relations.find((r) => r.userId === req.user.id); + + return { + id, + name, + type, + relations: relations.map((r) => ({ + id: r.user.id, + email: r.user.email, + firstName: r.user.firstName, + lastName: r.user.lastName, + role: r.role, + })), + scopes: [ + ...combineScopes({ + global: this.roleService.getRoleScopes(req.user.role), + ...(myRelation ? { project: this.roleService.getRoleScopes(myRelation.role) } : {}), + }), + ], + }; + } + + @Patch('/:projectId') + @ProjectScope('project:update') + async updateProject(req: ProjectRequest.Update) { + if (req.body.name) { + await this.projectsService.updateProject(req.body.name, req.params.projectId); + } + if (req.body.relations) { + try { + await this.projectsService.syncProjectRelations(req.params.projectId, req.body.relations); + } catch (e) { + if (e instanceof UnlicensedProjectRoleError) { + throw new BadRequestError(e.message); + } + throw e; + } + + void this.internalHooks.onTeamProjectUpdated({ + user_id: req.user.id, + role: req.user.role, + members: req.body.relations.map(({ userId, role }) => ({ user_id: userId, role })), + project_id: req.params.projectId, + }); + } + } + + @Delete('/:projectId') + @ProjectScope('project:delete') + async deleteProject(req: ProjectRequest.Delete) { + await this.projectsService.deleteProject(req.user, req.params.projectId, { + migrateToProject: req.query.transferId, + }); + + void this.internalHooks.onTeamProjectDeleted({ + user_id: req.user.id, + role: req.user.role, + project_id: req.params.projectId, + removal_type: req.query.transferId !== undefined ? 'transfer' : 'delete', + target_project_id: req.query.transferId, + }); + } +} diff --git a/packages/cli/src/controllers/role.controller.ts b/packages/cli/src/controllers/role.controller.ts new file mode 100644 index 0000000000..3a9cd3c376 --- /dev/null +++ b/packages/cli/src/controllers/role.controller.ts @@ -0,0 +1,22 @@ +import { Get, RestController } from '@/decorators'; +import { type AllRoleTypes, RoleService } from '@/services/role.service'; + +@RestController('/roles') +export class RoleController { + constructor(private readonly roleService: RoleService) {} + + @Get('/') + async getAllRoles() { + return Object.fromEntries( + Object.entries(this.roleService.getRoles()).map((e) => [ + e[0], + (e[1] as AllRoleTypes[]).map((r) => ({ + name: this.roleService.getRoleName(r), + role: r, + scopes: this.roleService.getRoleScopes(r), + licensed: this.roleService.isRoleLicensed(r), + })), + ]), + ); + } +} diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index 9cc1fd0321..391c98c70f 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -2,8 +2,6 @@ import { plainToInstance } from 'class-transformer'; import { AuthService } from '@/auth/auth.service'; import { User } from '@db/entities/User'; -import { SharedCredentials } from '@db/entities/SharedCredentials'; -import { SharedWorkflow } from '@db/entities/SharedWorkflow'; import { GlobalScope, Delete, Get, RestController, Patch, Licensed } from '@/decorators'; import { ListQuery, @@ -11,7 +9,6 @@ import { UserRoleChangePayload, UserSettingsUpdatePayload, } from '@/requests'; -import { ActiveWorkflowManager } from '@/ActiveWorkflowManager'; import type { PublicUser, ITelemetryUserDeletionData } from '@/Interfaces'; import { AuthIdentity } from '@db/entities/AuthIdentity'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; @@ -20,12 +17,17 @@ import { UserRepository } from '@db/repositories/user.repository'; import { UserService } from '@/services/user.service'; import { listQueryMiddleware } from '@/middlewares'; import { Logger } from '@/Logger'; -import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ExternalHooks } from '@/ExternalHooks'; import { InternalHooks } from '@/InternalHooks'; import { validateEntity } from '@/GenericHelpers'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { Project } from '@/databases/entities/Project'; +import { WorkflowService } from '@/workflows/workflow.service'; +import { CredentialsService } from '@/credentials/credentials.service'; +import { ProjectService } from '@/services/project.service'; @RestController('/users') export class UsersController { @@ -36,9 +38,12 @@ export class UsersController { private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly userRepository: UserRepository, - private readonly activeWorkflowManager: ActiveWorkflowManager, private readonly authService: AuthService, private readonly userService: UserService, + private readonly projectRepository: ProjectRepository, + private readonly workflowService: WorkflowService, + private readonly credentialsService: CredentialsService, + private readonly projectService: ProjectService, ) {} static ERROR_MESSAGES = { @@ -151,131 +156,92 @@ export class UsersController { const { transferId } = req.query; - if (transferId === idToDelete) { + const userToDelete = await this.userRepository.findOneBy({ id: idToDelete }); + + if (!userToDelete) { + throw new NotFoundError( + 'Request to delete a user failed because the user to delete was not found in DB', + ); + } + + const personalProjectToDelete = await this.projectRepository.getPersonalProjectForUserOrFail( + userToDelete.id, + ); + + if (transferId === personalProjectToDelete.id) { throw new BadRequestError( 'Request to delete a user failed because the user to delete and the transferee are the same user', ); } - const userIds = transferId ? [transferId, idToDelete] : [idToDelete]; - - const users = await this.userRepository.findManyByIds(userIds); - - if (!users.length || (transferId && users.length !== 2)) { - throw new NotFoundError( - 'Request to delete a user failed because the ID of the user to delete and/or the ID of the transferee were not found in DB', - ); - } - - const userToDelete = users.find((user) => user.id === req.params.id) as User; - const telemetryData: ITelemetryUserDeletionData = { user_id: req.user.id, target_user_old_status: userToDelete.isPending ? 'invited' : 'active', target_user_id: idToDelete, + migration_strategy: transferId ? 'transfer_data' : 'delete_data', }; - telemetryData.migration_strategy = transferId ? 'transfer_data' : 'delete_data'; - if (transferId) { - telemetryData.migration_user_id = transferId; - } + const transfereePersonalProject = await this.projectRepository.findOneBy({ id: transferId }); - if (transferId) { - const transferee = users.find((user) => user.id === transferId); - - await this.userService.getManager().transaction(async (transactionManager) => { - // Get all workflow ids belonging to user to delete - const sharedWorkflowIds = await transactionManager - .getRepository(SharedWorkflow) - .find({ - select: ['workflowId'], - where: { userId: userToDelete.id, role: 'workflow:owner' }, - }) - .then((sharedWorkflows) => sharedWorkflows.map(({ workflowId }) => workflowId)); - - // Prevents issues with unique key constraints since user being assigned - // workflows and credentials might be a sharee - await this.sharedWorkflowRepository.deleteByIds( - transactionManager, - sharedWorkflowIds, - transferee, + if (!transfereePersonalProject) { + throw new NotFoundError( + 'Request to delete a user failed because the transferee project was not found in DB', ); + } - // Transfer ownership of owned workflows - await transactionManager.update( - SharedWorkflow, - { user: userToDelete, role: 'workflow:owner' }, - { user: transferee }, - ); - - // Now do the same for creds - - // Get all workflow ids belonging to user to delete - const sharedCredentialIds = await transactionManager - .getRepository(SharedCredentials) - .find({ - select: ['credentialsId'], - where: { userId: userToDelete.id, role: 'credential:owner' }, - }) - .then((sharedCredentials) => sharedCredentials.map(({ credentialsId }) => credentialsId)); - - // Prevents issues with unique key constraints since user being assigned - // workflows and credentials might be a sharee - await this.sharedCredentialsRepository.deleteByIds( - transactionManager, - sharedCredentialIds, - transferee, - ); - - // Transfer ownership of owned credentials - await transactionManager.update( - SharedCredentials, - { user: userToDelete, role: 'credential:owner' }, - { user: transferee }, - ); - - await transactionManager.delete(AuthIdentity, { userId: userToDelete.id }); - - // This will remove all shared workflows and credentials not owned - await transactionManager.delete(User, { id: userToDelete.id }); + const transferee = await this.userRepository.findOneByOrFail({ + projectRelations: { + projectId: transfereePersonalProject.id, + role: 'project:personalOwner', + }, }); - void this.internalHooks.onUserDeletion({ - user: req.user, - telemetryData, - publicApi: false, + telemetryData.migration_user_id = transferee.id; + + await this.userService.getManager().transaction(async (trx) => { + await this.workflowService.transferAll( + personalProjectToDelete.id, + transfereePersonalProject.id, + trx, + ); + await this.credentialsService.transferAll( + personalProjectToDelete.id, + transfereePersonalProject.id, + trx, + ); }); - await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]); - return { success: true }; + + await this.projectService.clearCredentialCanUseExternalSecretsCache( + transfereePersonalProject.id, + ); } const [ownedSharedWorkflows, ownedSharedCredentials] = await Promise.all([ this.sharedWorkflowRepository.find({ - relations: ['workflow'], - where: { userId: userToDelete.id, role: 'workflow:owner' }, + select: { workflowId: true }, + where: { projectId: personalProjectToDelete.id, role: 'workflow:owner' }, }), this.sharedCredentialsRepository.find({ - relations: ['credentials'], - where: { userId: userToDelete.id, role: 'credential:owner' }, + relations: { credentials: true }, + where: { projectId: personalProjectToDelete.id, role: 'credential:owner' }, }), ]); - await this.userService.getManager().transaction(async (transactionManager) => { - const ownedWorkflows = await Promise.all( - ownedSharedWorkflows.map(async ({ workflow }) => { - if (workflow.active) { - // deactivate before deleting - await this.activeWorkflowManager.remove(workflow.id); - } - return workflow; - }), - ); - await transactionManager.remove(ownedWorkflows); - await transactionManager.remove(ownedSharedCredentials.map(({ credentials }) => credentials)); + const ownedCredentials = ownedSharedCredentials.map(({ credentials }) => credentials); - await transactionManager.delete(AuthIdentity, { userId: userToDelete.id }); - await transactionManager.delete(User, { id: userToDelete.id }); + for (const { workflowId } of ownedSharedWorkflows) { + await this.workflowService.delete(userToDelete, workflowId); + } + + for (const credential of ownedCredentials) { + await this.credentialsService.delete(credential); + } + + await this.userService.getManager().transaction(async (trx) => { + await trx.delete(AuthIdentity, { userId: userToDelete.id }); + await trx.delete(Project, { id: personalProjectToDelete.id }); + await trx.delete(User, { id: userToDelete.id }); }); void this.internalHooks.onUserDeletion({ @@ -285,6 +251,7 @@ export class UsersController { }); await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]); + return { success: true }; } @@ -308,11 +275,11 @@ export class UsersController { } if (req.user.role === 'global:admin' && targetUser.role === 'global:owner') { - throw new UnauthorizedError(NO_ADMIN_ON_OWNER); + throw new ForbiddenError(NO_ADMIN_ON_OWNER); } if (req.user.role === 'global:owner' && targetUser.role === 'global:owner') { - throw new UnauthorizedError(NO_OWNER_ON_OWNER); + throw new ForbiddenError(NO_OWNER_ON_OWNER); } await this.userService.update(targetUser.id, { role: payload.newRoleName }); @@ -324,6 +291,13 @@ export class UsersController { public_api: false, }); + const projects = await this.projectService.getUserOwnedOrAdminProjects(targetUser.id); + await Promise.all( + projects.map( + async (p) => await this.projectService.clearCredentialCanUseExternalSecretsCache(p.id), + ), + ); + return { success: true }; } } diff --git a/packages/cli/src/controllers/workflowStatistics.controller.ts b/packages/cli/src/controllers/workflowStatistics.controller.ts index caa9f3cae3..0c86786129 100644 --- a/packages/cli/src/controllers/workflowStatistics.controller.ts +++ b/packages/cli/src/controllers/workflowStatistics.controller.ts @@ -29,13 +29,15 @@ export class WorkflowStatisticsController { */ // TODO: move this into a new decorator `@ValidateWorkflowPermission` @Middleware() - async hasWorkflowAccess(req: StatisticsRequest.GetOne, res: Response, next: NextFunction) { + async hasWorkflowAccess(req: StatisticsRequest.GetOne, _res: Response, next: NextFunction) { const { user } = req; const workflowId = req.params.id; - const hasAccess = await this.sharedWorkflowRepository.hasAccess(workflowId, user); + const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [ + 'workflow:read', + ]); - if (hasAccess) { + if (workflow) { next(); } else { this.logger.verbose('User attempted to read a workflow without permissions', { diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index aba4e15437..2542c9d60d 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -1,41 +1,53 @@ import { deepCopy } from 'n8n-workflow'; import config from '@/config'; import { CredentialsService } from './credentials.service'; -import { CredentialRequest, ListQuery } from '@/requests'; +import { CredentialRequest } from '@/requests'; import { InternalHooks } from '@/InternalHooks'; import { Logger } from '@/Logger'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NamingService } from '@/services/naming.service'; import { License } from '@/License'; -import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; -import { OwnershipService } from '@/services/ownership.service'; import { EnterpriseCredentialsService } from './credentials.service.ee'; -import { Delete, Get, Licensed, Patch, Post, Put, RestController } from '@/decorators'; +import { + Delete, + Get, + Licensed, + Patch, + Post, + Put, + RestController, + ProjectScope, +} from '@/decorators'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { UserManagementMailer } from '@/UserManagement/email'; import * as Db from '@/Db'; import * as utils from '@/utils'; import { listQueryMiddleware } from '@/middlewares'; +import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository'; +import { In } from '@n8n/typeorm'; +import { SharedCredentials } from '@/databases/entities/SharedCredentials'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; @RestController('/credentials') export class CredentialsController { constructor( private readonly credentialsService: CredentialsService, private readonly enterpriseCredentialsService: EnterpriseCredentialsService, - private readonly credentialsRepository: CredentialsRepository, private readonly namingService: NamingService, private readonly license: License, private readonly logger: Logger, - private readonly ownershipService: OwnershipService, private readonly internalHooks: InternalHooks, private readonly userManagementMailer: UserManagementMailer, + private readonly sharedCredentialsRepository: SharedCredentialsRepository, + private readonly projectRelationRepository: ProjectRelationRepository, ) {} @Get('/', { middlewares: listQueryMiddleware }) - async getMany(req: ListQuery.Request) { + async getMany(req: CredentialRequest.GetMany) { return await this.credentialsService.getMany(req.user, { listQueryOptions: req.listQueryOptions, + includeScopes: req.query.includeScopes, }); } @@ -48,128 +60,73 @@ export class CredentialsController { }; } - @Get('/:id') + @Get('/:credentialId') + @ProjectScope('credential:read') async getOne(req: CredentialRequest.Get) { if (this.license.isSharingEnabled()) { - const { id: credentialId } = req.params; - const includeDecryptedData = req.query.includeData === 'true'; - - let credential = await this.credentialsRepository.findOne({ - where: { id: credentialId }, - relations: ['shared', 'shared.user'], - }); - - if (!credential) { - throw new NotFoundError( - 'Could not load the credential. If you think this is an error, ask the owner to share it with you again', - ); - } - - const userSharing = credential.shared?.find((shared) => shared.user.id === req.user.id); - - if (!userSharing && !req.user.hasGlobalScope('credential:read')) { - throw new UnauthorizedError('Forbidden.'); - } - - credential = this.ownershipService.addOwnedByAndSharedWith(credential); - - // Below, if `userSharing` does not exist, it means this credential is being - // fetched by the instance owner or an admin. In this case, they get the full data - if (!includeDecryptedData || userSharing?.role === 'credential:user') { - const { data: _, ...rest } = credential; - return { ...rest }; - } - - const { data: _, ...rest } = credential; - - const decryptedData = this.credentialsService.redact( - this.credentialsService.decrypt(credential), - credential, + const credentials = await this.enterpriseCredentialsService.getOne( + req.user, + req.params.credentialId, + // TODO: editor-ui is always sending this, maybe we can just rely on the + // the scopes and always decrypt the data if the user has the permissions + // to do so. + req.query.includeData === 'true', ); - return { data: decryptedData, ...rest }; + const scopes = await this.credentialsService.getCredentialScopes( + req.user, + req.params.credentialId, + ); + + return { ...credentials, scopes }; } // non-enterprise - const { id: credentialId } = req.params; - const includeDecryptedData = req.query.includeData === 'true'; - - const sharing = await this.credentialsService.getSharing( + const credentials = await this.credentialsService.getOne( req.user, - credentialId, - { allowGlobalScope: true, globalScope: 'credential:read' }, - ['credentials'], + req.params.credentialId, + req.query.includeData === 'true', ); - if (!sharing) { - throw new NotFoundError(`Credential with ID "${credentialId}" could not be found.`); - } - - const { credentials: credential } = sharing; - - const { data: _, ...rest } = credential; - - if (!includeDecryptedData) { - return { ...rest }; - } - - const decryptedData = this.credentialsService.redact( - this.credentialsService.decrypt(credential), - credential, + const scopes = await this.credentialsService.getCredentialScopes( + req.user, + req.params.credentialId, ); - return { data: decryptedData, ...rest }; + return { ...credentials, scopes }; } + // TODO: Write at least test cases for the failure paths. @Post('/test') async testCredentials(req: CredentialRequest.Test) { - if (this.license.isSharingEnabled()) { - const { credentials } = req.body; - - const credentialId = credentials.id; - const { ownsCredential } = await this.enterpriseCredentialsService.isOwned( - req.user, - credentialId, - ); - - const sharing = await this.enterpriseCredentialsService.getSharing(req.user, credentialId, { - allowGlobalScope: true, - globalScope: 'credential:read', - }); - if (!ownsCredential) { - if (!sharing) { - throw new UnauthorizedError('Forbidden'); - } - - const decryptedData = this.credentialsService.decrypt(sharing.credentials); - Object.assign(credentials, { data: decryptedData }); - } - - const mergedCredentials = deepCopy(credentials); - if (mergedCredentials.data && sharing?.credentials) { - const decryptedData = this.credentialsService.decrypt(sharing.credentials); - mergedCredentials.data = this.credentialsService.unredact( - mergedCredentials.data, - decryptedData, - ); - } - - return await this.credentialsService.test(req.user, mergedCredentials); - } - - // non-enterprise - const { credentials } = req.body; - const sharing = await this.credentialsService.getSharing(req.user, credentials.id, { - allowGlobalScope: true, - globalScope: 'credential:read', - }); + const storedCredential = await this.sharedCredentialsRepository.findCredentialForUser( + credentials.id, + req.user, + ['credential:read'], + ); + + if (!storedCredential) { + throw new ForbiddenError(); + } const mergedCredentials = deepCopy(credentials); - if (mergedCredentials.data && sharing?.credentials) { - const decryptedData = this.credentialsService.decrypt(sharing.credentials); + const decryptedData = this.credentialsService.decrypt(storedCredential); + + // When a sharee opens a credential, the fields and the credential data are missing + // so the payload will be empty + // We need to replace the credential contents with the db version if that's the case + // So the credential can be tested properly + this.credentialsService.replaceCredentialContentsForSharee( + req.user, + storedCredential, + decryptedData, + mergedCredentials, + ); + + if (mergedCredentials.data && storedCredential) { mergedCredentials.data = this.credentialsService.unredact( mergedCredentials.data, decryptedData, @@ -184,7 +141,12 @@ export class CredentialsController { const newCredential = await this.credentialsService.prepareCreateData(req.body); const encryptedData = this.credentialsService.createEncryptedData(null, newCredential); - const credential = await this.credentialsService.save(newCredential, encryptedData, req.user); + const credential = await this.credentialsService.save( + newCredential, + encryptedData, + req.user, + req.body.projectId, + ); void this.internalHooks.onUserCreatedCredentials({ user: req.user, @@ -194,24 +156,23 @@ export class CredentialsController { public_api: false, }); - return credential; + const scopes = await this.credentialsService.getCredentialScopes(req.user, credential.id); + + return { ...credential, scopes }; } - @Patch('/:id') + @Patch('/:credentialId') + @ProjectScope('credential:update') async updateCredentials(req: CredentialRequest.Update) { - const { id: credentialId } = req.params; + const { credentialId } = req.params; - const sharing = await this.credentialsService.getSharing( - req.user, + const credential = await this.sharedCredentialsRepository.findCredentialForUser( credentialId, - { - allowGlobalScope: true, - globalScope: 'credential:update', - }, - ['credentials'], + req.user, + ['credential:update'], ); - if (!sharing) { + if (!credential) { this.logger.info('Attempt to update credential blocked due to lack of permissions', { credentialId, userId: req.user.id, @@ -221,16 +182,6 @@ export class CredentialsController { ); } - if (sharing.role !== 'credential:owner' && !req.user.hasGlobalScope('credential:update')) { - this.logger.info('Attempt to update credential blocked due to lack of permissions', { - credentialId, - userId: req.user.id, - }); - throw new UnauthorizedError('You can only update credentials owned by you'); - } - - const { credentials: credential } = sharing; - const decryptedData = this.credentialsService.decrypt(credential); const preparedCredentialData = await this.credentialsService.prepareUpdateData( req.body, @@ -259,24 +210,23 @@ export class CredentialsController { credential_id: credential.id, }); - return { ...rest }; + const scopes = await this.credentialsService.getCredentialScopes(req.user, credential.id); + + return { ...rest, scopes }; } - @Delete('/:id') + @Delete('/:credentialId') + @ProjectScope('credential:delete') async deleteCredentials(req: CredentialRequest.Delete) { - const { id: credentialId } = req.params; + const { credentialId } = req.params; - const sharing = await this.credentialsService.getSharing( - req.user, + const credential = await this.sharedCredentialsRepository.findCredentialForUser( credentialId, - { - allowGlobalScope: true, - globalScope: 'credential:delete', - }, - ['credentials'], + req.user, + ['credential:delete'], ); - if (!sharing) { + if (!credential) { this.logger.info('Attempt to delete credential blocked due to lack of permissions', { credentialId, userId: req.user.id, @@ -286,16 +236,6 @@ export class CredentialsController { ); } - if (sharing.role !== 'credential:owner' && !req.user.hasGlobalScope('credential:delete')) { - this.logger.info('Attempt to delete credential blocked due to lack of permissions', { - credentialId, - userId: req.user.id, - }); - throw new UnauthorizedError('You can only remove credentials owned by you'); - } - - const { credentials: credential } = sharing; - await this.credentialsService.delete(credential); void this.internalHooks.onUserDeletedCredentials({ @@ -309,9 +249,10 @@ export class CredentialsController { } @Licensed('feat:sharing') - @Put('/:id/share') + @Put('/:credentialId/share') + @ProjectScope('credential:share') async shareCredentials(req: CredentialRequest.Share) { - const { id: credentialId } = req.params; + const { credentialId } = req.params; const { shareWithIds } = req.body; if ( @@ -321,59 +262,45 @@ export class CredentialsController { throw new BadRequestError('Bad request'); } - const isOwnedRes = await this.enterpriseCredentialsService.isOwned(req.user, credentialId); - const { ownsCredential } = isOwnedRes; - let { credential } = isOwnedRes; - if (!ownsCredential || !credential) { - credential = undefined; - // Allow owners/admins to share - if (req.user.hasGlobalScope('credential:share')) { - const sharedRes = await this.enterpriseCredentialsService.getSharing( - req.user, - credentialId, - { - allowGlobalScope: true, - globalScope: 'credential:share', - }, - ); - credential = sharedRes?.credentials; - } - if (!credential) { - throw new UnauthorizedError('Forbidden'); - } - } + const credential = await this.sharedCredentialsRepository.findCredentialForUser( + credentialId, + req.user, + ['credential:share'], + ); - const ownerIds = ( - await this.enterpriseCredentialsService.getSharings( - Db.getConnection().createEntityManager(), - credentialId, - ['shared'], - ) - ) - .filter((e) => e.role === 'credential:owner') - .map((e) => e.userId); + if (!credential) { + throw new ForbiddenError(); + } let amountRemoved: number | null = null; let newShareeIds: string[] = []; + await Db.transaction(async (trx) => { - // remove all sharings that are not supposed to exist anymore - const { affected } = await this.credentialsRepository.pruneSharings(trx, credentialId, [ - ...ownerIds, - ...shareWithIds, - ]); - if (affected) amountRemoved = affected; + const currentPersonalProjectIDs = credential.shared + .filter((sc) => sc.role === 'credential:user') + .map((sc) => sc.projectId); + const newPersonalProjectIds = shareWithIds; - const sharings = await this.enterpriseCredentialsService.getSharings(trx, credentialId); - - // extract the new sharings that need to be added - newShareeIds = utils.rightDiff( - [sharings, (sharing) => sharing.userId], - [shareWithIds, (shareeId) => shareeId], + const toShare = utils.rightDiff( + [currentPersonalProjectIDs, (id) => id], + [newPersonalProjectIds, (id) => id], + ); + const toUnshare = utils.rightDiff( + [newPersonalProjectIds, (id) => id], + [currentPersonalProjectIDs, (id) => id], ); - if (newShareeIds.length) { - await this.enterpriseCredentialsService.share(trx, credential, newShareeIds); + const deleteResult = await trx.delete(SharedCredentials, { + credentialsId: credentialId, + projectId: In(toUnshare), + }); + await this.enterpriseCredentialsService.shareWithProjects(credential, toShare, trx); + + if (deleteResult.affected) { + amountRemoved = deleteResult.affected; } + + newShareeIds = toShare; }); void this.internalHooks.onUserSharedCredentials({ @@ -386,9 +313,14 @@ export class CredentialsController { sharees_removed: amountRemoved, }); + const projectsRelations = await this.projectRelationRepository.findBy({ + projectId: In(newShareeIds), + role: 'project:personalOwner', + }); + await this.userManagementMailer.notifyCredentialsShared({ sharer: req.user, - newShareeIds, + newShareeIds: projectsRelations.map((pr) => pr.userId), credentialsName: credential.name, }); } diff --git a/packages/cli/src/credentials/credentials.service.ee.ts b/packages/cli/src/credentials/credentials.service.ee.ts index 0958a02db8..c90a2d0d57 100644 --- a/packages/cli/src/credentials/credentials.service.ee.ts +++ b/packages/cli/src/credentials/credentials.service.ee.ts @@ -1,77 +1,94 @@ -import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm'; -import type { SharedCredentials } from '@db/entities/SharedCredentials'; +import { In, type EntityManager } from '@n8n/typeorm'; import type { User } from '@db/entities/User'; -import { type CredentialsGetSharedOptions } from './credentials.service'; +import { CredentialsService } from './credentials.service'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; -import { UserRepository } from '@/databases/repositories/user.repository'; -import { CredentialsEntity } from '@/databases/entities/CredentialsEntity'; +import type { CredentialsEntity } from '@/databases/entities/CredentialsEntity'; import { Service } from 'typedi'; +import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { OwnershipService } from '@/services/ownership.service'; +import { Project } from '@/databases/entities/Project'; @Service() export class EnterpriseCredentialsService { constructor( - private readonly userRepository: UserRepository, private readonly sharedCredentialsRepository: SharedCredentialsRepository, + private readonly ownershipService: OwnershipService, + private readonly credentialsService: CredentialsService, ) {} - async isOwned(user: User, credentialId: string) { - const sharing = await this.getSharing(user, credentialId, { allowGlobalScope: false }, [ - 'credentials', - ]); - - if (!sharing || sharing.role !== 'credential:owner') return { ownsCredential: false }; - - const { credentials: credential } = sharing; - - return { ownsCredential: true, credential }; - } - - /** - * Retrieve the sharing that matches a user and a credential. - */ - async getSharing( - user: User, - credentialId: string, - options: CredentialsGetSharedOptions, - relations: string[] = ['credentials'], + async shareWithProjects( + credential: CredentialsEntity, + shareWithIds: string[], + entityManager?: EntityManager, ) { - const where: FindOptionsWhere = { credentialsId: credentialId }; + const em = entityManager ?? this.sharedCredentialsRepository.manager; - // Omit user from where if the requesting user has relevant - // global credential permissions. This allows the user to - // access credentials they don't own. - if (!options.allowGlobalScope || !user.hasGlobalScope(options.globalScope)) { - where.userId = user.id; - } - - return await this.sharedCredentialsRepository.findOne({ - where, - relations, - }); - } - - async getSharings(transaction: EntityManager, credentialId: string, relations = ['shared']) { - const credential = await transaction.findOne(CredentialsEntity, { - where: { id: credentialId }, - relations, + const projects = await em.find(Project, { + where: { id: In(shareWithIds), type: 'personal' }, }); - return credential?.shared ?? []; - } - - async share(transaction: EntityManager, credential: CredentialsEntity, shareWithIds: string[]) { - const users = await this.userRepository.getByIds(transaction, shareWithIds); - - const newSharedCredentials = users - .filter((user) => !user.isPending) - .map((user) => + const newSharedCredentials = projects + // We filter by role === 'project:personalOwner' above and there should + // always only be one owner. + .map((project) => this.sharedCredentialsRepository.create({ credentialsId: credential.id, - userId: user.id, role: 'credential:user', + projectId: project.id, }), ); - return await transaction.save(newSharedCredentials); + return await em.save(newSharedCredentials); + } + + async getOne(user: User, credentialId: string, includeDecryptedData: boolean) { + let credential: CredentialsEntity | null = null; + let decryptedData: ICredentialDataDecryptedObject | null = null; + + credential = includeDecryptedData + ? // Try to get the credential with `credential:update` scope, which + // are required for decrypting the data. + await this.sharedCredentialsRepository.findCredentialForUser( + credentialId, + user, + // TODO: replace credential:update with credential:decrypt once it lands + // see: https://n8nio.slack.com/archives/C062YRE7EG4/p1708531433206069?thread_ts=1708525972.054149&cid=C062YRE7EG4 + ['credential:read', 'credential:update'], + ) + : null; + + if (credential) { + // Decrypt the data if we found the credential with the `credential:update` + // scope. + decryptedData = this.credentialsService.redact( + this.credentialsService.decrypt(credential), + credential, + ); + } else { + // Otherwise try to find them with only the `credential:read` scope. In + // that case we return them without the decrypted data. + credential = await this.sharedCredentialsRepository.findCredentialForUser( + credentialId, + user, + ['credential:read'], + ); + } + + if (!credential) { + throw new NotFoundError( + 'Could not load the credential. If you think this is an error, ask the owner to share it with you again', + ); + } + + credential = this.ownershipService.addOwnedByAndSharedWith(credential); + + const { data: _, ...rest } = credential; + + if (decryptedData) { + return { data: decryptedData, ...rest }; + } + + return { ...rest }; } } diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index ac0598aa3f..d23dbf0cc1 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -5,8 +5,13 @@ import type { ICredentialType, INodeProperties, } from 'n8n-workflow'; -import { CREDENTIAL_EMPTY_VALUE, deepCopy, NodeHelpers } from 'n8n-workflow'; -import type { FindOptionsWhere } from '@n8n/typeorm'; +import { ApplicationError, CREDENTIAL_EMPTY_VALUE, deepCopy, NodeHelpers } from 'n8n-workflow'; +import { + In, + type EntityManager, + type FindOptionsRelations, + type FindOptionsWhere, +} from '@n8n/typeorm'; import type { Scope } from '@n8n/permissions'; import * as Db from '@/Db'; import type { ICredentialsDb } from '@/Interfaces'; @@ -25,6 +30,12 @@ import { CredentialsRepository } from '@db/repositories/credentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { Service } from 'typedi'; import { CredentialsTester } from '@/services/credentials-tester.service'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { ProjectService } from '@/services/project.service'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import type { ProjectRelation } from '@/databases/entities/ProjectRelation'; +import { RoleService } from '@/services/role.service'; export type CredentialsGetSharedOptions = | { allowGlobalScope: true; globalScope: Scope } @@ -40,62 +51,129 @@ export class CredentialsService { private readonly credentialsTester: CredentialsTester, private readonly externalHooks: ExternalHooks, private readonly credentialTypes: CredentialTypes, + private readonly projectRepository: ProjectRepository, + private readonly projectService: ProjectService, + private readonly roleService: RoleService, ) {} - async get(where: FindOptionsWhere, options?: { relations: string[] }) { - return await this.credentialsRepository.findOne({ - relations: options?.relations, - where, - }); - } - async getMany( user: User, - options: { listQueryOptions?: ListQuery.Options; onlyOwn?: boolean } = {}, + options: { + listQueryOptions?: ListQuery.Options; + onlyOwn?: boolean; + includeScopes?: string; + } = {}, ) { const returnAll = user.hasGlobalScope('credential:list') && !options.onlyOwn; const isDefaultSelect = !options.listQueryOptions?.select; - if (returnAll) { - const credentials = await this.credentialsRepository.findMany(options.listQueryOptions); - - return isDefaultSelect - ? credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c)) - : credentials; + let projectRelations: ProjectRelation[] | undefined = undefined; + if (options.includeScopes) { + projectRelations = await this.projectService.getProjectRelationsForUser(user); + if (options.listQueryOptions?.filter?.projectId && user.hasGlobalScope('credential:list')) { + // Only instance owners and admins have the credential:list scope + // Those users should be able to use _all_ credentials within their workflows. + // TODO: Change this so we filter by `workflowId` in this case. Require a slight FE change + const projectRelation = projectRelations.find( + (relation) => relation.projectId === options.listQueryOptions?.filter?.projectId, + ); + if (projectRelation?.role === 'project:personalOwner') { + // Will not affect team projects as these have admins, not owners. + delete options.listQueryOptions?.filter?.projectId; + } + } } - const ids = await this.sharedCredentialsRepository.getAccessibleCredentialIds([user.id]); + if (returnAll) { + let credentials = await this.credentialsRepository.findMany(options.listQueryOptions); - const credentials = await this.credentialsRepository.findMany( + if (isDefaultSelect) { + credentials = credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c)); + } + + if (options.includeScopes) { + credentials = credentials.map((c) => + this.roleService.addScopes(c, user, projectRelations!), + ); + } + + credentials.forEach((c) => { + // @ts-expect-error: This is to emulate the old behaviour of removing the shared + // field as part of `addOwnedByAndSharedWith`. We need this field in `addScopes` + // though. So to avoid leaking the information we just delete it. + delete c.shared; + }); + + return credentials; + } + + // If the workflow is part of a personal project we want to show the credentials the user making the request has access to, not the credentials the user owning the workflow has access to. + if (typeof options.listQueryOptions?.filter?.projectId === 'string') { + const project = await this.projectService.getProject( + options.listQueryOptions.filter.projectId, + ); + if (project?.type === 'personal') { + const currentUsersPersonalProject = await this.projectService.getPersonalProject(user); + options.listQueryOptions.filter.projectId = currentUsersPersonalProject?.id; + } + } + + const ids = await this.sharedCredentialsRepository.getCredentialIdsByUserAndRole([user.id], { + scopes: ['credential:read'], + }); + + let credentials = await this.credentialsRepository.findMany( options.listQueryOptions, ids, // only accessible credentials ); - return isDefaultSelect - ? credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c)) - : credentials; + if (isDefaultSelect) { + credentials = credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c)); + } + + if (options.includeScopes) { + credentials = credentials.map((c) => this.roleService.addScopes(c, user, projectRelations!)); + } + + credentials.forEach((c) => { + // @ts-expect-error: This is to emulate the old behaviour of removing the shared + // field as part of `addOwnedByAndSharedWith`. We need this field in `addScopes` + // though. So to avoid leaking the information we just delete it. + delete c.shared; + }); + + return credentials; } /** * Retrieve the sharing that matches a user and a credential. */ + // TODO: move to SharedCredentialsService async getSharing( user: User, credentialId: string, - options: CredentialsGetSharedOptions, - relations: string[] = ['credentials'], + globalScopes: Scope[], + relations: FindOptionsRelations = { credentials: true }, ): Promise { - const where: FindOptionsWhere = { credentialsId: credentialId }; + let where: FindOptionsWhere = { credentialsId: credentialId }; - // Omit user from where if the requesting user has relevant - // global credential permissions. This allows the user to - // access credentials they don't own. - if (!options.allowGlobalScope || !user.hasGlobalScope(options.globalScope)) { - where.userId = user.id; - where.role = 'credential:owner'; + if (!user.hasGlobalScope(globalScopes, { mode: 'allOf' })) { + where = { + ...where, + role: 'credential:owner', + project: { + projectRelations: { + role: 'project:personalOwner', + userId: user.id, + }, + }, + }; } - return await this.sharedCredentialsRepository.findOne({ where, relations }); + return await this.sharedCredentialsRepository.findOne({ + where, + relations, + }); } async prepareCreateData( @@ -128,7 +206,7 @@ export class CredentialsService { await validateEntity(updateData); // Do not overwrite the oauth data else data like the access or refresh token would get lost - // everytime anybody changes anything on the credentials even if it is just the name. + // every time anybody changes anything on the credentials even if it is just the name. if (decryptedData.oauthTokenData) { // @ts-ignore updateData.data.oauthTokenData = decryptedData.oauthTokenData; @@ -165,7 +243,12 @@ export class CredentialsService { return await this.credentialsRepository.findOneBy({ id: credentialId }); } - async save(credential: CredentialsEntity, encryptedData: ICredentialsDb, user: User) { + async save( + credential: CredentialsEntity, + encryptedData: ICredentialsDb, + user: User, + projectId?: string, + ) { // To avoid side effects const newCredential = new CredentialsEntity(); Object.assign(newCredential, credential, encryptedData); @@ -177,12 +260,31 @@ export class CredentialsService { savedCredential.data = newCredential.data; - const newSharedCredential = new SharedCredentials(); + const project = + projectId === undefined + ? await this.projectRepository.getPersonalProjectForUserOrFail(user.id) + : await this.projectService.getProjectWithScope( + user, + projectId, + ['credential:create'], + transactionManager, + ); - Object.assign(newSharedCredential, { + if (typeof projectId === 'string' && project === null) { + throw new BadRequestError( + "You don't have the permissions to save the workflow in this project.", + ); + } + + // Safe guard in case the personal project does not exist for whatever reason. + if (project === null) { + throw new ApplicationError('No personal project found'); + } + + const newSharedCredential = this.sharedCredentialsRepository.create({ role: 'credential:owner', - user, credentials: savedCredential, + projectId: project.id, }); await transactionManager.save(newSharedCredential); @@ -295,4 +397,134 @@ export class CredentialsService { this.unredactRestoreValues(mergedData, savedData); return mergedData; } + + async getOne(user: User, credentialId: string, includeDecryptedData: boolean) { + let sharing: SharedCredentials | null = null; + let decryptedData: ICredentialDataDecryptedObject | null = null; + + sharing = includeDecryptedData + ? // Try to get the credential with `credential:update` scope, which + // are required for decrypting the data. + await this.getSharing(user, credentialId, [ + 'credential:read', + // TODO: Enable this once the scope exists and has been added to the + // global:owner role. + // 'credential:decrypt', + ]) + : null; + + if (sharing) { + // Decrypt the data if we found the credential with the `credential:update` + // scope. + decryptedData = this.redact(this.decrypt(sharing.credentials), sharing.credentials); + } else { + // Otherwise try to find them with only the `credential:read` scope. In + // that case we return them without the decrypted data. + sharing = await this.getSharing(user, credentialId, ['credential:read']); + } + + if (!sharing) { + throw new NotFoundError(`Credential with ID "${credentialId}" could not be found.`); + } + + const { credentials: credential } = sharing; + + const { data: _, ...rest } = credential; + + if (decryptedData) { + return { data: decryptedData, ...rest }; + } + return { ...rest }; + } + + async getCredentialScopes(user: User, credentialId: string): Promise { + const userProjectRelations = await this.projectService.getProjectRelationsForUser(user); + const shared = await this.sharedCredentialsRepository.find({ + where: { + projectId: In([...new Set(userProjectRelations.map((pr) => pr.projectId))]), + credentialsId: credentialId, + }, + }); + return this.roleService.combineResourceScopes('credential', user, shared, userProjectRelations); + } + + /** + * Transfers all credentials owned by a project to another one. + * This has only been tested for personal projects. It may need to be amended + * for team projects. + **/ + async transferAll(fromProjectId: string, toProjectId: string, trx?: EntityManager) { + trx = trx ?? this.credentialsRepository.manager; + + // Get all shared credentials for both projects. + const allSharedCredentials = await trx.findBy(SharedCredentials, { + projectId: In([fromProjectId, toProjectId]), + }); + + const sharedCredentialsOfFromProject = allSharedCredentials.filter( + (sc) => sc.projectId === fromProjectId, + ); + + // For all credentials that the from-project owns transfer the ownership + // to the to-project. + // This will override whatever relationship the to-project already has to + // the resources at the moment. + const ownedCredentialIds = sharedCredentialsOfFromProject + .filter((sc) => sc.role === 'credential:owner') + .map((sc) => sc.credentialsId); + + await this.sharedCredentialsRepository.makeOwner(ownedCredentialIds, toProjectId, trx); + + // Delete the relationship to the from-project. + await this.sharedCredentialsRepository.deleteByIds(ownedCredentialIds, fromProjectId, trx); + + // Transfer relationships that are not `credential:owner`. + // This will NOT override whatever relationship the to-project already has + // to the resource at the moment. + const sharedCredentialIdsOfTransferee = allSharedCredentials + .filter((sc) => sc.projectId === toProjectId) + .map((sc) => sc.credentialsId); + + // All resources that are shared with the from-project, but not with the + // to-project. + const sharedCredentialsToTransfer = sharedCredentialsOfFromProject.filter( + (sc) => + sc.role !== 'credential:owner' && + !sharedCredentialIdsOfTransferee.includes(sc.credentialsId), + ); + + await trx.insert( + SharedCredentials, + sharedCredentialsToTransfer.map((sc) => ({ + credentialsId: sc.credentialsId, + projectId: toProjectId, + role: sc.role, + })), + ); + } + + replaceCredentialContentsForSharee( + user: User, + credential: CredentialsEntity, + decryptedData: ICredentialDataDecryptedObject, + mergedCredentials: ICredentialsDecrypted, + ) { + credential.shared.forEach((sharedCredentials) => { + if (sharedCredentials.role === 'credential:owner') { + if (sharedCredentials.project.type === 'personal') { + // Find the owner of this personal project + sharedCredentials.project.projectRelations.forEach((projectRelation) => { + if ( + projectRelation.role === 'project:personalOwner' && + projectRelation.user.id !== user.id + ) { + // If we realize that the current user does not own this credential + // We replace the payload with the stored decrypted data + mergedCredentials.data = decryptedData; + } + }); + } + } + }); + } } diff --git a/packages/cli/src/databases/config.ts b/packages/cli/src/databases/config.ts index 7db8e1b955..4e81a7a9ed 100644 --- a/packages/cli/src/databases/config.ts +++ b/packages/cli/src/databases/config.ts @@ -11,6 +11,7 @@ import { ApplicationError } from 'n8n-workflow'; import config from '@/config'; import { entities } from './entities'; +import { subscribers } from './subscribers'; import { mysqlMigrations } from './migrations/mysqldb'; import { postgresMigrations } from './migrations/postgresdb'; import { sqliteMigrations } from './migrations/sqlite'; @@ -32,6 +33,7 @@ const getCommonOptions = () => { return { entityPrefix, entities: Object.values(entities), + subscribers: Object.values(subscribers), migrationsTableName: `${entityPrefix}migrations`, migrationsRun: false, synchronize: false, diff --git a/packages/cli/src/databases/dsl/Column.ts b/packages/cli/src/databases/dsl/Column.ts index 359ffd65af..1c01562f49 100644 --- a/packages/cli/src/databases/dsl/Column.ts +++ b/packages/cli/src/databases/dsl/Column.ts @@ -94,9 +94,11 @@ export class Column { options.type = isPostgres ? 'timestamptz' : 'datetime'; } else if (type === 'json' && isSqlite) { options.type = 'text'; - } else if (type === 'uuid' && isMysql) { + } else if (type === 'uuid') { // mysql does not support uuid type - options.type = 'varchar(36)'; + if (isMysql) options.type = 'varchar(36)'; + // we haven't been defining length on "uuid" varchar on sqlite + if (isSqlite) options.type = 'varchar'; } if ((type === 'varchar' || type === 'timestamp') && length !== 'auto') { diff --git a/packages/cli/src/databases/dsl/Table.ts b/packages/cli/src/databases/dsl/Table.ts index 08cea8d29d..b2d3fcea39 100644 --- a/packages/cli/src/databases/dsl/Table.ts +++ b/packages/cli/src/databases/dsl/Table.ts @@ -46,7 +46,13 @@ export class CreateTable extends TableOperation { withForeignKey( columnName: string, - ref: { tableName: string; columnName: string; onDelete?: 'CASCADE'; onUpdate?: 'CASCADE' }, + ref: { + tableName: string; + columnName: string; + onDelete?: 'CASCADE'; + onUpdate?: 'CASCADE'; + name?: string; + }, ) { const foreignKey: TableForeignKeyOptions = { columnNames: [columnName], @@ -55,6 +61,7 @@ export class CreateTable extends TableOperation { }; if (ref.onDelete) foreignKey.onDelete = ref.onDelete; if (ref.onUpdate) foreignKey.onUpdate = ref.onUpdate; + if (ref.name) foreignKey.name = ref.name; this.foreignKeys.add(foreignKey); return this; } diff --git a/packages/cli/src/databases/entities/Project.ts b/packages/cli/src/databases/entities/Project.ts new file mode 100644 index 0000000000..5156ed35d7 --- /dev/null +++ b/packages/cli/src/databases/entities/Project.ts @@ -0,0 +1,25 @@ +import { Column, Entity, OneToMany } from '@n8n/typeorm'; +import { WithTimestampsAndStringId } from './AbstractEntity'; +import type { ProjectRelation } from './ProjectRelation'; +import type { SharedCredentials } from './SharedCredentials'; +import type { SharedWorkflow } from './SharedWorkflow'; + +export type ProjectType = 'personal' | 'team'; + +@Entity() +export class Project extends WithTimestampsAndStringId { + @Column({ length: 255, nullable: true }) + name: string; + + @Column({ length: 36 }) + type: ProjectType; + + @OneToMany('ProjectRelation', 'project') + projectRelations: ProjectRelation[]; + + @OneToMany('SharedCredentials', 'project') + sharedCredentials: SharedCredentials[]; + + @OneToMany('SharedWorkflow', 'project') + sharedWorkflows: SharedWorkflow[]; +} diff --git a/packages/cli/src/databases/entities/ProjectRelation.ts b/packages/cli/src/databases/entities/ProjectRelation.ts new file mode 100644 index 0000000000..e66a771120 --- /dev/null +++ b/packages/cli/src/databases/entities/ProjectRelation.ts @@ -0,0 +1,25 @@ +import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; +import { User } from './User'; +import { WithTimestamps } from './AbstractEntity'; +import { Project } from './Project'; + +// personalOwner is only used for personal projects +export type ProjectRole = 'project:personalOwner' | 'project:admin' | 'project:editor'; + +@Entity() +export class ProjectRelation extends WithTimestamps { + @Column() + role: ProjectRole; + + @ManyToOne('User', 'projectRelations') + user: User; + + @PrimaryColumn('uuid') + userId: string; + + @ManyToOne('Project', 'projectRelations') + project: Project; + + @PrimaryColumn() + projectId: string; +} diff --git a/packages/cli/src/databases/entities/SharedCredentials.ts b/packages/cli/src/databases/entities/SharedCredentials.ts index e43f3031d8..35335ddf08 100644 --- a/packages/cli/src/databases/entities/SharedCredentials.ts +++ b/packages/cli/src/databases/entities/SharedCredentials.ts @@ -1,7 +1,7 @@ import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; import { CredentialsEntity } from './CredentialsEntity'; -import { User } from './User'; import { WithTimestamps } from './AbstractEntity'; +import { Project } from './Project'; export type CredentialSharingRole = 'credential:owner' | 'credential:user'; @@ -10,15 +10,15 @@ export class SharedCredentials extends WithTimestamps { @Column() role: CredentialSharingRole; - @ManyToOne('User', 'sharedCredentials') - user: User; - - @PrimaryColumn() - userId: string; - @ManyToOne('CredentialsEntity', 'shared') credentials: CredentialsEntity; @PrimaryColumn() credentialsId: string; + + @ManyToOne('Project', 'sharedCredentials') + project: Project; + + @PrimaryColumn() + projectId: string; } diff --git a/packages/cli/src/databases/entities/SharedWorkflow.ts b/packages/cli/src/databases/entities/SharedWorkflow.ts index d5681f6467..a61fb00253 100644 --- a/packages/cli/src/databases/entities/SharedWorkflow.ts +++ b/packages/cli/src/databases/entities/SharedWorkflow.ts @@ -1,24 +1,24 @@ import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; import { WorkflowEntity } from './WorkflowEntity'; -import { User } from './User'; import { WithTimestamps } from './AbstractEntity'; +import { Project } from './Project'; -export type WorkflowSharingRole = 'workflow:owner' | 'workflow:editor' | 'workflow:user'; +export type WorkflowSharingRole = 'workflow:owner' | 'workflow:editor'; @Entity() export class SharedWorkflow extends WithTimestamps { @Column() role: WorkflowSharingRole; - @ManyToOne('User', 'sharedWorkflows') - user: User; - - @PrimaryColumn() - userId: string; - @ManyToOne('WorkflowEntity', 'shared') workflow: WorkflowEntity; @PrimaryColumn() workflowId: string; + + @ManyToOne('Project', 'sharedWorkflows') + project: Project; + + @PrimaryColumn() + projectId: string; } diff --git a/packages/cli/src/databases/entities/User.ts b/packages/cli/src/databases/entities/User.ts index 9538a3e60d..9aeb62d92c 100644 --- a/packages/cli/src/databases/entities/User.ts +++ b/packages/cli/src/databases/entities/User.ts @@ -18,16 +18,21 @@ import { objectRetriever, lowerCaser } from '../utils/transformers'; import { WithTimestamps, jsonColumnType } from './AbstractEntity'; import type { IPersonalizationSurveyAnswers } from '@/Interfaces'; import type { AuthIdentity } from './AuthIdentity'; -import { ownerPermissions, memberPermissions, adminPermissions } from '@/permissions/roles'; +import { + GLOBAL_OWNER_SCOPES, + GLOBAL_MEMBER_SCOPES, + GLOBAL_ADMIN_SCOPES, +} from '@/permissions/global-roles'; import { hasScope, type ScopeOptions, type Scope } from '@n8n/permissions'; +import type { ProjectRelation } from './ProjectRelation'; export type GlobalRole = 'global:owner' | 'global:admin' | 'global:member'; export type AssignableRole = Exclude; const STATIC_SCOPE_MAP: Record = { - 'global:owner': ownerPermissions, - 'global:member': memberPermissions, - 'global:admin': adminPermissions, + 'global:owner': GLOBAL_OWNER_SCOPES, + 'global:member': GLOBAL_MEMBER_SCOPES, + 'global:admin': GLOBAL_ADMIN_SCOPES, }; @Entity() @@ -85,6 +90,9 @@ export class User extends WithTimestamps implements IUser { @OneToMany('SharedCredentials', 'user') sharedCredentials: SharedCredentials[]; + @OneToMany('ProjectRelation', 'user') + projectRelations: ProjectRelation[]; + @Column({ type: Boolean, default: false }) disabled: boolean; @@ -138,6 +146,7 @@ export class User extends WithTimestamps implements IUser { { global: this.globalScopes, }, + undefined, scopeOptions, ); } @@ -146,4 +155,14 @@ export class User extends WithTimestamps implements IUser { const { password, apiKey, mfaSecret, mfaRecoveryCodes, ...rest } = this; return rest; } + + createPersonalProjectName() { + if (this.firstName && this.lastName && this.email) { + return `${this.firstName} ${this.lastName} <${this.email}>`; + } else if (this.email) { + return `<${this.email}>`; + } else { + return 'Unnamed Project'; + } + } } diff --git a/packages/cli/src/databases/entities/index.ts b/packages/cli/src/databases/entities/index.ts index db1f5a5ce7..71be3c07bb 100644 --- a/packages/cli/src/databases/entities/index.ts +++ b/packages/cli/src/databases/entities/index.ts @@ -19,6 +19,8 @@ import { WorkflowStatistics } from './WorkflowStatistics'; import { ExecutionMetadata } from './ExecutionMetadata'; import { ExecutionData } from './ExecutionData'; import { WorkflowHistory } from './WorkflowHistory'; +import { Project } from './Project'; +import { ProjectRelation } from './ProjectRelation'; export const entities = { AuthIdentity, @@ -41,4 +43,6 @@ export const entities = { ExecutionMetadata, ExecutionData, WorkflowHistory, + Project, + ProjectRelation, }; diff --git a/packages/cli/src/databases/migrations/common/1711390882123-MoveSshKeysToDatabase.ts b/packages/cli/src/databases/migrations/common/1711390882123-MoveSshKeysToDatabase.ts index 30d7d87c4a..be33118907 100644 --- a/packages/cli/src/databases/migrations/common/1711390882123-MoveSshKeysToDatabase.ts +++ b/packages/cli/src/databases/migrations/common/1711390882123-MoveSshKeysToDatabase.ts @@ -35,6 +35,11 @@ export class MoveSshKeysToDatabase1711390882123 implements ReversibleMigration { return; } + if (!privateKey && !publicKey) { + logger.info(`[${migrationName}] No SSH keys in filesystem, skipping`); + return; + } + const settings = escape.tableName('settings'); const key = escape.columnName('key'); const value = escape.columnName('value'); diff --git a/packages/cli/src/databases/migrations/common/1714133768519-CreateProject.ts b/packages/cli/src/databases/migrations/common/1714133768519-CreateProject.ts new file mode 100644 index 0000000000..b28d7a710b --- /dev/null +++ b/packages/cli/src/databases/migrations/common/1714133768519-CreateProject.ts @@ -0,0 +1,328 @@ +import type { MigrationContext, ReversibleMigration } from '@db/types'; +import type { ProjectRole } from '@/databases/entities/ProjectRelation'; +import type { User } from '@/databases/entities/User'; +import { generateNanoId } from '@/databases/utils/generators'; +import { ApplicationError } from 'n8n-workflow'; +import { nanoid } from 'nanoid'; + +const projectAdminRole: ProjectRole = 'project:personalOwner'; + +type RelationTable = 'shared_workflow' | 'shared_credentials'; + +const table = { + sharedCredentials: 'shared_credentials', + sharedCredentialsTemp: 'shared_credentials_2', + sharedWorkflow: 'shared_workflow', + sharedWorkflowTemp: 'shared_workflow_2', + project: 'project', + user: 'user', + projectRelation: 'project_relation', +} as const; + +function escapeNames(escape: MigrationContext['escape']) { + const t = { + project: escape.tableName(table.project), + projectRelation: escape.tableName(table.projectRelation), + sharedCredentials: escape.tableName(table.sharedCredentials), + sharedCredentialsTemp: escape.tableName(table.sharedCredentialsTemp), + sharedWorkflow: escape.tableName(table.sharedWorkflow), + sharedWorkflowTemp: escape.tableName(table.sharedWorkflowTemp), + user: escape.tableName(table.user), + }; + const c = { + createdAt: escape.columnName('createdAt'), + updatedAt: escape.columnName('updatedAt'), + workflowId: escape.columnName('workflowId'), + credentialsId: escape.columnName('credentialsId'), + userId: escape.columnName('userId'), + projectId: escape.columnName('projectId'), + firstName: escape.columnName('firstName'), + lastName: escape.columnName('lastName'), + }; + + return { t, c }; +} + +export class CreateProject1714133768519 implements ReversibleMigration { + async setupTables({ schemaBuilder: { createTable, column } }: MigrationContext) { + await createTable(table.project).withColumns( + column('id').varchar(36).primary.notNull, + column('name').varchar(255).notNull, + column('type').varchar(36).notNull, + ).withTimestamps; + + await createTable(table.projectRelation) + .withColumns( + column('projectId').varchar(36).primary.notNull, + column('userId').uuid.primary.notNull, + column('role').varchar().notNull, + ) + .withIndexOn('projectId') + .withIndexOn('userId') + .withForeignKey('projectId', { + tableName: table.project, + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('userId', { + tableName: 'user', + columnName: 'id', + onDelete: 'CASCADE', + }).withTimestamps; + } + + async alterSharedTable( + relationTableName: RelationTable, + { + escape, + isMysql, + runQuery, + schemaBuilder: { addForeignKey, addColumns, addNotNull, createIndex, column }, + }: MigrationContext, + ) { + const projectIdColumn = column('projectId').varchar(36).default('NULL'); + await addColumns(relationTableName, [projectIdColumn]); + + const relationTable = escape.tableName(relationTableName); + const { t, c } = escapeNames(escape); + + // Populate projectId + const subQuery = ` + SELECT P.id as ${c.projectId}, T.${c.userId} + FROM ${t.projectRelation} T + LEFT JOIN ${t.project} P + ON T.${c.projectId} = P.id AND P.type = 'personal' + LEFT JOIN ${relationTable} S + ON T.${c.userId} = S.${c.userId} + WHERE P.id IS NOT NULL + `; + const swQuery = isMysql + ? `UPDATE ${relationTable}, (${subQuery}) as mapping + SET ${relationTable}.${c.projectId} = mapping.${c.projectId} + WHERE ${relationTable}.${c.userId} = mapping.${c.userId}` + : `UPDATE ${relationTable} + SET ${c.projectId} = mapping.${c.projectId} + FROM (${subQuery}) as mapping + WHERE ${relationTable}.${c.userId} = mapping.${c.userId}`; + + await runQuery(swQuery); + + await addForeignKey(relationTableName, 'projectId', ['project', 'id']); + + await addNotNull(relationTableName, 'projectId'); + + // Index the new projectId column + await createIndex(relationTableName, ['projectId']); + } + + async alterSharedCredentials({ + escape, + runQuery, + schemaBuilder: { column, createTable, dropTable }, + }: MigrationContext) { + await createTable(table.sharedCredentialsTemp) + .withColumns( + column('credentialsId').varchar(36).notNull.primary, + column('projectId').varchar(36).notNull.primary, + column('role').text.notNull, + ) + .withForeignKey('credentialsId', { + tableName: 'credentials_entity', + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('projectId', { + tableName: table.project, + columnName: 'id', + onDelete: 'CASCADE', + }).withTimestamps; + + const { c, t } = escapeNames(escape); + + await runQuery(` + INSERT INTO ${t.sharedCredentialsTemp} (${c.createdAt}, ${c.updatedAt}, ${c.credentialsId}, ${c.projectId}, role) + SELECT ${c.createdAt}, ${c.updatedAt}, ${c.credentialsId}, ${c.projectId}, role FROM ${t.sharedCredentials}; + `); + + await dropTable(table.sharedCredentials); + await runQuery(`ALTER TABLE ${t.sharedCredentialsTemp} RENAME TO ${t.sharedCredentials};`); + } + + async alterSharedWorkflow({ + escape, + runQuery, + schemaBuilder: { column, createTable, dropTable }, + }: MigrationContext) { + await createTable(table.sharedWorkflowTemp) + .withColumns( + column('workflowId').varchar(36).notNull.primary, + column('projectId').varchar(36).notNull.primary, + column('role').text.notNull, + ) + .withForeignKey('workflowId', { + tableName: 'workflow_entity', + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('projectId', { + tableName: table.project, + columnName: 'id', + onDelete: 'CASCADE', + }).withTimestamps; + + const { c, t } = escapeNames(escape); + + await runQuery(` + INSERT INTO ${t.sharedWorkflowTemp} (${c.createdAt}, ${c.updatedAt}, ${c.workflowId}, ${c.projectId}, role) + SELECT ${c.createdAt}, ${c.updatedAt}, ${c.workflowId}, ${c.projectId}, role FROM ${t.sharedWorkflow}; + `); + + await dropTable(table.sharedWorkflow); + await runQuery(`ALTER TABLE ${t.sharedWorkflowTemp} RENAME TO ${t.sharedWorkflow};`); + } + + async createUserPersonalProjects({ runQuery, runInBatches, escape }: MigrationContext) { + const { c, t } = escapeNames(escape); + const getUserQuery = `SELECT id, ${c.firstName}, ${c.lastName}, email FROM ${t.user}`; + await runInBatches>( + getUserQuery, + async (users) => { + await Promise.all( + users.map(async (user) => { + const projectId = generateNanoId(); + const name = this.createPersonalProjectName(user.firstName, user.lastName, user.email); + await runQuery( + `INSERT INTO ${t.project} (id, type, name) VALUES (:projectId, 'personal', :name)`, + { + projectId, + name, + }, + ); + + await runQuery( + `INSERT INTO ${t.projectRelation} (${c.projectId}, ${c.userId}, role) VALUES (:projectId, :userId, :projectRole)`, + { + projectId, + userId: user.id, + projectRole: projectAdminRole, + }, + ); + }), + ); + }, + ); + } + + // Duplicated from packages/cli/src/databases/entities/User.ts + // Reason: + // This migration should work the same even if we refactor the function in + // `User.ts`. + createPersonalProjectName(firstName?: string, lastName?: string, email?: string) { + if (firstName && lastName && email) { + return `${firstName} ${lastName} <${email}>`; + } else if (email) { + return `<${email}>`; + } else { + return 'Unnamed Project'; + } + } + + async up(context: MigrationContext) { + await this.setupTables(context); + await this.createUserPersonalProjects(context); + await this.alterSharedTable(table.sharedCredentials, context); + await this.alterSharedCredentials(context); + await this.alterSharedTable(table.sharedWorkflow, context); + await this.alterSharedWorkflow(context); + } + + async down({ isMysql, logger, escape, runQuery, schemaBuilder: sb }: MigrationContext) { + const { t, c } = escapeNames(escape); + + // 0. check if all projects are personal projects + const [{ count: nonPersonalProjects }] = await runQuery<[{ count: number }]>( + `SELECT COUNT(*) FROM ${t.project} WHERE type <> 'personal';`, + ); + + if (nonPersonalProjects > 0) { + const message = + 'Down migration only possible when there are no projects. Please delete all projects that were created via the UI first.'; + logger.error(message); + throw new ApplicationError(message); + } + + // 1. create temp table for shared workflows + await sb + .createTable(table.sharedWorkflowTemp) + .withColumns( + sb.column('workflowId').varchar(36).notNull.primary, + sb.column('userId').uuid.notNull.primary, + sb.column('role').text.notNull, + ) + .withForeignKey('workflowId', { + tableName: 'workflow_entity', + columnName: 'id', + onDelete: 'CASCADE', + // In MySQL foreignKey names must be unique across all tables and + // TypeORM creates predictable names based on the columnName. + // So the current shared_workflow table's foreignKey for workflowId would + // clash with this one if we don't create a random name. + name: isMysql ? nanoid() : undefined, + }) + .withForeignKey('userId', { + tableName: table.user, + columnName: 'id', + onDelete: 'CASCADE', + }).withTimestamps; + + // 2. migrate data into temp table + await runQuery(` + INSERT INTO ${t.sharedWorkflowTemp} (${c.createdAt}, ${c.updatedAt}, ${c.workflowId}, role, ${c.userId}) + SELECT SW.${c.createdAt}, SW.${c.updatedAt}, SW.${c.workflowId}, SW.role, PR.${c.userId} + FROM ${t.sharedWorkflow} SW + LEFT JOIN project_relation PR on SW.${c.projectId} = PR.${c.projectId} AND PR.role = 'project:personalOwner' + `); + + // 3. drop shared workflow table + await sb.dropTable(table.sharedWorkflow); + + // 4. rename temp table + await runQuery(`ALTER TABLE ${t.sharedWorkflowTemp} RENAME TO ${t.sharedWorkflow};`); + + // 5. same for shared creds + await sb + .createTable(table.sharedCredentialsTemp) + .withColumns( + sb.column('credentialsId').varchar(36).notNull.primary, + sb.column('userId').uuid.notNull.primary, + sb.column('role').text.notNull, + ) + .withForeignKey('credentialsId', { + tableName: 'credentials_entity', + columnName: 'id', + onDelete: 'CASCADE', + // In MySQL foreignKey names must be unique across all tables and + // TypeORM creates predictable names based on the columnName. + // So the current shared_credentials table's foreignKey for credentialsId would + // clash with this one if we don't create a random name. + name: isMysql ? nanoid() : undefined, + }) + .withForeignKey('userId', { + tableName: table.user, + columnName: 'id', + onDelete: 'CASCADE', + }).withTimestamps; + await runQuery(` + INSERT INTO ${t.sharedCredentialsTemp} (${c.createdAt}, ${c.updatedAt}, ${c.credentialsId}, role, ${c.userId}) + SELECT SC.${c.createdAt}, SC.${c.updatedAt}, SC.${c.credentialsId}, SC.role, PR.${c.userId} + FROM ${t.sharedCredentials} SC + LEFT JOIN project_relation PR on SC.${c.projectId} = PR.${c.projectId} AND PR.role = 'project:personalOwner' + `); + await sb.dropTable(table.sharedCredentials); + await runQuery(`ALTER TABLE ${t.sharedCredentialsTemp} RENAME TO ${t.sharedCredentials};`); + + // 6. drop project and project relation table + await sb.dropTable(table.projectRelation); + await sb.dropTable(table.project); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index 7952a923ab..daa57b2c5b 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -51,6 +51,7 @@ import { ExecutionSoftDelete1693491613982 } from '../common/1693491613982-Execut import { AddWorkflowMetadata1695128658538 } from '../common/1695128658538-AddWorkflowMetadata'; import { ModifyWorkflowHistoryNodesAndConnections1695829275184 } from '../common/1695829275184-ModifyWorkflowHistoryNodesAndConnections'; import { AddGlobalAdminRole1700571993961 } from '../common/1700571993961-AddGlobalAdminRole'; +import { CreateProject1714133768519 } from '../common/1714133768519-CreateProject'; import { DropRoleMapping1705429061930 } from '../common/1705429061930-DropRoleMapping'; import { RemoveFailedExecutionStatus1711018413374 } from '../common/1711018413374-RemoveFailedExecutionStatus'; import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase'; @@ -113,4 +114,5 @@ export const mysqlMigrations: Migration[] = [ RemoveFailedExecutionStatus1711018413374, MoveSshKeysToDatabase1711390882123, RemoveNodesAccess1712044305787, + CreateProject1714133768519, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index cbf63389a7..b31e2970d2 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -50,6 +50,7 @@ import { AddWorkflowMetadata1695128658538 } from '../common/1695128658538-AddWor import { MigrateToTimestampTz1694091729095 } from './1694091729095-MigrateToTimestampTz'; import { ModifyWorkflowHistoryNodesAndConnections1695829275184 } from '../common/1695829275184-ModifyWorkflowHistoryNodesAndConnections'; import { AddGlobalAdminRole1700571993961 } from '../common/1700571993961-AddGlobalAdminRole'; +import { CreateProject1714133768519 } from '../common/1714133768519-CreateProject'; import { DropRoleMapping1705429061930 } from '../common/1705429061930-DropRoleMapping'; import { RemoveFailedExecutionStatus1711018413374 } from '../common/1711018413374-RemoveFailedExecutionStatus'; import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase'; @@ -111,4 +112,5 @@ export const postgresMigrations: Migration[] = [ RemoveFailedExecutionStatus1711018413374, MoveSshKeysToDatabase1711390882123, RemoveNodesAccess1712044305787, + CreateProject1714133768519, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index f26f070087..834354fd6b 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -48,6 +48,7 @@ import { ExecutionSoftDelete1693491613982 } from './1693491613982-ExecutionSoftD import { AddWorkflowMetadata1695128658538 } from '../common/1695128658538-AddWorkflowMetadata'; import { ModifyWorkflowHistoryNodesAndConnections1695829275184 } from '../common/1695829275184-ModifyWorkflowHistoryNodesAndConnections'; import { AddGlobalAdminRole1700571993961 } from '../common/1700571993961-AddGlobalAdminRole'; +import { CreateProject1714133768519 } from '../common/1714133768519-CreateProject'; import { DropRoleMapping1705429061930 } from './1705429061930-DropRoleMapping'; import { RemoveFailedExecutionStatus1711018413374 } from '../common/1711018413374-RemoveFailedExecutionStatus'; import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase'; @@ -107,6 +108,7 @@ const sqliteMigrations: Migration[] = [ RemoveFailedExecutionStatus1711018413374, MoveSshKeysToDatabase1711390882123, RemoveNodesAccess1712044305787, + CreateProject1714133768519, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/databases/repositories/credentials.repository.ts b/packages/cli/src/databases/repositories/credentials.repository.ts index 02ed714cad..5af221c81b 100644 --- a/packages/cli/src/databases/repositories/credentials.repository.ts +++ b/packages/cli/src/databases/repositories/credentials.repository.ts @@ -1,8 +1,7 @@ import { Service } from 'typedi'; -import { DataSource, In, Not, Repository, Like } from '@n8n/typeorm'; -import type { FindManyOptions, DeleteResult, EntityManager, FindOptionsWhere } from '@n8n/typeorm'; +import { DataSource, In, Repository, Like } from '@n8n/typeorm'; +import type { FindManyOptions } from '@n8n/typeorm'; import { CredentialsEntity } from '../entities/CredentialsEntity'; -import { SharedCredentials } from '../entities/SharedCredentials'; import type { ListQuery } from '@/requests'; @Service() @@ -11,18 +10,6 @@ export class CredentialsRepository extends Repository { super(CredentialsEntity, dataSource.manager); } - async pruneSharings( - transaction: EntityManager, - credentialId: string, - userIds: string[], - ): Promise { - const conditions: FindOptionsWhere = { - credentialsId: credentialId, - userId: Not(In(userIds)), - }; - return await transaction.delete(SharedCredentials, conditions); - } - async findStartingWith(credentialName: string) { return await this.find({ select: ['name'], @@ -45,7 +32,7 @@ export class CredentialsRepository extends Repository { type Select = Array; - const defaultRelations = ['shared', 'shared.user']; + const defaultRelations = ['shared', 'shared.project']; const defaultSelect: Select = ['id', 'name', 'type', 'createdAt', 'updatedAt']; if (!listQueryOptions) return { select: defaultSelect, relations: defaultRelations }; @@ -60,6 +47,11 @@ export class CredentialsRepository extends Repository { filter.type = Like(`%${filter.type}%`); } + if (typeof filter?.projectId === 'string' && filter.projectId !== '') { + filter.shared = { projectId: filter.projectId }; + delete filter.projectId; + } + if (filter) findManyOptions.where = filter; if (select) findManyOptions.select = select; if (take) findManyOptions.take = take; @@ -81,7 +73,11 @@ export class CredentialsRepository extends Repository { const findManyOptions: FindManyOptions = { where: { id: In(ids) } }; if (withSharings) { - findManyOptions.relations = ['shared', 'shared.user']; + findManyOptions.relations = { + shared: { + project: true, + }, + }; } return await this.find(findManyOptions); diff --git a/packages/cli/src/databases/repositories/project.repository.ts b/packages/cli/src/databases/repositories/project.repository.ts new file mode 100644 index 0000000000..faae0bb9cf --- /dev/null +++ b/packages/cli/src/databases/repositories/project.repository.ts @@ -0,0 +1,45 @@ +import { Service } from 'typedi'; +import type { EntityManager } from '@n8n/typeorm'; +import { DataSource, Repository } from '@n8n/typeorm'; +import { Project } from '../entities/Project'; + +@Service() +export class ProjectRepository extends Repository { + constructor(dataSource: DataSource) { + super(Project, dataSource.manager); + } + + async getPersonalProjectForUser(userId: string, entityManager?: EntityManager) { + const em = entityManager ?? this.manager; + + return await em.findOne(Project, { + where: { type: 'personal', projectRelations: { userId, role: 'project:personalOwner' } }, + }); + } + + async getPersonalProjectForUserOrFail(userId: string) { + return await this.findOneOrFail({ + where: { type: 'personal', projectRelations: { userId, role: 'project:personalOwner' } }, + }); + } + + async getAccessibleProjects(userId: string) { + return await this.find({ + where: [ + { type: 'personal' }, + { + projectRelations: { + userId, + }, + }, + ], + }); + } + + async getProjectCounts() { + return { + personal: await this.count({ where: { type: 'personal' } }), + team: await this.count({ where: { type: 'team' } }), + }; + } +} diff --git a/packages/cli/src/databases/repositories/projectRelation.repository.ts b/packages/cli/src/databases/repositories/projectRelation.repository.ts new file mode 100644 index 0000000000..bddfd6e38d --- /dev/null +++ b/packages/cli/src/databases/repositories/projectRelation.repository.ts @@ -0,0 +1,55 @@ +import { Service } from 'typedi'; +import { DataSource, In, Repository } from '@n8n/typeorm'; +import { ProjectRelation, type ProjectRole } from '../entities/ProjectRelation'; + +@Service() +export class ProjectRelationRepository extends Repository { + constructor(dataSource: DataSource) { + super(ProjectRelation, dataSource.manager); + } + + async getPersonalProjectOwners(projectIds: string[]) { + return await this.find({ + where: { + projectId: In(projectIds), + role: 'project:personalOwner', + }, + relations: { user: true }, + }); + } + + async getPersonalProjectsForUsers(userIds: string[]) { + const projectRelations = await this.find({ + where: { + userId: In(userIds), + role: 'project:personalOwner', + }, + }); + + return projectRelations.map((pr) => pr.projectId); + } + + /** + * Find the role of a user in a project. + */ + async findProjectRole({ userId, projectId }: { userId: string; projectId: string }) { + const relation = await this.findOneBy({ projectId, userId }); + + return relation?.role ?? null; + } + + /** Counts the number of users in each role, e.g. `{ admin: 2, member: 6, owner: 1 }` */ + async countUsersByRole() { + const rows = (await this.createQueryBuilder() + .select(['role', 'COUNT(role) as count']) + .groupBy('role') + .execute()) as Array<{ role: ProjectRole; count: string }>; + return rows.reduce( + (acc, row) => { + acc[row.role] = parseInt(row.count, 10); + return acc; + }, + {} as Record, + ); + } +} diff --git a/packages/cli/src/databases/repositories/sharedCredentials.repository.ts b/packages/cli/src/databases/repositories/sharedCredentials.repository.ts index 4b08c2174f..8d2d1fa7af 100644 --- a/packages/cli/src/databases/repositories/sharedCredentials.repository.ts +++ b/packages/cli/src/databases/repositories/sharedCredentials.repository.ts @@ -1,22 +1,53 @@ import { Service } from 'typedi'; -import type { EntityManager } from '@n8n/typeorm'; +import type { EntityManager, FindOptionsRelations, FindOptionsWhere } from '@n8n/typeorm'; import { DataSource, In, Not, Repository } from '@n8n/typeorm'; import { type CredentialSharingRole, SharedCredentials } from '../entities/SharedCredentials'; import type { User } from '../entities/User'; +import { RoleService } from '@/services/role.service'; +import type { Scope } from '@n8n/permissions'; +import type { Project } from '../entities/Project'; +import type { ProjectRole } from '../entities/ProjectRelation'; @Service() export class SharedCredentialsRepository extends Repository { - constructor(dataSource: DataSource) { + constructor( + dataSource: DataSource, + private readonly roleService: RoleService, + ) { super(SharedCredentials, dataSource.manager); } /** Get a credential if it has been shared with a user */ - async findCredentialForUser(credentialsId: string, user: User) { + async findCredentialForUser( + credentialsId: string, + user: User, + scopes: Scope[], + _relations?: FindOptionsRelations, + ) { + let where: FindOptionsWhere = { credentialsId }; + + if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) { + const projectRoles = this.roleService.rolesWithScope('project', scopes); + const credentialRoles = this.roleService.rolesWithScope('credential', scopes); + where = { + ...where, + role: In(credentialRoles), + project: { + projectRelations: { + role: In(projectRoles), + userId: user.id, + }, + }, + }; + } + const sharedCredential = await this.findOne({ - relations: ['credentials'], - where: { - credentialsId, - ...(!user.hasGlobalScope('credential:read') ? { userId: user.id } : {}), + where, + // TODO: write a small relations merger and use that one here + relations: { + credentials: { + shared: { project: { projectRelations: { user: true } } }, + }, }, }); if (!sharedCredential) return null; @@ -25,7 +56,7 @@ export class SharedCredentialsRepository extends Repository { async findByCredentialIds(credentialIds: string[], role: CredentialSharingRole) { return await this.find({ - relations: ['credentials', 'user'], + relations: { credentials: true, project: { projectRelations: { user: true } } }, where: { credentialsId: In(credentialIds), role, @@ -33,37 +64,91 @@ export class SharedCredentialsRepository extends Repository { }); } - async makeOwnerOfAllCredentials(user: User) { - return await this.update({ userId: Not(user.id), role: 'credential:owner' }, { user }); + async makeOwnerOfAllCredentials(project: Project) { + return await this.update( + { + projectId: Not(project.id), + role: 'credential:owner', + }, + { project }, + ); } - /** Get the IDs of all credentials owned by a user */ - async getOwnedCredentialIds(userIds: string[]) { - return await this.getCredentialIdsByUserAndRole(userIds, ['credential:owner']); + async makeOwner(credentialIds: string[], projectId: string, trx?: EntityManager) { + trx = trx ?? this.manager; + return await trx.upsert( + SharedCredentials, + credentialIds.map( + (credentialsId) => + ({ + projectId, + credentialsId, + role: 'credential:owner', + }) as const, + ), + ['projectId', 'credentialsId'], + ); } - /** Get the IDs of all credentials owned by or shared with a user */ - async getAccessibleCredentialIds(userIds: string[]) { - return await this.getCredentialIdsByUserAndRole(userIds, [ - 'credential:owner', - 'credential:user', - ]); - } + async getCredentialIdsByUserAndRole( + userIds: string[], + options: + | { scopes: Scope[] } + | { projectRoles: ProjectRole[]; credentialRoles: CredentialSharingRole[] }, + ) { + const projectRoles = + 'scopes' in options + ? this.roleService.rolesWithScope('project', options.scopes) + : options.projectRoles; + const credentialRoles = + 'scopes' in options + ? this.roleService.rolesWithScope('credential', options.scopes) + : options.credentialRoles; - private async getCredentialIdsByUserAndRole(userIds: string[], roles: CredentialSharingRole[]) { const sharings = await this.find({ where: { - userId: In(userIds), - role: In(roles), + role: In(credentialRoles), + project: { + projectRelations: { + userId: In(userIds), + role: In(projectRoles), + }, + }, }, }); return sharings.map((s) => s.credentialsId); } - async deleteByIds(transaction: EntityManager, sharedCredentialsIds: string[], user?: User) { - return await transaction.delete(SharedCredentials, { - user, + async deleteByIds(sharedCredentialsIds: string[], projectId: string, trx?: EntityManager) { + trx = trx ?? this.manager; + + return await trx.delete(SharedCredentials, { + projectId, credentialsId: In(sharedCredentialsIds), }); } + + async getFilteredAccessibleCredentials( + projectIds: string[], + credentialsIds: string[], + ): Promise { + return ( + await this.find({ + where: { + projectId: In(projectIds), + credentialsId: In(credentialsIds), + }, + select: ['credentialsId'], + }) + ).map((s) => s.credentialsId); + } + + async findCredentialOwningProject(credentialsId: string) { + return ( + await this.findOne({ + where: { credentialsId, role: 'credential:owner' }, + relations: { project: true }, + }) + )?.project; + } } diff --git a/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts b/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts index 3716daa45e..f8ff3523b2 100644 --- a/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts +++ b/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts @@ -4,33 +4,18 @@ import type { EntityManager, FindManyOptions, FindOptionsWhere } from '@n8n/type import { SharedWorkflow, type WorkflowSharingRole } from '../entities/SharedWorkflow'; import { type User } from '../entities/User'; import type { Scope } from '@n8n/permissions'; -import type { WorkflowEntity } from '../entities/WorkflowEntity'; +import { RoleService } from '@/services/role.service'; +import type { Project } from '../entities/Project'; @Service() export class SharedWorkflowRepository extends Repository { - constructor(dataSource: DataSource) { + constructor( + dataSource: DataSource, + private roleService: RoleService, + ) { super(SharedWorkflow, dataSource.manager); } - async hasAccess(workflowId: string, user: User) { - const where: FindOptionsWhere = { - workflowId, - }; - if (!user.hasGlobalScope('workflow:read')) { - where.userId = user.id; - } - return await this.exist({ where }); - } - - /** Get the IDs of all users this workflow is shared with */ - async getSharedUserIds(workflowId: string) { - const sharedWorkflows = await this.find({ - select: ['userId'], - where: { workflowId }, - }); - return sharedWorkflows.map((sharing) => sharing.userId); - } - async getSharedWorkflowIds(workflowIds: string[]) { const sharedWorkflows = await this.find({ select: ['workflowId'], @@ -43,11 +28,11 @@ export class SharedWorkflowRepository extends Repository { async findByWorkflowIds(workflowIds: string[]) { return await this.find({ - relations: ['user'], where: { role: 'workflow:owner', workflowId: In(workflowIds), }, + relations: { project: { projectRelations: { user: true } } }, }); } @@ -55,90 +40,49 @@ export class SharedWorkflowRepository extends Repository { userId: string, workflowId: string, ): Promise { - return await this.findOne({ - select: ['role'], - where: { workflowId, userId }, - }).then((shared) => shared?.role); - } - - async findSharing( - workflowId: string, - user: User, - scope: Scope, - { roles, extraRelations }: { roles?: WorkflowSharingRole[]; extraRelations?: string[] } = {}, - ) { - const where: FindOptionsWhere = { - workflow: { id: workflowId }, - }; - - if (!user.hasGlobalScope(scope)) { - where.user = { id: user.id }; - } - - if (roles) { - where.role = In(roles); - } - - const relations = ['workflow']; - - if (extraRelations) relations.push(...extraRelations); - - return await this.findOne({ relations, where }); - } - - async makeOwnerOfAllWorkflows(user: User) { - return await this.update({ userId: Not(user.id), role: 'workflow:owner' }, { user }); - } - - async getSharing( - user: User, - workflowId: string, - options: { allowGlobalScope: true; globalScope: Scope } | { allowGlobalScope: false }, - relations: string[] = ['workflow'], - ): Promise { - const where: FindOptionsWhere = { workflowId }; - - // Omit user from where if the requesting user has relevant - // global workflow permissions. This allows the user to - // access workflows they don't own. - if (!options.allowGlobalScope || !user.hasGlobalScope(options.globalScope)) { - where.userId = user.id; - } - - return await this.findOne({ where, relations }); - } - - async getSharedWorkflows( - user: User, - options: { - relations?: string[]; - workflowIds?: string[]; - }, - ): Promise { - return await this.find({ - where: { - ...(!['global:owner', 'global:admin'].includes(user.role) && { userId: user.id }), - ...(options.workflowIds && { workflowId: In(options.workflowIds) }), + const sharing = await this.findOne({ + // NOTE: We have to select everything that is used in the `where` clause. Otherwise typeorm will create an invalid query and we get this error: + // QueryFailedError: SQLITE_ERROR: no such column: distinctAlias.SharedWorkflow_... + select: { + role: true, + workflowId: true, + projectId: true, + }, + where: { + workflowId, + project: { projectRelations: { role: 'project:personalOwner', userId } }, }, - ...(options.relations && { relations: options.relations }), }); + + return sharing?.role; } - async share(transaction: EntityManager, workflow: WorkflowEntity, users: User[]) { - const newSharedWorkflows = users.reduce((acc, user) => { - if (user.isPending) { - return acc; - } - const entity: Partial = { - workflowId: workflow.id, - userId: user.id, - role: 'workflow:editor', - }; - acc.push(this.create(entity)); - return acc; - }, []); + async makeOwnerOfAllWorkflows(project: Project) { + return await this.update( + { + projectId: Not(project.id), + role: 'workflow:owner', + }, + { project }, + ); + } - return await transaction.save(newSharedWorkflows); + async makeOwner(workflowIds: string[], projectId: string, trx?: EntityManager) { + trx = trx ?? this.manager; + + return await trx.upsert( + SharedWorkflow, + workflowIds.map( + (workflowId) => + ({ + workflowId, + projectId, + role: 'workflow:owner', + }) as const, + ), + + ['projectId', 'workflowId'], + ); } async findWithFields( @@ -153,10 +97,107 @@ export class SharedWorkflowRepository extends Repository { }); } - async deleteByIds(transaction: EntityManager, sharedWorkflowIds: string[], user?: User) { - return await transaction.delete(SharedWorkflow, { - user, + async deleteByIds(sharedWorkflowIds: string[], projectId: string, trx?: EntityManager) { + trx = trx ?? this.manager; + + return await trx.delete(SharedWorkflow, { + projectId, workflowId: In(sharedWorkflowIds), }); } + + async findWorkflowForUser( + workflowId: string, + user: User, + scopes: Scope[], + { includeTags = false, em = this.manager } = {}, + ) { + let where: FindOptionsWhere = { workflowId }; + + if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) { + const projectRoles = this.roleService.rolesWithScope('project', scopes); + const workflowRoles = this.roleService.rolesWithScope('workflow', scopes); + + where = { + ...where, + role: In(workflowRoles), + project: { + projectRelations: { + role: In(projectRoles), + userId: user.id, + }, + }, + }; + } + + const sharedWorkflow = await em.findOne(SharedWorkflow, { + where, + relations: { + workflow: { + shared: { project: { projectRelations: { user: true } } }, + tags: includeTags, + }, + }, + }); + + if (!sharedWorkflow) { + return null; + } + + return sharedWorkflow.workflow; + } + + async findAllWorkflowsForUser(user: User, scopes: Scope[]) { + let where: FindOptionsWhere = {}; + + if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) { + const projectRoles = this.roleService.rolesWithScope('project', scopes); + const workflowRoles = this.roleService.rolesWithScope('workflow', scopes); + + where = { + ...where, + role: In(workflowRoles), + project: { + projectRelations: { + role: In(projectRoles), + userId: user.id, + }, + }, + }; + } + + const sharedWorkflows = await this.find({ + where, + relations: { + workflow: { + shared: { project: { projectRelations: { user: true } } }, + }, + }, + }); + + return sharedWorkflows.map((sw) => sw.workflow); + } + + /** + * Find the IDs of all the projects where a workflow is accessible. + */ + async findProjectIds(workflowId: string) { + const rows = await this.find({ where: { workflowId }, select: ['projectId'] }); + + const projectIds = rows.reduce((acc, row) => { + if (row.projectId) acc.push(row.projectId); + return acc; + }, []); + + return [...new Set(projectIds)]; + } + + async getWorkflowOwningProject(workflowId: string) { + return ( + await this.findOne({ + where: { workflowId, role: 'workflow:owner' }, + relations: { project: true }, + }) + )?.project; + } } diff --git a/packages/cli/src/databases/repositories/user.repository.ts b/packages/cli/src/databases/repositories/user.repository.ts index 6b81f8984b..4591c20498 100644 --- a/packages/cli/src/databases/repositories/user.repository.ts +++ b/packages/cli/src/databases/repositories/user.repository.ts @@ -1,9 +1,11 @@ import { Service } from 'typedi'; -import type { EntityManager, FindManyOptions } from '@n8n/typeorm'; +import type { DeepPartial, EntityManager, FindManyOptions } from '@n8n/typeorm'; import { DataSource, In, IsNull, Not, Repository } from '@n8n/typeorm'; import type { ListQuery } from '@/requests'; import { type GlobalRole, User } from '../entities/User'; +import { Project } from '../entities/Project'; +import { ProjectRelation } from '../entities/ProjectRelation'; @Service() export class UserRepository extends Repository { constructor(dataSource: DataSource) { @@ -16,6 +18,19 @@ export class UserRepository extends Repository { }); } + /** + * @deprecated Use `UserRepository.save` instead if you can. + * + * We need to use `save` so that that the subscriber in + * packages/cli/src/databases/entities/Project.ts receives the full user. + * With `update` it would only receive the updated fields, e.g. the `id` + * would be missing. test('does not use `Repository.update`, but + * `Repository.save` instead'. + */ + async update(...args: Parameters['update']>) { + return await super.update(...args); + } + async deleteAllExcept(user: User) { await this.delete({ id: Not(user.id) }); } @@ -104,4 +119,34 @@ export class UserRepository extends Repository { where: { id: In(userIds), password: Not(IsNull()) }, }); } + + async createUserWithProject( + user: DeepPartial, + transactionManager?: EntityManager, + ): Promise<{ user: User; project: Project }> { + const createInner = async (entityManager: EntityManager) => { + const newUser = entityManager.create(User, user); + const savedUser = await entityManager.save(newUser); + const savedProject = await entityManager.save( + entityManager.create(Project, { + type: 'personal', + name: savedUser.createPersonalProjectName(), + }), + ); + await entityManager.save( + entityManager.create(ProjectRelation, { + projectId: savedProject.id, + userId: savedUser.id, + role: 'project:personalOwner', + }), + ); + return { user: savedUser, project: savedProject }; + }; + if (transactionManager) { + return await createInner(transactionManager); + } + // TODO: use a transactions + // This is blocked by TypeORM having concurrency issues with transactions + return await createInner(this.manager); + } } diff --git a/packages/cli/src/databases/repositories/workflow.repository.ts b/packages/cli/src/databases/repositories/workflow.repository.ts index 9d0c09ea8a..5326df89ab 100644 --- a/packages/cli/src/databases/repositories/workflow.repository.ts +++ b/packages/cli/src/databases/repositories/workflow.repository.ts @@ -8,15 +8,12 @@ import { type FindOptionsWhere, type FindOptionsSelect, type FindManyOptions, - type EntityManager, - type DeleteResult, - Not, + type FindOptionsRelations, } from '@n8n/typeorm'; import type { ListQuery } from '@/requests'; import { isStringArray } from '@/utils'; import config from '@/config'; import { WorkflowEntity } from '../entities/WorkflowEntity'; -import { SharedWorkflow } from '../entities/SharedWorkflow'; import { WebhookEntity } from '../entities/WebhookEntity'; @Service() @@ -25,7 +22,10 @@ export class WorkflowRepository extends Repository { super(WorkflowEntity, dataSource.manager); } - async get(where: FindOptionsWhere, options?: { relations: string[] }) { + async get( + where: FindOptionsWhere, + options?: { relations: string[] | FindOptionsRelations }, + ) { return await this.findOne({ where, relations: options?.relations, @@ -35,7 +35,7 @@ export class WorkflowRepository extends Repository { async getAllActive() { return await this.find({ where: { active: true }, - relations: ['shared', 'shared.user'], + relations: { shared: { project: { projectRelations: true } } }, }); } @@ -50,7 +50,7 @@ export class WorkflowRepository extends Repository { async findById(workflowId: string) { return await this.findOne({ where: { id: workflowId }, - relations: ['shared', 'shared.user'], + relations: { shared: { project: { projectRelations: true } } }, }); } @@ -71,29 +71,6 @@ export class WorkflowRepository extends Repository { return totalTriggerCount ?? 0; } - async getSharings( - transaction: EntityManager, - workflowId: string, - relations = ['shared'], - ): Promise { - const workflow = await transaction.findOne(WorkflowEntity, { - where: { id: workflowId }, - relations, - }); - return workflow?.shared ?? []; - } - - async pruneSharings( - transaction: EntityManager, - workflowId: string, - userIds: string[], - ): Promise { - return await transaction.delete(SharedWorkflow, { - workflowId, - userId: Not(In(userIds)), - }); - } - async updateWorkflowTriggerCount(id: string, triggerCount: number): Promise { const qb = this.createQueryBuilder('workflow'); return await qb @@ -114,6 +91,11 @@ export class WorkflowRepository extends Repository { async getMany(sharedWorkflowIds: string[], options?: ListQuery.Options) { if (sharedWorkflowIds.length === 0) return { workflows: [], count: 0 }; + if (typeof options?.filter?.projectId === 'string' && options.filter.projectId !== '') { + options.filter.shared = { projectId: options.filter.projectId }; + delete options.filter.projectId; + } + const where: FindOptionsWhere = { ...options?.filter, id: In(sharedWorkflowIds), @@ -135,7 +117,7 @@ export class WorkflowRepository extends Repository { createdAt: true, updatedAt: true, versionId: true, - shared: { userId: true, role: true }, + shared: { role: true }, }; delete select?.ownedBy; // remove non-entity field, handled after query @@ -152,7 +134,7 @@ export class WorkflowRepository extends Repository { select.tags = { id: true, name: true }; } - if (isOwnedByIncluded) relations.push('shared', 'shared.user'); + if (isOwnedByIncluded) relations.push('shared', 'shared.project'); if (typeof where.name === 'string' && where.name !== '') { where.name = Like(`%${where.name}%`); diff --git a/packages/cli/src/databases/repositories/workflowStatistics.repository.ts b/packages/cli/src/databases/repositories/workflowStatistics.repository.ts index 0faef01840..a514c2d3b1 100644 --- a/packages/cli/src/databases/repositories/workflowStatistics.repository.ts +++ b/packages/cli/src/databases/repositories/workflowStatistics.repository.ts @@ -1,10 +1,8 @@ import { Service } from 'typedi'; -import { DataSource, QueryFailedError, Repository } from '@n8n/typeorm'; +import { DataSource, MoreThanOrEqual, QueryFailedError, Repository } from '@n8n/typeorm'; import config from '@/config'; import { StatisticsNames, WorkflowStatistics } from '../entities/WorkflowStatistics'; import type { User } from '@/databases/entities/User'; -import { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; -import { SharedWorkflow } from '@/databases/entities/SharedWorkflow'; type StatisticsInsertResult = 'insert' | 'failed' | 'alreadyExists'; type StatisticsUpsertResult = StatisticsInsertResult | 'update'; @@ -102,18 +100,18 @@ export class WorkflowStatisticsRepository extends Repository } async queryNumWorkflowsUserHasWithFiveOrMoreProdExecs(userId: User['id']): Promise { - return await this.createQueryBuilder('workflow_statistics') - .innerJoin(WorkflowEntity, 'workflow', 'workflow.id = workflow_statistics.workflowId') - .innerJoin( - SharedWorkflow, - 'shared_workflow', - 'shared_workflow.workflowId = workflow_statistics.workflowId', - ) - .where('shared_workflow.userId = :userId', { userId }) - .andWhere('workflow.active = :isActive', { isActive: true }) - .andWhere('workflow_statistics.name = :name', { name: StatisticsNames.productionSuccess }) - .andWhere('workflow_statistics.count >= 5') - .andWhere('role = :roleName', { roleName: 'workflow:owner' }) - .getCount(); + return await this.count({ + where: { + workflow: { + shared: { + role: 'workflow:owner', + project: { projectRelations: { userId, role: 'project:personalOwner' } }, + }, + active: true, + }, + name: StatisticsNames.productionSuccess, + count: MoreThanOrEqual(5), + }, + }); } } diff --git a/packages/cli/src/databases/subscribers/UserSubscriber.ts b/packages/cli/src/databases/subscribers/UserSubscriber.ts new file mode 100644 index 0000000000..e5fad5bf53 --- /dev/null +++ b/packages/cli/src/databases/subscribers/UserSubscriber.ts @@ -0,0 +1,73 @@ +import type { EntitySubscriberInterface, UpdateEvent } from '@n8n/typeorm'; +import { EventSubscriber } from '@n8n/typeorm'; +import { User } from '../entities/User'; +import Container from 'typedi'; +import { ProjectRepository } from '../repositories/project.repository'; +import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow'; +import { Logger } from '@/Logger'; +import { UserRepository } from '../repositories/user.repository'; +import { Project } from '../entities/Project'; + +@EventSubscriber() +export class UserSubscriber implements EntitySubscriberInterface { + listenTo() { + return User; + } + + async afterUpdate(event: UpdateEvent): Promise { + if (event.entity) { + const newUserData = event.entity; + + if (event.databaseEntity) { + const fields = event.updatedColumns.map((c) => c.propertyName); + + if ( + fields.includes('firstName') || + fields.includes('lastName') || + fields.includes('email') + ) { + const oldUser = event.databaseEntity; + const name = + newUserData instanceof User + ? newUserData.createPersonalProjectName() + : Container.get(UserRepository).create(newUserData).createPersonalProjectName(); + + const project = await Container.get(ProjectRepository).getPersonalProjectForUser( + oldUser.id, + ); + + if (!project) { + // Since this is benign we're not throwing the exception. We don't + // know if we're running inside a transaction and thus there is a risk + // that this could cause further data inconsistencies. + const message = "Could not update the personal project's name"; + Container.get(Logger).warn(message, event.entity); + const exception = new ApplicationError(message); + ErrorReporterProxy.warn(exception, event.entity); + return; + } + + project.name = name; + + await event.manager.save(Project, project); + } + } else { + // This means the user was updated using `Repository.update`. In this + // case we're missing the user's id and cannot update their project. + // + // When updating the user's firstName, lastName or email we must use + // `Repository.save`, so this is a bug and we should report it to sentry. + // + if (event.entity.firstName || event.entity.lastName || event.entity.email) { + // Since this is benign we're not throwing the exception. We don't + // know if we're running inside a transaction and thus there is a risk + // that this could cause further data inconsistencies. + const message = "Could not update the personal project's name"; + Container.get(Logger).warn(message, event.entity); + const exception = new ApplicationError(message); + ErrorReporterProxy.warn(exception, event.entity); + } + } + } + } +} diff --git a/packages/cli/src/databases/subscribers/index.ts b/packages/cli/src/databases/subscribers/index.ts new file mode 100644 index 0000000000..9d9383c4d7 --- /dev/null +++ b/packages/cli/src/databases/subscribers/index.ts @@ -0,0 +1,5 @@ +import { UserSubscriber } from './UserSubscriber'; + +export const subscribers = { + UserSubscriber, +}; diff --git a/packages/cli/src/decorators/Scoped.ts b/packages/cli/src/decorators/Scoped.ts new file mode 100644 index 0000000000..0d4644ae10 --- /dev/null +++ b/packages/cli/src/decorators/Scoped.ts @@ -0,0 +1,60 @@ +import type { Scope } from '@n8n/permissions'; +import type { RouteScopeMetadata } from './types'; +import { CONTROLLER_ROUTE_SCOPES } from './constants'; + +const Scoped = (scope: Scope | Scope[], { globalOnly } = { globalOnly: false }) => { + return (target: Function | object, handlerName?: string) => { + const controllerClass = handlerName ? target.constructor : target; + const scopes = (Reflect.getMetadata(CONTROLLER_ROUTE_SCOPES, controllerClass) ?? + {}) as RouteScopeMetadata; + + const metadata = { + scopes: Array.isArray(scope) ? scope : [scope], + globalOnly, + }; + + scopes[handlerName ?? '*'] = metadata; + Reflect.defineMetadata(CONTROLLER_ROUTE_SCOPES, scopes, controllerClass); + }; +}; + +/** + * Decorator for a controller method to ensure the user has a scope, + * checking only at the global level. + * + * To check only at project level as well, use the `@ProjectScope` decorator. + * + * @example + * ```ts + * @RestController() + * export class UsersController { + * @Delete('/:id') + * @GlobalScope('user:delete') + * async deleteUser(req, res) { ... } + * } + * ``` + */ +export const GlobalScope = (scope: Scope | Scope[]) => { + return Scoped(scope, { globalOnly: true }); +}; + +/** + * Decorator for a controller method to ensure the user has a scope, + * checking first at project level and then at global level. + * + * To check only at global level, use the `@GlobalScope` decorator. + * + * @example + * ```ts + * @RestController() + * export class WorkflowController { + * @Get('/:workflowId') + * @GlobalScope('workflow:read') + * async getWorkflow(req, res) { ... } + * } + * ``` + */ + +export const ProjectScope = (scope: Scope | Scope[]) => { + return Scoped(scope); +}; diff --git a/packages/cli/src/decorators/Scopes.ts b/packages/cli/src/decorators/Scopes.ts deleted file mode 100644 index aa2518017d..0000000000 --- a/packages/cli/src/decorators/Scopes.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Scope } from '@n8n/permissions'; -import type { ScopeMetadata } from './types'; -import { CONTROLLER_REQUIRED_SCOPES } from './constants'; - -export const GlobalScope = (scope: Scope | Scope[]) => { - // eslint-disable-next-line @typescript-eslint/ban-types - return (target: Function | object, handlerName?: string) => { - const controllerClass = handlerName ? target.constructor : target; - const scopes = (Reflect.getMetadata(CONTROLLER_REQUIRED_SCOPES, controllerClass) ?? - []) as ScopeMetadata; - scopes[handlerName ?? '*'] = Array.isArray(scope) ? scope : [scope]; - Reflect.defineMetadata(CONTROLLER_REQUIRED_SCOPES, scopes, controllerClass); - }; -}; diff --git a/packages/cli/src/decorators/constants.ts b/packages/cli/src/decorators/constants.ts index 1487f91a0f..8f3aac403d 100644 --- a/packages/cli/src/decorators/constants.ts +++ b/packages/cli/src/decorators/constants.ts @@ -2,4 +2,4 @@ export const CONTROLLER_ROUTES = 'CONTROLLER_ROUTES'; export const CONTROLLER_BASE_PATH = 'CONTROLLER_BASE_PATH'; export const CONTROLLER_MIDDLEWARES = 'CONTROLLER_MIDDLEWARES'; export const CONTROLLER_LICENSE_FEATURES = 'CONTROLLER_LICENSE_FEATURES'; -export const CONTROLLER_REQUIRED_SCOPES = 'CONTROLLER_REQUIRED_SCOPES'; +export const CONTROLLER_ROUTE_SCOPES = 'CONTROLLER_ROUTE_SCOPES'; diff --git a/packages/cli/src/decorators/index.ts b/packages/cli/src/decorators/index.ts index 94c94ef184..576b55cdd7 100644 --- a/packages/cli/src/decorators/index.ts +++ b/packages/cli/src/decorators/index.ts @@ -3,4 +3,4 @@ export { Get, Post, Put, Patch, Delete } from './Route'; export { Middleware } from './Middleware'; export { registerController } from './registerController'; export { Licensed } from './Licensed'; -export { GlobalScope } from './Scopes'; +export { GlobalScope, ProjectScope } from './Scoped'; diff --git a/packages/cli/src/decorators/registerController.ts b/packages/cli/src/decorators/registerController.ts index ab4c3c4f40..3cc0d35d99 100644 --- a/packages/cli/src/decorators/registerController.ts +++ b/packages/cli/src/decorators/registerController.ts @@ -2,13 +2,13 @@ import { Container } from 'typedi'; import { Router } from 'express'; import type { Application, Request, Response, RequestHandler } from 'express'; import { rateLimit as expressRateLimit } from 'express-rate-limit'; -import type { Scope } from '@n8n/permissions'; import { ApplicationError } from 'n8n-workflow'; import type { Class } from 'n8n-core'; import { AuthService } from '@/auth/auth.service'; import config from '@/config'; -import { inE2ETests, inTest } from '@/constants'; +import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error'; +import { inE2ETests, inTest, RESPONSE_ERROR_MESSAGES } from '@/constants'; import type { BooleanLicenseFeature } from '@/Interfaces'; import { License } from '@/License'; import type { AuthenticatedRequest } from '@/requests'; @@ -17,7 +17,7 @@ import { CONTROLLER_BASE_PATH, CONTROLLER_LICENSE_FEATURES, CONTROLLER_MIDDLEWARES, - CONTROLLER_REQUIRED_SCOPES, + CONTROLLER_ROUTE_SCOPES, CONTROLLER_ROUTES, } from './constants'; import type { @@ -25,8 +25,9 @@ import type { LicenseMetadata, MiddlewareMetadata, RouteMetadata, - ScopeMetadata, + RouteScopeMetadata, } from './types'; +import { userHasScope } from '@/permissions/checkAccess'; const throttle = expressRateLimit({ windowMs: 5 * 60 * 1000, // 5 minutes @@ -53,18 +54,24 @@ export const createLicenseMiddleware = return next(); }; -export const createGlobalScopeMiddleware = - (scopes: Scope[]): RequestHandler => - async ({ user }: AuthenticatedRequest, res, next) => { - if (scopes.length === 0) { - return next(); - } +export const createScopedMiddleware = + (routeScopeMetadata: RouteScopeMetadata[string]): RequestHandler => + async ( + req: AuthenticatedRequest<{ credentialId?: string; workflowId?: string; projectId?: string }>, + res, + next, + ) => { + if (!req.user) throw new UnauthenticatedError(); - if (!user) return res.status(401).json({ status: 'error', message: 'Unauthorized' }); + const { scopes, globalOnly } = routeScopeMetadata; - const hasScopes = user.hasGlobalScope(scopes); - if (!hasScopes) { - return res.status(403).json({ status: 'error', message: 'Unauthorized' }); + if (scopes.length === 0) return next(); + + if (!(await userHasScope(req.user, scopes, globalOnly, req.params))) { + return res.status(403).json({ + status: 'error', + message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE, + }); } return next(); @@ -84,8 +91,8 @@ export const registerController = (app: Application, controllerClass: Class 0) { @@ -112,7 +119,7 @@ export const registerController = (app: Application, controllerClass: Class { const features = licenseFeatures?.[handlerName] ?? licenseFeatures?.['*']; - const scopes = requiredScopes?.[handlerName] ?? requiredScopes?.['*']; + const scopes = routeScopes?.[handlerName] ?? routeScopes?.['*']; const handler = async (req: Request, res: Response) => await controller[handlerName](req, res); router[method]( @@ -121,7 +128,7 @@ export const registerController = (app: Application, controllerClass: Class; -export type ScopeMetadata = Record; +export type RouteScopeMetadata = { + [handlerName: string]: { + scopes: Scope[]; + globalOnly: boolean; + }; +}; export interface MiddlewareMetadata { handlerName: string; diff --git a/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts index eb82694930..f939ce39bb 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts @@ -29,6 +29,7 @@ import { Logger } from '@/Logger'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { WorkflowTagMappingRepository } from '@db/repositories/workflowTagMapping.repository'; +import type { ResourceOwner } from './types/resourceOwner'; @Service() export class SourceControlExportService { @@ -79,7 +80,7 @@ export class SourceControlExportService { private async writeExportableWorkflowsToExportFolder( workflowsToBeExported: WorkflowEntity[], - owners: Record, + owners: Record, ) { await Promise.all( workflowsToBeExported.map(async (e) => { @@ -109,8 +110,37 @@ export class SourceControlExportService { const workflows = await Container.get(WorkflowRepository).findByIds(workflowIds); // determine owner of each workflow to be exported - const owners: Record = {}; - sharedWorkflows.forEach((e) => (owners[e.workflowId] = e.user.email)); + const owners: Record = {}; + sharedWorkflows.forEach((e) => { + const project = e.project; + + if (!project) { + throw new ApplicationError(`Workflow ${e.workflow.display()} has no owner`); + } + + if (project.type === 'personal') { + const ownerRelation = project.projectRelations.find( + (pr) => pr.role === 'project:personalOwner', + ); + if (!ownerRelation) { + throw new ApplicationError(`Workflow ${e.workflow.display()} has no owner`); + } + owners[e.workflowId] = { + type: 'personal', + personalEmail: ownerRelation.user.email, + }; + } else if (project.type === 'team') { + owners[e.workflowId] = { + type: 'team', + teamId: project.id, + teamName: project.name, + }; + } else { + throw new ApplicationError( + `Workflow belongs to unknown project type: ${project.type as string}`, + ); + } + }); // write the workflows to the export folder as json files await this.writeExportableWorkflowsToExportFolder(workflows, owners); @@ -243,12 +273,31 @@ export class SourceControlExportService { const { name, type, data, id } = sharing.credentials; const credentials = new Credentials({ id, name }, type, data); + let owner: ResourceOwner | null = null; + if (sharing.project.type === 'personal') { + const ownerRelation = sharing.project.projectRelations.find( + (pr) => pr.role === 'project:personalOwner', + ); + if (ownerRelation) { + owner = { + type: 'personal', + personalEmail: ownerRelation.user.email, + }; + } + } else if (sharing.project.type === 'team') { + owner = { + type: 'team', + teamId: sharing.project.id, + teamName: sharing.project.name, + }; + } + const stub: ExportableCredential = { id, name, type, data: this.replaceCredentialData(credentials.getData()), - ownedBy: sharing.user.email, + ownedBy: owner, }; const filePath = this.getCredentialsPath(id); diff --git a/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts index 604e733e57..6a497c4410 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts @@ -26,13 +26,17 @@ import type { SourceControlledFile } from './types/sourceControlledFile'; import { VariablesService } from '../variables/variables.service.ee'; import { TagRepository } from '@db/repositories/tag.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; -import { UserRepository } from '@db/repositories/user.repository'; import { Logger } from '@/Logger'; import { CredentialsRepository } from '@db/repositories/credentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { WorkflowTagMappingRepository } from '@db/repositories/workflowTagMapping.repository'; import { VariablesRepository } from '@db/repositories/variables.repository'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import type { Project } from '@/databases/entities/Project'; +import type { ResourceOwner } from './types/resourceOwner'; +import { assertNever } from '@/utils'; +import { UserRepository } from '@/databases/repositories/user.repository'; @Service() export class SourceControlImportService { @@ -203,116 +207,94 @@ export class SourceControlImportService { } public async importWorkflowFromWorkFolder(candidates: SourceControlledFile[], userId: string) { - const workflowRunner = this.activeWorkflowManager; + const personalProject = + await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId); + const workflowManager = this.activeWorkflowManager; const candidateIds = candidates.map((c) => c.id); const existingWorkflows = await Container.get(WorkflowRepository).findByIds(candidateIds, { fields: ['id', 'name', 'versionId', 'active'], }); const allSharedWorkflows = await Container.get(SharedWorkflowRepository).findWithFields( candidateIds, - { select: ['workflowId', 'role', 'userId'] }, + { select: ['workflowId', 'role', 'projectId'] }, ); - const cachedOwnerIds = new Map(); - const importWorkflowsResult = await Promise.all( - candidates.map(async (candidate) => { - this.logger.debug(`Parsing workflow file ${candidate.file}`); - const importedWorkflow = jsonParse( - await fsReadFile(candidate.file, { encoding: 'utf8' }), - ); - if (!importedWorkflow?.id) { - return; - } - const existingWorkflow = existingWorkflows.find((e) => e.id === importedWorkflow.id); - importedWorkflow.active = existingWorkflow?.active ?? false; - this.logger.debug(`Updating workflow id ${importedWorkflow.id ?? 'new'}`); - const upsertResult = await Container.get(WorkflowRepository).upsert( - { ...importedWorkflow }, - ['id'], - ); - if (upsertResult?.identifiers?.length !== 1) { - throw new ApplicationError('Failed to upsert workflow', { - extra: { workflowId: importedWorkflow.id ?? 'new' }, - }); - } - // Update workflow owner to the user who exported the workflow, if that user exists - // in the instance, and the workflow doesn't already have an owner - let workflowOwnerId = userId; - if (cachedOwnerIds.has(importedWorkflow.owner)) { - workflowOwnerId = cachedOwnerIds.get(importedWorkflow.owner) ?? userId; - } else { - const foundUser = await Container.get(UserRepository).findOne({ - where: { - email: importedWorkflow.owner, - }, - select: ['id'], - }); - if (foundUser) { - cachedOwnerIds.set(importedWorkflow.owner, foundUser.id); - workflowOwnerId = foundUser.id; - } - } + const importWorkflowsResult = []; - const existingSharedWorkflowOwnerByRoleId = allSharedWorkflows.find( - (e) => e.workflowId === importedWorkflow.id && e.role === 'workflow:owner', - ); - const existingSharedWorkflowOwnerByUserId = allSharedWorkflows.find( - (e) => e.workflowId === importedWorkflow.id && e.role === 'workflow:owner', - ); - if (!existingSharedWorkflowOwnerByUserId && !existingSharedWorkflowOwnerByRoleId) { - // no owner exists yet, so create one - await Container.get(SharedWorkflowRepository).insert({ + // Due to SQLite concurrency issues, we cannot save all workflows at once + // as project creation might cause constraint issues. + // We must iterate over the array and run the whole process workflow by workflow + for (const candidate of candidates) { + this.logger.debug(`Parsing workflow file ${candidate.file}`); + const importedWorkflow = jsonParse( + await fsReadFile(candidate.file, { encoding: 'utf8' }), + ); + if (!importedWorkflow?.id) { + continue; + } + const existingWorkflow = existingWorkflows.find((e) => e.id === importedWorkflow.id); + importedWorkflow.active = existingWorkflow?.active ?? false; + this.logger.debug(`Updating workflow id ${importedWorkflow.id ?? 'new'}`); + const upsertResult = await Container.get(WorkflowRepository).upsert({ ...importedWorkflow }, [ + 'id', + ]); + if (upsertResult?.identifiers?.length !== 1) { + throw new ApplicationError('Failed to upsert workflow', { + extra: { workflowId: importedWorkflow.id ?? 'new' }, + }); + } + + const isOwnedLocally = allSharedWorkflows.some( + (w) => w.workflowId === importedWorkflow.id && w.role === 'workflow:owner', + ); + + if (!isOwnedLocally) { + const remoteOwnerProject: Project | null = importedWorkflow.owner + ? await this.findOrCreateOwnerProject(importedWorkflow.owner) + : null; + + await Container.get(SharedWorkflowRepository).upsert( + { workflowId: importedWorkflow.id, - userId: workflowOwnerId, + projectId: remoteOwnerProject?.id ?? personalProject.id, role: 'workflow:owner', - }); - } else if (existingSharedWorkflowOwnerByRoleId) { - // skip, because the workflow already has a global owner - } else if (existingSharedWorkflowOwnerByUserId && !existingSharedWorkflowOwnerByRoleId) { - // if the workflow has a non-global owner that is referenced by the owner file, - // and no existing global owner, update the owner to the user referenced in the owner file - await Container.get(SharedWorkflowRepository).update( - { - workflowId: importedWorkflow.id, - userId: workflowOwnerId, - }, - { role: 'workflow:owner' }, + }, + ['workflowId', 'projectId'], + ); + } + + if (existingWorkflow?.active) { + try { + // remove active pre-import workflow + this.logger.debug(`Deactivating workflow id ${existingWorkflow.id}`); + await workflowManager.remove(existingWorkflow.id); + // try activating the imported workflow + this.logger.debug(`Reactivating workflow id ${existingWorkflow.id}`); + await workflowManager.add(existingWorkflow.id, 'activate'); + // update the versionId of the workflow to match the imported workflow + } catch (error) { + this.logger.error(`Failed to activate workflow ${existingWorkflow.id}`, error as Error); + } finally { + await Container.get(WorkflowRepository).update( + { id: existingWorkflow.id }, + { versionId: importedWorkflow.versionId }, ); } - if (existingWorkflow?.active) { - try { - // remove active pre-import workflow - this.logger.debug(`Deactivating workflow id ${existingWorkflow.id}`); - await workflowRunner.remove(existingWorkflow.id); - // try activating the imported workflow - this.logger.debug(`Reactivating workflow id ${existingWorkflow.id}`); - await workflowRunner.add(existingWorkflow.id, 'activate'); - // update the versionId of the workflow to match the imported workflow - } catch (error) { - this.logger.error(`Failed to activate workflow ${existingWorkflow.id}`, error as Error); - } finally { - await Container.get(WorkflowRepository).update( - { id: existingWorkflow.id }, - { versionId: importedWorkflow.versionId }, - ); - } - } + } - return { - id: importedWorkflow.id ?? 'unknown', - name: candidate.file, - }; - }), - ); + importWorkflowsResult.push({ + id: importedWorkflow.id ?? 'unknown', + name: candidate.file, + }); + } return importWorkflowsResult.filter((e) => e !== undefined) as Array<{ id: string; name: string; }>; } - public async importCredentialsFromWorkFolder( - candidates: SourceControlledFile[], - importingUserId: string, - ) { + public async importCredentialsFromWorkFolder(candidates: SourceControlledFile[], userId: string) { + const personalProject = + await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId); const candidateIds = candidates.map((c) => c.id); const existingCredentials = await Container.get(CredentialsRepository).find({ where: { @@ -321,7 +303,7 @@ export class SourceControlImportService { select: ['id', 'name', 'type', 'data'], }); const existingSharedCredentials = await Container.get(SharedCredentialsRepository).find({ - select: ['userId', 'credentialsId', 'role'], + select: ['credentialsId', 'role'], where: { credentialsId: In(candidateIds), role: 'credential:owner', @@ -350,27 +332,22 @@ export class SourceControlImportService { await Container.get(CredentialsRepository).upsert(newCredentialObject, ['id']); const isOwnedLocally = existingSharedCredentials.some( - (c) => c.credentialsId === credential.id, + (c) => c.credentialsId === credential.id && c.role === 'credential:owner', ); if (!isOwnedLocally) { - const remoteOwnerId = credential.ownedBy - ? await Container.get(UserRepository) - .findOne({ - where: { email: credential.ownedBy }, - select: { id: true }, - }) - .then((user) => user?.id) + const remoteOwnerProject: Project | null = credential.ownedBy + ? await this.findOrCreateOwnerProject(credential.ownedBy) : null; const newSharedCredential = new SharedCredentials(); newSharedCredential.credentialsId = newCredentialObject.id as string; - newSharedCredential.userId = remoteOwnerId ?? importingUserId; + newSharedCredential.projectId = remoteOwnerProject?.id ?? personalProject.id; newSharedCredential.role = 'credential:owner'; await Container.get(SharedCredentialsRepository).upsert({ ...newSharedCredential }, [ 'credentialsId', - 'userId', + 'projectId', ]); } @@ -469,7 +446,7 @@ export class SourceControlImportService { if (!variable.key) { continue; } - // by default no value is stored remotely, so an empty string is retuned + // by default no value is stored remotely, so an empty string is returned // it must be changed to undefined so as to not overwrite existing values! if (variable.value === '') { variable.value = undefined; @@ -511,4 +488,52 @@ export class SourceControlImportService { return result; } + + private async findOrCreateOwnerProject(owner: ResourceOwner): Promise { + const projectRepository = Container.get(ProjectRepository); + const userRepository = Container.get(UserRepository); + if (typeof owner === 'string' || owner.type === 'personal') { + const email = typeof owner === 'string' ? owner : owner.personalEmail; + const user = await userRepository.findOne({ + where: { email }, + }); + if (!user) { + return null; + } + return await projectRepository.getPersonalProjectForUserOrFail(user.id); + } else if (owner.type === 'team') { + let teamProject = await projectRepository.findOne({ + where: { id: owner.teamId }, + }); + if (!teamProject) { + try { + teamProject = await projectRepository.save( + projectRepository.create({ + id: owner.teamId, + name: owner.teamName, + type: 'team', + }), + ); + } catch (e) { + teamProject = await projectRepository.findOne({ + where: { id: owner.teamId }, + }); + if (!teamProject) { + throw e; + } + } + } + + return teamProject; + } + + assertNever(owner); + + const errorOwner = owner as ResourceOwner; + throw new ApplicationError( + `Unknown resource owner type "${ + typeof errorOwner !== 'string' ? errorOwner.type : 'UNKNOWN' + }" found when importing from source controller`, + ); + } } diff --git a/packages/cli/src/environments/sourceControl/types/exportableCredential.ts b/packages/cli/src/environments/sourceControl/types/exportableCredential.ts index 36197da6ca..7ef071117f 100644 --- a/packages/cli/src/environments/sourceControl/types/exportableCredential.ts +++ b/packages/cli/src/environments/sourceControl/types/exportableCredential.ts @@ -1,4 +1,5 @@ import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; +import type { ResourceOwner } from './resourceOwner'; export interface ExportableCredential { id: string; @@ -10,5 +11,5 @@ export interface ExportableCredential { * Email of the user who owns this credential at the source instance. * Ownership is mirrored at target instance if user is also present there. */ - ownedBy: string | null; + ownedBy: ResourceOwner | null; } diff --git a/packages/cli/src/environments/sourceControl/types/exportableWorkflow.ts b/packages/cli/src/environments/sourceControl/types/exportableWorkflow.ts index 26b866ddc0..a0803bce87 100644 --- a/packages/cli/src/environments/sourceControl/types/exportableWorkflow.ts +++ b/packages/cli/src/environments/sourceControl/types/exportableWorkflow.ts @@ -1,4 +1,5 @@ import type { INode, IConnections, IWorkflowSettings } from 'n8n-workflow'; +import type { ResourceOwner } from './resourceOwner'; export interface ExportableWorkflow { id: string; @@ -8,5 +9,5 @@ export interface ExportableWorkflow { settings?: IWorkflowSettings; triggerCount: number; versionId: string; - owner: string; + owner: ResourceOwner; } diff --git a/packages/cli/src/environments/sourceControl/types/resourceOwner.ts b/packages/cli/src/environments/sourceControl/types/resourceOwner.ts new file mode 100644 index 0000000000..292ea9f181 --- /dev/null +++ b/packages/cli/src/environments/sourceControl/types/resourceOwner.ts @@ -0,0 +1,11 @@ +export type ResourceOwner = + | string + | { + type: 'personal'; + personalEmail: string; + } + | { + type: 'team'; + teamId: string; + teamName: string; + }; diff --git a/packages/cli/src/environments/variables/variables.controller.ee.ts b/packages/cli/src/environments/variables/variables.controller.ee.ts index 16a59f26d8..d2b7f62b94 100644 --- a/packages/cli/src/environments/variables/variables.controller.ee.ts +++ b/packages/cli/src/environments/variables/variables.controller.ee.ts @@ -1,5 +1,5 @@ import { VariablesRequest } from '@/requests'; -import { Delete, Get, Licensed, Patch, Post, GlobalScope, RestController } from '@/decorators'; +import { Delete, Get, GlobalScope, Licensed, Patch, Post, RestController } from '@/decorators'; import { VariablesService } from './variables.service.ee'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; diff --git a/packages/cli/src/errors/response-errors/forbidden.error.ts b/packages/cli/src/errors/response-errors/forbidden.error.ts new file mode 100644 index 0000000000..4856f7cd47 --- /dev/null +++ b/packages/cli/src/errors/response-errors/forbidden.error.ts @@ -0,0 +1,7 @@ +import { ResponseError } from './abstract/response.error'; + +export class ForbiddenError extends ResponseError { + constructor(message = 'Forbidden', hint?: string) { + super(message, 403, 403, hint); + } +} diff --git a/packages/cli/src/errors/response-errors/unauthenticated.error.ts b/packages/cli/src/errors/response-errors/unauthenticated.error.ts new file mode 100644 index 0000000000..7f1409da7f --- /dev/null +++ b/packages/cli/src/errors/response-errors/unauthenticated.error.ts @@ -0,0 +1,7 @@ +import { ResponseError } from './abstract/response.error'; + +export class UnauthenticatedError extends ResponseError { + constructor(message = 'Unauthenticated', hint?: string) { + super(message, 401, 401, hint); + } +} diff --git a/packages/cli/src/errors/response-errors/unauthorized.error.ts b/packages/cli/src/errors/response-errors/unauthorized.error.ts deleted file mode 100644 index bc8993c014..0000000000 --- a/packages/cli/src/errors/response-errors/unauthorized.error.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ResponseError } from './abstract/response.error'; - -export class UnauthorizedError extends ResponseError { - constructor(message: string, hint: string | undefined = undefined) { - super(message, 403, 403, hint); - } -} diff --git a/packages/cli/src/executions/execution.service.ee.ts b/packages/cli/src/executions/execution.service.ee.ts index 29e2c5b8f3..a7e9a51750 100644 --- a/packages/cli/src/executions/execution.service.ee.ts +++ b/packages/cli/src/executions/execution.service.ee.ts @@ -22,23 +22,24 @@ export class EnterpriseExecutionsService { if (!execution) return; - const relations = ['shared', 'shared.user']; - - const workflow = (await this.workflowRepository.get( - { id: execution.workflowId }, - { relations }, - )) as WorkflowWithSharingsAndCredentials; + const workflow = (await this.workflowRepository.get({ + id: execution.workflowId, + })) as WorkflowWithSharingsAndCredentials; if (!workflow) return; - this.enterpriseWorkflowService.addOwnerAndSharings(workflow); - await this.enterpriseWorkflowService.addCredentialsToWorkflow(workflow, req.user); + const workflowWithSharingsMetaData = + this.enterpriseWorkflowService.addOwnerAndSharings(workflow); + await this.enterpriseWorkflowService.addCredentialsToWorkflow( + workflowWithSharingsMetaData, + req.user, + ); execution.workflowData = { ...execution.workflowData, - ownedBy: workflow.ownedBy, - sharedWith: workflow.sharedWith, - usedCredentials: workflow.usedCredentials, + homeProject: workflowWithSharingsMetaData.homeProject, + sharedWithProjects: workflowWithSharingsMetaData.sharedWithProjects, + usedCredentials: workflowWithSharingsMetaData.usedCredentials, } as WorkflowWithSharingsAndCredentials; return execution; diff --git a/packages/cli/src/executions/executions.controller.ts b/packages/cli/src/executions/executions.controller.ts index bffb0c383f..9f7fd1b746 100644 --- a/packages/cli/src/executions/executions.controller.ts +++ b/packages/cli/src/executions/executions.controller.ts @@ -7,6 +7,7 @@ import { WorkflowSharingService } from '@/workflows/workflowSharing.service'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { parseRangeQuery } from './parse-range-query.middleware'; import type { User } from '@/databases/entities/User'; +import type { Scope } from '@n8n/permissions'; @RestController('/executions') export class ExecutionsController { @@ -17,15 +18,20 @@ export class ExecutionsController { private readonly license: License, ) {} - private async getAccessibleWorkflowIds(user: User) { - return this.license.isSharingEnabled() - ? await this.workflowSharingService.getSharedWorkflowIds(user) - : await this.workflowSharingService.getSharedWorkflowIds(user, ['workflow:owner']); + private async getAccessibleWorkflowIds(user: User, scope: Scope) { + if (this.license.isSharingEnabled()) { + return await this.workflowSharingService.getSharedWorkflowIds(user, { scopes: [scope] }); + } else { + return await this.workflowSharingService.getSharedWorkflowIds(user, { + workflowRoles: ['workflow:owner'], + projectRoles: ['project:personalOwner'], + }); + } } @Get('/', { middlewares: [parseRangeQuery] }) async getMany(req: ExecutionRequest.GetMany) { - const accessibleWorkflowIds = await this.getAccessibleWorkflowIds(req.user); + const accessibleWorkflowIds = await this.getAccessibleWorkflowIds(req.user, 'workflow:read'); if (accessibleWorkflowIds.length === 0) { return { count: 0, estimated: false, results: [] }; @@ -53,7 +59,7 @@ export class ExecutionsController { @Get('/:id') async getOne(req: ExecutionRequest.GetOne) { - const workflowIds = await this.getAccessibleWorkflowIds(req.user); + const workflowIds = await this.getAccessibleWorkflowIds(req.user, 'workflow:read'); if (workflowIds.length === 0) throw new NotFoundError('Execution not found'); @@ -64,7 +70,7 @@ export class ExecutionsController { @Post('/:id/stop') async stop(req: ExecutionRequest.Stop) { - const workflowIds = await this.getAccessibleWorkflowIds(req.user); + const workflowIds = await this.getAccessibleWorkflowIds(req.user, 'workflow:execute'); if (workflowIds.length === 0) throw new NotFoundError('Execution not found'); @@ -73,7 +79,7 @@ export class ExecutionsController { @Post('/:id/retry') async retry(req: ExecutionRequest.Retry) { - const workflowIds = await this.getAccessibleWorkflowIds(req.user); + const workflowIds = await this.getAccessibleWorkflowIds(req.user, 'workflow:execute'); if (workflowIds.length === 0) throw new NotFoundError('Execution not found'); @@ -82,7 +88,7 @@ export class ExecutionsController { @Post('/delete') async delete(req: ExecutionRequest.Delete) { - const workflowIds = await this.getAccessibleWorkflowIds(req.user); + const workflowIds = await this.getAccessibleWorkflowIds(req.user, 'workflow:execute'); if (workflowIds.length === 0) throw new NotFoundError('Execution not found'); diff --git a/packages/cli/src/jest.d.ts b/packages/cli/src/jest.d.ts index dcb4e4e7bf..af1963de3d 100644 --- a/packages/cli/src/jest.d.ts +++ b/packages/cli/src/jest.d.ts @@ -1,5 +1,7 @@ namespace jest { interface Matchers { toBeEmptyArray(): T; + toBeEmptySet(): T; + toBeSetContaining(...items: string[]): T; } } diff --git a/packages/cli/src/license/license.controller.ts b/packages/cli/src/license/license.controller.ts index 086ab3d4f8..c9b70609d3 100644 --- a/packages/cli/src/license/license.controller.ts +++ b/packages/cli/src/license/license.controller.ts @@ -1,4 +1,4 @@ -import { Get, Post, GlobalScope, RestController } from '@/decorators'; +import { Get, Post, RestController, GlobalScope } from '@/decorators'; import { LicenseRequest } from '@/requests'; import { LicenseService } from './license.service'; diff --git a/packages/cli/src/middlewares/listQuery/dtos/credentials.filter.dto.ts b/packages/cli/src/middlewares/listQuery/dtos/credentials.filter.dto.ts index 5b5cdb1a63..191799b157 100644 --- a/packages/cli/src/middlewares/listQuery/dtos/credentials.filter.dto.ts +++ b/packages/cli/src/middlewares/listQuery/dtos/credentials.filter.dto.ts @@ -13,6 +13,11 @@ export class CredentialsFilter extends BaseFilter { @Expose() type?: string; + @IsString() + @IsOptional() + @Expose() + projectId?: string; + static async fromString(rawFilter: string) { return await this.toFilter(rawFilter, CredentialsFilter); } diff --git a/packages/cli/src/middlewares/listQuery/dtos/workflow.filter.dto.ts b/packages/cli/src/middlewares/listQuery/dtos/workflow.filter.dto.ts index cadb945a60..d608589f00 100644 --- a/packages/cli/src/middlewares/listQuery/dtos/workflow.filter.dto.ts +++ b/packages/cli/src/middlewares/listQuery/dtos/workflow.filter.dto.ts @@ -20,6 +20,11 @@ export class WorkflowFilter extends BaseFilter { @Expose() tags?: string[]; + @IsString() + @IsOptional() + @Expose() + projectId?: string; + static async fromString(rawFilter: string) { return await this.toFilter(rawFilter, WorkflowFilter); } diff --git a/packages/cli/src/permissions/checkAccess.ts b/packages/cli/src/permissions/checkAccess.ts new file mode 100644 index 0000000000..f0b4166a47 --- /dev/null +++ b/packages/cli/src/permissions/checkAccess.ts @@ -0,0 +1,87 @@ +import { Container } from 'typedi'; +import { In } from '@n8n/typeorm'; + +import { RoleService } from '@/services/role.service'; +import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; +import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; +import { ProjectRepository } from '@db/repositories/project.repository'; +import type { User } from '@/databases/entities/User'; +import type { Scope } from '@n8n/permissions'; +import { ApplicationError } from 'n8n-workflow'; + +export const userHasScope = async ( + user: User, + scopes: Scope[], + globalOnly: boolean, + { + credentialId, + workflowId, + projectId, + }: { credentialId?: string; workflowId?: string; projectId?: string }, +): Promise => { + // Short circuit here since a global role will always have access + if (user.hasGlobalScope(scopes, { mode: 'allOf' })) { + return true; + } else if (globalOnly) { + // The above check already failed so the user doesn't have access + return false; + } + + const roleService = Container.get(RoleService); + const projectRoles = roleService.rolesWithScope('project', scopes); + const userProjectIds = ( + await Container.get(ProjectRepository).find({ + where: { + projectRelations: { + userId: user.id, + role: In(projectRoles), + }, + }, + select: ['id'], + }) + ).map((p) => p.id); + + if (credentialId) { + const exists = await Container.get(SharedCredentialsRepository).find({ + where: { + projectId: In(userProjectIds), + credentialsId: credentialId, + role: In(roleService.rolesWithScope('credential', scopes)), + }, + }); + + if (!exists.length) { + return false; + } + + return true; + } + + if (workflowId) { + const exists = await Container.get(SharedWorkflowRepository).find({ + where: { + projectId: In(userProjectIds), + workflowId, + role: In(roleService.rolesWithScope('workflow', scopes)), + }, + }); + + if (!exists.length) { + return false; + } + + return true; + } + + if (projectId) { + if (!userProjectIds.includes(projectId)) { + return false; + } + + return true; + } + + throw new ApplicationError( + "@ProjectScope decorator was used but does not have a credentialId, workflowId, or projectId in it's URL parameters. This is likely an implementation error. If you're a developer, please check you're URL is correct or that this should be using @GlobalScope.", + ); +}; diff --git a/packages/cli/src/permissions/roles.ts b/packages/cli/src/permissions/global-roles.ts similarity index 86% rename from packages/cli/src/permissions/roles.ts rename to packages/cli/src/permissions/global-roles.ts index 68d61af0b2..17303d2af1 100644 --- a/packages/cli/src/permissions/roles.ts +++ b/packages/cli/src/permissions/global-roles.ts @@ -1,6 +1,6 @@ import type { Scope } from '@n8n/permissions'; -export const ownerPermissions: Scope[] = [ +export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'auditLogs:manage', 'banner:dismiss', 'credential:create', @@ -41,6 +41,7 @@ export const ownerPermissions: Scope[] = [ 'orchestration:read', 'orchestration:list', 'saml:manage', + 'securityAudit:generate', 'sourceControl:pull', 'sourceControl:push', 'sourceControl:manage', @@ -69,9 +70,16 @@ export const ownerPermissions: Scope[] = [ 'workflow:share', 'workflow:execute', 'workersView:manage', + 'project:list', + 'project:create', + 'project:read', + 'project:update', + 'project:delete', ]; -export const adminPermissions: Scope[] = ownerPermissions.concat(); -export const memberPermissions: Scope[] = [ + +export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat(); + +export const GLOBAL_MEMBER_SCOPES: Scope[] = [ 'eventBusEvent:list', 'eventBusEvent:read', 'eventBusDestination:list', diff --git a/packages/cli/src/permissions/project-roles.ts b/packages/cli/src/permissions/project-roles.ts new file mode 100644 index 0000000000..3c649fb5e0 --- /dev/null +++ b/packages/cli/src/permissions/project-roles.ts @@ -0,0 +1,59 @@ +import type { Scope } from '@n8n/permissions'; + +/** + * Diff between admin in personal project and admin in other projects: + * - You cannot rename your personal project. + * - You cannot invite people to your personal project. + */ + +export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [ + 'workflow:create', + 'workflow:read', + 'workflow:update', + 'workflow:delete', + 'workflow:list', + 'workflow:execute', + 'credential:create', + 'credential:read', + 'credential:update', + 'credential:delete', + 'credential:list', + 'project:list', + 'project:read', + 'project:update', + 'project:delete', +]; + +export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [ + 'workflow:create', + 'workflow:read', + 'workflow:update', + 'workflow:delete', + 'workflow:list', + 'workflow:execute', + 'workflow:share', + 'credential:create', + 'credential:read', + 'credential:update', + 'credential:delete', + 'credential:list', + 'credential:share', + 'project:list', + 'project:read', +]; + +export const PROJECT_EDITOR_SCOPES: Scope[] = [ + 'workflow:create', + 'workflow:read', + 'workflow:update', + 'workflow:delete', + 'workflow:list', + 'workflow:execute', + 'credential:create', + 'credential:read', + 'credential:update', + 'credential:delete', + 'credential:list', + 'project:list', + 'project:read', +]; diff --git a/packages/cli/src/permissions/resource-roles.ts b/packages/cli/src/permissions/resource-roles.ts new file mode 100644 index 0000000000..429242a0c7 --- /dev/null +++ b/packages/cli/src/permissions/resource-roles.ts @@ -0,0 +1,24 @@ +import type { Scope } from '@n8n/permissions'; + +export const CREDENTIALS_SHARING_OWNER_SCOPES: Scope[] = [ + 'credential:read', + 'credential:update', + 'credential:delete', + 'credential:share', +]; + +export const CREDENTIALS_SHARING_USER_SCOPES: Scope[] = ['credential:read']; + +export const WORKFLOW_SHARING_OWNER_SCOPES: Scope[] = [ + 'workflow:read', + 'workflow:update', + 'workflow:delete', + 'workflow:execute', + 'workflow:share', +]; + +export const WORKFLOW_SHARING_EDITOR_SCOPES: Scope[] = [ + 'workflow:read', + 'workflow:update', + 'workflow:execute', +]; diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 36fc8940b7..f58e8ad464 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -14,11 +14,15 @@ import { Expose } from 'class-transformer'; import { IsBoolean, IsEmail, IsIn, IsOptional, IsString, Length } from 'class-validator'; import { NoXss } from '@db/utils/customValidators'; import type { PublicUser, SecretsProvider, SecretsProviderState } from '@/Interfaces'; -import { AssignableRole, type User } from '@db/entities/User'; +import { AssignableRole } from '@db/entities/User'; +import type { GlobalRole, User } from '@db/entities/User'; import type { Variables } from '@db/entities/Variables'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; import type { WorkflowHistory } from '@db/entities/WorkflowHistory'; +import type { Project, ProjectType } from '@db/entities/Project'; +import type { ProjectRole } from './databases/entities/ProjectRelation'; +import type { Scope } from '@n8n/permissions'; export class UserUpdatePayload implements Pick { @Expose() @@ -118,7 +122,9 @@ export namespace ListQuery { type SharedField = Partial>; - type OwnedByField = { ownedBy: SlimUser | null }; + type OwnedByField = { ownedBy: SlimUser | null; homeProject: SlimProject | null }; + + type ScopesField = { scopes: Scope[] }; export type Plain = BaseFields; @@ -126,23 +132,38 @@ export namespace ListQuery { export type WithOwnership = BaseFields & OwnedByField; - type SharedWithField = { sharedWith: SlimUser[] }; + type SharedWithField = { sharedWith: SlimUser[]; sharedWithProjects: SlimProject[] }; - export type WithOwnedByAndSharedWith = BaseFields & OwnedByField & SharedWithField; + export type WithOwnedByAndSharedWith = BaseFields & + OwnedByField & + SharedWithField & + SharedField; + + export type WithScopes = BaseFields & ScopesField & SharedField; } export namespace Credentials { - type OwnedByField = { ownedBy: SlimUser | null }; + type OwnedByField = { homeProject: SlimProject | null }; - type SharedWithField = { sharedWith: SlimUser[] }; + type SharedField = Partial>; - export type WithSharing = CredentialsEntity & Partial>; + type SharedWithField = { sharedWithProjects: SlimProject[] }; - export type WithOwnedByAndSharedWith = CredentialsEntity & OwnedByField & SharedWithField; + type ScopesField = { scopes: Scope[] }; + + export type WithSharing = CredentialsEntity & SharedField; + + export type WithOwnedByAndSharedWith = CredentialsEntity & + OwnedByField & + SharedWithField & + SharedField; + + export type WithScopes = CredentialsEntity & ScopesField & SharedField; } } type SlimUser = Pick; +export type SlimProject = Pick; export function hasSharing( workflows: ListQuery.Workflow.Plain[] | ListQuery.Workflow.WithSharing[], @@ -169,27 +190,32 @@ export interface AIGenerateCurlPayload { export declare namespace CredentialRequest { type CredentialProperties = Partial<{ - id: string; // delete if sent + id: string; // deleted if sent name: string; type: string; data: ICredentialDataDecryptedObject; + projectId?: string; }>; type Create = AuthenticatedRequest<{}, {}, CredentialProperties>; - type Get = AuthenticatedRequest<{ id: string }, {}, {}, Record>; + type Get = AuthenticatedRequest<{ credentialId: string }, {}, {}, Record>; + + type GetMany = AuthenticatedRequest<{}, {}, {}, ListQuery.Params & { includeScopes?: string }> & { + listQueryOptions: ListQuery.Options; + }; type Delete = Get; type GetAll = AuthenticatedRequest<{}, {}, {}, { filter: string }>; - type Update = AuthenticatedRequest<{ id: string }, {}, CredentialProperties>; + type Update = AuthenticatedRequest<{ credentialId: string }, {}, CredentialProperties>; type NewName = AuthenticatedRequest<{}, {}, {}, { name?: string }>; type Test = AuthenticatedRequest<{}, {}, INodeCredentialTestRequest>; - type Share = AuthenticatedRequest<{ id: string }, {}, { shareWithIds: string[] }>; + type Share = AuthenticatedRequest<{ credentialId: string }, {}, { shareWithIds: string[] }>; } // ---------------------------------- @@ -526,3 +552,57 @@ export declare namespace ActiveWorkflowRequest { type GetActivationError = AuthenticatedRequest<{ id: string }>; } + +// ---------------------------------- +// /projects +// ---------------------------------- + +export declare namespace ProjectRequest { + type GetAll = AuthenticatedRequest<{}, Project[]>; + + type Create = AuthenticatedRequest< + {}, + Project, + { + name: string; + } + >; + + type GetMyProjects = AuthenticatedRequest< + {}, + Array, + {}, + { + includeScopes?: boolean; + } + >; + type GetMyProjectsResponse = Array< + Project & { role: ProjectRole | GlobalRole; scopes?: Scope[] } + >; + + type GetPersonalProject = AuthenticatedRequest<{}, Project>; + + type ProjectRelationPayload = { userId: string; role: ProjectRole }; + type ProjectRelationResponse = { + id: string; + email: string; + firstName: string; + lastName: string; + role: ProjectRole; + }; + type ProjectWithRelations = { + id: string; + name: string | undefined; + type: ProjectType; + relations: ProjectRelationResponse[]; + scopes: Scope[]; + }; + + type Get = AuthenticatedRequest<{ projectId: string }, {}>; + type Update = AuthenticatedRequest< + { projectId: string }, + {}, + { name?: string; relations?: ProjectRelationPayload[] } + >; + type Delete = AuthenticatedRequest<{ projectId: string }, {}, {}, { transferId?: string }>; +} diff --git a/packages/cli/src/services/activeWorkflows.service.ts b/packages/cli/src/services/activeWorkflows.service.ts index 25de43fd1c..ae2c083d72 100644 --- a/packages/cli/src/services/activeWorkflows.service.ts +++ b/packages/cli/src/services/activeWorkflows.service.ts @@ -37,8 +37,10 @@ export class ActiveWorkflowsService { } async getActivationError(workflowId: string, user: User) { - const hasAccess = await this.sharedWorkflowRepository.hasAccess(workflowId, user); - if (!hasAccess) { + const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [ + 'workflow:read', + ]); + if (!workflow) { this.logger.verbose('User attempted to access workflow errors without permissions', { workflowId, userId: user.id, diff --git a/packages/cli/src/services/credentials-tester.service.ts b/packages/cli/src/services/credentials-tester.service.ts index bcb48e495c..c9ddc7b15e 100644 --- a/packages/cli/src/services/credentials-tester.service.ts +++ b/packages/cli/src/services/credentials-tester.service.ts @@ -194,7 +194,7 @@ export class CredentialsTester { 'internal' as WorkflowExecuteMode, undefined, undefined, - user.hasGlobalScope('externalSecret:use'), + await this.credentialsHelper.credentialCanUseExternalSecrets(credentialsDecrypted), ); } catch (error) { this.logger.debug('Credential test failed', error); diff --git a/packages/cli/src/services/events.service.ts b/packages/cli/src/services/events.service.ts index 10a0e7dc6c..8017597e41 100644 --- a/packages/cli/src/services/events.service.ts +++ b/packages/cli/src/services/events.service.ts @@ -49,21 +49,26 @@ export class EventsService extends EventEmitter { const upsertResult = await this.repository.upsertWorkflowStatistics(name, workflowId); if (name === StatisticsNames.productionSuccess && upsertResult === 'insert') { - const owner = await Container.get(OwnershipService).getWorkflowOwnerCached(workflowId); - const metrics = { - user_id: owner.id, - workflow_id: workflowId, - }; + const project = await Container.get(OwnershipService).getWorkflowProjectCached(workflowId); + if (project.type === 'personal') { + const owner = await Container.get(OwnershipService).getProjectOwnerCached(project.id); - if (!owner.settings?.userActivated) { - await Container.get(UserService).updateSettings(owner.id, { - firstSuccessfulWorkflowId: workflowId, - userActivated: true, - }); + const metrics = { + project_id: project.id, + workflow_id: workflowId, + user_id: owner?.id, + }; + + if (owner && !owner.settings?.userActivated) { + await Container.get(UserService).updateSettings(owner.id, { + firstSuccessfulWorkflowId: workflowId, + userActivated: true, + }); + } + + // Send the metrics + this.emit('telemetry.onFirstProductionWorkflowSuccess', metrics); } - - // Send the metrics - this.emit('telemetry.onFirstProductionWorkflowSuccess', metrics); } } catch (error) { this.logger.verbose('Unable to fire first workflow success telemetry event'); @@ -80,10 +85,12 @@ export class EventsService extends EventEmitter { if (insertResult === 'failed' || insertResult === 'alreadyExists') return; // Compile the metrics since this was a new data loaded event - const owner = await this.ownershipService.getWorkflowOwnerCached(workflowId); + const project = await this.ownershipService.getWorkflowProjectCached(workflowId); + const owner = await this.ownershipService.getProjectOwnerCached(project.id); let metrics = { - user_id: owner.id, + user_id: owner?.id, + project_id: project.id, workflow_id: workflowId, node_type: node.type, node_id: node.id, diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 95670b8234..65f0330b53 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -185,6 +185,11 @@ export class FrontendService { workflowHistory: false, workerView: false, advancedPermissions: false, + projects: { + team: { + limit: 0, + }, + }, }, mfa: { enabled: false, @@ -318,6 +323,8 @@ export class FrontendService { this.settings.binaryDataMode = config.getEnv('binaryDataManager.mode'); + this.settings.enterprise.projects.team.limit = this.license.getTeamProjectLimit(); + return this.settings; } diff --git a/packages/cli/src/services/import.service.ts b/packages/cli/src/services/import.service.ts index 11b7262256..c2226c65b9 100644 --- a/packages/cli/src/services/import.service.ts +++ b/packages/cli/src/services/import.service.ts @@ -1,4 +1,4 @@ -import { Service } from 'typedi'; +import Container, { Service } from 'typedi'; import { v4 as uuid } from 'uuid'; import { type INode, type INodeCredentialsDetails } from 'n8n-workflow'; @@ -12,6 +12,7 @@ import { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { WorkflowTagMapping } from '@db/entities/WorkflowTagMapping'; import type { TagEntity } from '@db/entities/TagEntity'; import type { ICredentialsDb } from '@/Interfaces'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; @Service() export class ImportService { @@ -30,7 +31,7 @@ export class ImportService { this.dbTags = await this.tagRepository.find(); } - async importWorkflows(workflows: WorkflowEntity[], userId: string) { + async importWorkflows(workflows: WorkflowEntity[], projectId: string) { await this.initRecords(); for (const workflow of workflows) { @@ -58,12 +59,17 @@ export class ImportService { const upsertResult = await tx.upsert(WorkflowEntity, workflow, ['id']); const workflowId = upsertResult.identifiers.at(0)?.id as string; + const personalProject = await Container.get(ProjectRepository).findOneByOrFail({ + id: projectId, + }); + // Create relationship if the workflow was inserted instead of updated. if (!exists) { - await tx.upsert(SharedWorkflow, { workflowId, userId, role: 'workflow:owner' }, [ - 'workflowId', - 'userId', - ]); + await tx.upsert( + SharedWorkflow, + { workflowId, projectId: personalProject.id, role: 'workflow:owner' }, + ['workflowId', 'projectId'], + ); } if (!workflow.tags?.length) continue; diff --git a/packages/cli/src/services/ownership.service.ts b/packages/cli/src/services/ownership.service.ts index 10c8da6334..bda4ddcc4b 100644 --- a/packages/cli/src/services/ownership.service.ts +++ b/packages/cli/src/services/ownership.service.ts @@ -1,37 +1,61 @@ import { Service } from 'typedi'; import { CacheService } from '@/services/cache/cache.service'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; -import type { User } from '@db/entities/User'; import { UserRepository } from '@db/repositories/user.repository'; import type { ListQuery } from '@/requests'; +import type { Project } from '@/databases/entities/Project'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import type { User } from '@/databases/entities/User'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; @Service() export class OwnershipService { constructor( private cacheService: CacheService, private userRepository: UserRepository, + private projectRepository: ProjectRepository, + private projectRelationRepository: ProjectRelationRepository, private sharedWorkflowRepository: SharedWorkflowRepository, ) {} /** - * Retrieve the user who owns the workflow. Note that workflow ownership is **immutable**. + * Retrieve the project that owns the workflow. Note that workflow ownership is **immutable**. */ - async getWorkflowOwnerCached(workflowId: string) { - const cachedValue = await this.cacheService.getHashValue( - 'workflow-ownership', + async getWorkflowProjectCached(workflowId: string): Promise { + const cachedValue = await this.cacheService.getHashValue( + 'workflow-project', workflowId, ); - if (cachedValue) return this.userRepository.create(cachedValue); + if (cachedValue) return this.projectRepository.create(cachedValue); const sharedWorkflow = await this.sharedWorkflowRepository.findOneOrFail({ where: { workflowId, role: 'workflow:owner' }, - relations: ['user'], + relations: ['project'], }); - void this.cacheService.setHash('workflow-ownership', { [workflowId]: sharedWorkflow.user }); + void this.cacheService.setHash('workflow-project', { [workflowId]: sharedWorkflow.project }); - return sharedWorkflow.user; + return sharedWorkflow.project; + } + + /** + * Retrieve the user that owns the project, or null if it's not an ownable project. Note that project ownership is **immutable**. + */ + async getProjectOwnerCached(projectId: string): Promise { + const cachedValue = await this.cacheService.getHashValue( + 'project-owner', + projectId, + ); + + if (cachedValue) this.userRepository.create(cachedValue); + if (cachedValue === null) return null; + + const ownerRel = await this.projectRelationRepository.getPersonalProjectOwners([projectId]); + const owner = ownerRel[0]?.user ?? null; + void this.cacheService.setHash('project-owner', { [projectId]: owner }); + + return owner; } addOwnedByAndSharedWith( @@ -43,23 +67,37 @@ export class OwnershipService { addOwnedByAndSharedWith( rawEntity: ListQuery.Workflow.WithSharing | ListQuery.Credentials.WithSharing, ): ListQuery.Workflow.WithOwnedByAndSharedWith | ListQuery.Credentials.WithOwnedByAndSharedWith { - const { shared, ...rest } = rawEntity; - - const entity = rest as + const shared = rawEntity.shared; + const entity = rawEntity as | ListQuery.Workflow.WithOwnedByAndSharedWith | ListQuery.Credentials.WithOwnedByAndSharedWith; - Object.assign(entity, { ownedBy: null, sharedWith: [] }); + Object.assign(entity, { + homeProject: null, + sharedWithProjects: [], + }); - shared?.forEach(({ user, role }) => { - const { id, email, firstName, lastName } = user; + if (shared === undefined) { + return entity; + } + + for (const sharedEntity of shared) { + const { project, role } = sharedEntity; if (role === 'credential:owner' || role === 'workflow:owner') { - entity.ownedBy = { id, email, firstName, lastName }; + entity.homeProject = { + id: project.id, + type: project.type, + name: project.name, + }; } else { - entity.sharedWith.push({ id, email, firstName, lastName }); + entity.sharedWithProjects.push({ + id: project.id, + type: project.type, + name: project.name, + }); } - }); + } return entity; } diff --git a/packages/cli/src/services/project.service.ts b/packages/cli/src/services/project.service.ts new file mode 100644 index 0000000000..1d65dd607d --- /dev/null +++ b/packages/cli/src/services/project.service.ts @@ -0,0 +1,343 @@ +import { Project, type ProjectType } from '@/databases/entities/Project'; +import { ProjectRelation } from '@/databases/entities/ProjectRelation'; +import type { ProjectRole } from '@/databases/entities/ProjectRelation'; +import type { User } from '@/databases/entities/User'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; +import type { FindOptionsWhere, EntityManager } from '@n8n/typeorm'; +import Container, { Service } from 'typedi'; +import { type Scope } from '@n8n/permissions'; +import { In, Not } from '@n8n/typeorm'; +import { RoleService } from './role.service'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; +import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { CacheService } from './cache/cache.service'; +import { License } from '@/License'; +import { UNLIMITED_LICENSE_QUOTA } from '@/constants'; + +export class TeamProjectOverQuotaError extends Error { + constructor(limit: number) { + super( + `Attempted to create a new project but quota is already exhausted. You may have a maximum of ${limit} team projects.`, + ); + } +} + +export class UnlicensedProjectRoleError extends Error { + constructor(role: ProjectRole) { + super(`Your instance is not licensed to use role "${role}".`); + } +} + +@Service() +export class ProjectService { + constructor( + private readonly sharedWorkflowRepository: SharedWorkflowRepository, + private readonly projectRepository: ProjectRepository, + private readonly projectRelationRepository: ProjectRelationRepository, + private readonly roleService: RoleService, + private readonly sharedCredentialsRepository: SharedCredentialsRepository, + private readonly cacheService: CacheService, + private readonly license: License, + ) {} + + private get workflowService() { + return import('@/workflows/workflow.service').then(({ WorkflowService }) => + Container.get(WorkflowService), + ); + } + + private get credentialsService() { + return import('@/credentials/credentials.service').then(({ CredentialsService }) => + Container.get(CredentialsService), + ); + } + + async deleteProject( + user: User, + projectId: string, + { migrateToProject }: { migrateToProject?: string } = {}, + ) { + const workflowService = await this.workflowService; + const credentialsService = await this.credentialsService; + + if (projectId === migrateToProject) { + throw new BadRequestError( + 'Request to delete a project failed because the project to delete and the project to migrate to are the same project', + ); + } + + const project = await this.getProjectWithScope(user, projectId, ['project:delete']); + if (!project) { + throw new NotFoundError(`Could not find project with ID: ${projectId}`); + } + + let targetProject: Project | null = null; + if (migrateToProject) { + targetProject = await this.getProjectWithScope(user, migrateToProject, [ + 'credential:create', + 'workflow:create', + ]); + + if (!targetProject) { + throw new NotFoundError( + `Could not find project to migrate to. ID: ${targetProject}. You may lack permissions to create workflow and credentials in the target project.`, + ); + } + } + + // 0. check if this is a team project + if (project.type !== 'team') { + throw new ForbiddenError( + `Can't delete project. Project with ID "${projectId}" is not a team project.`, + ); + } + + // 1. delete or migrate workflows owned by this project + const ownedSharedWorkflows = await this.sharedWorkflowRepository.find({ + where: { projectId: project.id, role: 'workflow:owner' }, + }); + + if (targetProject) { + await this.sharedWorkflowRepository.makeOwner( + ownedSharedWorkflows.map((sw) => sw.workflowId), + targetProject.id, + ); + } else { + for (const sharedWorkflow of ownedSharedWorkflows) { + await workflowService.delete(user, sharedWorkflow.workflowId); + } + } + + // 2. delete credentials owned by this project + const ownedCredentials = await this.sharedCredentialsRepository.find({ + where: { projectId: project.id, role: 'credential:owner' }, + relations: { credentials: true }, + }); + + if (targetProject) { + await this.sharedCredentialsRepository.makeOwner( + ownedCredentials.map((sc) => sc.credentialsId), + targetProject.id, + ); + } else { + for (const sharedCredential of ownedCredentials) { + await credentialsService.delete(sharedCredential.credentials); + } + } + + // 3. delete shared credentials into this project + // Cascading deletes take care of this. + + // 4. delete shared workflows into this project + // Cascading deletes take care of this. + + // 5. delete project + await this.projectRepository.remove(project); + + // 6. delete project relations + // Cascading deletes take care of this. + } + + /** + * Find all the projects where a workflow is accessible, + * along with the roles of a user in those projects. + */ + async findProjectsWorkflowIsIn(workflowId: string) { + return await this.sharedWorkflowRepository.findProjectIds(workflowId); + } + + async getAccessibleProjects(user: User): Promise { + // This user is probably an admin, show them everything + if (user.hasGlobalScope('project:read')) { + return await this.projectRepository.find(); + } + return await this.projectRepository.getAccessibleProjects(user.id); + } + + async getPersonalProjectOwners(projectIds: string[]): Promise { + return await this.projectRelationRepository.getPersonalProjectOwners(projectIds); + } + + async createTeamProject(name: string, adminUser: User, id?: string): Promise { + const limit = this.license.getTeamProjectLimit(); + if ( + limit !== UNLIMITED_LICENSE_QUOTA && + limit <= (await this.projectRepository.count({ where: { type: 'team' } })) + ) { + throw new TeamProjectOverQuotaError(limit); + } + + const project = await this.projectRepository.save( + this.projectRepository.create({ + id, + name, + type: 'team', + }), + ); + + // Link admin + await this.addUser(project.id, adminUser.id, 'project:admin'); + + return project; + } + + async updateProject(name: string, projectId: string): Promise { + const result = await this.projectRepository.update( + { + id: projectId, + type: 'team', + }, + { + name, + }, + ); + + if (!result.affected) { + throw new ForbiddenError('Project not found'); + } + return await this.projectRepository.findOneByOrFail({ id: projectId }); + } + + async getPersonalProject(user: User): Promise { + return await this.projectRepository.getPersonalProjectForUser(user.id); + } + + async getProjectRelationsForUser(user: User): Promise { + return await this.projectRelationRepository.find({ + where: { userId: user.id }, + relations: ['project'], + }); + } + + async syncProjectRelations( + projectId: string, + relations: Array<{ userId: string; role: ProjectRole }>, + ) { + const project = await this.projectRepository.findOneOrFail({ + where: { id: projectId, type: Not('personal') }, + relations: { projectRelations: true }, + }); + + // Check to see if the instance is licensed to use all roles provided + for (const r of relations) { + const existing = project.projectRelations.find((pr) => pr.userId === r.userId); + // We don't throw an error if the user already exists with that role so + // existing projects continue working as is. + if (existing?.role !== r.role && !this.roleService.isRoleLicensed(r.role)) { + throw new UnlicensedProjectRoleError(r.role); + } + } + + await this.projectRelationRepository.manager.transaction(async (em) => { + await this.pruneRelations(em, project); + await this.addManyRelations(em, project, relations); + }); + await this.clearCredentialCanUseExternalSecretsCache(projectId); + } + + async clearCredentialCanUseExternalSecretsCache(projectId: string) { + const shares = await this.sharedCredentialsRepository.find({ + where: { + projectId, + role: 'credential:owner', + }, + select: ['credentialsId'], + }); + if (shares.length) { + await this.cacheService.deleteMany( + shares.map((share) => `credential-can-use-secrets:${share.credentialsId}`), + ); + } + } + + async pruneRelations(em: EntityManager, project: Project) { + await em.delete(ProjectRelation, { projectId: project.id }); + } + + async addManyRelations( + em: EntityManager, + project: Project, + relations: Array<{ userId: string; role: ProjectRole }>, + ) { + await em.insert( + ProjectRelation, + relations.map((v) => + this.projectRelationRepository.create({ + projectId: project.id, + userId: v.userId, + role: v.role, + }), + ), + ); + } + + async getProjectWithScope( + user: User, + projectId: string, + scopes: Scope[], + entityManager?: EntityManager, + ) { + const em = entityManager ?? this.projectRepository.manager; + let where: FindOptionsWhere = { + id: projectId, + }; + + if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) { + const projectRoles = this.roleService.rolesWithScope('project', scopes); + + where = { + ...where, + projectRelations: { + role: In(projectRoles), + userId: user.id, + }, + }; + } + + return await em.findOne(Project, { + where, + }); + } + + async addUser(projectId: string, userId: string, role: ProjectRole) { + return await this.projectRelationRepository.save({ + projectId, + userId, + role, + }); + } + + async getProject(projectId: string): Promise { + return await this.projectRepository.findOneOrFail({ + where: { + id: projectId, + }, + }); + } + + async getProjectRelations(projectId: string): Promise { + return await this.projectRelationRepository.find({ + where: { projectId }, + relations: { user: true }, + }); + } + + async getUserOwnedOrAdminProjects(userId: string): Promise { + return await this.projectRepository.find({ + where: { + projectRelations: { + userId, + role: In(['project:personalOwner', 'project:admin']), + }, + }, + }); + } + + async getProjectCounts(): Promise> { + return await this.projectRepository.getProjectCounts(); + } +} diff --git a/packages/cli/src/services/role.service.ts b/packages/cli/src/services/role.service.ts new file mode 100644 index 0000000000..e9fa17eb68 --- /dev/null +++ b/packages/cli/src/services/role.service.ts @@ -0,0 +1,239 @@ +import type { ProjectRelation, ProjectRole } from '@/databases/entities/ProjectRelation'; +import type { + CredentialSharingRole, + SharedCredentials, +} from '@/databases/entities/SharedCredentials'; +import type { SharedWorkflow, WorkflowSharingRole } from '@/databases/entities/SharedWorkflow'; +import type { GlobalRole, User } from '@/databases/entities/User'; +import { + GLOBAL_ADMIN_SCOPES, + GLOBAL_MEMBER_SCOPES, + GLOBAL_OWNER_SCOPES, +} from '@/permissions/global-roles'; +import { + PERSONAL_PROJECT_OWNER_SCOPES, + PROJECT_EDITOR_SCOPES, + REGULAR_PROJECT_ADMIN_SCOPES, +} from '@/permissions/project-roles'; +import { + CREDENTIALS_SHARING_OWNER_SCOPES, + CREDENTIALS_SHARING_USER_SCOPES, + WORKFLOW_SHARING_EDITOR_SCOPES, + WORKFLOW_SHARING_OWNER_SCOPES, +} from '@/permissions/resource-roles'; +import type { ListQuery } from '@/requests'; +import { combineScopes, type Resource, type Scope } from '@n8n/permissions'; +import { Service } from 'typedi'; +import { ApplicationError } from 'n8n-workflow'; +import { License } from '@/License'; + +export type RoleNamespace = 'global' | 'project' | 'credential' | 'workflow'; + +const GLOBAL_SCOPE_MAP: Record = { + 'global:owner': GLOBAL_OWNER_SCOPES, + 'global:admin': GLOBAL_ADMIN_SCOPES, + 'global:member': GLOBAL_MEMBER_SCOPES, +}; + +const PROJECT_SCOPE_MAP: Record = { + 'project:admin': REGULAR_PROJECT_ADMIN_SCOPES, + 'project:personalOwner': PERSONAL_PROJECT_OWNER_SCOPES, + 'project:editor': PROJECT_EDITOR_SCOPES, +}; + +const CREDENTIALS_SHARING_SCOPE_MAP: Record = { + 'credential:owner': CREDENTIALS_SHARING_OWNER_SCOPES, + 'credential:user': CREDENTIALS_SHARING_USER_SCOPES, +}; + +const WORKFLOW_SHARING_SCOPE_MAP: Record = { + 'workflow:owner': WORKFLOW_SHARING_OWNER_SCOPES, + 'workflow:editor': WORKFLOW_SHARING_EDITOR_SCOPES, +}; + +interface AllMaps { + global: Record; + project: Record; + credential: Record; + workflow: Record; +} + +const ALL_MAPS: AllMaps = { + global: GLOBAL_SCOPE_MAP, + project: PROJECT_SCOPE_MAP, + credential: CREDENTIALS_SHARING_SCOPE_MAP, + workflow: WORKFLOW_SHARING_SCOPE_MAP, +} as const; + +const COMBINED_MAP = Object.fromEntries( + Object.values(ALL_MAPS).flatMap((o: Record) => Object.entries(o)), +) as Record; + +export interface RoleMap { + global: GlobalRole[]; + project: ProjectRole[]; + credential: CredentialSharingRole[]; + workflow: WorkflowSharingRole[]; +} +export type AllRoleTypes = GlobalRole | ProjectRole | WorkflowSharingRole | CredentialSharingRole; + +const ROLE_NAMES: Record< + GlobalRole | ProjectRole | WorkflowSharingRole | CredentialSharingRole, + string +> = { + 'global:owner': 'Owner', + 'global:admin': 'Admin', + 'global:member': 'Member', + 'project:personalOwner': 'Project Owner', + 'project:admin': 'Project Admin', + 'project:editor': 'Project Editor', + 'credential:user': 'Credential User', + 'credential:owner': 'Credential Owner', + 'workflow:owner': 'Workflow Owner', + 'workflow:editor': 'Workflow Editor', +}; + +@Service() +export class RoleService { + constructor(private readonly license: License) {} + + rolesWithScope(namespace: 'global', scopes: Scope | Scope[]): GlobalRole[]; + rolesWithScope(namespace: 'project', scopes: Scope | Scope[]): ProjectRole[]; + rolesWithScope(namespace: 'credential', scopes: Scope | Scope[]): CredentialSharingRole[]; + rolesWithScope(namespace: 'workflow', scopes: Scope | Scope[]): WorkflowSharingRole[]; + rolesWithScope(namespace: RoleNamespace, scopes: Scope | Scope[]) { + if (!Array.isArray(scopes)) { + scopes = [scopes]; + } + + return Object.keys(ALL_MAPS[namespace]).filter((k) => { + return scopes.every((s) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + ((ALL_MAPS[namespace] as any)[k] as Scope[]).includes(s), + ); + }); + } + + getRoles(): RoleMap { + return Object.fromEntries( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + Object.entries(ALL_MAPS).map((e) => [e[0], Object.keys(e[1])]), + ) as unknown as RoleMap; + } + + getRoleName(role: AllRoleTypes): string { + return ROLE_NAMES[role]; + } + + getRoleScopes( + role: GlobalRole | ProjectRole | WorkflowSharingRole | CredentialSharingRole, + filters?: Resource[], + ): Scope[] { + let scopes = COMBINED_MAP[role]; + if (filters) { + scopes = scopes.filter((s) => filters.includes(s.split(':')[0] as Resource)); + } + return scopes; + } + + /** + * Find all distinct scopes in a set of project roles. + */ + getScopesBy(projectRoles: Set) { + return [...projectRoles].reduce>((acc, projectRole) => { + for (const scope of PROJECT_SCOPE_MAP[projectRole] ?? []) { + acc.add(scope); + } + + return acc; + }, new Set()); + } + + addScopes( + rawWorkflow: ListQuery.Workflow.WithSharing | ListQuery.Workflow.WithOwnedByAndSharedWith, + user: User, + userProjectRelations: ProjectRelation[], + ): ListQuery.Workflow.WithScopes; + addScopes( + rawCredential: + | ListQuery.Credentials.WithSharing + | ListQuery.Credentials.WithOwnedByAndSharedWith, + user: User, + userProjectRelations: ProjectRelation[], + ): ListQuery.Credentials.WithScopes; + addScopes( + rawEntity: + | ListQuery.Workflow.WithSharing + | ListQuery.Credentials.WithOwnedByAndSharedWith + | ListQuery.Credentials.WithSharing + | ListQuery.Workflow.WithOwnedByAndSharedWith, + user: User, + userProjectRelations: ProjectRelation[], + ): ListQuery.Workflow.WithScopes | ListQuery.Credentials.WithScopes { + const shared = rawEntity.shared; + const entity = rawEntity as ListQuery.Workflow.WithScopes | ListQuery.Credentials.WithScopes; + + Object.assign(entity, { + scopes: [], + }); + + if (shared === undefined) { + return entity; + } + + if (!('active' in entity) && !('type' in entity)) { + throw new ApplicationError('Cannot detect if entity is a workflow or credential.'); + } + + entity.scopes = this.combineResourceScopes( + 'active' in entity ? 'workflow' : 'credential', + user, + shared, + userProjectRelations, + ); + + return entity; + } + + combineResourceScopes( + type: 'workflow' | 'credential', + user: User, + shared: SharedCredentials[] | SharedWorkflow[], + userProjectRelations: ProjectRelation[], + ): Scope[] { + const globalScopes = this.getRoleScopes(user.role, [type]); + const scopesSet: Set = new Set(globalScopes); + for (const sharedEntity of shared) { + const pr = userProjectRelations.find( + (p) => p.projectId === (sharedEntity.projectId ?? sharedEntity.project.id), + ); + let projectScopes: Scope[] = []; + if (pr) { + projectScopes = this.getRoleScopes(pr.role); + } + const resourceMask = this.getRoleScopes(sharedEntity.role); + const mergedScopes = combineScopes( + { + global: globalScopes, + project: projectScopes, + }, + { sharing: resourceMask }, + ); + mergedScopes.forEach((s) => scopesSet.add(s)); + } + return [...scopesSet].sort(); + } + + isRoleLicensed(role: AllRoleTypes) { + switch (role) { + case 'project:admin': + return this.license.isProjectRoleAdminLicensed(); + case 'project:editor': + return this.license.isProjectRoleEditorLicensed(); + case 'global:admin': + return this.license.isAdvancedPermissionsLicensed(); + default: + return true; + } + } +} diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index 0ce290da8b..e65e5a07c9 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -2,7 +2,7 @@ import { Container, Service } from 'typedi'; import type { IUserSettings } from 'n8n-workflow'; import { ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; -import { type AssignableRole, User } from '@db/entities/User'; +import type { User, AssignableRole } from '@db/entities/User'; import { UserRepository } from '@db/repositories/user.repository'; import type { PublicUser } from '@/Interfaces'; import type { PostHogClient } from '@/posthog'; @@ -23,7 +23,13 @@ export class UserService { ) {} async update(userId: string, data: Partial) { - return await this.userRepository.update(userId, data); + const user = await this.userRepository.findOneBy({ id: userId }); + + if (user) { + await this.userRepository.save({ ...user, ...data }, { transaction: true }); + } + + return; } getManager() { @@ -31,9 +37,15 @@ export class UserService { } async updateSettings(userId: string, newSettings: Partial) { - const { settings } = await this.userRepository.findOneOrFail({ where: { id: userId } }); + const user = await this.userRepository.findOneOrFail({ where: { id: userId } }); - return await this.userRepository.update(userId, { settings: { ...settings, ...newSettings } }); + if (user.settings) { + Object.assign(user.settings, newSettings); + } else { + user.settings = newSettings; + } + + await this.userRepository.save(user); } async toPublic( @@ -192,8 +204,10 @@ export class UserService { async (transactionManager) => await Promise.all( toCreateUsers.map(async ({ email, role }) => { - const newUser = transactionManager.create(User, { email, role }); - const savedUser = await transactionManager.save(newUser); + const { user: savedUser } = await this.userRepository.createUserWithProject( + { email, role }, + transactionManager, + ); createdUsers.set(email, savedUser.id); return savedUser; }), diff --git a/packages/cli/src/services/userOnboarding.service.ts b/packages/cli/src/services/userOnboarding.service.ts index 3f61a4aac0..7f3f3b8ce5 100644 --- a/packages/cli/src/services/userOnboarding.service.ts +++ b/packages/cli/src/services/userOnboarding.service.ts @@ -25,7 +25,12 @@ export class UserOnboardingService { const ownedWorkflowsIds = await this.sharedWorkflowRepository .find({ where: { - userId: user.id, + project: { + projectRelations: { + role: 'project:personalOwner', + userId: user.id, + }, + }, role: 'workflow:owner', }, select: ['workflowId'], diff --git a/packages/cli/src/sso/saml/saml.service.ee.ts b/packages/cli/src/sso/saml/saml.service.ee.ts index 92318cb146..1103bf73d9 100644 --- a/packages/cli/src/sso/saml/saml.service.ee.ts +++ b/packages/cli/src/sso/saml/saml.service.ee.ts @@ -349,7 +349,8 @@ export class SamlService { } catch (error) { // throw error; throw new AuthError( - `SAML Authentication failed. Could not parse SAML response. ${(error as Error).message}`, + // INFO: The error can be a string. Samlify rejects promises with strings. + `SAML Authentication failed. Could not parse SAML response. ${error instanceof Error ? error.message : error}`, ); } const { attributes, missingAttributes } = getMappedSamlAttributesFromFlowResult( diff --git a/packages/cli/src/sso/saml/samlHelpers.ts b/packages/cli/src/sso/saml/samlHelpers.ts index e87d73ba70..0334e01b4c 100644 --- a/packages/cli/src/sso/saml/samlHelpers.ts +++ b/packages/cli/src/sso/saml/samlHelpers.ts @@ -1,7 +1,7 @@ import { Container } from 'typedi'; import config from '@/config'; import { AuthIdentity } from '@db/entities/AuthIdentity'; -import { User } from '@db/entities/User'; +import type { User } from '@db/entities/User'; import { License } from '@/License'; import { PasswordUtility } from '@/services/password.utility'; import type { SamlPreferences } from './types/samlPreferences'; @@ -97,26 +97,29 @@ export function generatePassword(): string { } export async function createUserFromSamlAttributes(attributes: SamlUserAttributes): Promise { - const user = new User(); - const authIdentity = new AuthIdentity(); - const lowerCasedEmail = attributes.email?.toLowerCase() ?? ''; - user.email = lowerCasedEmail; - user.firstName = attributes.firstName; - user.lastName = attributes.lastName; - user.role = 'global:member'; - // generates a password that is not used or known to the user - user.password = await Container.get(PasswordUtility).hash(generatePassword()); - authIdentity.providerId = attributes.userPrincipalName; - authIdentity.providerType = 'saml'; - authIdentity.user = user; - const resultAuthIdentity = await Container.get(AuthIdentityRepository).save(authIdentity, { - transaction: false, + return await Container.get(UserRepository).manager.transaction(async (trx) => { + const { user } = await Container.get(UserRepository).createUserWithProject( + { + email: attributes.email.toLowerCase(), + firstName: attributes.firstName, + lastName: attributes.lastName, + role: 'global:member', + // generates a password that is not used or known to the user + password: await Container.get(PasswordUtility).hash(generatePassword()), + }, + trx, + ); + + await trx.save( + trx.create(AuthIdentity, { + providerId: attributes.userPrincipalName, + providerType: 'saml', + userId: user.id, + }), + ); + + return user; }); - if (!resultAuthIdentity) throw new AuthError('Could not create AuthIdentity'); - user.authIdentities = [authIdentity]; - const resultUser = await Container.get(UserRepository).save(user, { transaction: false }); - if (!resultUser) throw new AuthError('Could not create User'); - return resultUser; } export async function updateUserFromSamlAttributes( diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index 697cd1f03b..7e56859caa 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -13,6 +13,8 @@ import { N8N_VERSION } from '@/constants'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { SourceControlPreferencesService } from '../environments/sourceControl/sourceControlPreferences.service.ee'; import { UserRepository } from '@db/repositories/user.repository'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; type ExecutionTrackDataKey = 'manual_error' | 'manual_success' | 'prod_error' | 'prod_success'; @@ -126,6 +128,8 @@ export class Telemetry { source_control_set_up: Container.get(SourceControlPreferencesService).isSourceControlSetup(), branchName: sourceControlPreferences.branchName, read_only_instance: sourceControlPreferences.branchReadOnly, + team_projects: (await Container.get(ProjectRepository).getProjectCounts()).team, + project_role_count: await Container.get(ProjectRelationRepository).countUsersByRole(), }; allPromises.push(this.track('pulse', pulsePacket)); return await Promise.all(allPromises); diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 6926c825bf..028b80b551 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -85,3 +85,10 @@ export function rightDiff( return acc; }, []); } + +/** + * Asserts that the passed in type is never. + * Can be used to make sure the type is exhausted + * in switch statements or if/else chains. + */ +export const assertNever = (value: never) => {}; diff --git a/packages/cli/src/workflows/workflow.request.ts b/packages/cli/src/workflows/workflow.request.ts index 77d653a2a0..017e90606f 100644 --- a/packages/cli/src/workflows/workflow.request.ts +++ b/packages/cli/src/workflows/workflow.request.ts @@ -1,5 +1,5 @@ import type { IWorkflowDb } from '@/Interfaces'; -import type { AuthenticatedRequest } from '@/requests'; +import type { AuthenticatedRequest, ListQuery } from '@/requests'; import type { INode, IConnections, @@ -11,7 +11,7 @@ import type { export declare namespace WorkflowRequest { type CreateUpdatePayload = Partial<{ - id: string; // delete if sent + id: string; // deleted if sent name: string; nodes: INode[]; connections: IConnections; @@ -20,6 +20,7 @@ export declare namespace WorkflowRequest { tags: string[]; hash: string; meta: Record; + projectId: string; }>; type ManualRunPayload = { @@ -32,12 +33,16 @@ export declare namespace WorkflowRequest { type Create = AuthenticatedRequest<{}, {}, CreateUpdatePayload>; - type Get = AuthenticatedRequest<{ id: string }>; + type Get = AuthenticatedRequest<{ workflowId: string }>; + + type GetMany = AuthenticatedRequest<{}, {}, {}, ListQuery.Params & { includeScopes?: string }> & { + listQueryOptions: ListQuery.Options; + }; type Delete = Get; type Update = AuthenticatedRequest< - { id: string }, + { workflowId: string }, {}, CreateUpdatePayload, { forceSave?: string } @@ -45,7 +50,7 @@ export declare namespace WorkflowRequest { type NewName = AuthenticatedRequest<{}, {}, {}, { name?: string }>; - type ManualRun = AuthenticatedRequest<{}, {}, ManualRunPayload>; + type ManualRun = AuthenticatedRequest<{ workflowId: string }, {}, ManualRunPayload>; type Share = AuthenticatedRequest<{ workflowId: string }, {}, { shareWithIds: string[] }>; diff --git a/packages/cli/src/workflows/workflow.service.ee.ts b/packages/cli/src/workflows/workflow.service.ee.ts index a95536d80f..250b6e6015 100644 --- a/packages/cli/src/workflows/workflow.service.ee.ts +++ b/packages/cli/src/workflows/workflow.service.ee.ts @@ -15,7 +15,11 @@ import { Logger } from '@/Logger'; import type { CredentialUsedByWorkflow, WorkflowWithSharingsAndCredentials, + WorkflowWithSharingsMetaDataAndCredentials, } from './workflows.types'; +import { OwnershipService } from '@/services/ownership.service'; +import { In, type EntityManager } from '@n8n/typeorm'; +import { Project } from '@/databases/entities/Project'; @Service() export class EnterpriseWorkflowService { @@ -25,49 +29,48 @@ export class EnterpriseWorkflowService { private readonly workflowRepository: WorkflowRepository, private readonly credentialsRepository: CredentialsRepository, private readonly credentialsService: CredentialsService, + private readonly ownershipService: OwnershipService, ) {} - async isOwned( - user: User, - workflowId: string, - ): Promise<{ ownsWorkflow: boolean; workflow?: WorkflowEntity }> { - const sharing = await this.sharedWorkflowRepository.getSharing( - user, - workflowId, - { allowGlobalScope: false }, - ['workflow'], - ); + async shareWithProjects( + workflow: WorkflowEntity, + shareWithIds: string[], + entityManager: EntityManager, + ) { + const em = entityManager ?? this.sharedWorkflowRepository.manager; - if (!sharing || sharing.role !== 'workflow:owner') return { ownsWorkflow: false }; - - const { workflow } = sharing; - - return { ownsWorkflow: true, workflow }; - } - - addOwnerAndSharings(workflow: WorkflowWithSharingsAndCredentials): void { - workflow.ownedBy = null; - workflow.sharedWith = []; - if (!workflow.usedCredentials) { - workflow.usedCredentials = []; - } - - workflow.shared?.forEach(({ user, role }) => { - const { id, email, firstName, lastName } = user; - - if (role === 'workflow:owner') { - workflow.ownedBy = { id, email, firstName, lastName }; - return; - } - - workflow.sharedWith?.push({ id, email, firstName, lastName }); + const projects = await em.find(Project, { + where: { id: In(shareWithIds), type: 'personal' }, }); - delete workflow.shared; + const newSharedWorkflows = projects + // We filter by role === 'project:personalOwner' above and there should + // always only be one owner. + .map((project) => + this.sharedWorkflowRepository.create({ + workflowId: workflow.id, + role: 'workflow:editor', + projectId: project.id, + }), + ); + + return await em.save(newSharedWorkflows); + } + + addOwnerAndSharings( + workflow: WorkflowWithSharingsAndCredentials, + ): WorkflowWithSharingsMetaDataAndCredentials { + const workflowWithMetaData = this.ownershipService.addOwnedByAndSharedWith(workflow); + + return { + ...workflow, + ...workflowWithMetaData, + usedCredentials: workflow.usedCredentials ?? [], + }; } async addCredentialsToWorkflow( - workflow: WorkflowWithSharingsAndCredentials, + workflow: WorkflowWithSharingsMetaDataAndCredentials, currentUser: User, ): Promise { workflow.usedCredentials = []; @@ -100,14 +103,7 @@ export class EnterpriseWorkflowService { sharedWith: [], ownedBy: null, }; - credential.shared?.forEach(({ user, role }) => { - const { id, email, firstName, lastName } = user; - if (role === 'credential:owner') { - workflowCredential.ownedBy = { id, email, firstName, lastName }; - } else { - workflowCredential.sharedWith?.push({ id, email, firstName, lastName }); - } - }); + credential = this.ownershipService.addOwnedByAndSharedWith(credential); workflow.usedCredentials?.push(workflowCredential); }); } diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index a752e3e5c4..d03fd65646 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -8,8 +8,6 @@ import { BinaryDataService } from 'n8n-core'; import config from '@/config'; import type { User } from '@db/entities/User'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; -import type { WorkflowSharingRole } from '@db/entities/SharedWorkflow'; -import { ExecutionRepository } from '@db/repositories/execution.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { WorkflowTagMappingRepository } from '@db/repositories/workflowTagMapping.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; @@ -26,12 +24,19 @@ import { Logger } from '@/Logger'; import { OrchestrationService } from '@/services/orchestration.service'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { RoleService } from '@/services/role.service'; +import { WorkflowSharingService } from './workflowSharing.service'; +import { ProjectService } from '@/services/project.service'; +import { ExecutionRepository } from '@/databases/repositories/execution.repository'; +import type { Scope } from '@n8n/permissions'; +import type { EntityManager } from '@n8n/typeorm'; +import { In } from '@n8n/typeorm'; +import { SharedWorkflow } from '@/databases/entities/SharedWorkflow'; @Service() export class WorkflowService { constructor( private readonly logger: Logger, - private readonly executionRepository: ExecutionRepository, private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly workflowRepository: WorkflowRepository, private readonly workflowTagMappingRepository: WorkflowTagMappingRepository, @@ -42,36 +47,52 @@ export class WorkflowService { private readonly orchestrationService: OrchestrationService, private readonly externalHooks: ExternalHooks, private readonly activeWorkflowManager: ActiveWorkflowManager, + private readonly roleService: RoleService, + private readonly workflowSharingService: WorkflowSharingService, + private readonly projectService: ProjectService, + private readonly executionRepository: ExecutionRepository, ) {} - async getMany(sharedWorkflowIds: string[], options?: ListQuery.Options) { - const { workflows, count } = await this.workflowRepository.getMany(sharedWorkflowIds, options); + async getMany(user: User, options?: ListQuery.Options, includeScopes?: boolean) { + const sharedWorkflowIds = await this.workflowSharingService.getSharedWorkflowIds(user, { + scopes: ['workflow:read'], + }); - return hasSharing(workflows) - ? { - workflows: workflows.map((w) => this.ownershipService.addOwnedByAndSharedWith(w)), - count, - } - : { workflows, count }; + // eslint-disable-next-line prefer-const + let { workflows, count } = await this.workflowRepository.getMany(sharedWorkflowIds, options); + + if (hasSharing(workflows)) { + workflows = workflows.map((w) => this.ownershipService.addOwnedByAndSharedWith(w)); + } + + if (includeScopes) { + const projectRelations = await this.projectService.getProjectRelationsForUser(user); + workflows = workflows.map((w) => this.roleService.addScopes(w, user, projectRelations)); + } + + workflows.forEach((w) => { + // @ts-expect-error: This is to emulate the old behaviour of removing the shared + // field as part of `addOwnedByAndSharedWith`. We need this field in `addScopes` + // though. So to avoid leaking the information we just delete it. + delete w.shared; + }); + + return { workflows, count }; } // eslint-disable-next-line complexity async update( user: User, - workflow: WorkflowEntity, + workflowUpdateData: WorkflowEntity, workflowId: string, tagIds?: string[], forceSave?: boolean, - roles?: WorkflowSharingRole[], ): Promise { - const shared = await this.sharedWorkflowRepository.findSharing( - workflowId, - user, + const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [ 'workflow:update', - { roles }, - ); + ]); - if (!shared) { + if (!workflow) { this.logger.verbose('User attempted to update a workflow without permissions', { workflowId, userId: user.id, @@ -83,8 +104,8 @@ export class WorkflowService { if ( !forceSave && - workflow.versionId !== '' && - workflow.versionId !== shared.workflow.versionId + workflowUpdateData.versionId !== '' && + workflowUpdateData.versionId !== workflow.versionId ) { throw new BadRequestError( 'Your most recent changes may be lost, because someone else just updated this workflow. Open this workflow in a new tab to see those new updates.', @@ -92,25 +113,25 @@ export class WorkflowService { ); } - if (Object.keys(omit(workflow, ['id', 'versionId', 'active'])).length > 0) { + if (Object.keys(omit(workflowUpdateData, ['id', 'versionId', 'active'])).length > 0) { // Update the workflow's version when changing properties such as // `name`, `pinData`, `nodes`, `connections`, `settings` or `tags` - workflow.versionId = uuid(); + workflowUpdateData.versionId = uuid(); this.logger.verbose( `Updating versionId for workflow ${workflowId} for user ${user.id} after saving`, { - previousVersionId: shared.workflow.versionId, - newVersionId: workflow.versionId, + previousVersionId: workflow.versionId, + newVersionId: workflowUpdateData.versionId, }, ); } // check credentials for old format - await WorkflowHelpers.replaceInvalidCredentials(workflow); + await WorkflowHelpers.replaceInvalidCredentials(workflowUpdateData); - WorkflowHelpers.addNodeIds(workflow); + WorkflowHelpers.addNodeIds(workflowUpdateData); - await this.externalHooks.run('workflow.update', [workflow]); + await this.externalHooks.run('workflow.update', [workflowUpdateData]); /** * If the workflow being updated is stored as `active`, remove it from @@ -119,11 +140,11 @@ export class WorkflowService { * If a trigger or poller in the workflow was updated, the new value * will take effect only on removing and re-adding. */ - if (shared.workflow.active) { + if (workflow.active) { await this.activeWorkflowManager.remove(workflowId); } - const workflowSettings = workflow.settings ?? {}; + const workflowSettings = workflowUpdateData.settings ?? {}; const keysAllowingDefault = [ 'timezone', @@ -144,14 +165,14 @@ export class WorkflowService { delete workflowSettings.executionTimeout; } - if (workflow.name) { - workflow.updatedAt = new Date(); // required due to atomic update - await validateEntity(workflow); + if (workflowUpdateData.name) { + workflowUpdateData.updatedAt = new Date(); // required due to atomic update + await validateEntity(workflowUpdateData); } await this.workflowRepository.update( workflowId, - pick(workflow, [ + pick(workflowUpdateData, [ 'name', 'active', 'nodes', @@ -168,8 +189,8 @@ export class WorkflowService { await this.workflowTagMappingRepository.overwriteTaggings(workflowId, tagIds); } - if (workflow.versionId !== shared.workflow.versionId) { - await this.workflowHistoryService.saveVersion(user, workflow, workflowId); + if (workflowUpdateData.versionId !== workflow.versionId) { + await this.workflowHistoryService.saveVersion(user, workflowUpdateData, workflowId); } const relations = config.getEnv('workflowTagsDisabled') ? [] : ['tags']; @@ -200,16 +221,13 @@ export class WorkflowService { // When the workflow is supposed to be active add it again try { await this.externalHooks.run('workflow.activate', [updatedWorkflow]); - await this.activeWorkflowManager.add( - workflowId, - shared.workflow.active ? 'update' : 'activate', - ); + await this.activeWorkflowManager.add(workflowId, workflow.active ? 'update' : 'activate'); } catch (error) { // If workflow could not be activated set it again to inactive // and revert the versionId change so UI remains consistent await this.workflowRepository.update(workflowId, { active: false, - versionId: shared.workflow.versionId, + versionId: workflow.versionId, }); // Also set it in the returned data @@ -232,18 +250,15 @@ export class WorkflowService { async delete(user: User, workflowId: string): Promise { await this.externalHooks.run('workflow.delete', [workflowId]); - const sharedWorkflow = await this.sharedWorkflowRepository.findSharing( - workflowId, - user, + const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [ 'workflow:delete', - { roles: ['workflow:owner'] }, - ); + ]); - if (!sharedWorkflow) { + if (!workflow) { return; } - if (sharedWorkflow.workflow.active) { + if (workflow.active) { // deactivate before deleting await this.activeWorkflowManager.remove(workflowId); } @@ -261,6 +276,71 @@ export class WorkflowService { void Container.get(InternalHooks).onWorkflowDeleted(user, workflowId, false); await this.externalHooks.run('workflow.afterDelete', [workflowId]); - return sharedWorkflow.workflow; + return workflow; + } + + async getWorkflowScopes(user: User, workflowId: string): Promise { + const userProjectRelations = await this.projectService.getProjectRelationsForUser(user); + const shared = await this.sharedWorkflowRepository.find({ + where: { + projectId: In([...new Set(userProjectRelations.map((pr) => pr.projectId))]), + workflowId, + }, + }); + return this.roleService.combineResourceScopes('workflow', user, shared, userProjectRelations); + } + + /** + * Transfers all workflows owned by a project to another one. + * This has only been tested for personal projects. It may need to be amended + * for team projects. + **/ + async transferAll(fromProjectId: string, toProjectId: string, trx?: EntityManager) { + trx = trx ?? this.workflowRepository.manager; + + // Get all shared workflows for both projects. + const allSharedWorkflows = await trx.findBy(SharedWorkflow, { + projectId: In([fromProjectId, toProjectId]), + }); + const sharedWorkflowsOfFromProject = allSharedWorkflows.filter( + (sw) => sw.projectId === fromProjectId, + ); + + // For all workflows that the from-project owns transfer the ownership to + // the to-project. + // This will override whatever relationship the to-project already has to + // the resources at the moment. + + const ownedWorkflowIds = sharedWorkflowsOfFromProject + .filter((sw) => sw.role === 'workflow:owner') + .map((sw) => sw.workflowId); + + await this.sharedWorkflowRepository.makeOwner(ownedWorkflowIds, toProjectId, trx); + + // Delete the relationship to the from-project. + await this.sharedWorkflowRepository.deleteByIds(ownedWorkflowIds, fromProjectId, trx); + + // Transfer relationships that are not `workflow:owner`. + // This will NOT override whatever relationship the from-project already + // has to the resource at the moment. + const sharedWorkflowIdsOfTransferee = allSharedWorkflows + .filter((sw) => sw.projectId === toProjectId) + .map((sw) => sw.workflowId); + + // All resources that are shared with the from-project, but not with the + // to-project. + const sharedWorkflowsToTransfer = sharedWorkflowsOfFromProject.filter( + (sw) => + sw.role !== 'workflow:owner' && !sharedWorkflowIdsOfTransferee.includes(sw.workflowId), + ); + + await trx.insert( + SharedWorkflow, + sharedWorkflowsToTransfer.map((sw) => ({ + workflowId: sw.workflowId, + projectId: toProjectId, + role: sw.role, + })), + ); } } diff --git a/packages/cli/src/workflows/workflowExecution.service.ts b/packages/cli/src/workflows/workflowExecution.service.ts index c77b102449..e34c80e94e 100644 --- a/packages/cli/src/workflows/workflowExecution.service.ts +++ b/packages/cli/src/workflows/workflowExecution.service.ts @@ -34,6 +34,7 @@ import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData' import { TestWebhooks } from '@/TestWebhooks'; import { Logger } from '@/Logger'; import { PermissionChecker } from '@/UserManagement/PermissionChecker'; +import type { Project } from '@/databases/entities/Project'; @Service() export class WorkflowExecutionService { @@ -161,7 +162,7 @@ export class WorkflowExecutionService { async executeErrorWorkflow( workflowId: string, workflowErrorData: IWorkflowErrorData, - runningUser: User, + runningProject: Project, ): Promise { // Wrap everything in try/catch to make sure that no errors bubble up and all get caught here try { @@ -284,7 +285,7 @@ export class WorkflowExecutionService { executionMode, executionData: runExecutionData, workflowData, - userId: runningUser.id, + projectId: runningProject.id, }; await this.workflowRunner.run(runData); diff --git a/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts b/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts index 4bdc337bc3..b92fc440cc 100644 --- a/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts +++ b/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts @@ -1,4 +1,3 @@ -import type { SharedWorkflow } from '@db/entities/SharedWorkflow'; import type { User } from '@db/entities/User'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { WorkflowHistory } from '@db/entities/WorkflowHistory'; @@ -18,28 +17,23 @@ export class WorkflowHistoryService { private readonly sharedWorkflowRepository: SharedWorkflowRepository, ) {} - private async getSharedWorkflow(user: User, workflowId: string): Promise { - return await this.sharedWorkflowRepository.findOne({ - where: { - ...(!user.hasGlobalScope('workflow:read') && { userId: user.id }), - workflowId, - }, - }); - } - async getList( user: User, workflowId: string, take: number, skip: number, ): Promise>> { - const sharedWorkflow = await this.getSharedWorkflow(user, workflowId); - if (!sharedWorkflow) { + const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [ + 'workflow:read', + ]); + + if (!workflow) { throw new SharedWorkflowNotFoundError(''); } + return await this.workflowHistoryRepository.find({ where: { - workflowId: sharedWorkflow.workflowId, + workflowId: workflow.id, }, take, skip, @@ -49,13 +43,17 @@ export class WorkflowHistoryService { } async getVersion(user: User, workflowId: string, versionId: string): Promise { - const sharedWorkflow = await this.getSharedWorkflow(user, workflowId); - if (!sharedWorkflow) { + const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [ + 'workflow:read', + ]); + + if (!workflow) { throw new SharedWorkflowNotFoundError(''); } + const hist = await this.workflowHistoryRepository.findOne({ where: { - workflowId: sharedWorkflow.workflowId, + workflowId: workflow.id, versionId, }, }); diff --git a/packages/cli/src/workflows/workflowSharing.service.ts b/packages/cli/src/workflows/workflowSharing.service.ts index 93df8e0aca..8036831ed0 100644 --- a/packages/cli/src/workflows/workflowSharing.service.ts +++ b/packages/cli/src/workflows/workflowSharing.service.ts @@ -1,30 +1,61 @@ import { Service } from 'typedi'; -import { In, type FindOptionsWhere } from '@n8n/typeorm'; +import { In } from '@n8n/typeorm'; -import type { SharedWorkflow, WorkflowSharingRole } from '@db/entities/SharedWorkflow'; import type { User } from '@db/entities/User'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; +import { RoleService } from '@/services/role.service'; +import type { Scope } from '@n8n/permissions'; +import type { ProjectRole } from '@/databases/entities/ProjectRelation'; +import type { WorkflowSharingRole } from '@/databases/entities/SharedWorkflow'; @Service() export class WorkflowSharingService { - constructor(private readonly sharedWorkflowRepository: SharedWorkflowRepository) {} + constructor( + private readonly sharedWorkflowRepository: SharedWorkflowRepository, + private readonly roleService: RoleService, + ) {} /** - * Get the IDs of the workflows that have been shared with the user. - * Returns all IDs if user has the 'workflow:read' scope. + * Get the IDs of the workflows that have been shared with the user based on + * scope or roles. + * If `scopes` is passed the roles are inferred. Alternatively `projectRoles` + * and `workflowRoles` can be passed specifically. + * + * Returns all IDs if user has the 'workflow:read' global scope. */ - async getSharedWorkflowIds(user: User, roles?: WorkflowSharingRole[]): Promise { - const where: FindOptionsWhere = {}; - if (!user.hasGlobalScope('workflow:read')) { - where.userId = user.id; - } - if (roles?.length) { - where.role = In(roles); + async getSharedWorkflowIds( + user: User, + options: + | { scopes: Scope[] } + | { projectRoles: ProjectRole[]; workflowRoles: WorkflowSharingRole[] }, + ): Promise { + if (user.hasGlobalScope('workflow:read')) { + const sharedWorkflows = await this.sharedWorkflowRepository.find({ select: ['workflowId'] }); + return sharedWorkflows.map(({ workflowId }) => workflowId); } + + const projectRoles = + 'scopes' in options + ? this.roleService.rolesWithScope('project', options.scopes) + : options.projectRoles; + const workflowRoles = + 'scopes' in options + ? this.roleService.rolesWithScope('workflow', options.scopes) + : options.workflowRoles; + const sharedWorkflows = await this.sharedWorkflowRepository.find({ - where, + where: { + role: In(workflowRoles), + project: { + projectRelations: { + userId: user.id, + role: In(projectRoles), + }, + }, + }, select: ['workflowId'], }); + return sharedWorkflows.map(({ workflowId }) => workflowId); } } diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 3a775dcee7..497ff6edc9 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -7,16 +7,14 @@ import * as ResponseHelper from '@/ResponseHelper'; import * as WorkflowHelpers from '@/WorkflowHelpers'; import type { IWorkflowResponse } from '@/Interfaces'; import config from '@/config'; -import { Delete, Get, Patch, Post, Put, RestController } from '@/decorators'; -import { SharedWorkflow, type WorkflowSharingRole } from '@db/entities/SharedWorkflow'; +import { Delete, Get, Patch, Post, ProjectScope, Put, RestController } from '@/decorators'; +import { SharedWorkflow } from '@db/entities/SharedWorkflow'; import { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { TagRepository } from '@db/repositories/tag.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; -import { UserRepository } from '@db/repositories/user.repository'; import { validateEntity } from '@/GenericHelpers'; import { ExternalHooks } from '@/ExternalHooks'; -import { ListQuery } from '@/requests'; import { WorkflowService } from './workflow.service'; import { License } from '@/License'; import { InternalHooks } from '@/InternalHooks'; @@ -28,15 +26,20 @@ import { Logger } from '@/Logger'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NamingService } from '@/services/naming.service'; import { UserOnboardingService } from '@/services/userOnboarding.service'; import { CredentialsService } from '../credentials/credentials.service'; import { WorkflowRequest } from './workflow.request'; import { EnterpriseWorkflowService } from './workflow.service.ee'; import { WorkflowExecutionService } from './workflowExecution.service'; -import { WorkflowSharingService } from './workflowSharing.service'; import { UserManagementMailer } from '@/UserManagement/email'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { ProjectService } from '@/services/project.service'; +import { ApplicationError } from 'n8n-workflow'; +import { In, type FindOptionsRelations } from '@n8n/typeorm'; +import type { Project } from '@/databases/entities/Project'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; @RestController('/workflows') export class WorkflowsController { @@ -53,17 +56,21 @@ export class WorkflowsController { private readonly workflowRepository: WorkflowRepository, private readonly workflowService: WorkflowService, private readonly workflowExecutionService: WorkflowExecutionService, - private readonly workflowSharingService: WorkflowSharingService, private readonly sharedWorkflowRepository: SharedWorkflowRepository, - private readonly userRepository: UserRepository, private readonly license: License, private readonly mailer: UserManagementMailer, private readonly credentialsService: CredentialsService, + private readonly projectRepository: ProjectRepository, + private readonly projectService: ProjectService, + private readonly projectRelationRepository: ProjectRelationRepository, ) {} @Post('/') async create(req: WorkflowRequest.Create) { delete req.body.id; // delete if sent + // @ts-expect-error: We shouldn't accept this because it can + // mess with relations of other workflows + delete req.body.shared; const newWorkflow = new WorkflowEntity(); @@ -87,7 +94,7 @@ export class WorkflowsController { if (this.license.isSharingEnabled()) { // This is a new workflow, so we simply check if the user has access to - // all used workflows + // all used credentials const allCredentials = await this.credentialsService.getMany(req.user); @@ -103,20 +110,46 @@ export class WorkflowsController { } } - let savedWorkflow: undefined | WorkflowEntity; + let project: Project | null; + const savedWorkflow = await Db.transaction(async (transactionManager) => { + const workflow = await transactionManager.save(newWorkflow); - await Db.transaction(async (transactionManager) => { - savedWorkflow = await transactionManager.save(newWorkflow); + const { projectId } = req.body; + project = + projectId === undefined + ? await this.projectRepository.getPersonalProjectForUser(req.user.id, transactionManager) + : await this.projectService.getProjectWithScope( + req.user, + projectId, + ['workflow:create'], + transactionManager, + ); - const newSharedWorkflow = new SharedWorkflow(); + if (typeof projectId === 'string' && project === null) { + throw new BadRequestError( + "You don't have the permissions to save the workflow in this project.", + ); + } - Object.assign(newSharedWorkflow, { + // Safe guard in case the personal project does not exist for whatever reason. + if (project === null) { + throw new ApplicationError('No personal project found'); + } + + const newSharedWorkflow = this.sharedWorkflowRepository.create({ role: 'workflow:owner', - user: req.user, - workflow: savedWorkflow, + projectId: project.id, + workflow, }); await transactionManager.save(newSharedWorkflow); + + return await this.sharedWorkflowRepository.findWorkflowForUser( + workflow.id, + req.user, + ['workflow:read'], + { em: transactionManager, includeTags: true }, + ); }); if (!savedWorkflow) { @@ -132,26 +165,28 @@ export class WorkflowsController { }); } - await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]); - void this.internalHooks.onWorkflowCreated(req.user, newWorkflow, false); + const savedWorkflowWithMetaData = + this.enterpriseWorkflowService.addOwnerAndSharings(savedWorkflow); - return savedWorkflow; + // @ts-expect-error: This is added as part of addOwnerAndSharings but + // shouldn't be returned to the frontend + delete savedWorkflowWithMetaData.shared; + + await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]); + void this.internalHooks.onWorkflowCreated(req.user, newWorkflow, project!, false); + + const scopes = await this.workflowService.getWorkflowScopes(req.user, savedWorkflow.id); + + return { ...savedWorkflowWithMetaData, scopes }; } @Get('/', { middlewares: listQueryMiddleware }) - async getAll(req: ListQuery.Request, res: express.Response) { + async getAll(req: WorkflowRequest.GetMany, res: express.Response) { try { - const roles: WorkflowSharingRole[] = this.license.isSharingEnabled() - ? [] - : ['workflow:owner']; - const sharedWorkflowIds = await this.workflowSharingService.getSharedWorkflowIds( - req.user, - roles, - ); - const { workflows: data, count } = await this.workflowService.getMany( - sharedWorkflowIds, + req.user, req.listQueryOptions, + !!req.query.includeScopes, ); res.json({ count, data }); @@ -210,48 +245,60 @@ export class WorkflowsController { return workflowData; } - @Get('/:id') + @Get('/:workflowId') + @ProjectScope('workflow:read') async getWorkflow(req: WorkflowRequest.Get) { - const { id: workflowId } = req.params; + const { workflowId } = req.params; if (this.license.isSharingEnabled()) { - const relations = ['shared', 'shared.user']; + const relations: FindOptionsRelations = { + shared: { + project: { + projectRelations: true, + }, + }, + }; + if (!config.getEnv('workflowTagsDisabled')) { - relations.push('tags'); + relations.tags = true; } - const workflow = await this.workflowRepository.get({ id: workflowId }, { relations }); + const workflow = await this.sharedWorkflowRepository.findWorkflowForUser( + workflowId, + req.user, + ['workflow:read'], + { includeTags: !config.getEnv('workflowTagsDisabled') }, + ); if (!workflow) { throw new NotFoundError(`Workflow with ID "${workflowId}" does not exist`); } - const userSharing = workflow.shared?.find((shared) => shared.user.id === req.user.id); - if (!userSharing && !req.user.hasGlobalScope('workflow:read')) { - throw new UnauthorizedError( - 'You do not have permission to access this workflow. Ask the owner to share it with you', - ); - } - const enterpriseWorkflowService = this.enterpriseWorkflowService; - enterpriseWorkflowService.addOwnerAndSharings(workflow); - await enterpriseWorkflowService.addCredentialsToWorkflow(workflow, req.user); - return workflow; + const workflowWithMetaData = enterpriseWorkflowService.addOwnerAndSharings(workflow); + + await enterpriseWorkflowService.addCredentialsToWorkflow(workflowWithMetaData, req.user); + + // @ts-expect-error: This is added as part of addOwnerAndSharings but + // shouldn't be returned to the frontend + delete workflowWithMetaData.shared; + + const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId); + + return { ...workflowWithMetaData, scopes }; } // sharing disabled - const extraRelations = config.getEnv('workflowTagsDisabled') ? [] : ['workflow.tags']; - - const shared = await this.sharedWorkflowRepository.findSharing( + const workflow = await this.sharedWorkflowRepository.findWorkflowForUser( workflowId, req.user, - 'workflow:read', - { extraRelations }, + ['workflow:read'], + { includeTags: !config.getEnv('workflowTagsDisabled') }, ); - if (!shared) { + if (!workflow) { this.logger.verbose('User attempted to access a workflow without permissions', { workflowId, userId: req.user.id, @@ -261,12 +308,15 @@ export class WorkflowsController { ); } - return shared.workflow; + const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId); + + return { ...workflow, scopes }; } - @Patch('/:id') + @Patch('/:workflowId') + @ProjectScope('workflow:update') async update(req: WorkflowRequest.Update) { - const { id: workflowId } = req.params; + const { workflowId } = req.params; const forceSave = req.query.forceSave === 'true'; let updateData = new WorkflowEntity(); @@ -288,15 +338,17 @@ export class WorkflowsController { workflowId, tags, isSharingEnabled ? forceSave : true, - isSharingEnabled ? undefined : ['workflow:owner'], ); - return updatedWorkflow; + const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId); + + return { ...updatedWorkflow, scopes }; } - @Delete('/:id') + @Delete('/:workflowId') + @ProjectScope('workflow:delete') async delete(req: WorkflowRequest.Delete) { - const { id: workflowId } = req.params; + const { workflowId } = req.params; const workflow = await this.workflowService.delete(req.user, workflowId); if (!workflow) { @@ -312,19 +364,30 @@ export class WorkflowsController { return true; } - @Post('/run') + @Post('/:workflowId/run') + @ProjectScope('workflow:execute') async runManually(req: WorkflowRequest.ManualRun) { + if (!req.body.workflowData.id) { + throw new ApplicationError('You cannot execute a workflow without an ID', { + level: 'warning', + }); + } + + if (req.params.workflowId !== req.body.workflowData.id) { + throw new ApplicationError('Workflow ID in body does not match workflow ID in URL', { + level: 'warning', + }); + } + if (this.license.isSharingEnabled()) { const workflow = this.workflowRepository.create(req.body.workflowData); - if (req.body.workflowData.id !== undefined) { - const safeWorkflow = await this.enterpriseWorkflowService.preventTampering( - workflow, - workflow.id, - req.user, - ); - req.body.workflowData.nodes = safeWorkflow.nodes; - } + const safeWorkflow = await this.enterpriseWorkflowService.preventTampering( + workflow, + workflow.id, + req.user, + ); + req.body.workflowData.nodes = safeWorkflow.nodes; } return await this.workflowExecutionService.executeManually( @@ -335,6 +398,7 @@ export class WorkflowsController { } @Put('/:workflowId/share') + @ProjectScope('workflow:share') async share(req: WorkflowRequest.Share) { if (!this.license.isSharingEnabled()) throw new NotFoundError('Route not found'); @@ -348,59 +412,51 @@ export class WorkflowsController { throw new BadRequestError('Bad request'); } - const isOwnedRes = await this.enterpriseWorkflowService.isOwned(req.user, workflowId); - const { ownsWorkflow } = isOwnedRes; - let { workflow } = isOwnedRes; + const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, req.user, [ + 'workflow:share', + ]); - if (!ownsWorkflow || !workflow) { - workflow = undefined; - // Allow owners/admins to share - if (req.user.hasGlobalScope('workflow:share')) { - const sharedRes = await this.sharedWorkflowRepository.getSharing(req.user, workflowId, { - allowGlobalScope: true, - globalScope: 'workflow:share', - }); - workflow = sharedRes?.workflow; - } - if (!workflow) { - throw new UnauthorizedError('Forbidden'); - } + if (!workflow) { + throw new ForbiddenError(); } - const ownerIds = ( - await this.workflowRepository.getSharings( - Db.getConnection().createEntityManager(), - workflowId, - ['shared'], - ) - ) - .filter((e) => e.role === 'workflow:owner') - .map((e) => e.userId); - let newShareeIds: string[] = []; await Db.transaction(async (trx) => { - // remove all sharings that are not supposed to exist anymore - await this.workflowRepository.pruneSharings(trx, workflowId, [...ownerIds, ...shareWithIds]); + const currentPersonalProjectIDs = workflow.shared + .filter((sw) => sw.role === 'workflow:editor') + .map((sw) => sw.projectId); + const newPersonalProjectIDs = shareWithIds; - const sharings = await this.workflowRepository.getSharings(trx, workflowId); - - // extract the new sharings that need to be added - newShareeIds = utils.rightDiff( - [sharings, (sharing) => sharing.userId], - [shareWithIds, (shareeId) => shareeId], + const toShare = utils.rightDiff( + [currentPersonalProjectIDs, (id) => id], + [newPersonalProjectIDs, (id) => id], ); - if (newShareeIds.length) { - const users = await this.userRepository.getByIds(trx, newShareeIds); - await this.sharedWorkflowRepository.share(trx, workflow, users); - } + const toUnshare = utils.rightDiff( + [newPersonalProjectIDs, (id) => id], + [currentPersonalProjectIDs, (id) => id], + ); + + await trx.delete(SharedWorkflow, { + workflowId, + projectId: In(toUnshare), + }); + + await this.enterpriseWorkflowService.shareWithProjects(workflow, toShare, trx); + + newShareeIds = toShare; }); void this.internalHooks.onWorkflowSharingUpdate(workflowId, req.user.id, shareWithIds); + const projectsRelations = await this.projectRelationRepository.findBy({ + projectId: In(newShareeIds), + role: 'project:personalOwner', + }); + await this.mailer.notifyWorkflowShared({ sharer: req.user, - newShareeIds, + newShareeIds: projectsRelations.map((pr) => pr.userId), workflow, }); } diff --git a/packages/cli/src/workflows/workflows.types.ts b/packages/cli/src/workflows/workflows.types.ts index ef30bb18c7..cc9d0ef40f 100644 --- a/packages/cli/src/workflows/workflows.types.ts +++ b/packages/cli/src/workflows/workflows.types.ts @@ -1,14 +1,21 @@ import type { IUser } from 'n8n-workflow'; import type { SharedWorkflow } from '@db/entities/SharedWorkflow'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; +import type { SlimProject } from '@/requests'; export interface WorkflowWithSharingsAndCredentials extends Omit { - ownedBy?: IUser | null; - sharedWith?: IUser[]; + homeProject?: SlimProject; + sharedWithProjects?: SlimProject[]; usedCredentials?: CredentialUsedByWorkflow[]; shared?: SharedWorkflow[]; } +export interface WorkflowWithSharingsMetaDataAndCredentials extends Omit { + homeProject?: SlimProject | null; + sharedWithProjects: SlimProject[]; + usedCredentials?: CredentialUsedByWorkflow[]; +} + export interface CredentialUsedByWorkflow { id: string; name: string; diff --git a/packages/cli/test/extend-expect.ts b/packages/cli/test/extend-expect.ts index 328daf2b0b..5aba8574da 100644 --- a/packages/cli/test/extend-expect.ts +++ b/packages/cli/test/extend-expect.ts @@ -9,4 +9,26 @@ expect.extend({ : () => `Expected ${actual} not to be an empty array`, }; }, + + toBeEmptySet(this: jest.MatcherContext, actual: unknown) { + const pass = actual instanceof Set && actual.size === 0; + + return { + pass, + message: pass + ? () => `Expected ${[...actual]} to be an empty set` + : () => `Expected ${actual} not to be an empty set`, + }; + }, + + toBeSetContaining(this: jest.MatcherContext, actual: unknown, ...expectedElements: string[]) { + const pass = actual instanceof Set && expectedElements.every((e) => actual.has(e)); + + return { + pass, + message: pass + ? () => `Expected ${[...actual]} to be a set containing ${expectedElements}` + : () => `Expected ${actual} not to be a set containing ${expectedElements}`, + }; + }, }); diff --git a/packages/cli/test/integration/CredentialsHelper.test.ts b/packages/cli/test/integration/CredentialsHelper.test.ts new file mode 100644 index 0000000000..88738c3c26 --- /dev/null +++ b/packages/cli/test/integration/CredentialsHelper.test.ts @@ -0,0 +1,152 @@ +import Container from 'typedi'; +import * as testDb from '../integration/shared/testDb'; + +import { CredentialsHelper } from '@/CredentialsHelper'; +import { createOwner, createAdmin, createMember } from './shared/db/users'; +import type { User } from '@/databases/entities/User'; +import { saveCredential } from './shared/db/credentials'; +import { randomCredentialPayload } from './shared/random'; +import { createTeamProject, linkUserToProject } from './shared/db/projects'; + +let credentialHelper: CredentialsHelper; +let owner: User; +let admin: User; +let member: User; + +beforeAll(async () => { + await testDb.init(); + + credentialHelper = Container.get(CredentialsHelper); + owner = await createOwner(); + admin = await createAdmin(); + member = await createMember(); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +describe('CredentialsHelper', () => { + describe('credentialOwnedBySuperUsers', () => { + test.each([ + { + testName: 'owners are super users', + user: () => owner, + credentialRole: 'credential:owner', + expectedResult: true, + } as const, + { + testName: 'admins are super users', + user: () => admin, + credentialRole: 'credential:owner', + expectedResult: true, + } as const, + { + testName: 'owners need to own the credential', + user: () => owner, + credentialRole: 'credential:user', + expectedResult: false, + } as const, + { + testName: 'admins need to own the credential', + user: () => admin, + credentialRole: 'credential:user', + expectedResult: false, + } as const, + { + testName: 'members are no super users', + user: () => member, + credentialRole: 'credential:owner', + expectedResult: false, + } as const, + ])('$testName', async ({ user, credentialRole, expectedResult }) => { + const credential = await saveCredential(randomCredentialPayload(), { + user: user(), + role: credentialRole, + }); + + const result = await credentialHelper.credentialCanUseExternalSecrets(credential); + + expect(result).toBe(expectedResult); + }); + + test('credential in team project with instance owner as an admin can use external secrets', async () => { + const teamProject = await createTeamProject(); + const [credential] = await Promise.all([ + await saveCredential(randomCredentialPayload(), { + project: teamProject, + role: 'credential:owner', + }), + await linkUserToProject(owner, teamProject, 'project:admin'), + await linkUserToProject(member, teamProject, 'project:admin'), + ]); + + const result = await credentialHelper.credentialCanUseExternalSecrets(credential); + + expect(result).toBe(true); + }); + + test('credential in team project with instance admin as an admin can use external secrets', async () => { + const teamProject = await createTeamProject(); + const [credential] = await Promise.all([ + await saveCredential(randomCredentialPayload(), { + project: teamProject, + role: 'credential:owner', + }), + await linkUserToProject(admin, teamProject, 'project:admin'), + await linkUserToProject(member, teamProject, 'project:admin'), + ]); + + const result = await credentialHelper.credentialCanUseExternalSecrets(credential); + + expect(result).toBe(true); + }); + + test('credential in team project with instance owner as an editor cannot use external secrets', async () => { + const teamProject = await createTeamProject(); + const [credential] = await Promise.all([ + await saveCredential(randomCredentialPayload(), { + project: teamProject, + role: 'credential:owner', + }), + await linkUserToProject(owner, teamProject, 'project:editor'), + await linkUserToProject(member, teamProject, 'project:admin'), + ]); + + const result = await credentialHelper.credentialCanUseExternalSecrets(credential); + + expect(result).toBe(false); + }); + + test('credential in team project with instance admin as an editor cannot use external secrets', async () => { + const teamProject = await createTeamProject(); + const [credential] = await Promise.all([ + await saveCredential(randomCredentialPayload(), { + project: teamProject, + role: 'credential:owner', + }), + await linkUserToProject(admin, teamProject, 'project:editor'), + await linkUserToProject(member, teamProject, 'project:admin'), + ]); + + const result = await credentialHelper.credentialCanUseExternalSecrets(credential); + + expect(result).toBe(false); + }); + + test('credential in team project with no instance admin or owner as part of the project cannot use external secrets', async () => { + const teamProject = await createTeamProject(); + const [credential] = await Promise.all([ + await saveCredential(randomCredentialPayload(), { + project: teamProject, + role: 'credential:owner', + }), + await linkUserToProject(member, teamProject, 'project:admin'), + ]); + + const result = await credentialHelper.credentialCanUseExternalSecrets(credential); + + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/cli/test/integration/PermissionChecker.test.ts b/packages/cli/test/integration/PermissionChecker.test.ts index f80fbc02dd..6c176cb5dd 100644 --- a/packages/cli/test/integration/PermissionChecker.test.ts +++ b/packages/cli/test/integration/PermissionChecker.test.ts @@ -4,10 +4,9 @@ import type { INode, WorkflowSettings } from 'n8n-workflow'; import { SubworkflowOperationError, Workflow } from 'n8n-workflow'; import config from '@/config'; -import { User } from '@db/entities/User'; +import type { User } from '@db/entities/User'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; -import { UserRepository } from '@/databases/repositories/user.repository'; import { generateNanoId } from '@/databases/utils/generators'; import { License } from '@/License'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; @@ -28,7 +27,10 @@ import { mockNodeTypesData } from '../unit/Helpers'; import { affixRoleToSaveCredential } from '../integration/shared/db/credentials'; import { createOwner, createUser } from '../integration/shared/db/users'; import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository'; +import { getPersonalProject } from './shared/db/projects'; import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; +import { Project } from '@/databases/entities/Project'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; export const toTargetCallErrorMsg = (subworkflowId: string) => `Target workflow ID ${subworkflowId} may not be called`; @@ -71,9 +73,11 @@ export function createSubworkflow({ }); } +const ownershipService = mockInstance(OwnershipService); + const createWorkflow = async (nodes: INode[], workflowOwner?: User): Promise => { const workflowDetails = { - id: uuid(), + id: randomPositiveDigit().toString(), name: 'test', active: false, connections: {}, @@ -82,11 +86,13 @@ const createWorkflow = async (nodes: INode[], workflowOwner?: User): Promise { await testDb.init(); @@ -116,16 +122,12 @@ beforeAll(async () => { permissionChecker = Container.get(PermissionChecker); [owner, member] = await Promise.all([createOwner(), createUser()]); - - license = new LicenseMocker(); - license.mock(Container.get(License)); - license.setDefaults({ - features: ['feat:sharing'], - }); -}); - -beforeEach(() => { - license.reset(); + ownerPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + owner.id, + ); + memberPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + member.id, + ); }); describe('check()', () => { @@ -150,46 +152,19 @@ describe('check()', () => { ]; const workflow = await createWorkflow(nodes, member); + ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(memberPersonalProject); - await expect( - permissionChecker.check(workflow.id, member.id, workflow.nodes), - ).resolves.not.toThrow(); + await expect(permissionChecker.check(workflow.id, nodes)).resolves.not.toThrow(); }); - test('should allow if requesting user is instance owner', async () => { - const owner = await createOwner(); - const nodes: INode[] = [ - { - id: uuid(), - name: 'Action Network', - type: 'n8n-nodes-base.actionNetwork', - parameters: {}, - typeVersion: 1, - position: [0, 0], - credentials: { - actionNetworkApi: { - id: randomPositiveDigit().toString(), - name: 'Action Network Account', - }, - }, - }, - ]; - - const workflow = await createWorkflow(nodes); - - await expect( - permissionChecker.check(workflow.id, owner.id, workflow.nodes), - ).resolves.not.toThrow(); - }); - - test('should allow if workflow creds are valid subset (shared credential)', async () => { + test('should allow if workflow creds are valid subset', async () => { const ownerCred = await saveCredential(randomCred(), { user: owner }); const memberCred = await saveCredential(randomCred(), { user: member }); await Container.get(SharedCredentialsRepository).save( Container.get(SharedCredentialsRepository).create({ + projectId: (await getPersonalProject(member)).id, credentialsId: ownerCred.id, - userId: member.id, role: 'credential:user', }), ); @@ -225,119 +200,18 @@ describe('check()', () => { }, ]; - const workflow = await createWorkflow(nodes, member); + const workflowEntity = await createWorkflow(nodes, member); - await expect( - permissionChecker.check(workflow.id, member.id, workflow.nodes), - ).resolves.not.toThrow(); - }); + ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(memberPersonalProject); - test('should allow if workflow creds are valid subset (shared workflow)', async () => { - const ownerCred = await saveCredential(randomCred(), { user: owner }); - const memberCred = await saveCredential(randomCred(), { user: member }); - - const nodes: INode[] = [ - { - id: uuid(), - name: 'Action Network', - type: 'n8n-nodes-base.actionNetwork', - parameters: {}, - typeVersion: 1, - position: [0, 0], - credentials: { - actionNetworkApi: { - id: ownerCred.id, - name: ownerCred.name, - }, - }, - }, - { - id: uuid(), - name: 'Action Network 2', - type: 'n8n-nodes-base.actionNetwork', - parameters: {}, - typeVersion: 1, - position: [0, 0], - credentials: { - actionNetworkApi: { - id: memberCred.id, - name: memberCred.name, - }, - }, - }, - ]; - - const workflow = await createWorkflow(nodes, member); - await Container.get(SharedWorkflowRepository).save( - Container.get(SharedWorkflowRepository).create({ - workflowId: workflow.id, - userId: owner.id, - role: 'workflow:editor', - }), - ); - - await expect( - permissionChecker.check(workflow.id, member.id, workflow.nodes), - ).resolves.not.toThrow(); - }); - - test('should deny if workflow creds are valid subset but sharing is disabled', async () => { - const [owner, member] = await Promise.all([createOwner(), createUser()]); - - const ownerCred = await saveCredential(randomCred(), { user: owner }); - const memberCred = await saveCredential(randomCred(), { user: member }); - - await Container.get(SharedCredentialsRepository).save( - Container.get(SharedCredentialsRepository).create({ - credentialsId: ownerCred.id, - userId: member.id, - role: 'credential:user', - }), - ); - - const nodes: INode[] = [ - { - id: uuid(), - name: 'Action Network', - type: 'n8n-nodes-base.actionNetwork', - parameters: {}, - typeVersion: 1, - position: [0, 0], - credentials: { - actionNetworkApi: { - id: ownerCred.id, - name: ownerCred.name, - }, - }, - }, - { - id: uuid(), - name: 'Action Network 2', - type: 'n8n-nodes-base.actionNetwork', - parameters: {}, - typeVersion: 1, - position: [0, 0], - credentials: { - actionNetworkApi: { - id: memberCred.id, - name: memberCred.name, - }, - }, - }, - ]; - - const workflow = await createWorkflow(nodes, member); - - license.disable('feat:sharing'); - await expect(permissionChecker.check(workflow.id, member.id, nodes)).rejects.toThrow(); + await expect(permissionChecker.check(workflowEntity.id, nodes)).resolves.not.toThrow(); }); test('should deny if workflow creds are not valid subset', async () => { - const member = await createUser(); - const memberCred = await saveCredential(randomCred(), { user: member }); + const ownerCred = await saveCredential(randomCred(), { user: owner }); - const nodes: INode[] = [ + const nodes = [ { id: uuid(), name: 'Action Network', @@ -361,21 +235,73 @@ describe('check()', () => { position: [0, 0] as [number, number], credentials: { actionNetworkApi: { - id: 'non-existing-credential-id', - name: 'Non-existing credential name', + id: ownerCred.id, + name: ownerCred.name, }, }, }, ]; - const workflow = await createWorkflow(nodes, member); + const workflowEntity = await createWorkflow(nodes, member); - await expect(permissionChecker.check(workflow.id, member.id, workflow.nodes)).rejects.toThrow(); + await expect( + permissionChecker.check(workflowEntity.id, workflowEntity.nodes), + ).rejects.toThrow(); + }); + + test('should allow all credentials if current user is instance owner', async () => { + const memberCred = await saveCredential(randomCred(), { user: member }); + const ownerCred = await saveCredential(randomCred(), { user: owner }); + + const nodes = [ + { + id: uuid(), + name: 'Action Network', + type: 'n8n-nodes-base.actionNetwork', + parameters: {}, + typeVersion: 1, + position: [0, 0] as [number, number], + credentials: { + actionNetworkApi: { + id: memberCred.id, + name: memberCred.name, + }, + }, + }, + { + id: uuid(), + name: 'Action Network 2', + type: 'n8n-nodes-base.actionNetwork', + parameters: {}, + typeVersion: 1, + position: [0, 0] as [number, number], + credentials: { + actionNetworkApi: { + id: ownerCred.id, + name: ownerCred.name, + }, + }, + }, + ]; + + const workflowEntity = await createWorkflow(nodes, owner); + ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(ownerPersonalProject); + ownershipService.getProjectOwnerCached.mockResolvedValueOnce(owner); + + await expect( + permissionChecker.check(workflowEntity.id, workflowEntity.nodes), + ).resolves.not.toThrow(); }); }); describe('checkSubworkflowExecutePolicy()', () => { - const ownershipService = mockInstance(OwnershipService); + let license: LicenseMocker; + + beforeAll(() => { + license = new LicenseMocker(); + license.mock(Container.get(License)); + license.enable('feat:sharing'); + }); describe('no caller policy', () => { test('should fall back to N8N_WORKFLOW_CALLER_POLICY_DEFAULT_OPTION', async () => { @@ -384,7 +310,7 @@ describe('checkSubworkflowExecutePolicy()', () => { const parentWorkflow = createParentWorkflow(); const subworkflow = createSubworkflow(); // no caller policy - ownershipService.getWorkflowOwnerCached.mockResolvedValue(new User()); + ownershipService.getWorkflowProjectCached.mockResolvedValue(memberPersonalProject); const check = permissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id); @@ -401,11 +327,11 @@ describe('checkSubworkflowExecutePolicy()', () => { const parentWorkflow = createParentWorkflow(); const subworkflow = createSubworkflow({ policy: 'any' }); // should be overridden - const firstUser = Container.get(UserRepository).create({ id: uuid() }); - const secondUser = Container.get(UserRepository).create({ id: uuid() }); + const firstProject = Container.get(ProjectRepository).create({ id: uuid() }); + const secondProject = Container.get(ProjectRepository).create({ id: uuid() }); - ownershipService.getWorkflowOwnerCached.mockResolvedValueOnce(firstUser); // parent workflow - ownershipService.getWorkflowOwnerCached.mockResolvedValueOnce(secondUser); // subworkflow + ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(firstProject); // parent workflow + ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(secondProject); // subworkflow const check = permissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id); @@ -416,7 +342,7 @@ describe('checkSubworkflowExecutePolicy()', () => { } catch (error) { if (error instanceof SubworkflowOperationError) { expect(error.description).toBe( - `${firstUser.firstName} (${firstUser.email}) can make this change. You may need to tell them the ID of this workflow, which is ${subworkflow.id}`, + `An admin for the ${firstProject.name} project can make this change. You may need to tell them the ID of the sub-workflow, which is ${subworkflow.id}`, ); } } @@ -457,7 +383,7 @@ describe('checkSubworkflowExecutePolicy()', () => { test('should not throw', async () => { const parentWorkflow = createParentWorkflow(); const subworkflow = createSubworkflow({ policy: 'any' }); - ownershipService.getWorkflowOwnerCached.mockResolvedValue(new User()); + ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(new Project()); const check = permissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id); @@ -467,11 +393,11 @@ describe('checkSubworkflowExecutePolicy()', () => { describe('workflows-from-same-owner caller policy', () => { test('should deny if the two workflows are owned by different users', async () => { - const parentWorkflowOwner = Container.get(UserRepository).create({ id: uuid() }); - const subworkflowOwner = Container.get(UserRepository).create({ id: uuid() }); + const parentWorkflowProject = Container.get(ProjectRepository).create({ id: uuid() }); + const subworkflowOwner = Container.get(ProjectRepository).create({ id: uuid() }); - ownershipService.getWorkflowOwnerCached.mockResolvedValueOnce(parentWorkflowOwner); // parent workflow - ownershipService.getWorkflowOwnerCached.mockResolvedValueOnce(subworkflowOwner); // subworkflow + ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(parentWorkflowProject); // parent workflow + ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(subworkflowOwner); // subworkflow const subworkflow = createSubworkflow({ policy: 'workflowsFromSameOwner' }); @@ -483,10 +409,10 @@ describe('checkSubworkflowExecutePolicy()', () => { test('should allow if both workflows are owned by the same user', async () => { const parentWorkflow = createParentWorkflow(); - const bothWorkflowsOwner = Container.get(UserRepository).create({ id: uuid() }); + const bothWorkflowsProject = Container.get(ProjectRepository).create({ id: uuid() }); - ownershipService.getWorkflowOwnerCached.mockResolvedValueOnce(bothWorkflowsOwner); // parent workflow - ownershipService.getWorkflowOwnerCached.mockResolvedValueOnce(bothWorkflowsOwner); // subworkflow + ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(bothWorkflowsProject); // parent workflow + ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(bothWorkflowsProject); // subworkflow const subworkflow = createSubworkflow({ policy: 'workflowsFromSameOwner' }); diff --git a/packages/cli/test/integration/auth.api.test.ts b/packages/cli/test/integration/auth.api.test.ts index 9435fa7a7d..356a97b662 100644 --- a/packages/cli/test/integration/auth.api.test.ts +++ b/packages/cli/test/integration/auth.api.test.ts @@ -368,7 +368,8 @@ describe('GET /resolve-signup-token', () => { .query({ inviteeId }); // cause inconsistent DB state - await Container.get(UserRepository).update(owner.id, { email: '' }); + owner.email = ''; + await Container.get(UserRepository).save(owner); const fifth = await authOwnerAgent .get('/resolve-signup-token') .query({ inviterId: owner.id }) diff --git a/packages/cli/test/integration/commands/credentials.cmd.test.ts b/packages/cli/test/integration/commands/credentials.cmd.test.ts index 730a0cd5dd..6c25fa6152 100644 --- a/packages/cli/test/integration/commands/credentials.cmd.test.ts +++ b/packages/cli/test/integration/commands/credentials.cmd.test.ts @@ -8,6 +8,8 @@ import { mockInstance } from '../../shared/mocking'; import * as testDb from '../shared/testDb'; import { getAllCredentials, getAllSharedCredentials } from '../shared/db/credentials'; import { createMember, createOwner } from '../shared/db/users'; +import { getPersonalProject } from '../shared/db/projects'; +import { nanoid } from 'nanoid'; const oclifConfig = new Config({ root: __dirname }); @@ -36,6 +38,7 @@ test('import:credentials should import a credential', async () => { // ARRANGE // const owner = await createOwner(); + const ownerProject = await getPersonalProject(owner); // // ACT @@ -54,7 +57,11 @@ test('import:credentials should import a credential', async () => { expect(after).toMatchObject({ credentials: [expect.objectContaining({ id: '123', name: 'cred-aws-test' })], sharings: [ - expect.objectContaining({ credentialsId: '123', userId: owner.id, role: 'credential:owner' }), + expect.objectContaining({ + credentialsId: '123', + projectId: ownerProject.id, + role: 'credential:owner', + }), ], }); }); @@ -64,6 +71,7 @@ test('import:credentials should import a credential from separated files', async // ARRANGE // const owner = await createOwner(); + const ownerProject = await getPersonalProject(owner); // // ACT @@ -92,7 +100,7 @@ test('import:credentials should import a credential from separated files', async sharings: [ expect.objectContaining({ credentialsId: '123', - userId: owner.id, + projectId: ownerProject.id, role: 'credential:owner', }), ], @@ -104,6 +112,7 @@ test('`import:credentials --userId ...` should fail if the credential exists alr // ARRANGE // const owner = await createOwner(); + const ownerProject = await getPersonalProject(owner); const member = await createMember(); // import credential the first time, assigning it to the owner @@ -122,7 +131,7 @@ test('`import:credentials --userId ...` should fail if the credential exists alr sharings: [ expect.objectContaining({ credentialsId: '123', - userId: owner.id, + projectId: ownerProject.id, role: 'credential:owner', }), ], @@ -140,7 +149,7 @@ test('`import:credentials --userId ...` should fail if the credential exists alr `--userId=${member.id}`, ]), ).rejects.toThrowError( - `The credential with id "123" is already owned by the user with the id "${owner.id}". It can't be re-owned by the user with the id "${member.id}"`, + `The credential with ID "123" is already owned by the user with the ID "${owner.id}". It can't be re-owned by the user with the ID "${member.id}"`, ); // @@ -162,19 +171,20 @@ test('`import:credentials --userId ...` should fail if the credential exists alr sharings: [ expect.objectContaining({ credentialsId: '123', - userId: owner.id, + projectId: ownerProject.id, role: 'credential:owner', }), ], }); }); -test("only update credential, don't create or update owner if `--userId` is not passed", async () => { +test("only update credential, don't create or update owner if neither `--userId` nor `--projectId` is passed", async () => { // // ARRANGE // await createOwner(); const member = await createMember(); + const memberProject = await getPersonalProject(member); // import credential the first time, assigning it to a member await importCredential([ @@ -192,7 +202,7 @@ test("only update credential, don't create or update owner if `--userId` is not sharings: [ expect.objectContaining({ credentialsId: '123', - userId: member.id, + projectId: memberProject.id, role: 'credential:owner', }), ], @@ -225,9 +235,93 @@ test("only update credential, don't create or update owner if `--userId` is not sharings: [ expect.objectContaining({ credentialsId: '123', - userId: member.id, + projectId: memberProject.id, role: 'credential:owner', }), ], }); }); + +test('`import:credential --projectId ...` should fail if the credential already exists and is owned by another project', async () => { + // + // ARRANGE + // + const owner = await createOwner(); + const ownerProject = await getPersonalProject(owner); + const member = await createMember(); + const memberProject = await getPersonalProject(member); + + // import credential the first time, assigning it to the owner + await importCredential([ + '--input=./test/integration/commands/importCredentials/credentials.json', + `--userId=${owner.id}`, + ]); + + // making sure the import worked + const before = { + credentials: await getAllCredentials(), + sharings: await getAllSharedCredentials(), + }; + expect(before).toMatchObject({ + credentials: [expect.objectContaining({ id: '123', name: 'cred-aws-test' })], + sharings: [ + expect.objectContaining({ + credentialsId: '123', + projectId: ownerProject.id, + role: 'credential:owner', + }), + ], + }); + + // + // ACT + // + + // Import again while updating the name we try to assign the + // credential to another user. + await expect( + importCredential([ + '--input=./test/integration/commands/importCredentials/credentials-updated.json', + `--projectId=${memberProject.id}`, + ]), + ).rejects.toThrowError( + `The credential with ID "123" is already owned by the user with the ID "${owner.id}". It can't be re-owned by the project with the ID "${memberProject.id}".`, + ); + + // + // ASSERT + // + const after = { + credentials: await getAllCredentials(), + sharings: await getAllSharedCredentials(), + }; + + expect(after).toMatchObject({ + credentials: [ + expect.objectContaining({ + id: '123', + // only the name was updated + name: 'cred-aws-test', + }), + ], + sharings: [ + expect.objectContaining({ + credentialsId: '123', + projectId: ownerProject.id, + role: 'credential:owner', + }), + ], + }); +}); + +test('`import:credential --projectId ... --userId ...` fails explaining that only one of the options can be used at a time', async () => { + await expect( + importCredential([ + '--input=./test/integration/commands/importCredentials/credentials-updated.json', + `--projectId=${nanoid()}`, + `--userId=${nanoid()}`, + ]), + ).rejects.toThrowError( + 'You cannot use `--userId` and `--projectId` together. Use one or the other.', + ); +}); diff --git a/packages/cli/test/integration/commands/import.cmd.test.ts b/packages/cli/test/integration/commands/import.cmd.test.ts index e65325c471..362801e8c3 100644 --- a/packages/cli/test/integration/commands/import.cmd.test.ts +++ b/packages/cli/test/integration/commands/import.cmd.test.ts @@ -8,6 +8,8 @@ import { mockInstance } from '../../shared/mocking'; import * as testDb from '../shared/testDb'; import { getAllSharedWorkflows, getAllWorkflows } from '../shared/db/workflows'; import { createMember, createOwner } from '../shared/db/users'; +import { getPersonalProject } from '../shared/db/projects'; +import { nanoid } from 'nanoid'; const oclifConfig = new Config({ root: __dirname }); @@ -36,6 +38,7 @@ test('import:workflow should import active workflow and deactivate it', async () // ARRANGE // const owner = await createOwner(); + const ownerProject = await getPersonalProject(owner); // // ACT @@ -58,8 +61,16 @@ test('import:workflow should import active workflow and deactivate it', async () expect.objectContaining({ name: 'inactive-workflow', active: false }), ], sharings: [ - expect.objectContaining({ workflowId: '998', userId: owner.id, role: 'workflow:owner' }), - expect.objectContaining({ workflowId: '999', userId: owner.id, role: 'workflow:owner' }), + expect.objectContaining({ + workflowId: '998', + projectId: ownerProject.id, + role: 'workflow:owner', + }), + expect.objectContaining({ + workflowId: '999', + projectId: ownerProject.id, + role: 'workflow:owner', + }), ], }); }); @@ -69,6 +80,7 @@ test('import:workflow should import active workflow from combined file and deact // ARRANGE // const owner = await createOwner(); + const ownerProject = await getPersonalProject(owner); // // ACT @@ -90,8 +102,16 @@ test('import:workflow should import active workflow from combined file and deact expect.objectContaining({ name: 'inactive-workflow', active: false }), ], sharings: [ - expect.objectContaining({ workflowId: '998', userId: owner.id, role: 'workflow:owner' }), - expect.objectContaining({ workflowId: '999', userId: owner.id, role: 'workflow:owner' }), + expect.objectContaining({ + workflowId: '998', + projectId: ownerProject.id, + role: 'workflow:owner', + }), + expect.objectContaining({ + workflowId: '999', + projectId: ownerProject.id, + role: 'workflow:owner', + }), ], }); }); @@ -101,6 +121,7 @@ test('`import:workflow --userId ...` should fail if the workflow exists already // ARRANGE // const owner = await createOwner(); + const ownerProject = await getPersonalProject(owner); const member = await createMember(); // Import workflow the first time, assigning it to a member. @@ -119,7 +140,7 @@ test('`import:workflow --userId ...` should fail if the workflow exists already sharings: [ expect.objectContaining({ workflowId: '998', - userId: owner.id, + projectId: ownerProject.id, role: 'workflow:owner', }), ], @@ -136,7 +157,7 @@ test('`import:workflow --userId ...` should fail if the workflow exists already `--userId=${member.id}`, ]), ).rejects.toThrowError( - `The credential with id "998" is already owned by the user with the id "${owner.id}". It can't be re-owned by the user with the id "${member.id}"`, + `The credential with ID "998" is already owned by the user with the ID "${owner.id}". It can't be re-owned by the user with the ID "${member.id}"`, ); // @@ -152,7 +173,7 @@ test('`import:workflow --userId ...` should fail if the workflow exists already sharings: [ expect.objectContaining({ workflowId: '998', - userId: owner.id, + projectId: ownerProject.id, role: 'workflow:owner', }), ], @@ -165,6 +186,7 @@ test("only update the workflow, don't create or update the owner if `--userId` i // await createOwner(); const member = await createMember(); + const memberProject = await getPersonalProject(member); // Import workflow the first time, assigning it to a member. await importWorkflow([ @@ -182,7 +204,7 @@ test("only update the workflow, don't create or update the owner if `--userId` i sharings: [ expect.objectContaining({ workflowId: '998', - userId: member.id, + projectId: memberProject.id, role: 'workflow:owner', }), ], @@ -209,9 +231,86 @@ test("only update the workflow, don't create or update the owner if `--userId` i sharings: [ expect.objectContaining({ workflowId: '998', - userId: member.id, + projectId: memberProject.id, role: 'workflow:owner', }), ], }); }); + +test('`import:workflow --projectId ...` should fail if the credential already exists and is owned by another project', async () => { + // + // ARRANGE + // + const owner = await createOwner(); + const ownerProject = await getPersonalProject(owner); + const member = await createMember(); + const memberProject = await getPersonalProject(member); + + // Import workflow the first time, assigning it to a member. + await importWorkflow([ + '--input=./test/integration/commands/importWorkflows/combined-with-update/original.json', + `--userId=${owner.id}`, + ]); + + const before = { + workflows: await getAllWorkflows(), + sharings: await getAllSharedWorkflows(), + }; + // Make sure the workflow and sharing have been created. + expect(before).toMatchObject({ + workflows: [expect.objectContaining({ id: '998', name: 'active-workflow' })], + sharings: [ + expect.objectContaining({ + workflowId: '998', + projectId: ownerProject.id, + role: 'workflow:owner', + }), + ], + }); + + // + // ACT + // + // Import the same workflow again, with another name but the same ID, and try + // to assign it to the member. + await expect( + importWorkflow([ + '--input=./test/integration/commands/importWorkflows/combined-with-update/updated.json', + `--projectId=${memberProject.id}`, + ]), + ).rejects.toThrowError( + `The credential with ID "998" is already owned by the user with the ID "${owner.id}". It can't be re-owned by the project with the ID "${memberProject.id}"`, + ); + + // + // ASSERT + // + const after = { + workflows: await getAllWorkflows(), + sharings: await getAllSharedWorkflows(), + }; + // Make sure there is no new sharing and that the name DID NOT change. + expect(after).toMatchObject({ + workflows: [expect.objectContaining({ id: '998', name: 'active-workflow' })], + sharings: [ + expect.objectContaining({ + workflowId: '998', + projectId: ownerProject.id, + role: 'workflow:owner', + }), + ], + }); +}); + +test('`import:workflow --projectId ... --userId ...` fails explaining that only one of the options can be used at a time', async () => { + await expect( + importWorkflow([ + '--input=./test/integration/commands/importWorkflows/combined-with-update/updated.json', + `--userId=${nanoid()}`, + `--projectId=${nanoid()}`, + ]), + ).rejects.toThrowError( + 'You cannot use `--userId` and `--projectId` together. Use one or the other.', + ); +}); diff --git a/packages/cli/test/integration/commands/ldap/reset.test.ts b/packages/cli/test/integration/commands/ldap/reset.test.ts new file mode 100644 index 0000000000..8727b13e49 --- /dev/null +++ b/packages/cli/test/integration/commands/ldap/reset.test.ts @@ -0,0 +1,381 @@ +import { Reset } from '@/commands/ldap/reset'; +import { Config } from '@oclif/core'; + +import * as testDb from '../../shared/testDb'; +import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; +import { mockInstance } from '../../../shared/mocking'; +import { InternalHooks } from '@/InternalHooks'; +import { createLdapUser, createMember, getUserById } from '../../shared/db/users'; +import { createWorkflow } from '../../shared/db/workflows'; +import { randomCredentialPayload } from '../../shared/random'; +import { saveCredential } from '../../shared/db/credentials'; +import Container from 'typedi'; +import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; +import { EntityNotFoundError } from '@n8n/typeorm'; +import { Push } from '@/push'; +import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; +import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository'; +import { createTeamProject, findProject, getPersonalProject } from '../../shared/db/projects'; +import { WaitTracker } from '@/WaitTracker'; +import { getLdapSynchronizations, saveLdapSynchronization } from '@/Ldap/helpers'; +import { createLdapConfig } from '../../shared/ldap'; +import { LdapService } from '@/Ldap/ldap.service'; +import { v4 as uuid } from 'uuid'; + +const oclifConfig = new Config({ root: __dirname }); + +async function resetLDAP(argv: string[]) { + const cmd = new Reset(argv, oclifConfig); + try { + await cmd.init(); + } catch (error) { + console.error(error); + throw error; + } + await cmd.run(); +} + +beforeAll(async () => { + mockInstance(Push); + mockInstance(InternalHooks); + mockInstance(LoadNodesAndCredentials); + // This needs to be mocked, otherwise the time setInterval would prevent jest + // from exiting properly. + mockInstance(WaitTracker); + await testDb.init(); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +test('fails if neither `--userId` nor `--projectId` nor `--deleteWorkflowsAndCredentials` is passed', async () => { + await expect(resetLDAP([])).rejects.toThrowError( + 'You must use exactly one of `--userId`, `--projectId` or `--deleteWorkflowsAndCredentials`.', + ); +}); + +test.each([ + [`--userId=${uuid()}`, `--projectId=${uuid()}`, '--deleteWorkflowsAndCredentials'], + + [`--userId=${uuid()}`, `--projectId=${uuid()}`], + [`--userId=${uuid()}`, '--deleteWorkflowsAndCredentials'], + + ['--deleteWorkflowsAndCredentials', `--projectId=${uuid()}`], +])( + 'fails if more than one of `--userId`, `--projectId`, `--deleteWorkflowsAndCredentials` are passed', + async (...argv) => { + await expect(resetLDAP(argv)).rejects.toThrowError( + 'You must use exactly one of `--userId`, `--projectId` or `--deleteWorkflowsAndCredentials`.', + ); + }, +); + +describe('--deleteWorkflowsAndCredentials', () => { + test('deletes personal projects, workflows and credentials owned by LDAP managed users', async () => { + // + // ARRANGE + // + const member = await createLdapUser({ role: 'global:member' }, uuid()); + const memberProject = await getPersonalProject(member); + const workflow = await createWorkflow({}, member); + const credential = await saveCredential(randomCredentialPayload(), { + user: member, + role: 'credential:owner', + }); + + const normalMember = await createMember(); + const workflow2 = await createWorkflow({}, normalMember); + const credential2 = await saveCredential(randomCredentialPayload(), { + user: normalMember, + role: 'credential:owner', + }); + + // + // ACT + // + await resetLDAP(['--deleteWorkflowsAndCredentials']); + + // + // ASSERT + // + // LDAP user is deleted + await expect(getUserById(member.id)).rejects.toThrowError(EntityNotFoundError); + await expect(findProject(memberProject.id)).rejects.toThrowError(EntityNotFoundError); + await expect( + Container.get(WorkflowRepository).findOneBy({ id: workflow.id }), + ).resolves.toBeNull(); + await expect( + Container.get(CredentialsRepository).findOneBy({ id: credential.id }), + ).resolves.toBeNull(); + + // Non LDAP user is not deleted + await expect(getUserById(normalMember.id)).resolves.not.toThrowError(); + await expect( + Container.get(WorkflowRepository).findOneBy({ id: workflow2.id }), + ).resolves.not.toBeNull(); + await expect( + Container.get(CredentialsRepository).findOneBy({ id: credential2.id }), + ).resolves.not.toBeNull(); + }); + + test('deletes the LDAP sync history', async () => { + // + // ARRANGE + // + await saveLdapSynchronization({ + created: 1, + disabled: 1, + scanned: 1, + updated: 1, + endedAt: new Date(), + startedAt: new Date(), + error: '', + runMode: 'dry', + status: 'success', + }); + + // + // ACT + // + await resetLDAP(['--deleteWorkflowsAndCredentials']); + + // + // ASSERT + // + await expect(getLdapSynchronizations(0, 10)).resolves.toHaveLength(0); + }); + + test('resets LDAP settings', async () => { + // + // ARRANGE + // + await createLdapConfig(); + await expect(Container.get(LdapService).loadConfig()).resolves.toMatchObject({ + loginEnabled: true, + }); + + // + // ACT + // + await resetLDAP(['--deleteWorkflowsAndCredentials']); + + // + // ASSERT + // + await expect(Container.get(LdapService).loadConfig()).resolves.toMatchObject({ + loginEnabled: false, + }); + }); +}); + +describe('--userId', () => { + test('fails if the user does not exist', async () => { + const userId = uuid(); + await expect(resetLDAP([`--userId=${userId}`])).rejects.toThrowError( + `Could not find the user with the ID ${userId} or their personalProject.`, + ); + }); + + test('fails if the user to migrate to is also an LDAP user', async () => { + // + // ARRANGE + // + const member = await createLdapUser({ role: 'global:member' }, uuid()); + + await expect(resetLDAP([`--userId=${member.id}`])).rejects.toThrowError( + `Can't migrate workflows and credentials to the user with the ID ${member.id}. That user was created via LDAP and will be deleted as well.`, + ); + }); + + test("transfers all workflows and credentials to the user's personal project", async () => { + // + // ARRANGE + // + const member = await createLdapUser({ role: 'global:member' }, uuid()); + const memberProject = await getPersonalProject(member); + const workflow = await createWorkflow({}, member); + const credential = await saveCredential(randomCredentialPayload(), { + user: member, + role: 'credential:owner', + }); + + const normalMember = await createMember(); + const normalMemberProject = await getPersonalProject(normalMember); + const workflow2 = await createWorkflow({}, normalMember); + const credential2 = await saveCredential(randomCredentialPayload(), { + user: normalMember, + role: 'credential:owner', + }); + + // + // ACT + // + await resetLDAP([`--userId=${normalMember.id}`]); + + // + // ASSERT + // + // LDAP user is deleted + await expect(getUserById(member.id)).rejects.toThrowError(EntityNotFoundError); + await expect(findProject(memberProject.id)).rejects.toThrowError(EntityNotFoundError); + + // Their workflow and credential have been migrated to the normal user. + await expect( + Container.get(SharedWorkflowRepository).findOneBy({ + workflowId: workflow.id, + projectId: normalMemberProject.id, + }), + ).resolves.not.toBeNull(); + await expect( + Container.get(SharedCredentialsRepository).findOneBy({ + credentialsId: credential.id, + projectId: normalMemberProject.id, + }), + ).resolves.not.toBeNull(); + + // Non LDAP user is not deleted + await expect(getUserById(normalMember.id)).resolves.not.toThrowError(); + await expect( + Container.get(WorkflowRepository).findOneBy({ id: workflow2.id }), + ).resolves.not.toBeNull(); + await expect( + Container.get(CredentialsRepository).findOneBy({ id: credential2.id }), + ).resolves.not.toBeNull(); + }); +}); + +describe('--projectId', () => { + test('fails if the project does not exist', async () => { + const projectId = uuid(); + await expect(resetLDAP([`--projectId=${projectId}`])).rejects.toThrowError( + `Could not find the project with the ID ${projectId}.`, + ); + }); + + test('fails if the user to migrate to is also an LDAP user', async () => { + // + // ARRANGE + // + const member = await createLdapUser({ role: 'global:member' }, uuid()); + const memberProject = await getPersonalProject(member); + + await expect(resetLDAP([`--projectId=${memberProject.id}`])).rejects.toThrowError( + `Can't migrate workflows and credentials to the project with the ID ${memberProject.id}. That project is a personal project belonging to a user that was created via LDAP and will be deleted as well.`, + ); + }); + + test('transfers all workflows and credentials to a personal project', async () => { + // + // ARRANGE + // + const member = await createLdapUser({ role: 'global:member' }, uuid()); + const memberProject = await getPersonalProject(member); + const workflow = await createWorkflow({}, member); + const credential = await saveCredential(randomCredentialPayload(), { + user: member, + role: 'credential:owner', + }); + + const normalMember = await createMember(); + const normalMemberProject = await getPersonalProject(normalMember); + const workflow2 = await createWorkflow({}, normalMember); + const credential2 = await saveCredential(randomCredentialPayload(), { + user: normalMember, + role: 'credential:owner', + }); + + // + // ACT + // + await resetLDAP([`--projectId=${normalMemberProject.id}`]); + + // + // ASSERT + // + // LDAP user is deleted + await expect(getUserById(member.id)).rejects.toThrowError(EntityNotFoundError); + await expect(findProject(memberProject.id)).rejects.toThrowError(EntityNotFoundError); + + // Their workflow and credential have been migrated to the normal user. + await expect( + Container.get(SharedWorkflowRepository).findOneBy({ + workflowId: workflow.id, + projectId: normalMemberProject.id, + }), + ).resolves.not.toBeNull(); + await expect( + Container.get(SharedCredentialsRepository).findOneBy({ + credentialsId: credential.id, + projectId: normalMemberProject.id, + }), + ).resolves.not.toBeNull(); + + // Non LDAP user is not deleted + await expect(getUserById(normalMember.id)).resolves.not.toThrowError(); + await expect( + Container.get(WorkflowRepository).findOneBy({ id: workflow2.id }), + ).resolves.not.toBeNull(); + await expect( + Container.get(CredentialsRepository).findOneBy({ id: credential2.id }), + ).resolves.not.toBeNull(); + }); + + test('transfers all workflows and credentials to a team project', async () => { + // + // ARRANGE + // + const member = await createLdapUser({ role: 'global:member' }, uuid()); + const memberProject = await getPersonalProject(member); + const workflow = await createWorkflow({}, member); + const credential = await saveCredential(randomCredentialPayload(), { + user: member, + role: 'credential:owner', + }); + + const normalMember = await createMember(); + const workflow2 = await createWorkflow({}, normalMember); + const credential2 = await saveCredential(randomCredentialPayload(), { + user: normalMember, + role: 'credential:owner', + }); + + const teamProject = await createTeamProject(); + + // + // ACT + // + await resetLDAP([`--projectId=${teamProject.id}`]); + + // + // ASSERT + // + // LDAP user is deleted + await expect(getUserById(member.id)).rejects.toThrowError(EntityNotFoundError); + await expect(findProject(memberProject.id)).rejects.toThrowError(EntityNotFoundError); + + // Their workflow and credential have been migrated to the team project. + await expect( + Container.get(SharedWorkflowRepository).findOneBy({ + workflowId: workflow.id, + projectId: teamProject.id, + }), + ).resolves.not.toBeNull(); + await expect( + Container.get(SharedCredentialsRepository).findOneBy({ + credentialsId: credential.id, + projectId: teamProject.id, + }), + ).resolves.not.toBeNull(); + + // Non LDAP user is not deleted + await expect(getUserById(normalMember.id)).resolves.not.toThrowError(); + await expect( + Container.get(WorkflowRepository).findOneBy({ id: workflow2.id }), + ).resolves.not.toBeNull(); + await expect( + Container.get(CredentialsRepository).findOneBy({ id: credential2.id }), + ).resolves.not.toBeNull(); + }); +}); diff --git a/packages/cli/test/integration/commands/reset.cmd.test.ts b/packages/cli/test/integration/commands/reset.cmd.test.ts index fd32fee1fc..04e92dbdf2 100644 --- a/packages/cli/test/integration/commands/reset.cmd.test.ts +++ b/packages/cli/test/integration/commands/reset.cmd.test.ts @@ -7,7 +7,16 @@ import { UserRepository } from '@db/repositories/user.repository'; import { mockInstance } from '../../shared/mocking'; import * as testDb from '../shared/testDb'; -import { createUser } from '../shared/db/users'; +import { createMember, createUser } from '../shared/db/users'; +import { createWorkflow } from '../shared/db/workflows'; +import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; +import { getPersonalProject } from '../shared/db/projects'; +import { encryptCredentialData, saveCredential } from '../shared/db/credentials'; +import { randomCredentialPayload } from '../shared/random'; +import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository'; +import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; +import { CredentialsEntity } from '@/databases/entities/CredentialsEntity'; +import { SettingsRepository } from '@/databases/repositories/settings.repository'; beforeAll(async () => { mockInstance(InternalHooks); @@ -25,20 +34,75 @@ afterAll(async () => { }); // eslint-disable-next-line n8n-local-rules/no-skipped-tests -test.skip('user-management:reset should reset DB to default user state', async () => { - await createUser({ role: 'global:owner' }); +test('user-management:reset should reset DB to default user state', async () => { + // + // ARRANGE + // + const owner = await createUser({ role: 'global:owner' }); + const ownerProject = await getPersonalProject(owner); + // should be deleted + const member = await createMember(); + + // should be re-owned + const workflow = await createWorkflow({}, member); + const credential = await saveCredential(randomCredentialPayload(), { + user: member, + role: 'credential:owner', + }); + + // dangling credentials should also be re-owned + const danglingCredential = await Container.get(CredentialsRepository).save( + await encryptCredentialData(Object.assign(new CredentialsEntity(), randomCredentialPayload())), + ); + + // mark instance as set up + await Container.get(SettingsRepository).update( + { key: 'userManagement.isInstanceOwnerSetUp' }, + { value: 'true' }, + ); + + // + // ACT + // await Reset.run(); - const user = await Container.get(UserRepository).findOneBy({ role: 'global:owner' }); + // + // ASSERT + // - if (!user) { - fail('No owner found after DB reset to default user state'); - } + // check if the owner account was reset: + await expect( + Container.get(UserRepository).findOneBy({ role: 'global:owner' }), + ).resolves.toMatchObject({ + email: null, + firstName: null, + lastName: null, + password: null, + personalizationAnswers: null, + }); - expect(user.email).toBeNull(); - expect(user.firstName).toBeNull(); - expect(user.lastName).toBeNull(); - expect(user.password).toBeNull(); - expect(user.personalizationAnswers).toBeNull(); + // all members were deleted: + const members = await Container.get(UserRepository).findOneBy({ role: 'global:member' }); + expect(members).toBeNull(); + + // all workflows are owned by the owner: + await expect( + Container.get(SharedWorkflowRepository).findBy({ workflowId: workflow.id }), + ).resolves.toMatchObject([{ projectId: ownerProject.id, role: 'workflow:owner' }]); + + // all credentials are owned by the owner + await expect( + Container.get(SharedCredentialsRepository).findBy({ credentialsId: credential.id }), + ).resolves.toMatchObject([{ projectId: ownerProject.id, role: 'credential:owner' }]); + + // all dangling credentials are owned by the owner + await expect( + Container.get(SharedCredentialsRepository).findBy({ credentialsId: danglingCredential.id }), + ).resolves.toMatchObject([{ projectId: ownerProject.id, role: 'credential:owner' }]); + + // the instance is marked as not set up: + await expect( + Container.get(SettingsRepository).findBy({ key: 'userManagement.isInstanceOwnerSetUp' }), + ).resolves.toMatchObject([{ value: 'false' }]); }); diff --git a/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts b/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts index b6371fdf9e..390ab89191 100644 --- a/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts +++ b/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts @@ -26,6 +26,8 @@ import { import type { User } from '@/databases/entities/User'; import type { UserInvitationResult } from '../../shared/utils/users'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; describe('InvitationController', () => { const mailer = mockInstance(UserManagementMailer); @@ -36,9 +38,13 @@ describe('InvitationController', () => { let instanceOwner: User; let userRepository: UserRepository; + let projectRepository: ProjectRepository; + let projectRelationRepository: ProjectRelationRepository; beforeAll(async () => { userRepository = Container.get(UserRepository); + projectRepository = Container.get(ProjectRepository); + projectRelationRepository = Container.get(ProjectRelationRepository); instanceOwner = await createOwner(); }); @@ -271,6 +277,39 @@ describe('InvitationController', () => { assertStoredUserProps(storedUser); }); + test('should create personal project for shell account', async () => { + mailer.invite.mockResolvedValue({ emailSent: false }); + + const response: InvitationResponse = await testServer + .authAgentFor(instanceOwner) + .post('/invitations') + .send([{ email: randomEmail() }]) + .expect(200); + + const [result] = response.body.data; + + const storedUser = await userRepository.findOneByOrFail({ + id: result.user.id, + }); + + assertStoredUserProps(storedUser); + + const projectRelation = await projectRelationRepository.findOneOrFail({ + where: { + userId: storedUser.id, + role: 'project:personalOwner', + project: { + type: 'personal', + }, + }, + relations: { project: true }, + }); + + expect(projectRelation).not.toBeUndefined(); + expect(projectRelation.project.name).toBe(storedUser.createPersonalProjectName()); + expect(projectRelation.project.type).toBe('personal'); + }); + test('should create admin shell when advanced permissions is licensed', async () => { testServer.license.enable('feat:advancedPermissions'); diff --git a/packages/cli/test/integration/credentials.test.ts b/packages/cli/test/integration/credentials.test.ts index a25f0108cf..c33c833515 100644 --- a/packages/cli/test/integration/credentials.test.ts +++ b/packages/cli/test/integration/credentials.test.ts @@ -1,6 +1,7 @@ import { Container } from 'typedi'; import type { SuperAgentTest } from 'supertest'; +import type { Scope } from '@n8n/permissions'; import config from '@/config'; import type { ListQuery } from '@/requests'; import type { User } from '@db/entities/User'; @@ -12,24 +13,42 @@ import { randomCredentialPayload, randomName, randomString } from './shared/rand import * as testDb from './shared/testDb'; import type { SaveCredentialFunction } from './shared/types'; import * as utils from './shared/utils/'; -import { affixRoleToSaveCredential, shareCredentialWithUsers } from './shared/db/credentials'; +import { + affixRoleToSaveCredential, + shareCredentialWithProjects, + shareCredentialWithUsers, +} from './shared/db/credentials'; import { createManyUsers, createUser } from './shared/db/users'; import { Credentials } from 'n8n-core'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import type { Project } from '@/databases/entities/Project'; +import { ProjectService } from '@/services/project.service'; +import { createTeamProject, linkUserToProject } from './shared/db/projects'; // mock that credentialsSharing is not enabled jest.spyOn(License.prototype, 'isSharingEnabled').mockReturnValue(false); const testServer = utils.setupTestServer({ endpointGroups: ['credentials'] }); let owner: User; +let ownerPersonalProject: Project; let member: User; +let memberPersonalProject: Project; let secondMember: User; let authOwnerAgent: SuperAgentTest; let authMemberAgent: SuperAgentTest; let saveCredential: SaveCredentialFunction; +let projectRepository: ProjectRepository; +let sharedCredentialsRepository: SharedCredentialsRepository; +let projectService: ProjectService; beforeAll(async () => { + projectRepository = Container.get(ProjectRepository); + sharedCredentialsRepository = Container.get(SharedCredentialsRepository); + projectService = Container.get(ProjectService); owner = await createUser({ role: 'global:owner' }); + ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); member = await createUser({ role: 'global:member' }); + memberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(member.id); secondMember = await createUser({ role: 'global:member' }); saveCredential = affixRoleToSaveCredential('credential:owner'); @@ -86,22 +105,125 @@ describe('GET /credentials', () => { expect(member1Credential.data).toBeUndefined(); expect(member1Credential.id).toBe(savedCredential1.id); }); + + test('should return scopes when ?includeScopes=true', async () => { + const [member1, member2] = await createManyUsers(2, { + role: 'global:member', + }); + + const teamProject = await createTeamProject(undefined, member1); + await linkUserToProject(member2, teamProject, 'project:editor'); + + const [savedCredential1, savedCredential2] = await Promise.all([ + saveCredential(randomCredentialPayload(), { project: teamProject }), + saveCredential(randomCredentialPayload(), { user: member2 }), + ]); + + await shareCredentialWithProjects(savedCredential2, [teamProject]); + + { + const response = await testServer + .authAgentFor(member1) + .get('/credentials?includeScopes=true'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + + const creds = response.body.data as Array; + const cred1 = creds.find((c) => c.id === savedCredential1.id)!; + const cred2 = creds.find((c) => c.id === savedCredential2.id)!; + + // Team cred + expect(cred1.id).toBe(savedCredential1.id); + expect(cred1.scopes).toEqual( + ['credential:read', 'credential:update', 'credential:delete'].sort(), + ); + + // Shared cred + expect(cred2.id).toBe(savedCredential2.id); + expect(cred2.scopes).toEqual(['credential:read']); + } + + { + const response = await testServer + .authAgentFor(member2) + .get('/credentials?includeScopes=true'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + + const creds = response.body.data as Array; + const cred1 = creds.find((c) => c.id === savedCredential1.id)!; + const cred2 = creds.find((c) => c.id === savedCredential2.id)!; + + // Team cred + expect(cred1.id).toBe(savedCredential1.id); + expect(cred1.scopes).toEqual(['credential:delete', 'credential:read', 'credential:update']); + + // Shared cred + expect(cred2.id).toBe(savedCredential2.id); + expect(cred2.scopes).toEqual( + ['credential:read', 'credential:update', 'credential:delete', 'credential:share'].sort(), + ); + } + + { + const response = await testServer.authAgentFor(owner).get('/credentials?includeScopes=true'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + + const creds = response.body.data as Array; + const cred1 = creds.find((c) => c.id === savedCredential1.id)!; + const cred2 = creds.find((c) => c.id === savedCredential2.id)!; + + // Team cred + expect(cred1.id).toBe(savedCredential1.id); + expect(cred1.scopes).toEqual( + [ + 'credential:create', + 'credential:read', + 'credential:update', + 'credential:delete', + 'credential:list', + 'credential:share', + ].sort(), + ); + + // Shared cred + expect(cred2.id).toBe(savedCredential2.id); + expect(cred2.scopes).toEqual( + [ + 'credential:create', + 'credential:read', + 'credential:update', + 'credential:delete', + 'credential:list', + 'credential:share', + ].sort(), + ); + } + }); }); describe('POST /credentials', () => { test('should create cred', async () => { const payload = randomCredentialPayload(); - const response = await authOwnerAgent.post('/credentials').send(payload); + const response = await authMemberAgent.post('/credentials').send(payload); expect(response.statusCode).toBe(200); - const { id, name, type, data: encryptedData } = response.body.data; + const { id, name, type, data: encryptedData, scopes } = response.body.data; expect(name).toBe(payload.name); expect(type).toBe(payload.type); expect(encryptedData).not.toBe(payload.data); + expect(scopes).toEqual( + ['credential:read', 'credential:update', 'credential:delete', 'credential:share'].sort(), + ); + const credential = await Container.get(CredentialsRepository).findOneByOrFail({ id }); expect(credential.name).toBe(payload.name); @@ -109,11 +231,11 @@ describe('POST /credentials', () => { expect(credential.data).not.toBe(payload.data); const sharedCredential = await Container.get(SharedCredentialsRepository).findOneOrFail({ - relations: ['user', 'credentials'], + relations: { project: true, credentials: true }, where: { credentialsId: credential.id }, }); - expect(sharedCredential.user.id).toBe(owner.id); + expect(sharedCredential.project.id).toBe(memberPersonalProject.id); expect(sharedCredential.credentials.name).toBe(payload.name); }); @@ -137,6 +259,96 @@ describe('POST /credentials', () => { expect(secondResponse.body.data.id).not.toBe(8); }); + + test('creates credential in personal project by default', async () => { + // + // ACT + // + const response = await authOwnerAgent.post('/credentials').send(randomCredentialPayload()); + + // + // ASSERT + // + await sharedCredentialsRepository.findOneByOrFail({ + projectId: ownerPersonalProject.id, + credentialsId: response.body.data.id, + }); + }); + + test('creates credential in a specific project if the projectId is passed', async () => { + // + // ARRANGE + // + const project = await createTeamProject('Team Project', owner); + + // + // ACT + // + const response = await authOwnerAgent + .post('/credentials') + .send({ ...randomCredentialPayload(), projectId: project.id }); + + // + // ASSERT + // + await sharedCredentialsRepository.findOneByOrFail({ + projectId: project.id, + credentialsId: response.body.data.id, + }); + }); + + test('does not create the credential in a specific project if the user is not part of the project', async () => { + // + // ARRANGE + // + const project = await projectRepository.save( + projectRepository.create({ + name: 'Team Project', + type: 'team', + }), + ); + + // + // ACT + // + await authMemberAgent + .post('/credentials') + .send({ ...randomCredentialPayload(), projectId: project.id }) + // + // ASSERT + // + .expect(400, { + code: 400, + message: "You don't have the permissions to save the workflow in this project.", + }); + }); + + test('does not create the credential in a specific project if the user does not have the right role to do so', async () => { + // + // ARRANGE + // + const project = await projectRepository.save( + projectRepository.create({ + name: 'Team Project', + type: 'team', + }), + ); + await projectService.addUser(project.id, member.id, 'project:viewer'); + + // + // ACT + // + await authMemberAgent + .post('/credentials') + .send({ ...randomCredentialPayload(), projectId: project.id }) + // + // ASSERT + // + .expect(400, { + code: 400, + message: "You don't have the permissions to save the workflow in this project.", + }); + }); }); describe('DELETE /credentials/:id', () => { @@ -202,7 +414,7 @@ describe('DELETE /credentials/:id', () => { const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(403); const shellCredential = await Container.get(CredentialsRepository).findOneBy({ id: savedCredential.id, @@ -222,7 +434,7 @@ describe('DELETE /credentials/:id', () => { const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(403); const shellCredential = await Container.get(CredentialsRepository).findOneBy({ id: savedCredential.id, @@ -253,11 +465,22 @@ describe('PATCH /credentials/:id', () => { expect(response.statusCode).toBe(200); - const { id, name, type, data: encryptedData } = response.body.data; + const { id, name, type, data: encryptedData, scopes } = response.body.data; expect(name).toBe(patchPayload.name); expect(type).toBe(patchPayload.type); + expect(scopes).toEqual( + [ + 'credential:create', + 'credential:read', + 'credential:update', + 'credential:delete', + 'credential:list', + 'credential:share', + ].sort(), + ); + expect(encryptedData).not.toBe(patchPayload.data); const credential = await Container.get(CredentialsRepository).findOneByOrFail({ id }); @@ -345,7 +568,7 @@ describe('PATCH /credentials/:id', () => { .patch(`/credentials/${savedCredential.id}`) .send(patchPayload); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(403); const shellCredential = await Container.get(CredentialsRepository).findOneByOrFail({ id: savedCredential.id, @@ -363,7 +586,7 @@ describe('PATCH /credentials/:id', () => { .patch(`/credentials/${savedCredential.id}`) .send(patchPayload); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(403); const shellCredential = await Container.get(CredentialsRepository).findOneByOrFail({ id: savedCredential.id, @@ -402,11 +625,19 @@ describe('PATCH /credentials/:id', () => { } }); - test('should fail if cred not found', async () => { + test('should fail with a 404 if the credential does not exist and the actor has the global credential:update scope', async () => { const response = await authOwnerAgent.patch('/credentials/123').send(randomCredentialPayload()); expect(response.statusCode).toBe(404); }); + + test('should fail with a 403 if the credential does not exist and the actor does not have the global credential:update scope', async () => { + const response = await authMemberAgent + .patch('/credentials/123') + .send(randomCredentialPayload()); + + expect(response.statusCode).toBe(403); + }); }); describe('GET /credentials/new', () => { @@ -511,7 +742,7 @@ describe('GET /credentials/:id', () => { const response = await authMemberAgent.get(`/credentials/${savedCredential.id}`); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(403); expect(response.body.data).toBeUndefined(); // owner's cred not returned }); @@ -525,22 +756,21 @@ describe('GET /credentials/:id', () => { }); function validateMainCredentialData(credential: ListQuery.Credentials.WithOwnedByAndSharedWith) { - const { name, type, sharedWith, ownedBy } = credential; + const { name, type, sharedWithProjects, homeProject } = credential; expect(typeof name).toBe('string'); expect(typeof type).toBe('string'); - if (sharedWith) { - expect(Array.isArray(sharedWith)).toBe(true); + if (sharedWithProjects) { + expect(Array.isArray(sharedWithProjects)).toBe(true); } - if (ownedBy) { - const { id, email, firstName, lastName } = ownedBy; + if (homeProject) { + const { id, type, name } = homeProject; expect(typeof id).toBe('string'); - expect(typeof email).toBe('string'); - expect(typeof firstName).toBe('string'); - expect(typeof lastName).toBe('string'); + expect(typeof name).toBe('string'); + expect(type).toBe('personal'); } } diff --git a/packages/cli/test/integration/credentials.controller.test.ts b/packages/cli/test/integration/credentials/credentials.controller.test.ts similarity index 61% rename from packages/cli/test/integration/credentials.controller.test.ts rename to packages/cli/test/integration/credentials/credentials.controller.test.ts index df88c2b12c..7d0f9debe7 100644 --- a/packages/cli/test/integration/credentials.controller.test.ts +++ b/packages/cli/test/integration/credentials/credentials.controller.test.ts @@ -1,10 +1,14 @@ import type { ListQuery } from '@/requests'; import type { User } from '@db/entities/User'; -import * as testDb from './shared/testDb'; -import { setupTestServer } from './shared/utils/'; -import { randomCredentialPayload as payload } from './shared/random'; -import { saveCredential } from './shared/db/credentials'; -import { createMember, createOwner } from './shared/db/users'; +import * as testDb from '../shared/testDb'; +import { setupTestServer } from '../shared/utils'; +import { randomCredentialPayload as payload } from '../shared/random'; +import { saveCredential, shareCredentialWithUsers } from '../shared/db/credentials'; +import { createMember, createOwner } from '../shared/db/users'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import Container from 'typedi'; +import type { Project } from '@/databases/entities/Project'; +import { createTeamProject, linkUserToProject } from '../shared/db/projects'; const { any } = expect; @@ -13,11 +17,19 @@ const testServer = setupTestServer({ endpointGroups: ['credentials'] }); let owner: User; let member: User; +let ownerPersonalProject: Project; +let memberPersonalProject: Project; beforeEach(async () => { await testDb.truncate(['SharedCredentials', 'Credentials']); owner = await createOwner(); member = await createMember(); + ownerPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + owner.id, + ); + memberPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + member.id, + ); }); type GetAllResponse = { body: { data: ListQuery.Credentials.WithOwnedByAndSharedWith[] } }; @@ -171,6 +183,113 @@ describe('GET /credentials', () => { expect(_response.body.data).toHaveLength(0); }); + + test('should filter credentials by projectId', async () => { + const credential = await saveCredential(payload(), { user: owner, role: 'credential:owner' }); + + const response1: GetAllResponse = await testServer + .authAgentFor(owner) + .get('/credentials') + .query(`filter={ "projectId": "${ownerPersonalProject.id}" }`) + .expect(200); + + expect(response1.body.data).toHaveLength(1); + expect(response1.body.data[0].id).toBe(credential.id); + + const response2 = await testServer + .authAgentFor(owner) + .get('/credentials') + .query('filter={ "projectId": "Non-Existing Project ID" }') + .expect(200); + + expect(response2.body.data).toHaveLength(0); + }); + + test('should return all credentials in a team project that member is part of', async () => { + const teamProjectWithMember = await createTeamProject('Team Project With member', owner); + void (await linkUserToProject(member, teamProjectWithMember, 'project:editor')); + await saveCredential(payload(), { + project: teamProjectWithMember, + role: 'credential:owner', + }); + await saveCredential(payload(), { + project: teamProjectWithMember, + role: 'credential:owner', + }); + const response: GetAllResponse = await testServer + .authAgentFor(member) + .get('/credentials') + .query(`filter={ "projectId": "${teamProjectWithMember.id}" }`) + .expect(200); + + expect(response.body.data).toHaveLength(2); + }); + + test('should return no credentials in a team project that member not is part of', async () => { + const teamProjectWithoutMember = await createTeamProject( + 'Team Project Without member', + owner, + ); + + await saveCredential(payload(), { + project: teamProjectWithoutMember, + role: 'credential:owner', + }); + + const response = await testServer + .authAgentFor(member) + .get('/credentials') + .query(`filter={ "projectId": "${teamProjectWithoutMember.id}" }`) + .expect(200); + + expect(response.body.data).toHaveLength(0); + }); + + test('should return only owned and explicitly shared credentials when filtering by any personal project id', async () => { + // Create credential owned by `owner` and share it to `member` + const ownerCredential = await saveCredential(payload(), { + user: owner, + role: 'credential:owner', + }); + await shareCredentialWithUsers(ownerCredential, [member]); + // Create credential owned by `member` + const memberCredential = await saveCredential(payload(), { + user: member, + role: 'credential:owner', + }); + + // Simulate editing a workflow owned by `owner` so request credentials to their personal project + const response: GetAllResponse = await testServer + .authAgentFor(member) + .get('/credentials') + .query(`filter={ "projectId": "${ownerPersonalProject.id}" }`) + .expect(200); + + expect(response.body.data).toHaveLength(2); + expect(response.body.data.map((credential) => credential.id)).toContain(ownerCredential.id); + expect(response.body.data.map((credential) => credential.id)).toContain(memberCredential.id); + }); + + test('should return all credentials to instance owners when working on their own personal project', async () => { + const ownerCredential = await saveCredential(payload(), { + user: owner, + role: 'credential:owner', + }); + const memberCredential = await saveCredential(payload(), { + user: member, + role: 'credential:owner', + }); + + const response: GetAllResponse = await testServer + .authAgentFor(owner) + .get('/credentials') + .query(`filter={ "projectId": "${ownerPersonalProject.id}" }&includeScopes=true`) + .expect(200); + + expect(response.body.data).toHaveLength(2); + expect(response.body.data.map((credential) => credential.id)).toContain(ownerCredential.id); + expect(response.body.data.map((credential) => credential.id)).toContain(memberCredential.id); + }); }); describe('select', () => { @@ -264,20 +383,19 @@ describe('GET /credentials', () => { }); function validateCredential(credential: ListQuery.Credentials.WithOwnedByAndSharedWith) { - const { name, type, sharedWith, ownedBy } = credential; + const { name, type, sharedWithProjects, homeProject } = credential; expect(typeof name).toBe('string'); expect(typeof type).toBe('string'); expect('data' in credential).toBe(false); - if (sharedWith) expect(Array.isArray(sharedWith)).toBe(true); + if (sharedWithProjects) expect(Array.isArray(sharedWithProjects)).toBe(true); - if (ownedBy) { - const { id, email, firstName, lastName } = ownedBy; + if (homeProject) { + const { id, name, type } = homeProject; expect(typeof id).toBe('string'); - expect(typeof email).toBe('string'); - expect(typeof firstName).toBe('string'); - expect(typeof lastName).toBe('string'); + expect(typeof name).toBe('string'); + expect(type).toBe('personal'); } } diff --git a/packages/cli/test/integration/credentials.ee.test.ts b/packages/cli/test/integration/credentials/credentials.ee.test.ts similarity index 57% rename from packages/cli/test/integration/credentials.ee.test.ts rename to packages/cli/test/integration/credentials/credentials.ee.test.ts index 279f33c019..b00b0091f4 100644 --- a/packages/cli/test/integration/credentials.ee.test.ts +++ b/packages/cli/test/integration/credentials/credentials.ee.test.ts @@ -1,40 +1,66 @@ import { Container } from 'typedi'; import type { SuperAgentTest } from 'supertest'; import { In } from '@n8n/typeorm'; -import type { IUser } from 'n8n-workflow'; import type { ListQuery } from '@/requests'; import type { User } from '@db/entities/User'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; -import { randomCredentialPayload } from './shared/random'; -import * as testDb from './shared/testDb'; -import type { SaveCredentialFunction } from './shared/types'; -import * as utils from './shared/utils/'; -import { affixRoleToSaveCredential, shareCredentialWithUsers } from './shared/db/credentials'; -import { createManyUsers, createUser, createUserShell } from './shared/db/users'; +import { randomCredentialPayload } from '../shared/random'; +import * as testDb from '../shared/testDb'; +import type { SaveCredentialFunction } from '../shared/types'; +import * as utils from '../shared/utils'; +import { + affixRoleToSaveCredential, + shareCredentialWithProjects, + shareCredentialWithUsers, +} from '../shared/db/credentials'; +import { createManyUsers, createUser, createUserShell } from '../shared/db/users'; import { UserManagementMailer } from '@/UserManagement/email'; -import { mockInstance } from '../shared/mocking'; +import { mockInstance } from '../../shared/mocking'; import config from '@/config'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import type { Project } from '@/databases/entities/Project'; +import { ProjectService } from '@/services/project.service'; const testServer = utils.setupTestServer({ endpointGroups: ['credentials'], enabledFeatures: ['feat:sharing'], + quotas: { + 'quota:maxTeamProjects': -1, + }, }); let owner: User; +let ownerPersonalProject: Project; let member: User; +let memberPersonalProject: Project; let anotherMember: User; +let anotherMemberPersonalProject: Project; let authOwnerAgent: SuperAgentTest; let authAnotherMemberAgent: SuperAgentTest; let saveCredential: SaveCredentialFunction; const mailer = mockInstance(UserManagementMailer); -beforeAll(async () => { +let projectService: ProjectService; +let projectRepository: ProjectRepository; + +beforeEach(async () => { + await testDb.truncate(['SharedCredentials', 'Credentials', 'Project', 'ProjectRelation']); + projectRepository = Container.get(ProjectRepository); + projectService = Container.get(ProjectService); + owner = await createUser({ role: 'global:owner' }); + ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); + member = await createUser({ role: 'global:member' }); + memberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(member.id); + anotherMember = await createUser({ role: 'global:member' }); + anotherMemberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + anotherMember.id, + ); authOwnerAgent = testServer.authAgentFor(owner); authAnotherMemberAgent = testServer.authAgentFor(anotherMember); @@ -42,10 +68,6 @@ beforeAll(async () => { saveCredential = affixRoleToSaveCredential('credential:owner'); }); -beforeEach(async () => { - await testDb.truncate(['SharedCredentials', 'Credentials']); -}); - afterEach(() => { jest.clearAllMocks(); }); @@ -58,23 +80,35 @@ describe('GET /credentials', () => { const [member1, member2, member3] = await createManyUsers(3, { role: 'global:member', }); + const member1PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + member1.id, + ); + const member2PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + member2.id, + ); + const member3PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + member3.id, + ); const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); await saveCredential(randomCredentialPayload(), { user: member1 }); - const sharedWith = [member1, member2, member3]; - await shareCredentialWithUsers(savedCredential, sharedWith); + const sharedWith = [member1PersonalProject, member2PersonalProject, member3PersonalProject]; + await shareCredentialWithProjects(savedCredential, sharedWith); const response = await authOwnerAgent.get('/credentials'); expect(response.statusCode).toBe(200); expect(response.body.data).toHaveLength(2); // owner retrieved owner cred and member cred - const ownerCredential = response.body.data.find( - (e: ListQuery.Credentials.WithOwnedByAndSharedWith) => e.ownedBy?.id === owner.id, - ); - const memberCredential = response.body.data.find( - (e: ListQuery.Credentials.WithOwnedByAndSharedWith) => e.ownedBy?.id === member1.id, + const ownerCredential: ListQuery.Credentials.WithOwnedByAndSharedWith = response.body.data.find( + (e: ListQuery.Credentials.WithOwnedByAndSharedWith) => + e.homeProject?.id === ownerPersonalProject.id, ); + const memberCredential: ListQuery.Credentials.WithOwnedByAndSharedWith = + response.body.data.find( + (e: ListQuery.Credentials.WithOwnedByAndSharedWith) => + e.homeProject?.id === member1PersonalProject.id, + ); validateMainCredentialData(ownerCredential); expect(ownerCredential.data).toBeUndefined(); @@ -82,46 +116,48 @@ describe('GET /credentials', () => { validateMainCredentialData(memberCredential); expect(memberCredential.data).toBeUndefined(); - expect(ownerCredential.ownedBy).toMatchObject({ - id: owner.id, - email: owner.email, - firstName: owner.firstName, - lastName: owner.lastName, + expect(ownerCredential.homeProject).toMatchObject({ + id: ownerPersonalProject.id, + type: 'personal', + name: owner.createPersonalProjectName(), }); - expect(Array.isArray(ownerCredential.sharedWith)).toBe(true); - expect(ownerCredential.sharedWith).toHaveLength(3); + expect(Array.isArray(ownerCredential.sharedWithProjects)).toBe(true); + expect(ownerCredential.sharedWithProjects).toHaveLength(3); // Fix order issue (MySQL might return items in any order) - const ownerCredentialsSharedWithOrdered = [...ownerCredential.sharedWith!].sort( - (a: IUser, b: IUser) => (a.email < b.email ? -1 : 1), + const ownerCredentialsSharedWithOrdered = [...ownerCredential.sharedWithProjects].sort( + (a, b) => (a.id < b.id ? -1 : 1), ); - const orderedSharedWith = [...sharedWith].sort((a, b) => (a.email < b.email ? -1 : 1)); + const orderedSharedWith = [...sharedWith].sort((a, b) => (a.id < b.id ? -1 : 1)); - ownerCredentialsSharedWithOrdered.forEach((sharee: IUser, idx: number) => { + ownerCredentialsSharedWithOrdered.forEach((sharee, idx) => { expect(sharee).toMatchObject({ id: orderedSharedWith[idx].id, - email: orderedSharedWith[idx].email, - firstName: orderedSharedWith[idx].firstName, - lastName: orderedSharedWith[idx].lastName, + type: orderedSharedWith[idx].type, }); }); - expect(memberCredential.ownedBy).toMatchObject({ - id: member1.id, - email: member1.email, - firstName: member1.firstName, - lastName: member1.lastName, + expect(memberCredential.homeProject).toMatchObject({ + id: member1PersonalProject.id, + type: member1PersonalProject.type, + name: member1.createPersonalProjectName(), }); - expect(Array.isArray(memberCredential.sharedWith)).toBe(true); - expect(memberCredential.sharedWith).toHaveLength(0); + expect(Array.isArray(memberCredential.sharedWithProjects)).toBe(true); + expect(memberCredential.sharedWithProjects).toHaveLength(0); }); test('should return only relevant creds for member', async () => { const [member1, member2] = await createManyUsers(2, { role: 'global:member', }); + const member1PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + member1.id, + ); + const member2PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + member2.id, + ); await saveCredential(randomCredentialPayload(), { user: member2 }); const savedMemberCredential = await saveCredential(randomCredentialPayload(), { @@ -135,30 +171,50 @@ describe('GET /credentials', () => { expect(response.statusCode).toBe(200); expect(response.body.data).toHaveLength(1); // member retrieved only member cred - const [member1Credential] = response.body.data; + const [member1Credential]: [ListQuery.Credentials.WithOwnedByAndSharedWith] = + response.body.data; validateMainCredentialData(member1Credential); expect(member1Credential.data).toBeUndefined(); - expect(member1Credential.ownedBy).toMatchObject({ - id: member1.id, - email: member1.email, - firstName: member1.firstName, - lastName: member1.lastName, + expect(member1Credential.homeProject).toMatchObject({ + id: member1PersonalProject.id, + name: member1.createPersonalProjectName(), + type: member1PersonalProject.type, }); - expect(Array.isArray(member1Credential.sharedWith)).toBe(true); - expect(member1Credential.sharedWith).toHaveLength(1); - - const [sharee] = member1Credential.sharedWith; - - expect(sharee).toMatchObject({ - id: member2.id, - email: member2.email, - firstName: member2.firstName, - lastName: member2.lastName, + expect(member1Credential.sharedWithProjects).toHaveLength(1); + expect(member1Credential.sharedWithProjects[0]).toMatchObject({ + id: member2PersonalProject.id, + name: member2.createPersonalProjectName(), + type: member2PersonalProject.type, }); }); + + test('should show credentials that the user has access to through a team project they are part of', async () => { + // + // ARRANGE + // + const project1 = await projectService.createTeamProject('Team Project', member); + await projectService.addUser(project1.id, anotherMember.id, 'project:editor'); + // anotherMember should see this one + const credential1 = await saveCredential(randomCredentialPayload(), { project: project1 }); + + const project2 = await projectService.createTeamProject('Team Project', member); + // anotherMember should NOT see this one + await saveCredential(randomCredentialPayload(), { project: project2 }); + + // + // ACT + // + const response = await testServer.authAgentFor(anotherMember).get('/credentials'); + + // + // ASSERT + // + expect(response.body.data).toHaveLength(1); + expect(response.body.data[0].id).toBe(credential1.id); + }); }); // ---------------------------------------- @@ -172,16 +228,16 @@ describe('GET /credentials/:id', () => { expect(firstResponse.statusCode).toBe(200); - const { data: firstCredential } = firstResponse.body; + const firstCredential: ListQuery.Credentials.WithOwnedByAndSharedWith = firstResponse.body.data; validateMainCredentialData(firstCredential); expect(firstCredential.data).toBeUndefined(); - expect(firstCredential.ownedBy).toMatchObject({ - id: owner.id, - email: owner.email, - firstName: owner.firstName, - lastName: owner.lastName, + + expect(firstCredential.homeProject).toMatchObject({ + id: ownerPersonalProject.id, + name: owner.createPersonalProjectName(), + type: ownerPersonalProject.type, }); - expect(firstCredential.sharedWith).toHaveLength(0); + expect(firstCredential.sharedWithProjects).toHaveLength(0); const secondResponse = await authOwnerAgent .get(`/credentials/${savedCredential.id}`) @@ -198,77 +254,103 @@ describe('GET /credentials/:id', () => { const [member1, member2] = await createManyUsers(2, { role: 'global:member', }); + const member1PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + member1.id, + ); + const member2PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + member2.id, + ); const savedCredential = await saveCredential(randomCredentialPayload(), { user: member1 }); await shareCredentialWithUsers(savedCredential, [member2]); - const response1 = await authOwnerAgent.get(`/credentials/${savedCredential.id}`); + const response1 = await authOwnerAgent.get(`/credentials/${savedCredential.id}`).expect(200); - expect(response1.statusCode).toBe(200); + const credential: ListQuery.Credentials.WithOwnedByAndSharedWith = response1.body.data; - validateMainCredentialData(response1.body.data); - expect(response1.body.data.data).toBeUndefined(); - expect(response1.body.data.ownedBy).toMatchObject({ - id: member1.id, - email: member1.email, - firstName: member1.firstName, - lastName: member1.lastName, - }); - expect(response1.body.data.sharedWith).toHaveLength(1); - expect(response1.body.data.sharedWith[0]).toMatchObject({ - id: member2.id, - email: member2.email, - firstName: member2.firstName, - lastName: member2.lastName, + validateMainCredentialData(credential); + expect(credential.data).toBeUndefined(); + expect(credential).toMatchObject({ + homeProject: { + id: member1PersonalProject.id, + name: member1.createPersonalProjectName(), + type: member1PersonalProject.type, + }, + sharedWithProjects: [ + { + id: member2PersonalProject.id, + name: member2.createPersonalProjectName(), + type: member2PersonalProject.type, + }, + ], }); const response2 = await authOwnerAgent .get(`/credentials/${savedCredential.id}`) - .query({ includeData: true }); + .query({ includeData: true }) + .expect(200); - expect(response2.statusCode).toBe(200); + const credential2: ListQuery.Credentials.WithOwnedByAndSharedWith = response2.body.data; - validateMainCredentialData(response2.body.data); - expect(response2.body.data.data).toBeDefined(); // Instance owners should be capable of editing all credentials - expect(response2.body.data.sharedWith).toHaveLength(1); + validateMainCredentialData(credential); + expect(credential2.data).toBeDefined(); // Instance owners should be capable of editing all credentials + expect(credential2.sharedWithProjects).toHaveLength(1); }); test('should retrieve owned cred for member', async () => { const [member1, member2, member3] = await createManyUsers(3, { role: 'global:member', }); + const member1PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + member1.id, + ); + const member2PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + member2.id, + ); + const member3PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + member3.id, + ); const authMemberAgent = testServer.authAgentFor(member1); const savedCredential = await saveCredential(randomCredentialPayload(), { user: member1 }); await shareCredentialWithUsers(savedCredential, [member2, member3]); - const firstResponse = await authMemberAgent.get(`/credentials/${savedCredential.id}`); + const firstResponse = await authMemberAgent + .get(`/credentials/${savedCredential.id}`) + .expect(200); - expect(firstResponse.statusCode).toBe(200); - - const { data: firstCredential } = firstResponse.body; + const firstCredential: ListQuery.Credentials.WithOwnedByAndSharedWith = firstResponse.body.data; validateMainCredentialData(firstCredential); expect(firstCredential.data).toBeUndefined(); - expect(firstCredential.ownedBy).toMatchObject({ - id: member1.id, - email: member1.email, - firstName: member1.firstName, - lastName: member1.lastName, - }); - expect(firstCredential.sharedWith).toHaveLength(2); - firstCredential.sharedWith.forEach((sharee: IUser, idx: number) => { - expect([member2.id, member3.id]).toContain(sharee.id); + expect(firstCredential).toMatchObject({ + homeProject: { + id: member1PersonalProject.id, + name: member1.createPersonalProjectName(), + type: 'personal', + }, + sharedWithProjects: expect.arrayContaining([ + { + id: member2PersonalProject.id, + name: member2.createPersonalProjectName(), + type: member2PersonalProject.type, + }, + { + id: member3PersonalProject.id, + name: member3.createPersonalProjectName(), + type: member3PersonalProject.type, + }, + ]), }); const secondResponse = await authMemberAgent .get(`/credentials/${savedCredential.id}`) - .query({ includeData: true }); + .query({ includeData: true }) + .expect(200); - expect(secondResponse.statusCode).toBe(200); - - const { data: secondCredential } = secondResponse.body; + const secondCredential: ListQuery.Credentials.WithOwnedByAndSharedWith = + secondResponse.body.data; validateMainCredentialData(secondCredential); expect(secondCredential.data).toBeDefined(); - expect(firstCredential.sharedWith).toHaveLength(2); + expect(secondCredential.sharedWithProjects).toHaveLength(2); }); test('should not retrieve non-owned cred for member', async () => { @@ -305,13 +387,20 @@ describe('PUT /credentials/:id/share', () => { const [member1, member2, member3, member4, member5] = await createManyUsers(5, { role: 'global:member', }); - const shareWithIds = [member1.id, member2.id, member3.id]; + // TODO: write helper for getting multiple personal projects by user id + const shareWithProjectIds = ( + await Promise.all([ + projectRepository.getPersonalProjectForUserOrFail(member1.id), + projectRepository.getPersonalProjectForUserOrFail(member2.id), + projectRepository.getPersonalProjectForUserOrFail(member3.id), + ]) + ).map((project) => project.id); await shareCredentialWithUsers(savedCredential, [member4, member5]); const response = await authOwnerAgent .put(`/credentials/${savedCredential.id}/share`) - .send({ shareWithIds }); + .send({ shareWithIds: shareWithProjectIds }); expect(response.statusCode).toBe(200); expect(response.body.data).toBeUndefined(); @@ -321,40 +410,54 @@ describe('PUT /credentials/:id/share', () => { }); // check that sharings have been removed/added correctly - expect(sharedCredentials.length).toBe(shareWithIds.length + 1); // +1 for the owner + expect(sharedCredentials.length).toBe(shareWithProjectIds.length + 1); // +1 for the owner sharedCredentials.forEach((sharedCredential) => { - if (sharedCredential.userId === owner.id) { + if (sharedCredential.projectId === ownerPersonalProject.id) { expect(sharedCredential.role).toBe('credential:owner'); return; } - expect(shareWithIds).toContain(sharedCredential.userId); + expect(shareWithProjectIds).toContain(sharedCredential.projectId); expect(sharedCredential.role).toBe('credential:user'); }); expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(1); + expect(mailer.notifyCredentialsShared).toHaveBeenCalledWith( + expect.objectContaining({ + newShareeIds: expect.arrayContaining([member1.id, member2.id, member3.id]), + sharer: expect.objectContaining({ id: owner.id }), + credentialsName: savedCredential.name, + }), + ); }); test('should share the credential with the provided userIds', async () => { const [member1, member2, member3] = await createManyUsers(3, { role: 'global:member', }); - const memberIds = [member1.id, member2.id, member3.id]; + const projectIds = ( + await Promise.all([ + projectRepository.getPersonalProjectForUserOrFail(member1.id), + projectRepository.getPersonalProjectForUserOrFail(member2.id), + projectRepository.getPersonalProjectForUserOrFail(member3.id), + ]) + ).map((project) => project.id); + // const memberIds = [member1.id, member2.id, member3.id]; const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); const response = await authOwnerAgent .put(`/credentials/${savedCredential.id}/share`) - .send({ shareWithIds: memberIds }); + .send({ shareWithIds: projectIds }); expect(response.statusCode).toBe(200); expect(response.body.data).toBeUndefined(); // check that sharings got correctly set in DB const sharedCredentials = await Container.get(SharedCredentialsRepository).find({ - where: { credentialsId: savedCredential.id, userId: In([...memberIds]) }, + where: { credentialsId: savedCredential.id, projectId: In(projectIds) }, }); - expect(sharedCredentials.length).toBe(memberIds.length); + expect(sharedCredentials.length).toBe(projectIds.length); sharedCredentials.forEach((sharedCredential) => { expect(sharedCredential.role).toBe('credential:user'); @@ -362,7 +465,7 @@ describe('PUT /credentials/:id/share', () => { // check that owner still exists const ownerSharedCredential = await Container.get(SharedCredentialsRepository).findOneOrFail({ - where: { credentialsId: savedCredential.id, userId: owner.id }, + where: { credentialsId: savedCredential.id, projectId: ownerPersonalProject.id }, }); expect(ownerSharedCredential.role).toBe('credential:owner'); @@ -372,7 +475,7 @@ describe('PUT /credentials/:id/share', () => { test('should respond 403 for non-existing credentials', async () => { const response = await authOwnerAgent .put('/credentials/1234567/share') - .send({ shareWithIds: [member.id] }); + .send({ shareWithIds: [memberPersonalProject.id] }); expect(response.statusCode).toBe(403); expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(0); @@ -385,7 +488,7 @@ describe('PUT /credentials/:id/share', () => { const response = await authAnotherMemberAgent .put(`/credentials/${savedCredential.id}/share`) - .send({ shareWithIds: [owner.id] }); + .send({ shareWithIds: [ownerPersonalProject.id] }); expect(response.statusCode).toBe(403); const sharedCredentials = await Container.get(SharedCredentialsRepository).find({ @@ -400,7 +503,7 @@ describe('PUT /credentials/:id/share', () => { const response = await authAnotherMemberAgent .put(`/credentials/${savedCredential.id}/share`) - .send({ shareWithIds: [anotherMember.id] }); + .send({ shareWithIds: [anotherMemberPersonalProject.id] }); expect(response.statusCode).toBe(403); @@ -414,10 +517,13 @@ describe('PUT /credentials/:id/share', () => { test('should respond 403 for non-owned credentials for non-shared members sharing', async () => { const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); const tempUser = await createUser({ role: 'global:member' }); + const tempUserPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + tempUser.id, + ); const response = await authAnotherMemberAgent .put(`/credentials/${savedCredential.id}/share`) - .send({ shareWithIds: [tempUser.id] }); + .send({ shareWithIds: [tempUserPersonalProject.id] }); expect(response.statusCode).toBe(403); @@ -433,9 +539,9 @@ describe('PUT /credentials/:id/share', () => { const response = await authOwnerAgent .put(`/credentials/${savedCredential.id}/share`) - .send({ shareWithIds: [anotherMember.id] }); + .send({ shareWithIds: [anotherMemberPersonalProject.id] }) + .expect(200); - expect(response.statusCode).toBe(200); const sharedCredentials = await Container.get(SharedCredentialsRepository).find({ where: { credentialsId: savedCredential.id }, }); @@ -443,22 +549,29 @@ describe('PUT /credentials/:id/share', () => { expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(1); }); - test('should ignore pending sharee', async () => { + test('should not ignore pending sharee', async () => { const memberShell = await createUserShell('global:member'); + const memberShellPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + memberShell.id, + ); const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); - const response = await authOwnerAgent + await authOwnerAgent .put(`/credentials/${savedCredential.id}/share`) - .send({ shareWithIds: [memberShell.id] }); - - expect(response.statusCode).toBe(200); + .send({ shareWithIds: [memberShellPersonalProject.id] }) + .expect(200); const sharedCredentials = await Container.get(SharedCredentialsRepository).find({ where: { credentialsId: savedCredential.id }, }); - expect(sharedCredentials).toHaveLength(1); - expect(sharedCredentials[0].userId).toBe(owner.id); + expect(sharedCredentials).toHaveLength(2); + expect( + sharedCredentials.find((c) => c.projectId === ownerPersonalProject.id), + ).not.toBeUndefined(); + expect( + sharedCredentials.find((c) => c.projectId === memberShellPersonalProject.id), + ).not.toBeUndefined(); }); test('should ignore non-existing sharee', async () => { @@ -475,7 +588,7 @@ describe('PUT /credentials/:id/share', () => { }); expect(sharedCredentials).toHaveLength(1); - expect(sharedCredentials[0].userId).toBe(owner.id); + expect(sharedCredentials[0].projectId).toBe(ownerPersonalProject.id); expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(1); }); @@ -511,7 +624,7 @@ describe('PUT /credentials/:id/share', () => { }); expect(sharedCredentials).toHaveLength(1); - expect(sharedCredentials[0].userId).toBe(owner.id); + expect(sharedCredentials[0].projectId).toBe(ownerPersonalProject.id); expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(1); }); @@ -539,6 +652,6 @@ describe('PUT /credentials/:id/share', () => { function validateMainCredentialData(credential: ListQuery.Credentials.WithOwnedByAndSharedWith) { expect(typeof credential.name).toBe('string'); expect(typeof credential.type).toBe('string'); - expect(credential.ownedBy).toBeDefined(); - expect(Array.isArray(credential.sharedWith)).toBe(true); + expect(credential.homeProject).toBeDefined(); + expect(Array.isArray(credential.sharedWithProjects)).toBe(true); } diff --git a/packages/cli/test/integration/credentials/credentials.service.test.ts b/packages/cli/test/integration/credentials/credentials.service.test.ts new file mode 100644 index 0000000000..95aea5cbf8 --- /dev/null +++ b/packages/cli/test/integration/credentials/credentials.service.test.ts @@ -0,0 +1,55 @@ +import type { User } from '@/databases/entities/User'; +import type { CredentialsEntity } from '@/databases/entities/CredentialsEntity'; +import { saveCredential, shareCredentialWithUsers } from '../shared/db/credentials'; +import { createMember } from '../shared/db/users'; +import { randomCredentialPayload } from '../shared/random'; +import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository'; +import Container from 'typedi'; +import { CredentialsService } from '@/credentials/credentials.service'; +import * as testDb from '../shared/testDb'; + +const credentialPayload = randomCredentialPayload(); +let memberWhoOwnsCredential: User; +let memberWhoDoesNotOwnCredential: User; +let credential: CredentialsEntity; + +beforeAll(async () => { + await testDb.init(); + + memberWhoOwnsCredential = await createMember(); + memberWhoDoesNotOwnCredential = await createMember(); + credential = await saveCredential(credentialPayload, { + user: memberWhoOwnsCredential, + role: 'credential:owner', + }); + + await shareCredentialWithUsers(credential, [memberWhoDoesNotOwnCredential]); +}); + +describe('credentials service', () => { + describe('replaceCredentialContentsForSharee', () => { + it('should replace the contents of the credential for sharee', async () => { + const storedCredential = await Container.get( + SharedCredentialsRepository, + ).findCredentialForUser(credential.id, memberWhoDoesNotOwnCredential, ['credential:read']); + + const decryptedData = Container.get(CredentialsService).decrypt(storedCredential!); + + const mergedCredentials = { + id: credential.id, + name: credential.name, + type: credential.type, + data: { accessToken: '' }, + }; + + Container.get(CredentialsService).replaceCredentialContentsForSharee( + memberWhoDoesNotOwnCredential, + storedCredential!, + decryptedData, + mergedCredentials, + ); + + expect(mergedCredentials.data).toEqual({ accessToken: credentialPayload.data.accessToken }); + }); + }); +}); diff --git a/packages/cli/test/integration/database/repositories/project.repository.test.ts b/packages/cli/test/integration/database/repositories/project.repository.test.ts new file mode 100644 index 0000000000..449277adba --- /dev/null +++ b/packages/cli/test/integration/database/repositories/project.repository.test.ts @@ -0,0 +1,155 @@ +import Container from 'typedi'; +import { createMember, createOwner } from '../../shared/db/users'; +import * as testDb from '../../shared/testDb'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { EntityNotFoundError } from '@n8n/typeorm'; +import { createTeamProject } from '../../shared/db/projects'; +import { AuthIdentity } from '@/databases/entities/AuthIdentity'; +import { UserRepository } from '@/databases/repositories/user.repository'; + +describe('ProjectRepository', () => { + beforeAll(async () => { + await testDb.init(); + }); + + beforeEach(async () => { + await testDb.truncate(['User', 'Workflow', 'Project']); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + describe('getPersonalProjectForUser', () => { + it('returns the personal project', async () => { + // + // ARRANGE + // + const owner = await createOwner(); + const ownerPersonalProject = await Container.get(ProjectRepository).findOneByOrFail({ + projectRelations: { userId: owner.id }, + }); + + // + // ACT + // + const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUser( + owner.id, + ); + + // + // ASSERT + // + if (!personalProject) { + fail('Expected personalProject to be defined.'); + } + expect(personalProject).toBeDefined(); + expect(personalProject.id).toBe(ownerPersonalProject.id); + }); + + it('does not return non personal projects', async () => { + // + // ARRANGE + // + const owner = await createOwner(); + await Container.get(ProjectRepository).delete({}); + await createTeamProject(undefined, owner); + + // + // ACT + // + const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUser( + owner.id, + ); + + // + // ASSERT + // + expect(personalProject).toBeNull(); + }); + }); + + describe('getPersonalProjectForUserOrFail', () => { + it('returns the personal project', async () => { + // + // ARRANGE + // + const owner = await createOwner(); + const ownerPersonalProject = await Container.get(ProjectRepository).findOneByOrFail({ + projectRelations: { userId: owner.id }, + }); + + // + // ACT + // + const personalProject = await Container.get( + ProjectRepository, + ).getPersonalProjectForUserOrFail(owner.id); + + // + // ASSERT + // + if (!personalProject) { + fail('Expected personalProject to be defined.'); + } + expect(personalProject).toBeDefined(); + expect(personalProject.id).toBe(ownerPersonalProject.id); + }); + + it('does not return non personal projects', async () => { + // + // ARRANGE + // + const owner = await createOwner(); + await Container.get(ProjectRepository).delete({}); + await createTeamProject(undefined, owner); + + // + // ACT + // + const promise = Container.get(ProjectRepository).getPersonalProjectForUserOrFail(owner.id); + + // + // ASSERT + // + await expect(promise).rejects.toThrowError(EntityNotFoundError); + }); + }); + + describe('update personal project name', () => { + // TypeORM enters an infinite loop if you create entities with circular + // references and pass this to the `Repository.create` function. + // + // This actually happened in combination with SAML. + // `samlHelpers.updateUserFromSamlAttributes` and + // `samlHelpers.createUserFromSamlAttributes` would create a User and an + // AuthIdentity and assign them to one another. Then it would call + // `UserRepository.save(user)`. This would then call the UserSubscriber in + // `database/entities/Project.ts` which would pass the circular User into + // `UserRepository.create` and cause the infinite loop. + // + // This test simulates that behavior and makes sure the UserSubscriber + // checks if the entity is already a user and does not pass it into + // `UserRepository.create` in that case. + test('do not pass a User instance with circular references into `UserRepository.create`', async () => { + // + // ARRANGE + // + const user = await createMember(); + + const authIdentity = new AuthIdentity(); + authIdentity.providerId = user.email; + authIdentity.providerType = 'saml'; + authIdentity.user = user; + + user.firstName = `updated ${user.firstName}`; + user.authIdentities = []; + user.authIdentities.push(authIdentity); + + // + // ACT & ASSERT + // + await expect(Container.get(UserRepository).save(user)).resolves.not.toThrow(); + }); + }); +}); diff --git a/packages/cli/test/integration/environments/SourceControl.test.ts b/packages/cli/test/integration/environments/SourceControl.test.ts index f1da8d0672..e7f5b349fb 100644 --- a/packages/cli/test/integration/environments/SourceControl.test.ts +++ b/packages/cli/test/integration/environments/SourceControl.test.ts @@ -9,10 +9,15 @@ import type { SourceControlledFile } from '@/environments/sourceControl/types/so import * as utils from '../shared/utils/'; import { createUser } from '../shared/db/users'; +import { mockInstance } from '../../shared/mocking'; +import { WaitTracker } from '@/WaitTracker'; let authOwnerAgent: SuperAgentTest; let owner: User; +// This is necessary for the tests to shutdown cleanly. +mockInstance(WaitTracker); + const testServer = utils.setupTestServer({ endpointGroups: ['sourceControl', 'license', 'auth'], enabledFeatures: ['feat:sourceControl', 'feat:sharing'], diff --git a/packages/cli/test/integration/environments/source-control-import.service.test.ts b/packages/cli/test/integration/environments/source-control-import.service.test.ts index fd3327cedd..4665178a3f 100644 --- a/packages/cli/test/integration/environments/source-control-import.service.test.ts +++ b/packages/cli/test/integration/environments/source-control-import.service.test.ts @@ -13,6 +13,11 @@ import { SharedCredentialsRepository } from '@/databases/repositories/sharedCred import { mockInstance } from '../../shared/mocking'; import type { SourceControlledFile } from '@/environments/sourceControl/types/sourceControlledFile'; import type { ExportableCredential } from '@/environments/sourceControl/types/exportableCredential'; +import { createTeamProject, getPersonalProject } from '../shared/db/projects'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { saveCredential } from '../shared/db/credentials'; +import { randomCredentialPayload } from '../shared/random'; +import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; describe('SourceControlImportService', () => { let service: SourceControlImportService; @@ -66,9 +71,11 @@ describe('SourceControlImportService', () => { importingUser.id, ); + const personalProject = await getPersonalProject(member); + const sharing = await Container.get(SharedCredentialsRepository).findOneBy({ credentialsId: CREDENTIAL_ID, - userId: member.id, + projectId: personalProject.id, role: 'credential:owner', }); @@ -101,9 +108,11 @@ describe('SourceControlImportService', () => { importingUser.id, ); + const personalProject = await getPersonalProject(importingUser); + const sharing = await Container.get(SharedCredentialsRepository).findOneBy({ credentialsId: CREDENTIAL_ID, - userId: importingUser.id, + projectId: personalProject.id, role: 'credential:owner', }); @@ -136,9 +145,11 @@ describe('SourceControlImportService', () => { importingUser.id, ); + const personalProject = await getPersonalProject(importingUser); + const sharing = await Container.get(SharedCredentialsRepository).findOneBy({ credentialsId: CREDENTIAL_ID, - userId: importingUser.id, + projectId: personalProject.id, role: 'credential:owner', }); @@ -146,4 +157,199 @@ describe('SourceControlImportService', () => { }); }); }); + + describe('if owner specified by `ownedBy` does not exist at target instance', () => { + it('should assign the credential ownership to the importing user if it was owned by a personal project in the source instance', async () => { + const importingUser = await getGlobalOwner(); + + fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); + + const CREDENTIAL_ID = nanoid(); + + const stub: ExportableCredential = { + id: CREDENTIAL_ID, + name: 'My Credential', + type: 'someCredentialType', + data: {}, + ownedBy: { + type: 'personal', + personalEmail: 'test@example.com', + }, // user at source instance owns credential + }; + + jest.spyOn(utils, 'jsonParse').mockReturnValue(stub); + + cipher.encrypt.mockReturnValue('some-encrypted-data'); + + await service.importCredentialsFromWorkFolder( + [mock({ id: CREDENTIAL_ID })], + importingUser.id, + ); + + const personalProject = await getPersonalProject(importingUser); + + const sharing = await Container.get(SharedCredentialsRepository).findOneBy({ + credentialsId: CREDENTIAL_ID, + projectId: personalProject.id, + role: 'credential:owner', + }); + + expect(sharing).toBeTruthy(); // original user missing, so importing user owns credential + }); + + it('should create a new team project if the credential was owned by a team project in the source instance', async () => { + const importingUser = await getGlobalOwner(); + + fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); + + const CREDENTIAL_ID = nanoid(); + + const stub: ExportableCredential = { + id: CREDENTIAL_ID, + name: 'My Credential', + type: 'someCredentialType', + data: {}, + ownedBy: { + type: 'team', + teamId: '1234-asdf', + teamName: 'Marketing', + }, // user at source instance owns credential + }; + + jest.spyOn(utils, 'jsonParse').mockReturnValue(stub); + + cipher.encrypt.mockReturnValue('some-encrypted-data'); + + { + const project = await Container.get(ProjectRepository).findOne({ + where: [ + { + id: '1234-asdf', + }, + { name: 'Marketing' }, + ], + }); + + expect(project?.id).not.toBe('1234-asdf'); + expect(project?.name).not.toBe('Marketing'); + } + + await service.importCredentialsFromWorkFolder( + [mock({ id: CREDENTIAL_ID })], + importingUser.id, + ); + + const sharing = await Container.get(SharedCredentialsRepository).findOne({ + where: { + credentialsId: CREDENTIAL_ID, + role: 'credential:owner', + }, + relations: { project: true }, + }); + + expect(sharing?.project.id).toBe('1234-asdf'); + expect(sharing?.project.name).toBe('Marketing'); + expect(sharing?.project.type).toBe('team'); + + expect(sharing).toBeTruthy(); // original user missing, so importing user owns credential + }); + }); + + describe('if owner specified by `ownedBy` does exist at target instance', () => { + it('should use the existing team project if credential owning project is found', async () => { + const importingUser = await getGlobalOwner(); + + fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); + + const CREDENTIAL_ID = nanoid(); + + const project = await createTeamProject('Sales'); + + const stub: ExportableCredential = { + id: CREDENTIAL_ID, + name: 'My Credential', + type: 'someCredentialType', + data: {}, + ownedBy: { + type: 'team', + teamId: project.id, + teamName: 'Sales', + }, + }; + + jest.spyOn(utils, 'jsonParse').mockReturnValue(stub); + + cipher.encrypt.mockReturnValue('some-encrypted-data'); + + await service.importCredentialsFromWorkFolder( + [mock({ id: CREDENTIAL_ID })], + importingUser.id, + ); + + const sharing = await Container.get(SharedCredentialsRepository).findOneBy({ + credentialsId: CREDENTIAL_ID, + projectId: project.id, + role: 'credential:owner', + }); + + expect(sharing).toBeTruthy(); + }); + + it('should not change the owner if the credential is owned by somebody else on the target instance', async () => { + cipher.encrypt.mockReturnValue('some-encrypted-data'); + + const importingUser = await getGlobalOwner(); + + fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); + + const targetProject = await createTeamProject('Marketing'); + const credential = await saveCredential(randomCredentialPayload(), { + project: targetProject, + role: 'credential:owner', + }); + + const sourceProjectId = nanoid(); + + const stub: ExportableCredential = { + id: credential.id, + name: 'My Credential', + type: 'someCredentialType', + data: {}, + ownedBy: { + type: 'team', + teamId: sourceProjectId, + teamName: 'Sales', + }, + }; + + jest.spyOn(utils, 'jsonParse').mockReturnValue(stub); + + await service.importCredentialsFromWorkFolder( + [mock({ id: credential.id })], + importingUser.id, + ); + + await expect( + Container.get(SharedCredentialsRepository).findBy({ + credentialsId: credential.id, + }), + ).resolves.toMatchObject([ + { + projectId: targetProject.id, + role: 'credential:owner', + }, + ]); + await expect( + Container.get(CredentialsRepository).findBy({ + id: credential.id, + }), + ).resolves.toMatchObject([ + { + name: stub.name, + type: stub.type, + data: 'some-encrypted-data', + }, + ]); + }); + }); }); diff --git a/packages/cli/test/integration/executions.controller.test.ts b/packages/cli/test/integration/executions.controller.test.ts index 02f8c3f25f..866a680cca 100644 --- a/packages/cli/test/integration/executions.controller.test.ts +++ b/packages/cli/test/integration/executions.controller.test.ts @@ -1,20 +1,20 @@ import type { User } from '@db/entities/User'; -import { EnterpriseExecutionsService } from '@/executions/execution.service.ee'; -import { WaitTracker } from '@/WaitTracker'; import { createSuccessfulExecution, getAllExecutions } from './shared/db/executions'; -import { createOwner } from './shared/db/users'; -import { createWorkflow } from './shared/db/workflows'; +import { createMember, createOwner } from './shared/db/users'; +import { createWorkflow, shareWorkflowWithUsers } from './shared/db/workflows'; import * as testDb from './shared/testDb'; import { setupTestServer } from './shared/utils'; import { mockInstance } from '../shared/mocking'; +import { WaitTracker } from '@/WaitTracker'; -mockInstance(EnterpriseExecutionsService); -mockInstance(WaitTracker); - -let testServer = setupTestServer({ endpointGroups: ['executions'] }); +const testServer = setupTestServer({ endpointGroups: ['executions'] }); let owner: User; +let member: User; + +// This is necessary for the tests to shutdown cleanly. +mockInstance(WaitTracker); const saveExecution = async ({ belongingTo }: { belongingTo: User }) => { const workflow = await createWorkflow({}, belongingTo); @@ -23,7 +23,44 @@ const saveExecution = async ({ belongingTo }: { belongingTo: User }) => { beforeEach(async () => { await testDb.truncate(['Execution', 'Workflow', 'SharedWorkflow']); + testServer.license.reset(); owner = await createOwner(); + member = await createMember(); +}); + +describe('GET /executions', () => { + test('only returns executions of shared workflows if sharing is enabled', async () => { + const workflow = await createWorkflow({}, owner); + await shareWorkflowWithUsers(workflow, [member]); + await createSuccessfulExecution(workflow); + + const response1 = await testServer.authAgentFor(member).get('/executions').expect(200); + expect(response1.body.data.count).toBe(0); + + testServer.license.enable('feat:sharing'); + + const response2 = await testServer.authAgentFor(member).get('/executions').expect(200); + expect(response2.body.data.count).toBe(1); + }); +}); + +describe('GET /executions/:id', () => { + test('only returns executions of shared workflows if sharing is enabled', async () => { + const workflow = await createWorkflow({}, owner); + await shareWorkflowWithUsers(workflow, [member]); + const execution = await createSuccessfulExecution(workflow); + + await testServer.authAgentFor(member).get(`/executions/${execution.id}`).expect(404); + + testServer.license.enable('feat:sharing'); + + const response = await testServer + .authAgentFor(member) + .get(`/executions/${execution.id}`) + .expect(200); + + expect(response.body.data.id).toBe(execution.id); + }); }); describe('POST /executions/delete', () => { diff --git a/packages/cli/test/integration/import.service.test.ts b/packages/cli/test/integration/import.service.test.ts index 2cfdbe4080..99252bdab6 100644 --- a/packages/cli/test/integration/import.service.test.ts +++ b/packages/cli/test/integration/import.service.test.ts @@ -21,16 +21,20 @@ import { } from './shared/db/workflows'; import type { User } from '@db/entities/User'; +import type { Project } from '@/databases/entities/Project'; +import { getPersonalProject } from './shared/db/projects'; describe('ImportService', () => { let importService: ImportService; let tagRepository: TagRepository; let owner: User; + let ownerPersonalProject: Project; beforeAll(async () => { await testDb.init(); owner = await createOwner(); + ownerPersonalProject = await getPersonalProject(owner); tagRepository = Container.get(TagRepository); @@ -52,7 +56,7 @@ describe('ImportService', () => { test('should import credless and tagless workflow', async () => { const workflowToImport = await createWorkflow(); - await importService.importWorkflows([workflowToImport], owner.id); + await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); const dbWorkflow = await getWorkflowById(workflowToImport.id); @@ -64,27 +68,32 @@ describe('ImportService', () => { test('should make user owner of imported workflow', async () => { const workflowToImport = newWorkflow(); - await importService.importWorkflows([workflowToImport], owner.id); + await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); const dbSharing = await Container.get(SharedWorkflowRepository).findOneOrFail({ - where: { workflowId: workflowToImport.id, userId: owner.id, role: 'workflow:owner' }, + where: { + workflowId: workflowToImport.id, + projectId: ownerPersonalProject.id, + role: 'workflow:owner', + }, }); - expect(dbSharing.userId).toBe(owner.id); + expect(dbSharing.projectId).toBe(ownerPersonalProject.id); }); test('should not change the owner if it already exists', async () => { const member = await createMember(); + const memberPersonalProject = await getPersonalProject(member); const workflowToImport = await createWorkflow(undefined, owner); - await importService.importWorkflows([workflowToImport], member.id); + await importService.importWorkflows([workflowToImport], memberPersonalProject.id); const sharings = await getAllSharedWorkflows(); expect(sharings).toMatchObject([ expect.objectContaining({ workflowId: workflowToImport.id, - userId: owner.id, + projectId: ownerPersonalProject.id, role: 'workflow:owner', }), ]); @@ -93,7 +102,7 @@ describe('ImportService', () => { test('should deactivate imported workflow if active', async () => { const workflowToImport = await createWorkflow({ active: true }); - await importService.importWorkflows([workflowToImport], owner.id); + await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); const dbWorkflow = await getWorkflowById(workflowToImport.id); @@ -121,7 +130,7 @@ describe('ImportService', () => { const workflowToImport = await createWorkflow({ nodes }); - await importService.importWorkflows([workflowToImport], owner.id); + await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); const dbWorkflow = await getWorkflowById(workflowToImport.id); @@ -141,7 +150,7 @@ describe('ImportService', () => { const workflowToImport = await createWorkflow({ tags: [tag] }); - await importService.importWorkflows([workflowToImport], owner.id); + await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); const dbWorkflow = await Container.get(WorkflowRepository).findOneOrFail({ where: { id: workflowToImport.id }, @@ -162,7 +171,7 @@ describe('ImportService', () => { const workflowToImport = await createWorkflow({ tags: [tag] }); - await importService.importWorkflows([workflowToImport], owner.id); + await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); const dbWorkflow = await Container.get(WorkflowRepository).findOneOrFail({ where: { id: workflowToImport.id }, @@ -181,7 +190,7 @@ describe('ImportService', () => { const workflowToImport = await createWorkflow({ tags: [tag] }); - await importService.importWorkflows([workflowToImport], owner.id); + await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); const dbWorkflow = await Container.get(WorkflowRepository).findOneOrFail({ where: { id: workflowToImport.id }, diff --git a/packages/cli/test/integration/ldap/ldap.api.test.ts b/packages/cli/test/integration/ldap/ldap.api.test.ts index 17cb3e7b54..0ab2f84913 100644 --- a/packages/cli/test/integration/ldap/ldap.api.test.ts +++ b/packages/cli/test/integration/ldap/ldap.api.test.ts @@ -2,15 +2,13 @@ import Container from 'typedi'; import type { SuperAgentTest } from 'supertest'; import type { Entry as LdapUser } from 'ldapts'; import { Not } from '@n8n/typeorm'; -import { jsonParse } from 'n8n-workflow'; import { Cipher } from 'n8n-core'; import config from '@/config'; import type { User } from '@db/entities/User'; -import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/Ldap/constants'; +import { LDAP_DEFAULT_CONFIGURATION } from '@/Ldap/constants'; import { LdapService } from '@/Ldap/ldap.service'; import { saveLdapSynchronization } from '@/Ldap/helpers'; -import type { LdapConfig } from '@/Ldap/types'; import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from '@/sso/ssoHelpers'; import { randomEmail, randomName, uniqueId } from './../shared/random'; @@ -19,28 +17,15 @@ import * as utils from '../shared/utils/'; import { createLdapUser, createUser, getAllUsers, getLdapIdentities } from '../shared/db/users'; import { UserRepository } from '@db/repositories/user.repository'; -import { SettingsRepository } from '@db/repositories/settings.repository'; import { AuthProviderSyncHistoryRepository } from '@db/repositories/authProviderSyncHistory.repository'; +import { getPersonalProject } from '../shared/db/projects'; +import { createLdapConfig, defaultLdapConfig } from '../shared/ldap'; jest.mock('@/telemetry'); let owner: User; let authOwnerAgent: SuperAgentTest; -const defaultLdapConfig = { - ...LDAP_DEFAULT_CONFIGURATION, - loginEnabled: true, - loginLabel: '', - ldapIdAttribute: 'uid', - firstNameAttribute: 'givenName', - lastNameAttribute: 'sn', - emailAttribute: 'mail', - loginIdAttribute: 'mail', - baseDn: 'baseDn', - bindingAdminDn: 'adminDn', - bindingAdminPassword: 'adminPassword', -}; - const testServer = utils.setupTestServer({ endpointGroups: ['auth', 'ldap'], enabledFeatures: ['feat:ldap'], @@ -74,18 +59,6 @@ beforeEach(async () => { await setCurrentAuthenticationMethod('email'); }); -const createLdapConfig = async (attributes: Partial = {}): Promise => { - const { value: ldapConfig } = await Container.get(SettingsRepository).save({ - key: LDAP_FEATURE_NAME, - value: JSON.stringify({ - ...defaultLdapConfig, - ...attributes, - }), - loadOnStartup: true, - }); - return await jsonParse(ldapConfig); -}; - test('Member role should not be able to access ldap routes', async () => { const member = await createUser({ role: 'global:member' }); const authAgent = testServer.authAgentFor(member); @@ -366,6 +339,8 @@ describe('POST /ldap/sync', () => { expect(memberUser.email).toBe(ldapUser.mail); expect(memberUser.lastName).toBe(ldapUser.sn); expect(memberUser.firstName).toBe(ldapUser.givenName); + const memberProject = getPersonalProject(memberUser); + expect(memberProject).toBeDefined(); const authIdentities = await getLdapIdentities(); expect(authIdentities.length).toBe(1); @@ -509,6 +484,8 @@ describe('POST /login', () => { expect(localLdapUsers[0].firstName).toBe(ldapUser.givenName); expect(localLdapIdentities[0].providerId).toBe(ldapUser.uid); expect(localLdapUsers[0].disabled).toBe(false); + + await expect(getPersonalProject(localLdapUsers[0])).resolves.toBeDefined(); }; test('should allow new LDAP user to login and synchronize data', async () => { diff --git a/packages/cli/test/integration/license.api.test.ts b/packages/cli/test/integration/license.api.test.ts index 3d1fc4cda8..370d36f436 100644 --- a/packages/cli/test/integration/license.api.test.ts +++ b/packages/cli/test/integration/license.api.test.ts @@ -6,6 +6,7 @@ import { License } from '@/License'; import * as testDb from './shared/testDb'; import * as utils from './shared/utils/'; import { createUserShell } from './shared/db/users'; +import { RESPONSE_ERROR_MESSAGES } from '@/constants'; const MOCK_SERVER_URL = 'https://server.com/v1'; const MOCK_RENEW_OFFSET = 259200; @@ -57,7 +58,7 @@ describe('POST /license/activate', () => { await authMemberAgent .post('/license/activate') .send({ activationKey: 'abcde' }) - .expect(403, UNAUTHORIZED_RESPONSE); + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); test('errors out properly', async () => { @@ -79,7 +80,9 @@ describe('POST /license/renew', () => { }); test('does not work for regular users', async () => { - await authMemberAgent.post('/license/renew').expect(403, UNAUTHORIZED_RESPONSE); + await authMemberAgent + .post('/license/renew') + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); test('errors out properly', async () => { diff --git a/packages/cli/test/integration/me.api.test.ts b/packages/cli/test/integration/me.api.test.ts index 2f4be391cf..1a7eb1b4ff 100644 --- a/packages/cli/test/integration/me.api.test.ts +++ b/packages/cli/test/integration/me.api.test.ts @@ -15,6 +15,7 @@ import * as utils from './shared/utils/'; import { addApiKey, createUser, createUserShell } from './shared/db/users'; import Container from 'typedi'; import { UserRepository } from '@db/repositories/user.repository'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; const testServer = utils.setupTestServer({ endpointGroups: ['me'] }); @@ -65,6 +66,12 @@ describe('Owner shell', () => { expect(storedOwnerShell.email).toBe(validPayload.email.toLowerCase()); expect(storedOwnerShell.firstName).toBe(validPayload.firstName); expect(storedOwnerShell.lastName).toBe(validPayload.lastName); + + const storedPersonalProject = await Container.get( + ProjectRepository, + ).getPersonalProjectForUserOrFail(storedOwnerShell.id); + + expect(storedPersonalProject.name).toBe(storedOwnerShell.createPersonalProjectName()); } }); @@ -77,6 +84,12 @@ describe('Owner shell', () => { expect(storedOwnerShell.email).toBeNull(); expect(storedOwnerShell.firstName).toBeNull(); expect(storedOwnerShell.lastName).toBeNull(); + + const storedPersonalProject = await Container.get( + ProjectRepository, + ).getPersonalProjectForUserOrFail(storedOwnerShell.id); + + expect(storedPersonalProject.name).toBe(storedOwnerShell.createPersonalProjectName()); } }); @@ -176,9 +189,7 @@ describe('Member', () => { test('PATCH /me should succeed with valid inputs', async () => { for (const validPayload of VALID_PATCH_ME_PAYLOADS) { - const response = await authMemberAgent.patch('/me').send(validPayload); - - expect(response.statusCode).toBe(200); + const response = await authMemberAgent.patch('/me').send(validPayload).expect(200); const { id, @@ -207,6 +218,11 @@ describe('Member', () => { expect(storedMember.email).toBe(validPayload.email.toLowerCase()); expect(storedMember.firstName).toBe(validPayload.firstName); expect(storedMember.lastName).toBe(validPayload.lastName); + + const storedPersonalProject = + await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(id); + + expect(storedPersonalProject.name).toBe(storedMember.createPersonalProjectName()); } }); @@ -219,6 +235,12 @@ describe('Member', () => { expect(storedMember.email).toBe(member.email); expect(storedMember.firstName).toBe(member.firstName); expect(storedMember.lastName).toBe(member.lastName); + + const storedPersonalProject = await Container.get( + ProjectRepository, + ).getPersonalProjectForUserOrFail(storedMember.id); + + expect(storedPersonalProject.name).toBe(storedMember.createPersonalProjectName()); } }); @@ -336,6 +358,12 @@ describe('Owner', () => { expect(storedOwner.email).toBe(validPayload.email.toLowerCase()); expect(storedOwner.firstName).toBe(validPayload.firstName); expect(storedOwner.lastName).toBe(validPayload.lastName); + + const storedPersonalProject = await Container.get( + ProjectRepository, + ).getPersonalProjectForUserOrFail(storedOwner.id); + + expect(storedPersonalProject.name).toBe(storedOwner.createPersonalProjectName()); } }); }); @@ -357,11 +385,11 @@ const VALID_PATCH_ME_PAYLOADS = [ firstName: randomName(), lastName: randomName(), }, - { - email: randomEmail().toUpperCase(), - firstName: randomName(), - lastName: randomName(), - }, + // { + // email: randomEmail().toUpperCase(), + // firstName: randomName(), + // lastName: randomName(), + // }, ]; const INVALID_PATCH_ME_PAYLOADS = [ diff --git a/packages/cli/test/integration/project.api.test.ts b/packages/cli/test/integration/project.api.test.ts new file mode 100644 index 0000000000..a2371014d9 --- /dev/null +++ b/packages/cli/test/integration/project.api.test.ts @@ -0,0 +1,1179 @@ +import * as testDb from './shared/testDb'; +import * as utils from './shared/utils/'; +import { createMember, createOwner, createUser } from './shared/db/users'; +import { + createTeamProject, + linkUserToProject, + getPersonalProject, + findProject, + getProjectRelations, +} from './shared/db/projects'; +import Container from 'typedi'; +import type { Project } from '@/databases/entities/Project'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; +import type { ProjectRole } from '@/databases/entities/ProjectRelation'; +import { EntityNotFoundError } from '@n8n/typeorm'; +import { createWorkflow, shareWorkflowWithProjects } from './shared/db/workflows'; +import { + getCredentialById, + saveCredential, + shareCredentialWithProjects, +} from './shared/db/credentials'; +import { randomCredentialPayload } from './shared/random'; +import { getWorkflowById } from '@/PublicApi/v1/handlers/workflows/workflows.service'; +import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; +import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository'; +import type { GlobalRole } from '@/databases/entities/User'; +import type { Scope } from '@n8n/permissions'; +import { CacheService } from '@/services/cache/cache.service'; +import { mockInstance } from '../shared/mocking'; +import { ActiveWorkflowManager } from '@/ActiveWorkflowManager'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; + +const testServer = utils.setupTestServer({ + endpointGroups: ['project'], + enabledFeatures: [ + 'feat:advancedPermissions', + 'feat:projectRole:admin', + 'feat:projectRole:editor', + 'feat:projectRole:viewer', + ], + quotas: { + 'quota:maxTeamProjects': -1, + }, +}); + +// The `ActiveWorkflowRunner` keeps the event loop alive, which in turn leads to jest not shutting down cleanly. +// We don't need it for the tests here, so we can mock it and make the tests exit cleanly. +mockInstance(ActiveWorkflowManager); + +beforeEach(async () => { + await testDb.truncate(['User', 'Project']); +}); + +describe('GET /projects/', () => { + test('member should get all personal projects and team projects they are apart of', async () => { + const [testUser1, testUser2, testUser3] = await Promise.all([ + createUser(), + createUser(), + createUser(), + ]); + const [teamProject1, teamProject2] = await Promise.all([ + createTeamProject(undefined, testUser1), + createTeamProject(), + ]); + + const [personalProject1, personalProject2, personalProject3] = await Promise.all([ + getPersonalProject(testUser1), + getPersonalProject(testUser2), + getPersonalProject(testUser3), + ]); + + const memberAgent = testServer.authAgentFor(testUser1); + + const resp = await memberAgent.get('/projects/'); + expect(resp.status).toBe(200); + const respProjects = resp.body.data as Project[]; + expect(respProjects.length).toBe(4); + + expect( + [personalProject1, personalProject2, personalProject3].every((v, i) => { + const p = respProjects.find((p) => p.id === v.id); + if (!p) { + return false; + } + const u = [testUser1, testUser2, testUser3][i]; + return p.name === u.createPersonalProjectName(); + }), + ).toBe(true); + expect(respProjects.find((p) => p.id === teamProject1.id)).not.toBeUndefined(); + expect(respProjects.find((p) => p.id === teamProject2.id)).toBeUndefined(); + }); + + test('owner should get all projects', async () => { + const [ownerUser, testUser1, testUser2, testUser3] = await Promise.all([ + createOwner(), + createUser(), + createUser(), + createUser(), + ]); + const [teamProject1, teamProject2] = await Promise.all([ + createTeamProject(undefined, testUser1), + createTeamProject(), + ]); + + const [ownerProject, personalProject1, personalProject2, personalProject3] = await Promise.all([ + getPersonalProject(ownerUser), + getPersonalProject(testUser1), + getPersonalProject(testUser2), + getPersonalProject(testUser3), + ]); + + const memberAgent = testServer.authAgentFor(ownerUser); + + const resp = await memberAgent.get('/projects/'); + expect(resp.status).toBe(200); + const respProjects = resp.body.data as Project[]; + expect(respProjects.length).toBe(6); + + expect( + [ownerProject, personalProject1, personalProject2, personalProject3].every((v, i) => { + const p = respProjects.find((p) => p.id === v.id); + if (!p) { + return false; + } + const u = [ownerUser, testUser1, testUser2, testUser3][i]; + return p.name === u.createPersonalProjectName(); + }), + ).toBe(true); + expect(respProjects.find((p) => p.id === teamProject1.id)).not.toBeUndefined(); + expect(respProjects.find((p) => p.id === teamProject2.id)).not.toBeUndefined(); + }); +}); + +describe('GET /projects/count', () => { + test('should return correct number of projects', async () => { + const [firstUser] = await Promise.all([ + createUser(), + createUser(), + createUser(), + createUser(), + createTeamProject(), + createTeamProject(), + createTeamProject(), + ]); + + const resp = await testServer.authAgentFor(firstUser).get('/projects/count'); + + expect(resp.body.data.personal).toBe(4); + expect(resp.body.data.team).toBe(3); + }); +}); + +describe('GET /projects/my-projects', () => { + test('member should get all projects they are apart of', async () => { + // + // ARRANGE + // + const [testUser1, testUser2, testUser3] = await Promise.all([ + createUser(), + createUser(), + createUser(), + ]); + const [teamProject1, teamProject2] = await Promise.all([ + createTeamProject(undefined, testUser1), + createTeamProject(undefined, testUser2), + ]); + + const [personalProject1, personalProject2, personalProject3] = await Promise.all([ + getPersonalProject(testUser1), + getPersonalProject(testUser2), + getPersonalProject(testUser3), + ]); + + // + // ACT + // + const resp = await testServer + .authAgentFor(testUser1) + .get('/projects/my-projects') + .query({ includeScopes: true }) + .expect(200); + const respProjects: Array = + resp.body.data; + + // + // ASSERT + // + expect(respProjects.length).toBe(2); + + const projectsExpected = [ + [ + personalProject1, + { + role: 'project:personalOwner', + scopes: ['project:list', 'project:read', 'credential:create'], + }, + ], + [ + teamProject1, + { + role: 'project:admin', + scopes: [ + 'project:list', + 'project:read', + 'project:update', + 'project:delete', + 'credential:create', + ], + }, + ], + ] as const; + + for (const [project, expected] of projectsExpected) { + const p = respProjects.find((p) => p.id === project.id)!; + + expect(p.role).toBe(expected.role); + expect(expected.scopes.every((s) => p.scopes?.includes(s as Scope))).toBe(true); + } + + expect(respProjects).not.toContainEqual(expect.objectContaining({ id: personalProject2.id })); + expect(respProjects).not.toContainEqual(expect.objectContaining({ id: personalProject3.id })); + expect(respProjects).not.toContainEqual(expect.objectContaining({ id: teamProject2.id })); + }); + + test('owner should get all projects they are apart of', async () => { + // + // ARRANGE + // + const [ownerUser, testUser1, testUser2, testUser3] = await Promise.all([ + createOwner(), + createUser(), + createUser(), + createUser(), + ]); + const [teamProject1, teamProject2, teamProject3, teamProject4] = await Promise.all([ + // owner has no relation ship + createTeamProject(undefined, testUser1), + // owner is admin + createTeamProject(undefined, ownerUser), + // owner is viewer + createTeamProject(undefined, testUser2), + // this project has no relationship at all + createTeamProject(), + ]); + + await linkUserToProject(ownerUser, teamProject3, 'project:editor'); + + const [ownerProject, personalProject1, personalProject2, personalProject3] = await Promise.all([ + getPersonalProject(ownerUser), + getPersonalProject(testUser1), + getPersonalProject(testUser2), + getPersonalProject(testUser3), + ]); + + // + // ACT + // + const resp = await testServer + .authAgentFor(ownerUser) + .get('/projects/my-projects') + .query({ includeScopes: true }) + .expect(200); + const respProjects: Array = + resp.body.data; + + // + // ASSERT + // + expect(respProjects.length).toBe(5); + + const projectsExpected = [ + [ + ownerProject, + { + role: 'project:personalOwner', + scopes: [ + 'project:list', + 'project:create', + 'project:read', + 'project:update', + 'project:delete', + 'credential:create', + ], + }, + ], + [ + teamProject1, + { + role: 'global:owner', + scopes: [ + 'project:list', + 'project:create', + 'project:read', + 'project:update', + 'project:delete', + 'credential:create', + ], + }, + ], + [ + teamProject2, + { + role: 'project:admin', + scopes: [ + 'project:list', + 'project:create', + 'project:read', + 'project:update', + 'project:delete', + 'credential:create', + ], + }, + ], + [ + teamProject3, + { + role: 'project:editor', + scopes: [ + 'project:list', + 'project:create', + 'project:read', + 'project:update', + 'project:delete', + 'credential:create', + ], + }, + ], + [ + teamProject4, + { + role: 'global:owner', + scopes: [ + 'project:list', + 'project:create', + 'project:read', + 'project:update', + 'project:delete', + 'credential:create', + ], + }, + ], + ] as const; + + for (const [project, expected] of projectsExpected) { + const p = respProjects.find((p) => p.id === project.id)!; + + expect(p.role).toBe(expected.role); + expect(expected.scopes.every((s) => p.scopes?.includes(s as Scope))).toBe(true); + } + + expect(respProjects).not.toContainEqual(expect.objectContaining({ id: personalProject1.id })); + expect(respProjects).not.toContainEqual(expect.objectContaining({ id: personalProject2.id })); + expect(respProjects).not.toContainEqual(expect.objectContaining({ id: personalProject3.id })); + }); +}); + +describe('GET /projects/personal', () => { + test("should return the user's personal project", async () => { + const user = await createUser(); + const project = await getPersonalProject(user); + + const memberAgent = testServer.authAgentFor(user); + + const resp = await memberAgent.get('/projects/personal'); + expect(resp.status).toBe(200); + const respProject = resp.body.data as Project & { scopes: Scope[] }; + expect(respProject.id).toEqual(project.id); + expect(respProject.scopes).not.toBeUndefined(); + }); + + test("should return 404 if user doesn't have a personal project", async () => { + const user = await createUser(); + const project = await getPersonalProject(user); + await testDb.truncate(['Project']); + + const memberAgent = testServer.authAgentFor(user); + + const resp = await memberAgent.get('/projects/personal'); + expect(resp.status).toBe(404); + const respProject = resp.body?.data as Project; + expect(respProject?.id).not.toEqual(project.id); + }); +}); + +describe('POST /projects/', () => { + test('should create a team project', async () => { + const ownerUser = await createOwner(); + const ownerAgent = testServer.authAgentFor(ownerUser); + + const resp = await ownerAgent.post('/projects/').send({ name: 'Test Team Project' }); + expect(resp.status).toBe(200); + const respProject = resp.body.data as Project; + expect(respProject.name).toEqual('Test Team Project'); + expect(async () => { + await findProject(respProject.id); + }).not.toThrow(); + }); + + test('should allow to create a team projects if below the quota', async () => { + testServer.license.setQuota('quota:maxTeamProjects', 1); + const ownerUser = await createOwner(); + const ownerAgent = testServer.authAgentFor(ownerUser); + + await ownerAgent.post('/projects/').send({ name: 'Test Team Project' }).expect(200); + expect(await Container.get(ProjectRepository).count({ where: { type: 'team' } })).toBe(1); + }); + + test('should fail to create a team project if at quota', async () => { + testServer.license.setQuota('quota:maxTeamProjects', 1); + await Promise.all([createTeamProject()]); + const ownerUser = await createOwner(); + const ownerAgent = testServer.authAgentFor(ownerUser); + + await ownerAgent.post('/projects/').send({ name: 'Test Team Project' }).expect(400, { + code: 400, + message: + 'Attempted to create a new project but quota is already exhausted. You may have a maximum of 1 team projects.', + }); + + expect(await Container.get(ProjectRepository).count({ where: { type: 'team' } })).toBe(1); + }); + + test('should fail to create a team project if above the quota', async () => { + testServer.license.setQuota('quota:maxTeamProjects', 1); + await Promise.all([createTeamProject(), createTeamProject()]); + const ownerUser = await createOwner(); + const ownerAgent = testServer.authAgentFor(ownerUser); + + await ownerAgent.post('/projects/').send({ name: 'Test Team Project' }).expect(400, { + code: 400, + message: + 'Attempted to create a new project but quota is already exhausted. You may have a maximum of 1 team projects.', + }); + + expect(await Container.get(ProjectRepository).count({ where: { type: 'team' } })).toBe(2); + }); +}); + +describe('PATCH /projects/:projectId', () => { + test('should update a team project name', async () => { + const ownerUser = await createOwner(); + const ownerAgent = testServer.authAgentFor(ownerUser); + + const teamProject = await createTeamProject(); + + const resp = await ownerAgent.patch(`/projects/${teamProject.id}`).send({ name: 'New Name' }); + expect(resp.status).toBe(200); + + const updatedProject = await findProject(teamProject.id); + expect(updatedProject.name).toEqual('New Name'); + }); + + test('should not allow viewers to edit team project name', async () => { + const testUser = await createUser(); + const teamProject = await createTeamProject(); + await linkUserToProject(testUser, teamProject, 'project:viewer'); + + const memberAgent = testServer.authAgentFor(testUser); + + const resp = await memberAgent.patch(`/projects/${teamProject.id}`).send({ name: 'New Name' }); + expect(resp.status).toBe(403); + + const updatedProject = await findProject(teamProject.id); + expect(updatedProject.name).not.toEqual('New Name'); + }); + + test('should not allow owners to edit personal project name', async () => { + const user = await createUser(); + const personalProject = await getPersonalProject(user); + + const ownerUser = await createOwner(); + const ownerAgent = testServer.authAgentFor(ownerUser); + + const resp = await ownerAgent + .patch(`/projects/${personalProject.id}`) + .send({ name: 'New Name' }); + expect(resp.status).toBe(403); + + const updatedProject = await findProject(personalProject.id); + expect(updatedProject.name).not.toEqual('New Name'); + }); +}); + +describe('PATCH /projects/:projectId', () => { + test('should add or remove users from a project', async () => { + const [ownerUser, testUser1, testUser2, testUser3] = await Promise.all([ + createOwner(), + createUser(), + createUser(), + createUser(), + ]); + const [teamProject1, teamProject2] = await Promise.all([ + createTeamProject(undefined, testUser1), + createTeamProject(undefined, testUser2), + ]); + const [credential1, credential2] = await Promise.all([ + saveCredential(randomCredentialPayload(), { + role: 'credential:owner', + project: teamProject1, + }), + saveCredential(randomCredentialPayload(), { + role: 'credential:owner', + project: teamProject2, + }), + saveCredential(randomCredentialPayload(), { + role: 'credential:owner', + project: teamProject2, + }), + ]); + await shareCredentialWithProjects(credential2, [teamProject1]); + + await linkUserToProject(ownerUser, teamProject2, 'project:editor'); + await linkUserToProject(testUser2, teamProject2, 'project:editor'); + + const memberAgent = testServer.authAgentFor(testUser1); + + const deleteSpy = jest.spyOn(Container.get(CacheService), 'deleteMany'); + const resp = await memberAgent.patch(`/projects/${teamProject1.id}`).send({ + name: teamProject1.name, + relations: [ + { userId: testUser1.id, role: 'project:admin' }, + { userId: testUser3.id, role: 'project:editor' }, + { userId: ownerUser.id, role: 'project:viewer' }, + ] as Array<{ + userId: string; + role: ProjectRole; + }>, + }); + expect(resp.status).toBe(200); + + expect(deleteSpy).toBeCalledWith([`credential-can-use-secrets:${credential1.id}`]); + deleteSpy.mockClear(); + + const [tp1Relations, tp2Relations] = await Promise.all([ + getProjectRelations({ projectId: teamProject1.id }), + getProjectRelations({ projectId: teamProject2.id }), + ]); + + expect(tp1Relations.length).toBe(3); + expect(tp2Relations.length).toBe(2); + + expect(tp1Relations.find((p) => p.userId === testUser1.id)).not.toBeUndefined(); + expect(tp1Relations.find((p) => p.userId === testUser2.id)).toBeUndefined(); + expect(tp1Relations.find((p) => p.userId === testUser1.id)?.role).toBe('project:admin'); + expect(tp1Relations.find((p) => p.userId === testUser3.id)?.role).toBe('project:editor'); + expect(tp1Relations.find((p) => p.userId === ownerUser.id)?.role).toBe('project:viewer'); + + // Check we haven't modified the other team project + expect(tp2Relations.find((p) => p.userId === testUser2.id)).not.toBeUndefined(); + expect(tp2Relations.find((p) => p.userId === testUser1.id)).toBeUndefined(); + expect(tp2Relations.find((p) => p.userId === testUser2.id)?.role).toBe('project:editor'); + expect(tp2Relations.find((p) => p.userId === ownerUser.id)?.role).toBe('project:editor'); + }); + + test('should not add or remove users from a project if lacking permissions', async () => { + const [ownerUser, testUser1, testUser2, testUser3] = await Promise.all([ + createOwner(), + createUser(), + createUser(), + createUser(), + ]); + const [teamProject1, teamProject2] = await Promise.all([ + createTeamProject(undefined, testUser2), + createTeamProject(), + ]); + + await linkUserToProject(testUser1, teamProject1, 'project:viewer'); + await linkUserToProject(ownerUser, teamProject2, 'project:editor'); + await linkUserToProject(testUser2, teamProject2, 'project:editor'); + + const memberAgent = testServer.authAgentFor(testUser1); + + const resp = await memberAgent.patch(`/projects/${teamProject1.id}`).send({ + name: teamProject1.name, + relations: [ + { userId: testUser1.id, role: 'project:admin' }, + { userId: testUser3.id, role: 'project:editor' }, + { userId: ownerUser.id, role: 'project:viewer' }, + ] as Array<{ + userId: string; + role: ProjectRole; + }>, + }); + expect(resp.status).toBe(403); + + const [tp1Relations, tp2Relations] = await Promise.all([ + getProjectRelations({ projectId: teamProject1.id }), + getProjectRelations({ projectId: teamProject2.id }), + ]); + + expect(tp1Relations.length).toBe(2); + expect(tp2Relations.length).toBe(2); + + expect(tp1Relations.find((p) => p.userId === testUser1.id)).not.toBeUndefined(); + expect(tp1Relations.find((p) => p.userId === testUser2.id)).not.toBeUndefined(); + expect(tp1Relations.find((p) => p.userId === testUser1.id)?.role).toBe('project:viewer'); + expect(tp1Relations.find((p) => p.userId === testUser2.id)?.role).toBe('project:admin'); + expect(tp1Relations.find((p) => p.userId === testUser3.id)).toBeUndefined(); + + // Check we haven't modified the other team project + expect(tp2Relations.find((p) => p.userId === testUser2.id)).not.toBeUndefined(); + expect(tp2Relations.find((p) => p.userId === testUser1.id)).toBeUndefined(); + expect(tp2Relations.find((p) => p.userId === testUser2.id)?.role).toBe('project:editor'); + expect(tp2Relations.find((p) => p.userId === ownerUser.id)?.role).toBe('project:editor'); + }); + + test('should not add from a project adding user with an unlicensed role', async () => { + testServer.license.disable('feat:projectRole:editor'); + const [testUser1, testUser2, testUser3] = await Promise.all([ + createUser(), + createUser(), + createUser(), + ]); + const teamProject = await createTeamProject(undefined, testUser2); + + await linkUserToProject(testUser1, teamProject, 'project:admin'); + + const memberAgent = testServer.authAgentFor(testUser2); + + const resp = await memberAgent.patch(`/projects/${teamProject.id}`).send({ + name: teamProject.name, + relations: [ + { userId: testUser2.id, role: 'project:admin' }, + { userId: testUser1.id, role: 'project:editor' }, + ] as Array<{ + userId: string; + role: ProjectRole; + }>, + }); + expect(resp.status).toBe(400); + + const tpRelations = await getProjectRelations({ projectId: teamProject.id }); + expect(tpRelations.length).toBe(2); + + expect(tpRelations.find((p) => p.userId === testUser1.id)).not.toBeUndefined(); + expect(tpRelations.find((p) => p.userId === testUser2.id)).not.toBeUndefined(); + expect(tpRelations.find((p) => p.userId === testUser1.id)?.role).toBe('project:admin'); + expect(tpRelations.find((p) => p.userId === testUser2.id)?.role).toBe('project:admin'); + expect(tpRelations.find((p) => p.userId === testUser3.id)).toBeUndefined(); + }); + + test("should not edit a relation of a project when changing a user's role to an unlicensed role", async () => { + testServer.license.disable('feat:projectRole:editor'); + const [testUser1, testUser2, testUser3] = await Promise.all([ + createUser(), + createUser(), + createUser(), + ]); + const teamProject = await createTeamProject(undefined, testUser2); + + await linkUserToProject(testUser1, teamProject, 'project:admin'); + await linkUserToProject(testUser3, teamProject, 'project:admin'); + + const memberAgent = testServer.authAgentFor(testUser2); + + const resp = await memberAgent.patch(`/projects/${teamProject.id}`).send({ + name: teamProject.name, + relations: [ + { userId: testUser2.id, role: 'project:admin' }, + { userId: testUser1.id, role: 'project:editor' }, + { userId: testUser3.id, role: 'project:editor' }, + ] as Array<{ + userId: string; + role: ProjectRole; + }>, + }); + expect(resp.status).toBe(400); + + const tpRelations = await getProjectRelations({ projectId: teamProject.id }); + expect(tpRelations.length).toBe(3); + + expect(tpRelations.find((p) => p.userId === testUser1.id)).not.toBeUndefined(); + expect(tpRelations.find((p) => p.userId === testUser2.id)).not.toBeUndefined(); + expect(tpRelations.find((p) => p.userId === testUser1.id)?.role).toBe('project:admin'); + expect(tpRelations.find((p) => p.userId === testUser2.id)?.role).toBe('project:admin'); + expect(tpRelations.find((p) => p.userId === testUser3.id)?.role).toBe('project:admin'); + }); + + test("should edit a relation of a project when changing a user's role to an licensed role but unlicensed roles are present", async () => { + testServer.license.disable('feat:projectRole:viewer'); + const [testUser1, testUser2, testUser3] = await Promise.all([ + createUser(), + createUser(), + createUser(), + ]); + const teamProject = await createTeamProject(undefined, testUser2); + + await linkUserToProject(testUser1, teamProject, 'project:viewer'); + await linkUserToProject(testUser3, teamProject, 'project:editor'); + + const memberAgent = testServer.authAgentFor(testUser2); + + const resp = await memberAgent.patch(`/projects/${teamProject.id}`).send({ + name: teamProject.name, + relations: [ + { userId: testUser1.id, role: 'project:viewer' }, + { userId: testUser2.id, role: 'project:admin' }, + { userId: testUser3.id, role: 'project:admin' }, + ] as Array<{ + userId: string; + role: ProjectRole; + }>, + }); + expect(resp.status).toBe(200); + + const tpRelations = await getProjectRelations({ projectId: teamProject.id }); + expect(tpRelations.length).toBe(3); + + expect(tpRelations.find((p) => p.userId === testUser1.id)).not.toBeUndefined(); + expect(tpRelations.find((p) => p.userId === testUser2.id)).not.toBeUndefined(); + expect(tpRelations.find((p) => p.userId === testUser3.id)).not.toBeUndefined(); + expect(tpRelations.find((p) => p.userId === testUser1.id)?.role).toBe('project:viewer'); + expect(tpRelations.find((p) => p.userId === testUser2.id)?.role).toBe('project:admin'); + expect(tpRelations.find((p) => p.userId === testUser3.id)?.role).toBe('project:admin'); + }); + + test('should not add or remove users from a personal project', async () => { + const [testUser1, testUser2] = await Promise.all([createUser(), createUser()]); + + const personalProject = await getPersonalProject(testUser1); + + const memberAgent = testServer.authAgentFor(testUser1); + + const resp = await memberAgent.patch(`/projects/${personalProject.id}`).send({ + relations: [ + { userId: testUser1.id, role: 'project:personalOwner' }, + { userId: testUser2.id, role: 'project:admin' }, + ] as Array<{ + userId: string; + role: ProjectRole; + }>, + }); + expect(resp.status).toBe(403); + + const p1Relations = await getProjectRelations({ projectId: personalProject.id }); + expect(p1Relations.length).toBe(1); + }); +}); + +describe('GET /project/:projectId', () => { + test('should get project details and relations', async () => { + const [ownerUser, testUser1, testUser2, _testUser3] = await Promise.all([ + createOwner(), + createUser(), + createUser(), + createUser(), + ]); + const [teamProject1, teamProject2] = await Promise.all([ + createTeamProject(undefined, testUser2), + createTeamProject(), + ]); + + await linkUserToProject(testUser1, teamProject1, 'project:editor'); + await linkUserToProject(ownerUser, teamProject2, 'project:editor'); + await linkUserToProject(testUser2, teamProject2, 'project:editor'); + + const memberAgent = testServer.authAgentFor(testUser1); + + const resp = await memberAgent.get(`/projects/${teamProject1.id}`); + expect(resp.status).toBe(200); + + expect(resp.body.data.id).toBe(teamProject1.id); + expect(resp.body.data.name).toBe(teamProject1.name); + + expect(resp.body.data.relations.length).toBe(2); + expect(resp.body.data.relations).toContainEqual({ + id: testUser1.id, + email: testUser1.email, + firstName: testUser1.firstName, + lastName: testUser1.lastName, + role: 'project:editor', + }); + expect(resp.body.data.relations).toContainEqual({ + id: testUser2.id, + email: testUser2.email, + firstName: testUser2.firstName, + lastName: testUser2.lastName, + role: 'project:admin', + }); + }); +}); + +describe('DELETE /project/:projectId', () => { + test('allows the project:owner to delete a project', async () => { + const member = await createMember(); + const project = await createTeamProject(undefined, member); + + await testServer.authAgentFor(member).delete(`/projects/${project.id}`).expect(200); + + const projectInDB = findProject(project.id); + + await expect(projectInDB).rejects.toThrowError(EntityNotFoundError); + }); + + test('allows the instance owner to delete a team project their are not related to', async () => { + const owner = await createOwner(); + + const member = await createMember(); + const project = await createTeamProject(undefined, member); + + await testServer.authAgentFor(owner).delete(`/projects/${project.id}`).expect(200); + + await expect(findProject(project.id)).rejects.toThrowError(EntityNotFoundError); + }); + + test('does not allow instance members to delete their personal project', async () => { + const member = await createMember(); + const project = await getPersonalProject(member); + + await testServer.authAgentFor(member).delete(`/projects/${project.id}`).expect(403); + + const projectInDB = await findProject(project.id); + + expect(projectInDB).toHaveProperty('id', project.id); + }); + + test('does not allow instance owners to delete their personal projects', async () => { + const owner = await createOwner(); + const project = await getPersonalProject(owner); + + await testServer.authAgentFor(owner).delete(`/projects/${project.id}`).expect(403); + + const projectInDB = await findProject(project.id); + + expect(projectInDB).toHaveProperty('id', project.id); + }); + + test.each(['project:editor', 'project:viewer'] as ProjectRole[])( + 'does not allow users with the role %s to delete a project', + async (role) => { + const member = await createMember(); + const project = await createTeamProject(); + + await linkUserToProject(member, project, role); + + await testServer.authAgentFor(member).delete(`/projects/${project.id}`).expect(403); + + const projectInDB = await findProject(project.id); + + expect(projectInDB).toHaveProperty('id', project.id); + }, + ); + + test('deletes all workflows and credentials it owns as well as the sharings into other projects', async () => { + // + // ARRANGE + // + const member = await createMember(); + + const otherProject = await createTeamProject(undefined, member); + const sharedWorkflow1 = await createWorkflow({}, otherProject); + const sharedWorkflow2 = await createWorkflow({}, otherProject); + const sharedCredential = await saveCredential(randomCredentialPayload(), { + project: otherProject, + role: 'credential:owner', + }); + + const projectToBeDeleted = await createTeamProject(undefined, member); + const ownedWorkflow = await createWorkflow({}, projectToBeDeleted); + const ownedCredential = await saveCredential(randomCredentialPayload(), { + project: projectToBeDeleted, + role: 'credential:owner', + }); + + await shareCredentialWithProjects(sharedCredential, [otherProject]); + await shareWorkflowWithProjects(sharedWorkflow1, [ + { project: otherProject, role: 'workflow:editor' }, + ]); + await shareWorkflowWithProjects(sharedWorkflow2, [ + { project: otherProject, role: 'workflow:user' }, + ]); + + // + // ACT + // + await testServer.authAgentFor(member).delete(`/projects/${projectToBeDeleted.id}`).expect(200); + + // + // ASSERT + // + + // Make sure the project and owned workflow and credential where deleted. + await expect(getWorkflowById(ownedWorkflow.id)).resolves.toBeNull(); + await expect(getCredentialById(ownedCredential.id)).resolves.toBeNull(); + await expect(findProject(projectToBeDeleted.id)).rejects.toThrowError(EntityNotFoundError); + + // Make sure the shared workflow and credential were not deleted + await expect(getWorkflowById(sharedWorkflow1.id)).resolves.not.toBeNull(); + await expect(getCredentialById(sharedCredential.id)).resolves.not.toBeNull(); + + // Make sure the sharings for them have been deleted + await expect( + Container.get(SharedWorkflowRepository).findOneByOrFail({ + projectId: projectToBeDeleted.id, + workflowId: sharedWorkflow1.id, + }), + ).rejects.toThrowError(EntityNotFoundError); + await expect( + Container.get(SharedCredentialsRepository).findOneByOrFail({ + projectId: projectToBeDeleted.id, + credentialsId: sharedCredential.id, + }), + ).rejects.toThrowError(EntityNotFoundError); + }); + + test('unshares all workflows and credentials that were shared with the project', async () => { + // + // ARRANGE + // + const member = await createMember(); + + const projectToBeDeleted = await createTeamProject(undefined, member); + const ownedWorkflow1 = await createWorkflow({}, projectToBeDeleted); + const ownedWorkflow2 = await createWorkflow({}, projectToBeDeleted); + const ownedCredential = await saveCredential(randomCredentialPayload(), { + project: projectToBeDeleted, + role: 'credential:owner', + }); + + const otherProject = await createTeamProject(undefined, member); + + await shareCredentialWithProjects(ownedCredential, [otherProject]); + await shareWorkflowWithProjects(ownedWorkflow1, [ + { project: otherProject, role: 'workflow:editor' }, + ]); + await shareWorkflowWithProjects(ownedWorkflow2, [ + { project: otherProject, role: 'workflow:user' }, + ]); + + // + // ACT + // + await testServer.authAgentFor(member).delete(`/projects/${projectToBeDeleted.id}`).expect(200); + + // + // ASSERT + // + + // Make sure the project and owned workflow and credential where deleted. + await expect(getWorkflowById(ownedWorkflow1.id)).resolves.toBeNull(); + await expect(getWorkflowById(ownedWorkflow2.id)).resolves.toBeNull(); + await expect(getCredentialById(ownedCredential.id)).resolves.toBeNull(); + await expect(findProject(projectToBeDeleted.id)).rejects.toThrowError(EntityNotFoundError); + + // Make sure the sharings for them into the other project have been deleted + await expect( + Container.get(SharedWorkflowRepository).findOneByOrFail({ + projectId: projectToBeDeleted.id, + workflowId: ownedWorkflow1.id, + }), + ).rejects.toThrowError(EntityNotFoundError); + await expect( + Container.get(SharedWorkflowRepository).findOneByOrFail({ + projectId: projectToBeDeleted.id, + workflowId: ownedWorkflow2.id, + }), + ).rejects.toThrowError(EntityNotFoundError); + await expect( + Container.get(SharedCredentialsRepository).findOneByOrFail({ + projectId: projectToBeDeleted.id, + credentialsId: ownedCredential.id, + }), + ).rejects.toThrowError(EntityNotFoundError); + }); + + test('deletes the project relations', async () => { + // + // ARRANGE + // + const member = await createMember(); + const editor = await createMember(); + const viewer = await createMember(); + + const project = await createTeamProject(undefined, member); + await linkUserToProject(editor, project, 'project:editor'); + await linkUserToProject(viewer, project, 'project:viewer'); + + // + // ACT + // + await testServer.authAgentFor(member).delete(`/projects/${project.id}`).expect(200); + + // + // ASSERT + // + await expect( + Container.get(ProjectRelationRepository).findOneByOrFail({ + projectId: project.id, + userId: member.id, + }), + ).rejects.toThrowError(EntityNotFoundError); + await expect( + Container.get(ProjectRelationRepository).findOneByOrFail({ + projectId: project.id, + userId: editor.id, + }), + ).rejects.toThrowError(EntityNotFoundError); + await expect( + Container.get(ProjectRelationRepository).findOneByOrFail({ + projectId: project.id, + userId: viewer.id, + }), + ).rejects.toThrowError(EntityNotFoundError); + }); + + // Tests related to migrating workflows and credentials to new project: + + test('should fail if the project to delete does not exist', async () => { + const member = await createMember(); + + await testServer.authAgentFor(member).delete('/projects/1234').expect(403); + }); + + test('should fail to delete if project to migrate to and the project to delete are the same', async () => { + const member = await createMember(); + const project = await createTeamProject(undefined, member); + + await testServer + .authAgentFor(member) + .delete(`/projects/${project.id}`) + .query({ transferId: project.id }) + .expect(400); + }); + + test('does not migrate credentials and projects if the user does not have the permissions to create workflows or credentials in the target project', async () => { + // + // ARRANGE + // + const member = await createMember(); + + const projectToBeDeleted = await createTeamProject(undefined, member); + const targetProject = await createTeamProject(); + await linkUserToProject(member, targetProject, 'project:viewer'); + + // + // ACT + // + await testServer + .authAgentFor(member) + .delete(`/projects/${projectToBeDeleted.id}`) + .query({ transferId: targetProject.id }) + // + // ASSERT + // + .expect(404); + }); + + test('migrates workflows and credentials to another project if `migrateToProject` is passed', async () => { + // + // ARRANGE + // + const member = await createMember(); + + const projectToBeDeleted = await createTeamProject(undefined, member); + const targetProject = await createTeamProject(undefined, member); + const otherProject = await createTeamProject(undefined, member); + + // these should be re-owned to the targetProject + const ownedCredential = await saveCredential(randomCredentialPayload(), { + project: projectToBeDeleted, + role: 'credential:owner', + }); + const ownedWorkflow = await createWorkflow({}, projectToBeDeleted); + + // these should stay intact + await shareCredentialWithProjects(ownedCredential, [otherProject]); + await shareWorkflowWithProjects(ownedWorkflow, [ + { project: otherProject, role: 'workflow:editor' }, + ]); + + // + // ACT + // + await testServer + .authAgentFor(member) + .delete(`/projects/${projectToBeDeleted.id}`) + .query({ transferId: targetProject.id }) + .expect(200); + + // + // ASSERT + // + + // projectToBeDeleted is deleted + await expect(findProject(projectToBeDeleted.id)).rejects.toThrowError(EntityNotFoundError); + + // ownedWorkflow has not been deleted + await expect(getWorkflowById(ownedWorkflow.id)).resolves.toBeDefined(); + + // ownedCredential has not been deleted + await expect(getCredentialById(ownedCredential.id)).resolves.toBeDefined(); + + // there is a sharing for ownedWorkflow and targetProject + await expect( + Container.get(SharedCredentialsRepository).findOneByOrFail({ + credentialsId: ownedCredential.id, + projectId: targetProject.id, + role: 'credential:owner', + }), + ).resolves.toBeDefined(); + + // there is a sharing for ownedCredential and targetProject + await expect( + Container.get(SharedWorkflowRepository).findOneByOrFail({ + workflowId: ownedWorkflow.id, + projectId: targetProject.id, + role: 'workflow:owner', + }), + ).resolves.toBeDefined(); + + // there is a sharing for ownedWorkflow and otherProject + await expect( + Container.get(SharedWorkflowRepository).findOneByOrFail({ + workflowId: ownedWorkflow.id, + projectId: otherProject.id, + role: 'workflow:editor', + }), + ).resolves.toBeDefined(); + + // there is a sharing for ownedCredential and otherProject + await expect( + Container.get(SharedCredentialsRepository).findOneByOrFail({ + credentialsId: ownedCredential.id, + projectId: otherProject.id, + role: 'credential:user', + }), + ).resolves.toBeDefined(); + }); + + // This test is testing behavior that is explicitly not enabled right now, + // but we want this to work if we in the future allow sharing of credentials + // and/or workflows between team projects. + test('should upgrade a projects role if the workflow/credential is already shared with it', async () => { + // + // ARRANGE + // + const member = await createMember(); + const project = await createTeamProject(undefined, member); + const credential = await saveCredential(randomCredentialPayload(), { + project, + role: 'credential:owner', + }); + const workflow = await createWorkflow({}, project); + const projectToMigrateTo = await createTeamProject(undefined, member); + + await shareWorkflowWithProjects(workflow, [ + { project: projectToMigrateTo, role: 'workflow:editor' }, + ]); + await shareCredentialWithProjects(credential, [projectToMigrateTo]); + + // + // ACT + // + await testServer + .authAgentFor(member) + .delete(`/projects/${project.id}`) + .query({ transferId: projectToMigrateTo.id }) + .expect(200); + + // + // ASSERT + // + + await expect( + Container.get(SharedCredentialsRepository).findOneByOrFail({ + credentialsId: credential.id, + projectId: projectToMigrateTo.id, + role: 'credential:owner', + }), + ).resolves.toBeDefined(); + await expect( + Container.get(SharedWorkflowRepository).findOneByOrFail({ + workflowId: workflow.id, + projectId: projectToMigrateTo.id, + role: 'workflow:owner', + }), + ).resolves.toBeDefined(); + }); +}); diff --git a/packages/cli/test/integration/project.service.integration.test.ts b/packages/cli/test/integration/project.service.integration.test.ts new file mode 100644 index 0000000000..77d388c161 --- /dev/null +++ b/packages/cli/test/integration/project.service.integration.test.ts @@ -0,0 +1,116 @@ +import Container from 'typedi'; +import { ProjectService } from '@/services/project.service'; +import * as testDb from './shared/testDb'; +import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; +import { createUser } from './shared/db/users'; +import { createWorkflow } from './shared/db/workflows'; +import { linkUserToProject, createTeamProject } from './shared/db/projects'; + +describe('ProjectService', () => { + let projectService: ProjectService; + + let sharedWorkflowRepository: SharedWorkflowRepository; + + beforeAll(async () => { + await testDb.init(); + + projectService = Container.get(ProjectService); + + sharedWorkflowRepository = Container.get(SharedWorkflowRepository); + }); + + afterEach(async () => { + await testDb.truncate(['User', 'Project', 'ProjectRelation', 'Workflow', 'SharedWorkflow']); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + describe('findRolesInProjects', () => { + describe('when user has roles in projects where workflow is accessible', () => { + it('should return roles and project IDs', async () => { + const user = await createUser(); + const secondUser = await createUser(); // @TODO: Needed only to satisfy index in legacy column + + const firstProject = await createTeamProject('Project 1'); + const secondProject = await createTeamProject('Project 2'); + + await linkUserToProject(user, firstProject, 'project:admin'); + await linkUserToProject(user, secondProject, 'project:viewer'); + + const workflow = await createWorkflow(); + + await sharedWorkflowRepository.insert({ + userId: user.id, // @TODO: Legacy column + projectId: firstProject.id, + workflowId: workflow.id, + role: 'workflow:owner', + }); + + await sharedWorkflowRepository.insert({ + userId: secondUser.id, // @TODO: Legacy column + projectId: secondProject.id, + workflowId: workflow.id, + role: 'workflow:user', + }); + + const projectIds = await projectService.findProjectsWorkflowIsIn(workflow.id); + + expect(projectIds).toEqual(expect.arrayContaining([firstProject.id, secondProject.id])); + }); + }); + + describe('when user has no roles in projects where workflow is accessible', () => { + it('should return project IDs but no roles', async () => { + const user = await createUser(); + const secondUser = await createUser(); // @TODO: Needed only to satisfy index in legacy column + + const firstProject = await createTeamProject('Project 1'); + const secondProject = await createTeamProject('Project 2'); + + // workflow shared with projects, but user not added to any project + + const workflow = await createWorkflow(); + + await sharedWorkflowRepository.insert({ + userId: user.id, // @TODO: Legacy column + projectId: firstProject.id, + workflowId: workflow.id, + role: 'workflow:owner', + }); + + await sharedWorkflowRepository.insert({ + userId: secondUser.id, // @TODO: Legacy column + projectId: secondProject.id, + workflowId: workflow.id, + role: 'workflow:user', + }); + + const projectIds = await projectService.findProjectsWorkflowIsIn(workflow.id); + + expect(projectIds).toEqual(expect.arrayContaining([firstProject.id, secondProject.id])); + }); + }); + + describe('when user has roles in projects where workflow is inaccessible', () => { + it('should return project IDs but no roles', async () => { + const user = await createUser(); + + const firstProject = await createTeamProject('Project 1'); + const secondProject = await createTeamProject('Project 2'); + + await linkUserToProject(user, firstProject, 'project:admin'); + await linkUserToProject(user, secondProject, 'project:viewer'); + + const workflow = await createWorkflow(); + + // user added to projects, but workflow not shared with projects + + const projectIds = await projectService.findProjectsWorkflowIsIn(workflow.id); + + expect(projectIds).toHaveLength(0); + }); + }); + }); +}); diff --git a/packages/cli/test/integration/publicApi/credentials.test.ts b/packages/cli/test/integration/publicApi/credentials.test.ts index d028834638..378b3725c8 100644 --- a/packages/cli/test/integration/publicApi/credentials.test.ts +++ b/packages/cli/test/integration/publicApi/credentials.test.ts @@ -63,8 +63,16 @@ describe('POST /credentials', () => { expect(credential.data).not.toBe(payload.data); const sharedCredential = await Container.get(SharedCredentialsRepository).findOneOrFail({ - relations: ['user', 'credentials'], - where: { credentialsId: credential.id, userId: owner.id }, + relations: { credentials: true }, + where: { + credentialsId: credential.id, + project: { + type: 'personal', + projectRelations: { + userId: owner.id, + }, + }, + }, }); expect(sharedCredential.role).toEqual('credential:owner'); @@ -203,7 +211,7 @@ describe('DELETE /credentials/:id', () => { const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(403); const shellCredential = await Container.get(CredentialsRepository).findOneBy({ id: savedCredential.id, diff --git a/packages/cli/test/integration/publicApi/executions.test.ts b/packages/cli/test/integration/publicApi/executions.test.ts index 3cc6bd4302..f80438f8a7 100644 --- a/packages/cli/test/integration/publicApi/executions.test.ts +++ b/packages/cli/test/integration/publicApi/executions.test.ts @@ -132,6 +132,7 @@ describe('GET /executions/:id', () => { }); test('member should be able to fetch executions of workflows shared with him', async () => { + testServer.license.enable('feat:sharing'); const workflow = await createWorkflow({}, user1); const execution = await createSuccessfulExecution(workflow); @@ -434,6 +435,7 @@ describe('GET /executions', () => { }); test('member should also see executions of workflows shared with him', async () => { + testServer.license.enable('feat:sharing'); const [firstWorkflowForUser1, secondWorkflowForUser1] = await createManyWorkflows(2, {}, user1); await createManyExecutions(2, firstWorkflowForUser1, createSuccessfulExecution); await createManyExecutions(2, secondWorkflowForUser1, createSuccessfulExecution); diff --git a/packages/cli/test/integration/publicApi/workflows.test.ts b/packages/cli/test/integration/publicApi/workflows.test.ts index 9ef71e8aff..21863b552c 100644 --- a/packages/cli/test/integration/publicApi/workflows.test.ts +++ b/packages/cli/test/integration/publicApi/workflows.test.ts @@ -17,9 +17,13 @@ import { createUser } from '../shared/db/users'; import { createWorkflow, createWorkflowWithTrigger } from '../shared/db/workflows'; import { createTag } from '../shared/db/tags'; import { mockInstance } from '../../shared/mocking'; +import type { Project } from '@/databases/entities/Project'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; let owner: User; +let ownerPersonalProject: Project; let member: User; +let memberPersonalProject: Project; let authOwnerAgent: SuperAgentTest; let authMemberAgent: SuperAgentTest; let activeWorkflowManager: ActiveWorkflowManager; @@ -34,11 +38,17 @@ beforeAll(async () => { role: 'global:owner', apiKey: randomApiKey(), }); + ownerPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + owner.id, + ); member = await createUser({ role: 'global:member', apiKey: randomApiKey(), }); + memberPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + member.id, + ); await utils.initNodeTypes(); @@ -254,10 +264,7 @@ describe('GET /workflows', () => { test('should return all owned workflows filtered by name', async () => { const workflowName = 'Workflow 1'; - const [workflow] = await Promise.all([ - createWorkflow({ name: workflowName }, member), - createWorkflow({}, member), - ]); + await Promise.all([createWorkflow({ name: workflowName }, member), createWorkflow({}, member)]); const response = await authMemberAgent.get(`/workflows?name=${workflowName}`); @@ -274,7 +281,7 @@ describe('GET /workflows', () => { name, createdAt, updatedAt, - tags: wfTags, + tags, } = response.body.data[0]; expect(id).toBeDefined(); @@ -286,6 +293,7 @@ describe('GET /workflows', () => { expect(settings).toBeDefined(); expect(createdAt).toBeDefined(); expect(updatedAt).toBeDefined(); + expect(tags).toEqual([]); }); test('should return all workflows for owner', async () => { @@ -508,7 +516,7 @@ describe('POST /workflows/:id/activate', () => { // check whether the workflow is on the database const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: member.id, + projectId: memberPersonalProject.id, workflowId: workflow.id, }, relations: ['workflow'], @@ -523,9 +531,7 @@ describe('POST /workflows/:id/activate', () => { test('should set non-owned workflow as active when owner', async () => { const workflow = await createWorkflowWithTrigger({}, member); - const response = await authMemberAgent.post(`/workflows/${workflow.id}/activate`); - - expect(response.statusCode).toBe(200); + const response = await authMemberAgent.post(`/workflows/${workflow.id}/activate`).expect(200); const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = response.body; @@ -543,7 +549,7 @@ describe('POST /workflows/:id/activate', () => { // check whether the workflow is on the database const sharedOwnerWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: owner.id, + projectId: ownerPersonalProject.id, workflowId: workflow.id, }, }); @@ -552,7 +558,7 @@ describe('POST /workflows/:id/activate', () => { const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: member.id, + projectId: memberPersonalProject.id, workflowId: workflow.id, }, relations: ['workflow'], @@ -606,7 +612,7 @@ describe('POST /workflows/:id/deactivate', () => { // get the workflow after it was deactivated const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: member.id, + projectId: memberPersonalProject.id, workflowId: workflow.id, }, relations: ['workflow'], @@ -643,7 +649,7 @@ describe('POST /workflows/:id/deactivate', () => { // check whether the workflow is deactivated in the database const sharedOwnerWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: owner.id, + projectId: ownerPersonalProject.id, workflowId: workflow.id, }, }); @@ -652,7 +658,7 @@ describe('POST /workflows/:id/deactivate', () => { const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: member.id, + projectId: memberPersonalProject.id, workflowId: workflow.id, }, relations: ['workflow'], @@ -720,7 +726,7 @@ describe('POST /workflows', () => { // check if created workflow in DB const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: member.id, + projectId: memberPersonalProject.id, workflowId: response.body.id, }, relations: ['workflow'], @@ -959,7 +965,7 @@ describe('PUT /workflows/:id', () => { // check updated workflow in DB const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: member.id, + projectId: memberPersonalProject.id, workflowId: response.body.id, }, relations: ['workflow'], @@ -1128,7 +1134,7 @@ describe('PUT /workflows/:id', () => { // check updated workflow in DB const sharedOwnerWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: owner.id, + projectId: ownerPersonalProject.id, workflowId: response.body.id, }, }); @@ -1137,7 +1143,7 @@ describe('PUT /workflows/:id', () => { const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: member.id, + projectId: memberPersonalProject.id, workflowId: response.body.id, }, relations: ['workflow'], @@ -1269,7 +1275,7 @@ describe('PUT /workflows/:id/tags', () => { // Check the association in DB const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: member.id, + projectId: memberPersonalProject.id, workflowId: workflow.id, }, relations: ['workflow.tags'], @@ -1304,7 +1310,7 @@ describe('PUT /workflows/:id/tags', () => { // Check the association in DB const oldSharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: member.id, + projectId: memberPersonalProject.id, workflowId: workflow.id, }, relations: ['workflow.tags'], @@ -1357,7 +1363,7 @@ describe('PUT /workflows/:id/tags', () => { // Check the association in DB const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: member.id, + projectId: memberPersonalProject.id, workflowId: workflow.id, }, relations: ['workflow.tags'], @@ -1391,7 +1397,7 @@ describe('PUT /workflows/:id/tags', () => { // Check the association in DB const oldSharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: member.id, + projectId: memberPersonalProject.id, workflowId: workflow.id, }, relations: ['workflow.tags'], @@ -1431,7 +1437,7 @@ describe('PUT /workflows/:id/tags', () => { // Check the association in DB const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: member.id, + projectId: memberPersonalProject.id, workflowId: workflow.id, }, relations: ['workflow.tags'], diff --git a/packages/cli/test/integration/role.api.test.ts b/packages/cli/test/integration/role.api.test.ts new file mode 100644 index 0000000000..d5afc38f0c --- /dev/null +++ b/packages/cli/test/integration/role.api.test.ts @@ -0,0 +1,165 @@ +import type { SuperAgentTest } from 'supertest'; +import * as utils from './shared/utils/'; +import { createMember } from './shared/db/users'; +import type { GlobalRole } from '@/databases/entities/User'; +import type { ProjectRole } from '@/databases/entities/ProjectRelation'; +import type { CredentialSharingRole } from '@/databases/entities/SharedCredentials'; +import type { WorkflowSharingRole } from '@/databases/entities/SharedWorkflow'; +import { RoleService } from '@/services/role.service'; +import Container from 'typedi'; +import type { Scope } from '@n8n/permissions'; + +const testServer = utils.setupTestServer({ + endpointGroups: ['role'], +}); + +let memberAgent: SuperAgentTest; + +const expectedCategories = ['global', 'project', 'credential', 'workflow'] as const; +let expectedGlobalRoles: Array<{ + name: string; + role: GlobalRole; + scopes: Scope[]; + licensed: boolean; +}>; +let expectedProjectRoles: Array<{ + name: string; + role: ProjectRole; + scopes: Scope[]; + licensed: boolean; +}>; +let expectedCredentialRoles: Array<{ + name: string; + role: CredentialSharingRole; + scopes: Scope[]; + licensed: boolean; +}>; +let expectedWorkflowRoles: Array<{ + name: string; + role: WorkflowSharingRole; + scopes: Scope[]; + licensed: boolean; +}>; + +beforeAll(async () => { + memberAgent = testServer.authAgentFor(await createMember()); + + expectedGlobalRoles = [ + { + name: 'Owner', + role: 'global:owner', + scopes: Container.get(RoleService).getRoleScopes('global:owner'), + licensed: true, + }, + { + name: 'Admin', + role: 'global:admin', + scopes: Container.get(RoleService).getRoleScopes('global:admin'), + licensed: false, + }, + { + name: 'Member', + role: 'global:member', + scopes: Container.get(RoleService).getRoleScopes('global:member'), + licensed: true, + }, + ]; + expectedProjectRoles = [ + { + name: 'Project Owner', + role: 'project:personalOwner', + scopes: Container.get(RoleService).getRoleScopes('project:personalOwner'), + licensed: true, + }, + { + name: 'Project Admin', + role: 'project:admin', + scopes: Container.get(RoleService).getRoleScopes('project:admin'), + licensed: false, + }, + { + name: 'Project Editor', + role: 'project:editor', + scopes: Container.get(RoleService).getRoleScopes('project:editor'), + licensed: false, + }, + ]; + expectedCredentialRoles = [ + { + name: 'Credential Owner', + role: 'credential:owner', + scopes: Container.get(RoleService).getRoleScopes('credential:owner'), + licensed: true, + }, + { + name: 'Credential User', + role: 'credential:user', + scopes: Container.get(RoleService).getRoleScopes('credential:user'), + licensed: true, + }, + ]; + expectedWorkflowRoles = [ + { + name: 'Workflow Owner', + role: 'workflow:owner', + scopes: Container.get(RoleService).getRoleScopes('workflow:owner'), + licensed: true, + }, + { + name: 'Workflow Editor', + role: 'workflow:editor', + scopes: Container.get(RoleService).getRoleScopes('workflow:editor'), + licensed: true, + }, + ]; +}); + +describe('GET /roles/', () => { + test('should return all role categories', async () => { + const resp = await memberAgent.get('/roles/'); + + expect(resp.status).toBe(200); + + const data: Record = resp.body.data; + + const categories = [...Object.keys(data)]; + expect(categories.length).toBe(expectedCategories.length); + expect(expectedCategories.every((c) => categories.includes(c))).toBe(true); + }); + + test('should return fixed global roles', async () => { + const resp = await memberAgent.get('/roles/'); + + expect(resp.status).toBe(200); + for (const role of expectedGlobalRoles) { + expect(resp.body.data.global).toContainEqual(role); + } + }); + + test('should return fixed project roles', async () => { + const resp = await memberAgent.get('/roles/'); + + expect(resp.status).toBe(200); + for (const role of expectedProjectRoles) { + expect(resp.body.data.project).toContainEqual(role); + } + }); + + test('should return fixed credential sharing roles', async () => { + const resp = await memberAgent.get('/roles/'); + + expect(resp.status).toBe(200); + for (const role of expectedCredentialRoles) { + expect(resp.body.data.credential).toContainEqual(role); + } + }); + + test('should return fixed workflow sharing roles', async () => { + const resp = await memberAgent.get('/roles/'); + + expect(resp.status).toBe(200); + for (const role of expectedWorkflowRoles) { + expect(resp.body.data.workflow).toContainEqual(role); + } + }); +}); diff --git a/packages/cli/test/integration/saml/samlHelpers.test.ts b/packages/cli/test/integration/saml/samlHelpers.test.ts new file mode 100644 index 0000000000..7941efada1 --- /dev/null +++ b/packages/cli/test/integration/saml/samlHelpers.test.ts @@ -0,0 +1,44 @@ +import * as helpers from '@/sso/saml/samlHelpers'; +import type { SamlUserAttributes } from '@/sso/saml/types/samlUserAttributes'; +import { getPersonalProject } from '../../integration/shared/db/projects'; + +import * as testDb from '../shared/testDb'; + +beforeAll(async () => { + await testDb.init(); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +describe('sso/saml/samlHelpers', () => { + describe('createUserFromSamlAttributes', () => { + test('Creates personal project for user', async () => { + // + // ARRANGE + // + const samlUserAttributes: SamlUserAttributes = { + firstName: 'Nathan', + lastName: 'Nathaniel', + email: 'n@8.n', + userPrincipalName: 'Huh?', + }; + + // + // ACT + // + const user = await helpers.createUserFromSamlAttributes(samlUserAttributes); + + // + // ASSERT + // + expect(user).toMatchObject({ + firstName: samlUserAttributes.firstName, + lastName: samlUserAttributes.lastName, + email: samlUserAttributes.email, + }); + await expect(getPersonalProject(user)).resolves.not.toBeNull(); + }); + }); +}); diff --git a/packages/cli/test/integration/services/project.service.test.ts b/packages/cli/test/integration/services/project.service.test.ts new file mode 100644 index 0000000000..54cdad2b3c --- /dev/null +++ b/packages/cli/test/integration/services/project.service.test.ts @@ -0,0 +1,202 @@ +import { ProjectService } from '@/services/project.service'; +import * as testDb from '../shared/testDb'; +import Container from 'typedi'; +import { createMember } from '../shared/db/users'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; +import type { ProjectRole } from '@/databases/entities/ProjectRelation'; +import type { Scope } from '@n8n/permissions'; + +let projectRepository: ProjectRepository; +let projectService: ProjectService; +let projectRelationRepository: ProjectRelationRepository; + +beforeAll(async () => { + await testDb.init(); + + projectRepository = Container.get(ProjectRepository); + projectService = Container.get(ProjectService); + projectRelationRepository = Container.get(ProjectRelationRepository); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +afterEach(async () => { + await testDb.truncate(['User']); +}); + +describe('ProjectService', () => { + describe('addUser', () => { + it.each([ + 'project:viewer', + 'project:admin', + 'project:editor', + 'project:personalOwner', + ] as ProjectRole[])( + 'creates a relation between the user and the project using the role %s', + async (role) => { + // + // ARRANGE + // + const member = await createMember(); + const project = await projectRepository.save( + projectRepository.create({ + name: 'Team Project', + type: 'team', + }), + ); + + // + // ACT + // + await projectService.addUser(project.id, member.id, role); + + // + // ASSERT + // + await projectRelationRepository.findOneOrFail({ + where: { userId: member.id, projectId: project.id, role }, + }); + }, + ); + + it('changes the role the user has in the project if the user is already part of the project, instead of creating a new relationship', async () => { + // + // ARRANGE + // + const member = await createMember(); + const project = await projectRepository.save( + projectRepository.create({ + name: 'Team Project', + type: 'team', + }), + ); + await projectService.addUser(project.id, member.id, 'project:viewer'); + + await projectRelationRepository.findOneOrFail({ + where: { userId: member.id, projectId: project.id, role: 'project:viewer' }, + }); + + // + // ACT + // + await projectService.addUser(project.id, member.id, 'project:admin'); + + // + // ASSERT + // + const relationships = await projectRelationRepository.find({ + where: { userId: member.id, projectId: project.id }, + }); + + expect(relationships).toHaveLength(1); + expect(relationships[0]).toHaveProperty('role', 'project:admin'); + }); + }); + + describe('getProjectWithScope', () => { + it.each([ + { role: 'project:admin', scope: 'workflow:list' }, + { role: 'project:admin', scope: 'workflow:create' }, + ] as Array<{ + role: ProjectRole; + scope: Scope; + }>)( + 'should return the project if the user has the role $role and wants the scope $scope', + async ({ role, scope }) => { + // + // ARRANGE + // + const projectOwner = await createMember(); + const project = await projectRepository.save( + projectRepository.create({ + name: 'Team Project', + type: 'team', + }), + ); + await projectService.addUser(project.id, projectOwner.id, role); + + // + // ACT + // + const projectFromService = await projectService.getProjectWithScope( + projectOwner, + project.id, + [scope], + ); + + // + // ASSERT + // + if (projectFromService === null) { + fail('Expected projectFromService not to be null'); + } + expect(project.id).toBe(projectFromService.id); + }, + ); + + it.each([ + { role: 'project:viewer', scope: 'workflow:create' }, + { role: 'project:viewer', scope: 'credential:create' }, + ] as Array<{ + role: ProjectRole; + scope: Scope; + }>)( + 'should return the project if the user has the role $role and wants the scope $scope', + async ({ role, scope }) => { + // + // ARRANGE + // + const projectViewer = await createMember(); + const project = await projectRepository.save( + projectRepository.create({ + name: 'Team Project', + type: 'team', + }), + ); + await projectService.addUser(project.id, projectViewer.id, role); + + // + // ACT + // + const projectFromService = await projectService.getProjectWithScope( + projectViewer, + project.id, + [scope], + ); + + // + // ASSERT + // + expect(projectFromService).toBeNull(); + }, + ); + + it('should not return the project if the user is not part of it', async () => { + // + // ARRANGE + // + const member = await createMember(); + const project = await projectRepository.save( + projectRepository.create({ + name: 'Team Project', + type: 'team', + }), + ); + + // + // ACT + // + const projectFromService = await projectService.getProjectWithScope(member, project.id, [ + 'workflow:list', + ]); + + // + // ASSERT + // + expect(projectFromService).toBeNull(); + }); + }); +}); diff --git a/packages/cli/test/integration/shared/db/credentials.ts b/packages/cli/test/integration/shared/db/credentials.ts index 85b46d26a3..9464f06bf0 100644 --- a/packages/cli/test/integration/shared/db/credentials.ts +++ b/packages/cli/test/integration/shared/db/credentials.ts @@ -6,15 +6,19 @@ import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials. import type { CredentialSharingRole } from '@db/entities/SharedCredentials'; import type { ICredentialsDb } from '@/Interfaces'; import type { CredentialPayload } from '../types'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import type { Project } from '@/databases/entities/Project'; -async function encryptCredentialData(credential: CredentialsEntity) { +export async function encryptCredentialData( + credential: CredentialsEntity, +): Promise { const { createCredentialsFromCredentialsEntity } = await import('@/CredentialsHelper'); const coreCredential = createCredentialsFromCredentialsEntity(credential, true); // @ts-ignore coreCredential.setData(credential.data); - return coreCredential.getDataToSave() as ICredentialsDb; + return Object.assign(credential, coreCredential.getDataToSave()); } const emptyAttributes = { @@ -46,43 +50,89 @@ export async function createCredentials(attributes: Partial = */ export async function saveCredential( credentialPayload: CredentialPayload, - { user, role }: { user: User; role: CredentialSharingRole }, + options: + | { user: User; role: CredentialSharingRole } + | { + project: Project; + role: CredentialSharingRole; + }, ) { + const role = options.role; const newCredential = new CredentialsEntity(); Object.assign(newCredential, credentialPayload); - const encryptedData = await encryptCredentialData(newCredential); - - Object.assign(newCredential, encryptedData); + await encryptCredentialData(newCredential); const savedCredential = await Container.get(CredentialsRepository).save(newCredential); savedCredential.data = newCredential.data; - await Container.get(SharedCredentialsRepository).save({ - user, - credentials: savedCredential, - role, - }); + if ('user' in options) { + const user = options.user; + const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + user.id, + ); + + await Container.get(SharedCredentialsRepository).save({ + user, + credentials: savedCredential, + role, + project: personalProject, + }); + } else { + const project = options.project; + + await Container.get(SharedCredentialsRepository).save({ + credentials: savedCredential, + role, + project, + }); + } return savedCredential; } export async function shareCredentialWithUsers(credential: CredentialsEntity, users: User[]) { - const newSharedCredentials = users.map((user) => - Container.get(SharedCredentialsRepository).create({ - userId: user.id, - credentialsId: credential.id, - role: 'credential:user', + const newSharedCredentials = await Promise.all( + users.map(async (user) => { + const personalProject = await Container.get( + ProjectRepository, + ).getPersonalProjectForUserOrFail(user.id); + + return Container.get(SharedCredentialsRepository).create({ + credentialsId: credential.id, + role: 'credential:user', + projectId: personalProject.id, + }); }), ); + + return await Container.get(SharedCredentialsRepository).save(newSharedCredentials); +} + +export async function shareCredentialWithProjects( + credential: CredentialsEntity, + projects: Project[], +) { + const newSharedCredentials = await Promise.all( + projects.map(async (project) => { + return Container.get(SharedCredentialsRepository).create({ + credentialsId: credential.id, + role: 'credential:user', + projectId: project.id, + }); + }), + ); + return await Container.get(SharedCredentialsRepository).save(newSharedCredentials); } export function affixRoleToSaveCredential(role: CredentialSharingRole) { - return async (credentialPayload: CredentialPayload, { user }: { user: User }) => - await saveCredential(credentialPayload, { user, role }); + return async ( + credentialPayload: CredentialPayload, + options: { user: User } | { project: Project }, + ) => await saveCredential(credentialPayload, { ...options, role }); } export async function getAllCredentials() { diff --git a/packages/cli/test/integration/shared/db/projects.ts b/packages/cli/test/integration/shared/db/projects.ts new file mode 100644 index 0000000000..60548575b3 --- /dev/null +++ b/packages/cli/test/integration/shared/db/projects.ts @@ -0,0 +1,63 @@ +import Container from 'typedi'; + +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { randomName } from '../random'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; +import type { User } from '@/databases/entities/User'; +import type { Project } from '@/databases/entities/Project'; +import type { ProjectRelation, ProjectRole } from '@/databases/entities/ProjectRelation'; + +export const createTeamProject = async (name?: string, adminUser?: User) => { + const projectRepository = Container.get(ProjectRepository); + const project = await projectRepository.save( + projectRepository.create({ + name: name ?? randomName(), + type: 'team', + }), + ); + + if (adminUser) { + await linkUserToProject(adminUser, project, 'project:admin'); + } + + return project; +}; + +export const linkUserToProject = async (user: User, project: Project, role: ProjectRole) => { + const projectRelationRepository = Container.get(ProjectRelationRepository); + await projectRelationRepository.save( + projectRelationRepository.create({ + projectId: project.id, + userId: user.id, + role, + }), + ); +}; + +export const getPersonalProject = async (user: User): Promise => { + return await Container.get(ProjectRepository).findOneOrFail({ + where: { + projectRelations: { + userId: user.id, + role: 'project:personalOwner', + }, + type: 'personal', + }, + }); +}; + +export const findProject = async (id: string): Promise => { + return await Container.get(ProjectRepository).findOneOrFail({ + where: { id }, + }); +}; + +export const getProjectRelations = async ({ + projectId, + userId, + role, +}: Partial): Promise => { + return await Container.get(ProjectRelationRepository).find({ + where: { projectId, userId, role }, + }); +}; diff --git a/packages/cli/test/integration/shared/db/users.ts b/packages/cli/test/integration/shared/db/users.ts index 4f4ed8af18..81ca3b199e 100644 --- a/packages/cli/test/integration/shared/db/users.ts +++ b/packages/cli/test/integration/shared/db/users.ts @@ -1,7 +1,7 @@ import Container from 'typedi'; import { hash } from 'bcryptjs'; import { AuthIdentity } from '@db/entities/AuthIdentity'; -import type { GlobalRole, User } from '@db/entities/User'; +import { type GlobalRole, type User } from '@db/entities/User'; import { AuthIdentityRepository } from '@db/repositories/authIdentity.repository'; import { UserRepository } from '@db/repositories/user.repository'; import { TOTPService } from '@/Mfa/totp.service'; @@ -27,9 +27,10 @@ export async function newUser(attributes: Partial = {}): Promise { /** Store a user object in the DB */ export async function createUser(attributes: Partial = {}): Promise { - const user = await newUser(attributes); + const userInstance = await newUser(attributes); + const { user } = await Container.get(UserRepository).createUserWithProject(userInstance); user.computeIsOwner(); - return await Container.get(UserRepository).save(user); + return user; } export async function createLdapUser(attributes: Partial, ldapId: string): Promise { @@ -90,7 +91,8 @@ export async function createUserShell(role: GlobalRole): Promise { shell.email = randomEmail(); } - return await Container.get(UserRepository).save(shell); + const { user } = await Container.get(UserRepository).createUserWithProject(shell); + return user; } /** @@ -100,12 +102,15 @@ export async function createManyUsers( amount: number, attributes: Partial = {}, ): Promise { - const users = await Promise.all( + const result = await Promise.all( Array(amount) .fill(0) - .map(async () => await newUser(attributes)), + .map(async () => { + const userInstance = await newUser(attributes); + return await Container.get(UserRepository).createUserWithProject(userInstance); + }), ); - return await Container.get(UserRepository).save(users); + return result.map((result) => result.user); } export async function addApiKey(user: User): Promise { @@ -127,7 +132,7 @@ export const getUserById = async (id: string) => export const getLdapIdentities = async () => await Container.get(AuthIdentityRepository).find({ where: { providerType: 'ldap' }, - relations: ['user'], + relations: { user: true }, }); export async function getGlobalOwner() { diff --git a/packages/cli/test/integration/shared/db/workflows.ts b/packages/cli/test/integration/shared/db/workflows.ts index 18a97a693b..f81ac044c3 100644 --- a/packages/cli/test/integration/shared/db/workflows.ts +++ b/packages/cli/test/integration/shared/db/workflows.ts @@ -2,11 +2,13 @@ import Container from 'typedi'; import type { DeepPartial } from '@n8n/typeorm'; import { v4 as uuid } from 'uuid'; -import type { User } from '@db/entities/User'; +import { User } from '@db/entities/User'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; -import type { SharedWorkflow } from '@db/entities/SharedWorkflow'; +import type { SharedWorkflow, WorkflowSharingRole } from '@db/entities/SharedWorkflow'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { Project } from '@/databases/entities/Project'; export async function createManyWorkflows( amount: number, @@ -48,28 +50,71 @@ export function newWorkflow(attributes: Partial = {}): WorkflowE * @param attributes workflow attributes * @param user user to assign the workflow to */ -export async function createWorkflow(attributes: Partial = {}, user?: User) { +export async function createWorkflow( + attributes: Partial = {}, + userOrProject?: User | Project, +) { const workflow = await Container.get(WorkflowRepository).save(newWorkflow(attributes)); - if (user) { - await Container.get(SharedWorkflowRepository).save({ - user, - workflow, - role: 'workflow:owner', - }); + if (userOrProject instanceof User) { + const user = userOrProject; + const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(user.id); + await Container.get(SharedWorkflowRepository).save( + Container.get(SharedWorkflowRepository).create({ + project, + workflow, + role: 'workflow:owner', + }), + ); } + + if (userOrProject instanceof Project) { + const project = userOrProject; + await Container.get(SharedWorkflowRepository).save( + Container.get(SharedWorkflowRepository).create({ + project, + workflow, + role: 'workflow:owner', + }), + ); + } + return workflow; } export async function shareWorkflowWithUsers(workflow: WorkflowEntity, users: User[]) { - const sharedWorkflows: Array> = users.map((user) => ({ - userId: user.id, - workflowId: workflow.id, - role: 'workflow:editor', - })); + const sharedWorkflows: Array> = await Promise.all( + users.map(async (user) => { + const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + user.id, + ); + return { + projectId: project.id, + workflowId: workflow.id, + role: 'workflow:editor', + }; + }), + ); return await Container.get(SharedWorkflowRepository).save(sharedWorkflows); } +export async function shareWorkflowWithProjects( + workflow: WorkflowEntity, + projectsWithRole: Array<{ project: Project; role?: WorkflowSharingRole }>, +) { + const newSharedWorkflow = await Promise.all( + projectsWithRole.map(async ({ project, role }) => { + return Container.get(SharedWorkflowRepository).create({ + workflowId: workflow.id, + role: role ?? 'workflow:editor', + projectId: project.id, + }); + }), + ); + + return await Container.get(SharedWorkflowRepository).save(newSharedWorkflow); +} + export async function getWorkflowSharing(workflow: WorkflowEntity) { return await Container.get(SharedWorkflowRepository).findBy({ workflowId: workflow.id, diff --git a/packages/cli/test/integration/shared/ldap.ts b/packages/cli/test/integration/shared/ldap.ts new file mode 100644 index 0000000000..1223bd0f07 --- /dev/null +++ b/packages/cli/test/integration/shared/ldap.ts @@ -0,0 +1,33 @@ +import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/Ldap/constants'; +import type { LdapConfig } from '@/Ldap/types'; +import { SettingsRepository } from '@/databases/repositories/settings.repository'; +import { jsonParse } from 'n8n-workflow'; +import Container from 'typedi'; + +export const defaultLdapConfig = { + ...LDAP_DEFAULT_CONFIGURATION, + loginEnabled: true, + loginLabel: '', + ldapIdAttribute: 'uid', + firstNameAttribute: 'givenName', + lastNameAttribute: 'sn', + emailAttribute: 'mail', + loginIdAttribute: 'mail', + baseDn: 'baseDn', + bindingAdminDn: 'adminDn', + bindingAdminPassword: 'adminPassword', +}; + +export const createLdapConfig = async ( + attributes: Partial = {}, +): Promise => { + const { value: ldapConfig } = await Container.get(SettingsRepository).save({ + key: LDAP_FEATURE_NAME, + value: JSON.stringify({ + ...defaultLdapConfig, + ...attributes, + }), + loadOnStartup: true, + }); + return await jsonParse(ldapConfig); +}; diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts index c5b25f109d..514b04a6b3 100644 --- a/packages/cli/test/integration/shared/testDb.ts +++ b/packages/cli/test/integration/shared/testDb.ts @@ -51,12 +51,16 @@ const repositories = [ 'AuthProviderSyncHistory', 'Credentials', 'EventDestinations', + 'Execution', 'ExecutionData', 'ExecutionMetadata', - 'Execution', 'InstalledNodes', 'InstalledPackages', + 'Project', + 'ProjectRelation', 'Role', + 'Project', + 'ProjectRelation', 'Settings', 'SharedCredentials', 'SharedWorkflow', diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 8355d6f39c..5efa857ff2 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -7,6 +7,7 @@ import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; import type { User } from '@db/entities/User'; import type { BooleanLicenseFeature, ICredentialsDb, NumericLicenseFeature } from '@/Interfaces'; import type { LicenseMocker } from './license'; +import type { Project } from '@/databases/entities/Project'; type EndpointGroup = | 'me' @@ -32,7 +33,9 @@ type EndpointGroup = | 'workflowHistory' | 'binaryData' | 'invitations' - | 'debug'; + | 'debug' + | 'project' + | 'role'; export interface SetupProps { endpointGroups?: EndpointGroup[]; @@ -57,5 +60,5 @@ export type CredentialPayload = { export type SaveCredentialFunction = ( credentialPayload: CredentialPayload, - { user }: { user: User }, + options: { user: User } | { project: Project }, ) => Promise; diff --git a/packages/cli/test/integration/shared/utils/testServer.ts b/packages/cli/test/integration/shared/utils/testServer.ts index d6b9aa40ef..49a6cbaa27 100644 --- a/packages/cli/test/integration/shared/utils/testServer.ts +++ b/packages/cli/test/integration/shared/utils/testServer.ts @@ -257,6 +257,16 @@ export const setupTestServer = ({ const { DebugController } = await import('@/controllers/debug.controller'); registerController(app, DebugController); break; + + case 'project': + const { ProjectController } = await import('@/controllers/project.controller'); + registerController(app, ProjectController); + break; + + case 'role': + const { RoleController } = await import('@/controllers/role.controller'); + registerController(app, RoleController); + break; } } } diff --git a/packages/cli/test/integration/user.repository.test.ts b/packages/cli/test/integration/user.repository.test.ts index 6929326b95..d333454aa5 100644 --- a/packages/cli/test/integration/user.repository.test.ts +++ b/packages/cli/test/integration/user.repository.test.ts @@ -2,6 +2,8 @@ import Container from 'typedi'; import { UserRepository } from '@db/repositories/user.repository'; import { createAdmin, createMember, createOwner } from './shared/db/users'; import * as testDb from './shared/testDb'; +import { randomEmail } from './shared/random'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; describe('UserRepository', () => { let userRepository: UserRepository; @@ -38,4 +40,25 @@ describe('UserRepository', () => { }); }); }); + + describe('createUserWithProject()', () => { + test('should create personal project for a user', async () => { + const { user, project } = await userRepository.createUserWithProject({ + email: randomEmail(), + role: 'global:member', + }); + + const projectRelation = await Container.get(ProjectRelationRepository).findOneOrFail({ + where: { + userId: user.id, + project: { + type: 'personal', + }, + }, + relations: ['project'], + }); + + expect(projectRelation.project.id).toBe(project.id); + }); + }); }); diff --git a/packages/cli/test/integration/users.api.test.ts b/packages/cli/test/integration/users.api.test.ts index 5c9bdc2600..b58f88795d 100644 --- a/packages/cli/test/integration/users.api.test.ts +++ b/packages/cli/test/integration/users.api.test.ts @@ -8,15 +8,25 @@ import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials. import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { ExecutionService } from '@/executions/execution.service'; -import { getCredentialById, saveCredential } from './shared/db/credentials'; +import { + getCredentialById, + saveCredential, + shareCredentialWithUsers, +} from './shared/db/credentials'; import { createAdmin, createMember, createOwner, getUserById } from './shared/db/users'; -import { createWorkflow, getWorkflowById } from './shared/db/workflows'; +import { createWorkflow, getWorkflowById, shareWorkflowWithUsers } from './shared/db/workflows'; import { SUCCESS_RESPONSE_BODY } from './shared/constants'; import { validateUser } from './shared/utils/users'; -import { randomName } from './shared/random'; +import { randomCredentialPayload } from './shared/random'; import * as utils from './shared/utils/'; import * as testDb from './shared/testDb'; import { mockInstance } from '../shared/mocking'; +import { RESPONSE_ERROR_MESSAGES } from '@/constants'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { createTeamProject, getPersonalProject, linkUserToProject } from './shared/db/projects'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; +import { CacheService } from '@/services/cache/cache.service'; +import { v4 as uuid } from 'uuid'; mockInstance(ExecutionService); @@ -25,6 +35,12 @@ const testServer = utils.setupTestServer({ enabledFeatures: ['feat:advancedPermissions'], }); +let projectRepository: ProjectRepository; + +beforeAll(() => { + projectRepository = Container.get(ProjectRepository); +}); + describe('GET /users', () => { let owner: User; let member: User; @@ -229,110 +245,338 @@ describe('GET /users', () => { describe('DELETE /users/:id', () => { let owner: User; - let member: User; let ownerAgent: SuperAgentTest; beforeAll(async () => { await testDb.truncate(['User']); owner = await createOwner(); - member = await createMember(); ownerAgent = testServer.authAgentFor(owner); }); test('should delete user and their resources', async () => { - const savedWorkflow = await createWorkflow({ name: randomName() }, member); + // + // ARRANGE + // + // @TODO: Include active workflow and check whether webhook has been removed - const savedCredential = await saveCredential( - { name: randomName(), type: '', data: {} }, - { user: member, role: 'credential:owner' }, - ); + const member = await createMember(); + const memberPersonalProject = await getPersonalProject(member); - const response = await ownerAgent.delete(`/users/${member.id}`); + // stays untouched + const teamProject = await createTeamProject(); + // will be deleted + await linkUserToProject(member, teamProject, 'project:admin'); - expect(response.statusCode).toBe(200); - expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); + const [savedWorkflow, savedCredential, teamWorkflow, teamCredential] = await Promise.all([ + // personal resource -> deleted + createWorkflow({}, member), + saveCredential(randomCredentialPayload(), { + user: member, + role: 'credential:owner', + }), + // resources in a team project -> untouched + createWorkflow({}, teamProject), + saveCredential(randomCredentialPayload(), { + project: teamProject, + role: 'credential:owner', + }), + ]); + + // + // ACT + // + await ownerAgent.delete(`/users/${member.id}`).expect(200, SUCCESS_RESPONSE_BODY); + + // + // ASSERT + // + const userRepository = Container.get(UserRepository); + const projectRepository = Container.get(ProjectRepository); + const projectRelationRepository = Container.get(ProjectRelationRepository); + const sharedWorkflowRepository = Container.get(SharedWorkflowRepository); + const sharedCredentialsRepository = Container.get(SharedCredentialsRepository); + + await Promise.all([ + // user, their personal project and their relationship to the team project is gone + expect(userRepository.findOneBy({ id: member.id })).resolves.toBeNull(), + expect(projectRepository.findOneBy({ id: memberPersonalProject.id })).resolves.toBeNull(), + expect( + projectRelationRepository.findOneBy({ userId: member.id, projectId: teamProject.id }), + ).resolves.toBeNull(), + + // their personal workflows and and credentials are gone + expect( + sharedWorkflowRepository.findOneBy({ + workflowId: savedWorkflow.id, + projectId: memberPersonalProject.id, + }), + ).resolves.toBeNull(), + expect( + sharedCredentialsRepository.findOneBy({ + credentialsId: savedCredential.id, + projectId: memberPersonalProject.id, + }), + ).resolves.toBeNull(), + + // team workflows and credentials are untouched + expect( + sharedWorkflowRepository.findOneBy({ + workflowId: teamWorkflow.id, + projectId: teamProject.id, + role: 'workflow:owner', + }), + ).resolves.not.toBeNull(), + expect( + sharedCredentialsRepository.findOneBy({ + credentialsId: teamCredential.id, + projectId: teamProject.id, + role: 'credential:owner', + }), + ).resolves.not.toBeNull(), + ]); const user = await Container.get(UserRepository).findOneBy({ id: member.id }); - const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ - relations: ['user'], - where: { userId: member.id, role: 'workflow:owner' }, + where: { projectId: memberPersonalProject.id, role: 'workflow:owner' }, }); - const sharedCredential = await Container.get(SharedCredentialsRepository).findOne({ - relations: ['user'], - where: { userId: member.id, role: 'credential:owner' }, + where: { projectId: memberPersonalProject.id, role: 'credential:owner' }, }); - const workflow = await getWorkflowById(savedWorkflow.id); - const credential = await getCredentialById(savedCredential.id); - // @TODO: Include active workflow and check whether webhook has been removed - expect(user).toBeNull(); expect(sharedWorkflow).toBeNull(); expect(sharedCredential).toBeNull(); expect(workflow).toBeNull(); expect(credential).toBeNull(); - - // restore - - member = await createMember(); }); - test('should delete user and transfer their resources', async () => { - const [savedWorkflow, savedCredential] = await Promise.all([ - await createWorkflow({ name: randomName() }, member), - await saveCredential( - { name: randomName(), type: '', data: {} }, - { - user: member, + test('should delete user and team relations and transfer their personal resources', async () => { + // + // ARRANGE + // + const [member, transferee, otherMember] = await Promise.all([ + createMember(), + createMember(), + createMember(), + ]); + + // stays untouched + const teamProject = await createTeamProject(); + await Promise.all([ + // will be deleted + linkUserToProject(member, teamProject, 'project:admin'), + + // stays untouched + linkUserToProject(transferee, teamProject, 'project:editor'), + ]); + + const [ + ownedWorkflow, + ownedCredential, + teamWorkflow, + teamCredential, + sharedByOtherMemberWorkflow, + sharedByOtherMemberCredential, + sharedByTransfereeWorkflow, + sharedByTransfereeCredential, + ] = await Promise.all([ + // personal resource + // -> transferred to transferee's personal project + createWorkflow({}, member), + saveCredential(randomCredentialPayload(), { + user: member, + role: 'credential:owner', + }), + + // resources in a team project + // -> untouched + createWorkflow({}, teamProject), + saveCredential(randomCredentialPayload(), { + project: teamProject, + role: 'credential:owner', + }), + + // credential and workflow that are shared with the user to delete + // -> transferred to transferee's personal project + createWorkflow({}, otherMember), + saveCredential(randomCredentialPayload(), { + user: otherMember, + role: 'credential:owner', + }), + + // credential and workflow that are shared with the user to delete but owned by the transferee + // -> not transferred but deleted + createWorkflow({}, transferee), + saveCredential(randomCredentialPayload(), { + user: transferee, + role: 'credential:owner', + }), + ]); + + await Promise.all([ + shareWorkflowWithUsers(sharedByOtherMemberWorkflow, [member]), + shareCredentialWithUsers(sharedByOtherMemberCredential, [member]), + + shareWorkflowWithUsers(sharedByTransfereeWorkflow, [member]), + shareCredentialWithUsers(sharedByTransfereeCredential, [member]), + ]); + + const [memberPersonalProject, transfereePersonalProject] = await Promise.all([ + getPersonalProject(member), + getPersonalProject(transferee), + ]); + + const deleteSpy = jest.spyOn(Container.get(CacheService), 'deleteMany'); + + // + // ACT + // + await ownerAgent + .delete(`/users/${member.id}`) + .query({ transferId: transfereePersonalProject.id }) + .expect(200); + + // + // ASSERT + // + + expect(deleteSpy).toBeCalledWith( + expect.arrayContaining([ + `credential-can-use-secrets:${sharedByTransfereeCredential.id}`, + `credential-can-use-secrets:${ownedCredential.id}`, + ]), + ); + deleteSpy.mockClear(); + + const userRepository = Container.get(UserRepository); + const projectRepository = Container.get(ProjectRepository); + const projectRelationRepository = Container.get(ProjectRelationRepository); + const sharedWorkflowRepository = Container.get(SharedWorkflowRepository); + const sharedCredentialsRepository = Container.get(SharedCredentialsRepository); + + await Promise.all([ + // user, their personal project and their relationship to the team project is gone + expect(userRepository.findOneBy({ id: member.id })).resolves.toBeNull(), + expect(projectRepository.findOneBy({ id: memberPersonalProject.id })).resolves.toBeNull(), + expect( + projectRelationRepository.findOneBy({ + projectId: teamProject.id, + userId: member.id, + }), + ).resolves.toBeNull(), + + // their owned workflow and credential are transferred to the transferee + expect( + sharedWorkflowRepository.findOneBy({ + workflowId: ownedWorkflow.id, + projectId: transfereePersonalProject.id, + role: 'workflow:owner', + }), + ).resolves.not.toBeNull, + expect( + sharedCredentialsRepository.findOneBy({ + credentialsId: ownedCredential.id, + projectId: transfereePersonalProject.id, role: 'credential:owner', - }, + }), + ).resolves.not.toBeNull(), + + // the credential and workflow shared with them by another member is now shared with the transferee + expect( + sharedWorkflowRepository.findOneBy({ + workflowId: sharedByOtherMemberWorkflow.id, + projectId: transfereePersonalProject.id, + role: 'workflow:editor', + }), + ).resolves.not.toBeNull(), + expect( + sharedCredentialsRepository.findOneBy({ + credentialsId: sharedByOtherMemberCredential.id, + projectId: transfereePersonalProject.id, + role: 'credential:user', + }), ), + + // the transferee is still owner of the workflow and credential they shared with the user to delete + expect( + sharedWorkflowRepository.findOneBy({ + workflowId: sharedByTransfereeWorkflow.id, + projectId: transfereePersonalProject.id, + role: 'workflow:owner', + }), + ).resolves.not.toBeNull(), + expect( + sharedCredentialsRepository.findOneBy({ + credentialsId: sharedByTransfereeCredential.id, + projectId: transfereePersonalProject.id, + role: 'credential:owner', + }), + ).resolves.not.toBeNull(), + + // the transferee's relationship to the team project is unchanged + expect( + projectRepository.findOneBy({ + id: teamProject.id, + projectRelations: { + userId: transferee.id, + role: 'project:editor', + }, + }), + ).resolves.not.toBeNull(), + + // the sharing of the team workflow is unchanged + expect( + sharedWorkflowRepository.findOneBy({ + workflowId: teamWorkflow.id, + projectId: teamProject.id, + role: 'workflow:owner', + }), + ).resolves.not.toBeNull(), + + // the sharing of the team credential is unchanged + expect( + sharedCredentialsRepository.findOneBy({ + credentialsId: teamCredential.id, + projectId: teamProject.id, + role: 'credential:owner', + }), + ).resolves.not.toBeNull(), ]); - - const response = await ownerAgent.delete(`/users/${member.id}`).query({ - transferId: owner.id, - }); - - expect(response.statusCode).toBe(200); - - const [user, sharedWorkflow, sharedCredential] = await Promise.all([ - await Container.get(UserRepository).findOneBy({ id: member.id }), - await Container.get(SharedWorkflowRepository).findOneOrFail({ - relations: ['workflow'], - where: { userId: owner.id }, - }), - await Container.get(SharedCredentialsRepository).findOneOrFail({ - relations: ['credentials'], - where: { userId: owner.id }, - }), - ]); - - expect(user).toBeNull(); - expect(sharedWorkflow.workflow.id).toBe(savedWorkflow.id); - expect(sharedCredential.credentials.id).toBe(savedCredential.id); }); test('should fail to delete self', async () => { - const response = await ownerAgent.delete(`/users/${owner.id}`); - - expect(response.statusCode).toBe(400); + await ownerAgent.delete(`/users/${owner.id}`).expect(400); const user = await getUserById(owner.id); expect(user).toBeDefined(); }); - test('should fail to delete if user to delete is transferee', async () => { - const response = await ownerAgent.delete(`/users/${member.id}`).query({ - transferId: member.id, - }); + test('should fail to delete a user that does not exist', async () => { + await ownerAgent.delete(`/users/${uuid()}`).query({ transferId: '' }).expect(404); + }); - expect(response.statusCode).toBe(400); + test('should fail to transfer to a project that does not exist', async () => { + const member = await createMember(); + + await ownerAgent.delete(`/users/${member.id}`).query({ transferId: 'foobar' }).expect(404); + + const user = await Container.get(UserRepository).findOneBy({ id: member.id }); + + expect(user).toBeDefined(); + }); + + test('should fail to delete if user to delete is transferee', async () => { + const member = await createMember(); + const personalProject = await getPersonalProject(member); + + await ownerAgent + .delete(`/users/${member.id}`) + .query({ transferId: personalProject.id }) + .expect(400); const user = await Container.get(UserRepository).findOneBy({ id: member.id }); @@ -355,8 +599,6 @@ describe('PATCH /users/:id/role', () => { const { NO_ADMIN_ON_OWNER, NO_USER, NO_OWNER_ON_OWNER } = UsersController.ERROR_MESSAGES.CHANGE_ROLE; - const UNAUTHORIZED = 'Unauthorized'; - beforeAll(async () => { await testDb.truncate(['User']); @@ -400,66 +642,66 @@ describe('PATCH /users/:id/role', () => { describe('member', () => { test('should fail to demote owner to member', async () => { - const response = await memberAgent.patch(`/users/${owner.id}/role`).send({ - newRoleName: 'global:member', - }); - - expect(response.statusCode).toBe(403); - expect(response.body.message).toBe(UNAUTHORIZED); + await memberAgent + .patch(`/users/${owner.id}/role`) + .send({ + newRoleName: 'global:member', + }) + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); test('should fail to demote owner to admin', async () => { - const response = await memberAgent.patch(`/users/${owner.id}/role`).send({ - newRoleName: 'global:admin', - }); - - expect(response.statusCode).toBe(403); - expect(response.body.message).toBe(UNAUTHORIZED); + await memberAgent + .patch(`/users/${owner.id}/role`) + .send({ + newRoleName: 'global:admin', + }) + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); test('should fail to demote admin to member', async () => { - const response = await memberAgent.patch(`/users/${admin.id}/role`).send({ - newRoleName: 'global:member', - }); - - expect(response.statusCode).toBe(403); - expect(response.body.message).toBe(UNAUTHORIZED); + await memberAgent + .patch(`/users/${admin.id}/role`) + .send({ + newRoleName: 'global:member', + }) + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); test('should fail to promote other member to owner', async () => { - const response = await memberAgent.patch(`/users/${otherMember.id}/role`).send({ - newRoleName: 'global:owner', - }); - - expect(response.statusCode).toBe(403); - expect(response.body.message).toBe(UNAUTHORIZED); + await memberAgent + .patch(`/users/${otherMember.id}/role`) + .send({ + newRoleName: 'global:owner', + }) + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); test('should fail to promote other member to admin', async () => { - const response = await memberAgent.patch(`/users/${otherMember.id}/role`).send({ - newRoleName: 'global:admin', - }); - - expect(response.statusCode).toBe(403); - expect(response.body.message).toBe(UNAUTHORIZED); + await memberAgent + .patch(`/users/${otherMember.id}/role`) + .send({ + newRoleName: 'global:admin', + }) + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); test('should fail to promote self to admin', async () => { - const response = await memberAgent.patch(`/users/${member.id}/role`).send({ - newRoleName: 'global:admin', - }); - - expect(response.statusCode).toBe(403); - expect(response.body.message).toBe(UNAUTHORIZED); + await memberAgent + .patch(`/users/${member.id}/role`) + .send({ + newRoleName: 'global:admin', + }) + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); test('should fail to promote self to owner', async () => { - const response = await memberAgent.patch(`/users/${member.id}/role`).send({ - newRoleName: 'global:owner', - }); - - expect(response.statusCode).toBe(403); - expect(response.body.message).toBe(UNAUTHORIZED); + await memberAgent + .patch(`/users/${member.id}/role`) + .send({ + newRoleName: 'global:owner', + }) + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); }); @@ -625,4 +867,40 @@ describe('PATCH /users/:id/role', () => { adminAgent = testServer.authAgentFor(admin); }); }); + + test("should clear credential external secrets usability cache when changing a user's role", async () => { + const user = await createAdmin(); + + const [project1, project2] = await Promise.all([ + createTeamProject(undefined, user), + createTeamProject(), + ]); + + const [credential1, credential2, credential3] = await Promise.all([ + saveCredential(randomCredentialPayload(), { + user, + role: 'credential:owner', + }), + saveCredential(randomCredentialPayload(), { + project: project1, + role: 'credential:owner', + }), + saveCredential(randomCredentialPayload(), { + project: project2, + role: 'credential:owner', + }), + linkUserToProject(user, project2, 'project:editor'), + ]); + + const deleteSpy = jest.spyOn(Container.get(CacheService), 'deleteMany'); + const response = await ownerAgent.patch(`/users/${user.id}/role`).send({ + newRoleName: 'global:member', + }); + + expect(deleteSpy).toBeCalledTimes(2); + deleteSpy.mockClear(); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toStrictEqual({ success: true }); + }); }); diff --git a/packages/cli/test/integration/workflowHistoryManager.test.ts b/packages/cli/test/integration/workflowHistoryManager.test.ts index 0b20f77c4b..85a114abee 100644 --- a/packages/cli/test/integration/workflowHistoryManager.test.ts +++ b/packages/cli/test/integration/workflowHistoryManager.test.ts @@ -34,6 +34,10 @@ describe('Workflow History Manager', () => { license.getWorkflowHistoryPruneLimit.mockReturnValue(-1); }); + afterAll(async () => { + await testDb.terminate(); + }); + test('should prune on interval', () => { const pruneSpy = jest.spyOn(manager, 'prune'); const currentCount = pruneSpy.mock.calls.length; diff --git a/packages/cli/test/integration/workflows/workflow.service.ee.test.ts b/packages/cli/test/integration/workflows/workflow.service.ee.test.ts index 4504775f2e..55287c5f22 100644 --- a/packages/cli/test/integration/workflows/workflow.service.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflow.service.ee.test.ts @@ -29,6 +29,7 @@ describe('EnterpriseWorkflowService', () => { Container.get(WorkflowRepository), Container.get(CredentialsRepository), mock(), + mock(), ); }); diff --git a/packages/cli/test/integration/workflows/workflow.service.test.ts b/packages/cli/test/integration/workflows/workflow.service.test.ts index 7927c094df..8c9e35983e 100644 --- a/packages/cli/test/integration/workflows/workflow.service.test.ts +++ b/packages/cli/test/integration/workflows/workflow.service.test.ts @@ -23,7 +23,6 @@ beforeAll(async () => { await testDb.init(); workflowService = new WorkflowService( - mock(), mock(), Container.get(SharedWorkflowRepository), Container.get(WorkflowRepository), @@ -35,6 +34,10 @@ beforeAll(async () => { orchestrationService, mock(), activeWorkflowManager, + mock(), + mock(), + mock(), + mock(), ); }); @@ -43,10 +46,6 @@ afterEach(async () => { jest.restoreAllMocks(); }); -afterAll(async () => { - await testDb.terminate(); -}); - describe('update()', () => { test('should remove and re-add to active workflows on `active: true` payload', async () => { const owner = await createOwner(); diff --git a/packages/cli/test/integration/workflows/workflowSharing.service.test.ts b/packages/cli/test/integration/workflows/workflowSharing.service.test.ts new file mode 100644 index 0000000000..1907770fb1 --- /dev/null +++ b/packages/cli/test/integration/workflows/workflowSharing.service.test.ts @@ -0,0 +1,117 @@ +import Container from 'typedi'; + +import type { User } from '@db/entities/User'; +import { WorkflowSharingService } from '@/workflows/workflowSharing.service'; + +import * as testDb from '../shared/testDb'; +import { createUser } from '../shared/db/users'; +import { createWorkflow, shareWorkflowWithUsers } from '../shared/db/workflows'; +import { ProjectService } from '@/services/project.service'; +import { LicenseMocker } from '../shared/license'; +import { License } from '@/License'; + +let owner: User; +let member: User; +let anotherMember: User; +let workflowSharingService: WorkflowSharingService; +let projectService: ProjectService; + +beforeAll(async () => { + await testDb.init(); + owner = await createUser({ role: 'global:owner' }); + member = await createUser({ role: 'global:member' }); + anotherMember = await createUser({ role: 'global:member' }); + let license: LicenseMocker; + license = new LicenseMocker(); + license.mock(Container.get(License)); + license.enable('feat:sharing'); + license.setQuota('quota:maxTeamProjects', -1); + workflowSharingService = Container.get(WorkflowSharingService); + projectService = Container.get(ProjectService); +}); + +beforeEach(async () => { + await testDb.truncate(['Workflow', 'SharedWorkflow', 'WorkflowHistory']); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +describe('WorkflowSharingService', () => { + describe('getSharedWorkflowIds', () => { + it('should show all workflows to owners', async () => { + owner.role = 'global:owner'; + const workflow1 = await createWorkflow({}, member); + const workflow2 = await createWorkflow({}, anotherMember); + const sharedWorkflowIds = await workflowSharingService.getSharedWorkflowIds(owner, { + scopes: ['workflow:read'], + }); + expect(sharedWorkflowIds).toHaveLength(2); + expect(sharedWorkflowIds).toContain(workflow1.id); + expect(sharedWorkflowIds).toContain(workflow2.id); + }); + + it('should show shared workflows to users', async () => { + member.role = 'global:member'; + const workflow1 = await createWorkflow({}, anotherMember); + const workflow2 = await createWorkflow({}, anotherMember); + const workflow3 = await createWorkflow({}, anotherMember); + await shareWorkflowWithUsers(workflow1, [member]); + await shareWorkflowWithUsers(workflow3, [member]); + const sharedWorkflowIds = await workflowSharingService.getSharedWorkflowIds(member, { + scopes: ['workflow:read'], + }); + expect(sharedWorkflowIds).toHaveLength(2); + expect(sharedWorkflowIds).toContain(workflow1.id); + expect(sharedWorkflowIds).toContain(workflow3.id); + expect(sharedWorkflowIds).not.toContain(workflow2.id); + }); + + it('should show workflows that the user has access to through a team project they are part of', async () => { + // + // ARRANGE + // + const project = await projectService.createTeamProject('Team Project', member); + await projectService.addUser(project.id, anotherMember.id, 'project:admin'); + const workflow = await createWorkflow(undefined, project); + + // + // ACT + // + const sharedWorkflowIds = await workflowSharingService.getSharedWorkflowIds(anotherMember, { + scopes: ['workflow:read'], + }); + + // + // ASSERT + // + expect(sharedWorkflowIds).toContain(workflow.id); + }); + + it('should show workflows that the user has update access to', async () => { + // + // ARRANGE + // + const project1 = await projectService.createTeamProject('Team Project 1', member); + const workflow1 = await createWorkflow(undefined, project1); + const project2 = await projectService.createTeamProject('Team Project 2', member); + const workflow2 = await createWorkflow(undefined, project2); + await projectService.addUser(project1.id, anotherMember.id, 'project:admin'); + await projectService.addUser(project2.id, anotherMember.id, 'project:viewer'); + + // + // ACT + // + const sharedWorkflowIds = await workflowSharingService.getSharedWorkflowIds(anotherMember, { + scopes: ['workflow:update'], + }); + + // + // ASSERT + // + expect(sharedWorkflowIds).toContain(workflow1.id); + expect(sharedWorkflowIds).not.toContain(workflow2.id); + }); + }); +}); diff --git a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts index 3963fedb35..25d1114898 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts @@ -6,7 +6,6 @@ import type { INode } from 'n8n-workflow'; import type { User } from '@db/entities/User'; import { WorkflowHistoryRepository } from '@db/repositories/workflowHistory.repository'; import { ActiveWorkflowManager } from '@/ActiveWorkflowManager'; -import { WorkflowSharingService } from '@/workflows/workflowSharing.service'; import { mockInstance } from '../../shared/mocking'; import * as utils from '../shared/utils/'; @@ -15,20 +14,29 @@ import type { SaveCredentialFunction } from '../shared/types'; import { makeWorkflow } from '../shared/utils/'; import { randomCredentialPayload } from '../shared/random'; import { affixRoleToSaveCredential, shareCredentialWithUsers } from '../shared/db/credentials'; -import { createUser } from '../shared/db/users'; +import { createUser, createUserShell } from '../shared/db/users'; import { createWorkflow, getWorkflowSharing, shareWorkflowWithUsers } from '../shared/db/workflows'; import { License } from '@/License'; import { UserManagementMailer } from '@/UserManagement/email'; import config from '@/config'; +import type { WorkflowWithSharingsMetaDataAndCredentials } from '@/workflows/workflows.types'; +import type { Project } from '@/databases/entities/Project'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { createTag } from '../shared/db/tags'; let owner: User; +let ownerPersonalProject: Project; let member: User; +let memberPersonalProject: Project; let anotherMember: User; +let anotherMemberPersonalProject: Project; let authOwnerAgent: SuperAgentTest; let authMemberAgent: SuperAgentTest; let authAnotherMemberAgent: SuperAgentTest; let saveCredential: SaveCredentialFunction; +let projectRepository: ProjectRepository; + const activeWorkflowManager = mockInstance(ActiveWorkflowManager); const sharingSpy = jest.spyOn(License.prototype, 'isSharingEnabled').mockReturnValue(true); @@ -40,9 +48,16 @@ const license = testServer.license; const mailer = mockInstance(UserManagementMailer); beforeAll(async () => { + projectRepository = Container.get(ProjectRepository); + owner = await createUser({ role: 'global:owner' }); + ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); member = await createUser({ role: 'global:member' }); + memberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(member.id); anotherMember = await createUser({ role: 'global:member' }); + anotherMemberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + anotherMember.id, + ); authOwnerAgent = testServer.authAgentFor(owner); authMemberAgent = testServer.authAgentFor(member); @@ -57,7 +72,7 @@ beforeEach(async () => { activeWorkflowManager.add.mockReset(); activeWorkflowManager.remove.mockReset(); - await testDb.truncate(['Workflow', 'SharedWorkflow', 'WorkflowHistory']); + await testDb.truncate(['Workflow', 'SharedWorkflow', 'WorkflowHistory', 'Tag']); }); afterEach(() => { @@ -77,14 +92,14 @@ describe('router should switch based on flag', () => { await authOwnerAgent .put(`/workflows/${savedWorkflowId}/share`) - .send({ shareWithIds: [member.id] }) + .send({ shareWithIds: [memberPersonalProject.id] }) .expect(404); }); test('when sharing is enabled', async () => { await authOwnerAgent .put(`/workflows/${savedWorkflowId}/share`) - .send({ shareWithIds: [member.id] }) + .send({ shareWithIds: [memberPersonalProject.id] }) .expect(200); }); }); @@ -95,13 +110,20 @@ describe('PUT /workflows/:id', () => { const response = await authOwnerAgent .put(`/workflows/${workflow.id}/share`) - .send({ shareWithIds: [member.id] }); + .send({ shareWithIds: [memberPersonalProject.id] }); expect(response.statusCode).toBe(200); const sharedWorkflows = await getWorkflowSharing(workflow); expect(sharedWorkflows).toHaveLength(2); expect(mailer.notifyWorkflowShared).toHaveBeenCalledTimes(1); + expect(mailer.notifyWorkflowShared).toHaveBeenCalledWith( + expect.objectContaining({ + newShareeIds: [member.id], + sharer: expect.objectContaining({ id: owner.id }), + workflow: expect.objectContaining({ id: workflow.id }), + }), + ); }); test('PUT /workflows/:id/share should succeed when sharing with invalid user-id', async () => { @@ -117,12 +139,30 @@ describe('PUT /workflows/:id', () => { expect(sharedWorkflows).toHaveLength(1); }); + test('PUT /workflows/:id/share should allow sharing with pending users', async () => { + const workflow = await createWorkflow({}, owner); + const memberShell = await createUserShell('global:member'); + const memberShellPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + memberShell.id, + ); + + const response = await authOwnerAgent + .put(`/workflows/${workflow.id}/share`) + .send({ shareWithIds: [memberShellPersonalProject.id] }); + + expect(response.statusCode).toBe(200); + + const sharedWorkflows = await getWorkflowSharing(workflow); + expect(sharedWorkflows).toHaveLength(2); + expect(mailer.notifyWorkflowShared).toHaveBeenCalledTimes(1); + }); + test('PUT /workflows/:id/share should allow sharing with multiple users', async () => { const workflow = await createWorkflow({}, owner); const response = await authOwnerAgent .put(`/workflows/${workflow.id}/share`) - .send({ shareWithIds: [member.id, anotherMember.id] }); + .send({ shareWithIds: [memberPersonalProject.id, anotherMemberPersonalProject.id] }); expect(response.statusCode).toBe(200); @@ -136,7 +176,7 @@ describe('PUT /workflows/:id', () => { const response = await authOwnerAgent .put(`/workflows/${workflow.id}/share`) - .send({ shareWithIds: [member.id, anotherMember.id] }); + .send({ shareWithIds: [memberPersonalProject.id, anotherMemberPersonalProject.id] }); expect(response.statusCode).toBe(200); @@ -145,7 +185,7 @@ describe('PUT /workflows/:id', () => { const secondResponse = await authOwnerAgent .put(`/workflows/${workflow.id}/share`) - .send({ shareWithIds: [member.id] }); + .send({ shareWithIds: [memberPersonalProject.id] }); expect(secondResponse.statusCode).toBe(200); const secondSharedWorkflows = await getWorkflowSharing(workflow); @@ -158,7 +198,7 @@ describe('PUT /workflows/:id', () => { const response = await authMemberAgent .put(`/workflows/${workflow.id}/share`) - .send({ shareWithIds: [anotherMember.id] }); + .send({ shareWithIds: [anotherMemberPersonalProject.id] }); expect(response.statusCode).toBe(200); @@ -172,7 +212,7 @@ describe('PUT /workflows/:id', () => { const response = await authOwnerAgent .put(`/workflows/${workflow.id}/share`) - .send({ shareWithIds: [anotherMember.id] }); + .send({ shareWithIds: [anotherMemberPersonalProject.id] }); expect(response.statusCode).toBe(200); @@ -188,7 +228,7 @@ describe('PUT /workflows/:id', () => { const response = await authAnotherMemberAgent .put(`/workflows/${workflow.id}/share`) - .send({ shareWithIds: [anotherMember.id, owner.id] }); + .send({ shareWithIds: [anotherMemberPersonalProject.id, ownerPersonalProject.id] }); expect(response.statusCode).toBe(403); @@ -202,7 +242,7 @@ describe('PUT /workflows/:id', () => { const response = await authAnotherMemberAgent .put(`/workflows/${workflow.id}/share`) - .send({ shareWithIds: [anotherMember.id] }); + .send({ shareWithIds: [anotherMemberPersonalProject.id] }); expect(response.statusCode).toBe(403); @@ -215,10 +255,13 @@ describe('PUT /workflows/:id', () => { const workflow = await createWorkflow({}, member); const tempUser = await createUser({ role: 'global:member' }); + const tempUserPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + tempUser.id, + ); const response = await authAnotherMemberAgent .put(`/workflows/${workflow.id}/share`) - .send({ shareWithIds: [tempUser.id] }); + .send({ shareWithIds: [tempUserPersonalProject.id] }); expect(response.statusCode).toBe(403); @@ -234,7 +277,7 @@ describe('PUT /workflows/:id', () => { const response = await authOwnerAgent .put(`/workflows/${workflow.id}/share`) - .send({ shareWithIds: [member.id] }); + .send({ shareWithIds: [memberPersonalProject.id] }); expect(response.statusCode).toBe(200); @@ -275,39 +318,49 @@ describe('GET /workflows/:id', () => { test('GET should return a workflow with owner', async () => { const workflow = await createWorkflow({}, owner); - const response = await authOwnerAgent.get(`/workflows/${workflow.id}`); + const response = await authOwnerAgent.get(`/workflows/${workflow.id}`).expect(200); + const responseWorkflow: WorkflowWithSharingsMetaDataAndCredentials = response.body.data; - expect(response.statusCode).toBe(200); - expect(response.body.data.ownedBy).toMatchObject({ - id: owner.id, - email: owner.email, - firstName: owner.firstName, - lastName: owner.lastName, + expect(responseWorkflow.homeProject).toMatchObject({ + id: ownerPersonalProject.id, + name: owner.createPersonalProjectName(), + type: 'personal', }); - expect(response.body.data.sharedWith).toHaveLength(0); + expect(responseWorkflow.sharedWithProjects).toHaveLength(0); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((responseWorkflow as any).shared).toBeUndefined(); + }); + + test('should return tags', async () => { + const tag = await createTag({ name: 'A' }); + const workflow = await createWorkflow({ tags: [tag] }, owner); + + const response = await authOwnerAgent.get(`/workflows/${workflow.id}`).expect(200); + + expect(response.body.data).toMatchObject({ + tags: [expect.objectContaining({ id: tag.id, name: tag.name })], + }); }); test('GET should return shared workflow with user data', async () => { const workflow = await createWorkflow({}, owner); await shareWorkflowWithUsers(workflow, [member]); - const response = await authOwnerAgent.get(`/workflows/${workflow.id}`); + const response = await authOwnerAgent.get(`/workflows/${workflow.id}`).expect(200); + const responseWorkflow: WorkflowWithSharingsMetaDataAndCredentials = response.body.data; - expect(response.statusCode).toBe(200); - expect(response.body.data.ownedBy).toMatchObject({ - id: owner.id, - email: owner.email, - firstName: owner.firstName, - lastName: owner.lastName, + expect(responseWorkflow.homeProject).toMatchObject({ + id: ownerPersonalProject.id, + name: owner.createPersonalProjectName(), + type: 'personal', }); - expect(response.body.data.sharedWith).toHaveLength(1); - expect(response.body.data.sharedWith[0]).toMatchObject({ - id: member.id, - email: member.email, - firstName: member.firstName, - lastName: member.lastName, + expect(responseWorkflow.sharedWithProjects).toHaveLength(1); + expect(responseWorkflow.sharedWithProjects[0]).toMatchObject({ + id: memberPersonalProject.id, + name: member.createPersonalProjectName(), + type: 'personal', }); }); @@ -315,17 +368,16 @@ describe('GET /workflows/:id', () => { const workflow = await createWorkflow({}, owner); await shareWorkflowWithUsers(workflow, [member, anotherMember]); - const response = await authOwnerAgent.get(`/workflows/${workflow.id}`); + const response = await authOwnerAgent.get(`/workflows/${workflow.id}`).expect(200); + const responseWorkflow: WorkflowWithSharingsMetaDataAndCredentials = response.body.data; - expect(response.statusCode).toBe(200); - expect(response.body.data.ownedBy).toMatchObject({ - id: owner.id, - email: owner.email, - firstName: owner.firstName, - lastName: owner.lastName, + expect(responseWorkflow.homeProject).toMatchObject({ + id: ownerPersonalProject.id, + name: owner.createPersonalProjectName(), + type: 'personal', }); - expect(response.body.data.sharedWith).toHaveLength(2); + expect(responseWorkflow.sharedWithProjects).toHaveLength(2); }); test('GET should return workflow with credentials owned by user', async () => { @@ -337,10 +389,11 @@ describe('GET /workflows/:id', () => { }); const workflow = await createWorkflow(workflowPayload, owner); - const response = await authOwnerAgent.get(`/workflows/${workflow.id}`); + const response = await authOwnerAgent.get(`/workflows/${workflow.id}`).expect(200); + const responseWorkflow: WorkflowWithSharingsMetaDataAndCredentials = response.body.data; expect(response.statusCode).toBe(200); - expect(response.body.data.usedCredentials).toMatchObject([ + expect(responseWorkflow.usedCredentials).toMatchObject([ { id: savedCredential.id, name: savedCredential.name, @@ -348,7 +401,7 @@ describe('GET /workflows/:id', () => { }, ]); - expect(response.body.data.sharedWith).toHaveLength(0); + expect(responseWorkflow.sharedWithProjects).toHaveLength(0); }); test('GET should return workflow with credentials saying owner does not have access when not shared', async () => { @@ -360,10 +413,10 @@ describe('GET /workflows/:id', () => { }); const workflow = await createWorkflow(workflowPayload, owner); - const response = await authOwnerAgent.get(`/workflows/${workflow.id}`); + const response = await authOwnerAgent.get(`/workflows/${workflow.id}`).expect(200); + const responseWorkflow: WorkflowWithSharingsMetaDataAndCredentials = response.body.data; - expect(response.statusCode).toBe(200); - expect(response.body.data.usedCredentials).toMatchObject([ + expect(responseWorkflow.usedCredentials).toMatchObject([ { id: savedCredential.id, name: savedCredential.name, @@ -371,7 +424,7 @@ describe('GET /workflows/:id', () => { }, ]); - expect(response.body.data.sharedWith).toHaveLength(0); + expect(responseWorkflow.sharedWithProjects).toHaveLength(0); }); test('GET should return workflow with credentials for all users with or without access', async () => { @@ -384,27 +437,31 @@ describe('GET /workflows/:id', () => { const workflow = await createWorkflow(workflowPayload, member); await shareWorkflowWithUsers(workflow, [anotherMember]); - const responseMember1 = await authMemberAgent.get(`/workflows/${workflow.id}`); - expect(responseMember1.statusCode).toBe(200); - expect(responseMember1.body.data.usedCredentials).toMatchObject([ + const responseMember1 = await authMemberAgent.get(`/workflows/${workflow.id}`).expect(200); + const member1Workflow: WorkflowWithSharingsMetaDataAndCredentials = responseMember1.body.data; + + expect(member1Workflow.usedCredentials).toMatchObject([ { id: savedCredential.id, name: savedCredential.name, currentUserHasAccess: true, // one user has access }, ]); - expect(responseMember1.body.data.sharedWith).toHaveLength(1); + expect(member1Workflow.sharedWithProjects).toHaveLength(1); - const responseMember2 = await authAnotherMemberAgent.get(`/workflows/${workflow.id}`); - expect(responseMember2.statusCode).toBe(200); - expect(responseMember2.body.data.usedCredentials).toMatchObject([ + const responseMember2 = await authAnotherMemberAgent + .get(`/workflows/${workflow.id}`) + .expect(200); + const member2Workflow: WorkflowWithSharingsMetaDataAndCredentials = responseMember2.body.data; + + expect(member2Workflow.usedCredentials).toMatchObject([ { id: savedCredential.id, name: savedCredential.name, currentUserHasAccess: false, // the other one doesn't }, ]); - expect(responseMember2.body.data.sharedWith).toHaveLength(1); + expect(member2Workflow.sharedWithProjects).toHaveLength(1); }); test('GET should return workflow with credentials for all users with access', async () => { @@ -419,27 +476,32 @@ describe('GET /workflows/:id', () => { const workflow = await createWorkflow(workflowPayload, member); await shareWorkflowWithUsers(workflow, [anotherMember]); - const responseMember1 = await authMemberAgent.get(`/workflows/${workflow.id}`); - expect(responseMember1.statusCode).toBe(200); - expect(responseMember1.body.data.usedCredentials).toMatchObject([ - { - id: savedCredential.id, - name: savedCredential.name, - currentUserHasAccess: true, - }, - ]); - expect(responseMember1.body.data.sharedWith).toHaveLength(1); + const responseMember1 = await authMemberAgent.get(`/workflows/${workflow.id}`).expect(200); + const member1Workflow: WorkflowWithSharingsMetaDataAndCredentials = responseMember1.body.data; - const responseMember2 = await authAnotherMemberAgent.get(`/workflows/${workflow.id}`); - expect(responseMember2.statusCode).toBe(200); - expect(responseMember2.body.data.usedCredentials).toMatchObject([ + expect(member1Workflow.usedCredentials).toMatchObject([ { id: savedCredential.id, name: savedCredential.name, currentUserHasAccess: true, }, ]); - expect(responseMember2.body.data.sharedWith).toHaveLength(1); + expect(member1Workflow.sharedWithProjects).toHaveLength(1); + + const responseMember2 = await authAnotherMemberAgent + .get(`/workflows/${workflow.id}`) + .expect(200); + const member2Workflow: WorkflowWithSharingsMetaDataAndCredentials = responseMember2.body.data; + + expect(responseMember2.statusCode).toBe(200); + expect(member2Workflow.usedCredentials).toMatchObject([ + { + id: savedCredential.id, + name: savedCredential.name, + currentUserHasAccess: true, + }, + ]); + expect(member2Workflow.sharedWithProjects).toHaveLength(1); }); }); @@ -739,7 +801,7 @@ describe('PATCH /workflows/:id - validate credential permissions to user', () => }, ], }); - expect(response.statusCode).toBe(400); + expect(response.statusCode).toBe(403); }); it('Should succeed but prevent modifying node attributes other than position, name and disabled', async () => { @@ -814,7 +876,10 @@ describe('PATCH /workflows/:id - validate credential permissions to user', () => const createResponse = await authMemberAgent.post('/workflows').send(workflow); const { id, versionId } = createResponse.body.data; - await authMemberAgent.put(`/workflows/${id}/share`).send({ shareWithIds: [anotherMember.id] }); + await authMemberAgent + .put(`/workflows/${id}/share`) + .send({ shareWithIds: [anotherMemberPersonalProject.id] }) + .expect(200); const response = await authAnotherMemberAgent.patch(`/workflows/${id}`).send({ versionId, @@ -832,7 +897,9 @@ describe('PATCH /workflows/:id - validate interim updates', () => { const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); const { id, versionId: ownerVersionId } = createResponse.body.data; - await authOwnerAgent.put(`/workflows/${id}/share`).send({ shareWithIds: [member.id] }); + await authOwnerAgent + .put(`/workflows/${id}/share`) + .send({ shareWithIds: [memberPersonalProject.id] }); // member accesses and updates workflow name @@ -865,7 +932,9 @@ describe('PATCH /workflows/:id - validate interim updates', () => { const { versionId: ownerSecondVersionId } = updateResponse.body.data; - await authOwnerAgent.put(`/workflows/${id}/share`).send({ shareWithIds: [member.id] }); + await authOwnerAgent + .put(`/workflows/${id}/share`) + .send({ shareWithIds: [memberPersonalProject.id] }); // member accesses workflow @@ -893,7 +962,9 @@ describe('PATCH /workflows/:id - validate interim updates', () => { const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); const { id, versionId: ownerVersionId } = createResponse.body.data; - await authOwnerAgent.put(`/workflows/${id}/share`).send({ shareWithIds: [member.id] }); + await authOwnerAgent + .put(`/workflows/${id}/share`) + .send({ shareWithIds: [memberPersonalProject.id] }); // member accesses and activates workflow @@ -923,7 +994,9 @@ describe('PATCH /workflows/:id - validate interim updates', () => { .send({ name: 'Update by owner', versionId: ownerFirstVersionId }); const { versionId: ownerSecondVersionId } = updateResponse.body.data; - await authOwnerAgent.put(`/workflows/${id}/share`).send({ shareWithIds: [member.id] }); + await authOwnerAgent + .put(`/workflows/${id}/share`) + .send({ shareWithIds: [memberPersonalProject.id] }); // member accesses workflow @@ -951,7 +1024,9 @@ describe('PATCH /workflows/:id - validate interim updates', () => { const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); const { id, versionId: ownerVersionId } = createResponse.body.data; - await authOwnerAgent.put(`/workflows/${id}/share`).send({ shareWithIds: [member.id] }); + await authOwnerAgent + .put(`/workflows/${id}/share`) + .send({ shareWithIds: [memberPersonalProject.id] }); // member accesses workflow @@ -979,11 +1054,13 @@ describe('PATCH /workflows/:id - validate interim updates', () => { const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); const { id, versionId: ownerVersionId } = createResponse.body.data; - await authOwnerAgent.put(`/workflows/${id}/share`).send({ shareWithIds: [member.id] }); + await authOwnerAgent + .put(`/workflows/${id}/share`) + .send({ shareWithIds: [memberPersonalProject.id] }); // member accesses workflow - const memberGetResponse = await authMemberAgent.get(`/workflows/${id}`); + const memberGetResponse = await authMemberAgent.get(`/workflows/${id}`).expect(200); const { versionId: memberVersionId } = memberGetResponse.body.data; // owner updates workflow settings @@ -1003,33 +1080,6 @@ describe('PATCH /workflows/:id - validate interim updates', () => { }); }); -describe('getSharedWorkflowIds', () => { - it('should show all workflows to owners', async () => { - owner.role = 'global:owner'; - const workflow1 = await createWorkflow({}, member); - const workflow2 = await createWorkflow({}, anotherMember); - const sharedWorkflowIds = - await Container.get(WorkflowSharingService).getSharedWorkflowIds(owner); - expect(sharedWorkflowIds).toHaveLength(2); - expect(sharedWorkflowIds).toContain(workflow1.id); - expect(sharedWorkflowIds).toContain(workflow2.id); - }); - - it('should show shared workflows to users', async () => { - member.role = 'global:member'; - const workflow1 = await createWorkflow({}, anotherMember); - const workflow2 = await createWorkflow({}, anotherMember); - const workflow3 = await createWorkflow({}, anotherMember); - await shareWorkflowWithUsers(workflow1, [member]); - await shareWorkflowWithUsers(workflow3, [member]); - const sharedWorkflowIds = - await Container.get(WorkflowSharingService).getSharedWorkflowIds(member); - expect(sharedWorkflowIds).toHaveLength(2); - expect(sharedWorkflowIds).toContain(workflow1.id); - expect(sharedWorkflowIds).toContain(workflow3.id); - }); -}); - describe('PATCH /workflows/:id - workflow history', () => { test('Should create workflow history version when licensed', async () => { license.enable('feat:workflowHistory'); diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index fa1e3cba79..0c1a22ae8a 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -17,13 +17,22 @@ import * as testDb from '../shared/testDb'; import { makeWorkflow, MOCK_PINDATA } from '../shared/utils/'; import { randomCredentialPayload } from '../shared/random'; import { saveCredential } from '../shared/db/credentials'; -import { createOwner } from '../shared/db/users'; -import { createWorkflow } from '../shared/db/workflows'; +import { createManyUsers, createMember, createOwner } from '../shared/db/users'; +import { createWorkflow, shareWorkflowWithProjects } from '../shared/db/workflows'; import { createTag } from '../shared/db/tags'; import { License } from '@/License'; +import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { ProjectService } from '@/services/project.service'; +import { createTeamProject, linkUserToProject } from '../shared/db/projects'; +import type { Scope } from '@n8n/permissions'; let owner: User; +let member: User; +let anotherMember: User; + let authOwnerAgent: SuperAgentTest; +let authMemberAgent: SuperAgentTest; jest.spyOn(License.prototype, 'isSharingEnabled').mockReturnValue(false); @@ -34,9 +43,15 @@ const { objectContaining, arrayContaining, any } = expect; const activeWorkflowManagerLike = mockInstance(ActiveWorkflowManager); +let projectRepository: ProjectRepository; + beforeAll(async () => { + projectRepository = Container.get(ProjectRepository); owner = await createOwner(); authOwnerAgent = testServer.authAgentFor(owner); + member = await createMember(); + authMemberAgent = testServer.authAgentFor(member); + anotherMember = await createMember(); }); beforeEach(async () => { @@ -62,6 +77,52 @@ describe('POST /workflows', () => { expect(pinData).toBeNull(); }); + test('should return scopes on created workflow', async () => { + const payload = { + name: 'testing', + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + ], + connections: {}, + staticData: null, + settings: { + saveExecutionProgress: true, + saveManualExecutions: true, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + active: false, + }; + + const response = await authMemberAgent.post('/workflows').send(payload); + + expect(response.statusCode).toBe(200); + + const { + data: { id, scopes }, + } = response.body; + + expect(id).toBeDefined(); + expect(scopes).toEqual( + [ + 'workflow:delete', + 'workflow:execute', + 'workflow:read', + 'workflow:share', + 'workflow:update', + ].sort(), + ); + }); + test('should create workflow history version when licensed', async () => { license.enable('feat:workflowHistory'); const payload = { @@ -151,6 +212,151 @@ describe('POST /workflows', () => { await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), ).toBe(0); }); + + test('create workflow in personal project by default', async () => { + // + // ARRANGE + // + const tag = await createTag({ name: 'A' }); + const workflow = makeWorkflow(); + const personalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); + + // + // ACT + // + const response = await authOwnerAgent + .post('/workflows') + .send({ ...workflow, tags: [tag.id] }) + .expect(200); + + // + // ASSERT + // + await Container.get(SharedWorkflowRepository).findOneOrFail({ + where: { + projectId: personalProject.id, + workflowId: response.body.data.id, + }, + }); + expect(response.body.data).toMatchObject({ + active: false, + id: expect.any(String), + name: workflow.name, + sharedWithProjects: [], + usedCredentials: [], + homeProject: { + id: personalProject.id, + name: personalProject.name, + type: personalProject.type, + }, + tags: [{ id: tag.id, name: tag.name }], + }); + expect(response.body.data.shared).toBeUndefined(); + }); + + test('creates workflow in a specific project if the projectId is passed', async () => { + // + // ARRANGE + // + const tag = await createTag({ name: 'A' }); + const workflow = makeWorkflow(); + const project = await projectRepository.save( + projectRepository.create({ + name: 'Team Project', + type: 'team', + }), + ); + await Container.get(ProjectService).addUser(project.id, owner.id, 'project:admin'); + + // + // ACT + // + const response = await authOwnerAgent + .post('/workflows') + .send({ ...workflow, projectId: project.id, tags: [tag.id] }) + .expect(200); + + // + // ASSERT + // + await Container.get(SharedWorkflowRepository).findOneOrFail({ + where: { + projectId: project.id, + workflowId: response.body.data.id, + }, + }); + expect(response.body.data).toMatchObject({ + active: false, + id: expect.any(String), + name: workflow.name, + sharedWithProjects: [], + usedCredentials: [], + homeProject: { + id: project.id, + name: project.name, + type: project.type, + }, + tags: [{ id: tag.id, name: tag.name }], + }); + expect(response.body.data.shared).toBeUndefined(); + }); + + test('does not create the workflow in a specific project if the user is not part of the project', async () => { + // + // ARRANGE + // + const workflow = makeWorkflow(); + const project = await projectRepository.save( + projectRepository.create({ + name: 'Team Project', + type: 'team', + }), + ); + + // + // ACT + // + await testServer + .authAgentFor(member) + .post('/workflows') + .send({ ...workflow, projectId: project.id }) + // + // ASSERT + // + .expect(400, { + code: 400, + message: "You don't have the permissions to save the workflow in this project.", + }); + }); + + test('does not create the workflow in a specific project if the user does not have the right role to do so', async () => { + // + // ARRANGE + // + const workflow = makeWorkflow(); + const project = await projectRepository.save( + projectRepository.create({ + name: 'Team Project', + type: 'team', + }), + ); + await Container.get(ProjectService).addUser(project.id, member.id, 'project:viewer'); + + // + // ACT + // + await testServer + .authAgentFor(member) + .post('/workflows') + .send({ ...workflow, projectId: project.id }) + // + // ASSERT + // + .expect(400, { + code: 400, + message: "You don't have the permissions to save the workflow in this project.", + }); + }); }); describe('GET /workflows/:id', () => { @@ -165,6 +371,17 @@ describe('GET /workflows/:id', () => { const { pinData } = workflowRetrievalResponse.body.data as { pinData: IPinData }; expect(pinData).toMatchObject(MOCK_PINDATA); }); + + test('should return tags', async () => { + const tag = await createTag({ name: 'A' }); + const workflow = await createWorkflow({ tags: [tag] }, owner); + + const response = await authOwnerAgent.get(`/workflows/${workflow.id}`).expect(200); + + expect(response.body.data).toMatchObject({ + tags: [expect.objectContaining({ id: tag.id, name: tag.name })], + }); + }); }); describe('GET /workflows', () => { @@ -179,6 +396,7 @@ describe('GET /workflows', () => { user: owner, role: 'credential:owner', }); + const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); const nodes: INode[] = [ { @@ -215,13 +433,12 @@ describe('GET /workflows', () => { updatedAt: any(String), tags: [{ id: any(String), name: 'A' }], versionId: any(String), - ownedBy: { - id: owner.id, - email: any(String), - firstName: any(String), - lastName: any(String), + homeProject: { + id: ownerPersonalProject.id, + name: owner.createPersonalProjectName(), + type: ownerPersonalProject.type, }, - sharedWith: [], + sharedWithProjects: [], }), objectContaining({ id: any(String), @@ -231,13 +448,12 @@ describe('GET /workflows', () => { updatedAt: any(String), tags: [], versionId: any(String), - ownedBy: { - id: owner.id, - email: any(String), - firstName: any(String), - lastName: any(String), + homeProject: { + id: ownerPersonalProject.id, + name: owner.createPersonalProjectName(), + type: ownerPersonalProject.type, }, - sharedWith: [], + sharedWithProjects: [], }), ]), }); @@ -247,10 +463,142 @@ describe('GET /workflows', () => { ); expect(found.nodes).toBeUndefined(); - expect(found.sharedWith).toHaveLength(0); + expect(found.sharedWithProjects).toHaveLength(0); expect(found.usedCredentials).toBeUndefined(); }); + test('should return workflows with scopes when ?includeScopes=true', async () => { + const [member1, member2] = await createManyUsers(2, { + role: 'global:member', + }); + + const teamProject = await createTeamProject(undefined, member1); + await linkUserToProject(member2, teamProject, 'project:editor'); + + const credential = await saveCredential(randomCredentialPayload(), { + user: owner, + role: 'credential:owner', + }); + + const nodes: INode[] = [ + { + id: uuid(), + name: 'Action Network', + type: 'n8n-nodes-base.actionNetwork', + parameters: {}, + typeVersion: 1, + position: [0, 0], + credentials: { + actionNetworkApi: { + id: credential.id, + name: credential.name, + }, + }, + }, + ]; + + const tag = await createTag({ name: 'A' }); + + const [savedWorkflow1, savedWorkflow2] = await Promise.all([ + createWorkflow({ name: 'First', nodes, tags: [tag] }, teamProject), + createWorkflow({ name: 'Second' }, member2), + ]); + + await shareWorkflowWithProjects(savedWorkflow2, [{ project: teamProject }]); + + { + const response = await testServer.authAgentFor(member1).get('/workflows?includeScopes=true'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + + const workflows = response.body.data as Array; + const wf1 = workflows.find((w) => w.id === savedWorkflow1.id)!; + const wf2 = workflows.find((w) => w.id === savedWorkflow2.id)!; + + // Team workflow + expect(wf1.id).toBe(savedWorkflow1.id); + expect(wf1.scopes).toEqual( + ['workflow:read', 'workflow:update', 'workflow:delete', 'workflow:execute'].sort(), + ); + + // Shared workflow + expect(wf2.id).toBe(savedWorkflow2.id); + expect(wf2.scopes).toEqual(['workflow:read', 'workflow:update', 'workflow:execute'].sort()); + } + + { + const response = await testServer.authAgentFor(member2).get('/workflows?includeScopes=true'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + + const workflows = response.body.data as Array; + const wf1 = workflows.find((w) => w.id === savedWorkflow1.id)!; + const wf2 = workflows.find((w) => w.id === savedWorkflow2.id)!; + + // Team workflow + expect(wf1.id).toBe(savedWorkflow1.id); + expect(wf1.scopes).toEqual([ + 'workflow:delete', + 'workflow:execute', + 'workflow:read', + 'workflow:update', + ]); + + // Shared workflow + expect(wf2.id).toBe(savedWorkflow2.id); + expect(wf2.scopes).toEqual( + [ + 'workflow:read', + 'workflow:update', + 'workflow:delete', + 'workflow:execute', + 'workflow:share', + ].sort(), + ); + } + + { + const response = await testServer.authAgentFor(owner).get('/workflows?includeScopes=true'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + + const workflows = response.body.data as Array; + const wf1 = workflows.find((w) => w.id === savedWorkflow1.id)!; + const wf2 = workflows.find((w) => w.id === savedWorkflow2.id)!; + + // Team workflow + expect(wf1.id).toBe(savedWorkflow1.id); + expect(wf1.scopes).toEqual( + [ + 'workflow:create', + 'workflow:read', + 'workflow:update', + 'workflow:delete', + 'workflow:list', + 'workflow:share', + 'workflow:execute', + ].sort(), + ); + + // Shared workflow + expect(wf2.id).toBe(savedWorkflow2.id); + expect(wf2.scopes).toEqual( + [ + 'workflow:create', + 'workflow:read', + 'workflow:update', + 'workflow:delete', + 'workflow:list', + 'workflow:share', + 'workflow:execute', + ].sort(), + ); + } + }); + describe('filter', () => { test('should filter workflows by field: name', async () => { await createWorkflow({ name: 'First' }, owner); @@ -298,6 +646,26 @@ describe('GET /workflows', () => { data: [objectContaining({ name: 'First', tags: [{ id: any(String), name: 'A' }] })], }); }); + + test('should filter workflows by projectId', async () => { + const workflow = await createWorkflow({ name: 'First' }, owner); + const pp = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(owner.id); + + const response1 = await authOwnerAgent + .get('/workflows') + .query(`filter={ "projectId": "${pp.id}" }`) + .expect(200); + + expect(response1.body.data).toHaveLength(1); + expect(response1.body.data[0].id).toBe(workflow.id); + + const response2 = await authOwnerAgent + .get('/workflows') + .query('filter={ "projectId": "Non-Existing Project ID" }') + .expect(200); + + expect(response2.body.data).toHaveLength(0); + }); }); describe('select', () => { @@ -419,6 +787,9 @@ describe('GET /workflows', () => { test('should select workflow field: ownedBy', async () => { await createWorkflow({}, owner); await createWorkflow({}, owner); + const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + owner.id, + ); const response = await authOwnerAgent .get('/workflows') @@ -430,23 +801,21 @@ describe('GET /workflows', () => { data: arrayContaining([ { id: any(String), - ownedBy: { - id: owner.id, - email: any(String), - firstName: any(String), - lastName: any(String), + homeProject: { + id: ownerPersonalProject.id, + name: owner.createPersonalProjectName(), + type: ownerPersonalProject.type, }, - sharedWith: [], + sharedWithProjects: [], }, { id: any(String), - ownedBy: { - id: owner.id, - email: any(String), - firstName: any(String), - lastName: any(String), + homeProject: { + id: ownerPersonalProject.id, + name: owner.createPersonalProjectName(), + type: ownerPersonalProject.type, }, - sharedWith: [], + sharedWithProjects: [], }, ]), }); @@ -645,7 +1014,7 @@ describe('POST /workflows/run', () => { test('should prevent tampering if sharing is enabled', async () => { sharingSpy.mockReturnValue(true); - await authOwnerAgent.post('/workflows/run').send({ workflowData: workflow }); + await authOwnerAgent.post(`/workflows/${workflow.id}/run`).send({ workflowData: workflow }); expect(tamperingSpy).toHaveBeenCalledTimes(1); }); @@ -653,8 +1022,70 @@ describe('POST /workflows/run', () => { test('should skip tampering prevention if sharing is disabled', async () => { sharingSpy.mockReturnValue(false); - await authOwnerAgent.post('/workflows/run').send({ workflowData: workflow }); + await authOwnerAgent.post(`/workflows/${workflow.id}/run`).send({ workflowData: workflow }); expect(tamperingSpy).not.toHaveBeenCalled(); }); }); + +describe('DELETE /workflows/:id', () => { + test('deletes a workflow owned by the user', async () => { + const workflow = await createWorkflow({}, owner); + + await authOwnerAgent.delete(`/workflows/${workflow.id}`).send().expect(200); + + const workflowInDb = await Container.get(WorkflowRepository).findById(workflow.id); + const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({ + workflowId: workflow.id, + }); + + expect(workflowInDb).toBeNull(); + expect(sharedWorkflowsInDb).toHaveLength(0); + }); + + test('deletes a workflow owned by the user, even if the user is just a member', async () => { + const workflow = await createWorkflow({}, member); + + await testServer.authAgentFor(member).delete(`/workflows/${workflow.id}`).send().expect(200); + + const workflowInDb = await Container.get(WorkflowRepository).findById(workflow.id); + const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({ + workflowId: workflow.id, + }); + + expect(workflowInDb).toBeNull(); + expect(sharedWorkflowsInDb).toHaveLength(0); + }); + + test('does not delete a workflow that is not owned by the user', async () => { + const workflow = await createWorkflow({}, member); + + await testServer + .authAgentFor(anotherMember) + .delete(`/workflows/${workflow.id}`) + .send() + .expect(403); + + const workflowsInDb = await Container.get(WorkflowRepository).findById(workflow.id); + const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({ + workflowId: workflow.id, + }); + + expect(workflowsInDb).not.toBeNull(); + expect(sharedWorkflowsInDb).toHaveLength(1); + }); + + test("allows the owner to delete workflows they don't own", async () => { + const workflow = await createWorkflow({}, member); + + await authOwnerAgent.delete(`/workflows/${workflow.id}`).send().expect(200); + + const workflowsInDb = await Container.get(WorkflowRepository).findById(workflow.id); + const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({ + workflowId: workflow.id, + }); + + expect(workflowsInDb).toBeNull(); + expect(sharedWorkflowsInDb).toHaveLength(0); + }); +}); diff --git a/packages/cli/test/unit/InternalHooks.test.ts b/packages/cli/test/unit/InternalHooks.test.ts index 46ea316239..6dbb4ab5f2 100644 --- a/packages/cli/test/unit/InternalHooks.test.ts +++ b/packages/cli/test/unit/InternalHooks.test.ts @@ -25,6 +25,8 @@ describe('InternalHooks', () => { mock(), mock(), license, + mock(), + mock(), ); beforeEach(() => jest.clearAllMocks()); diff --git a/packages/cli/test/unit/Ldap/helpers.test.ts b/packages/cli/test/unit/Ldap/helpers.test.ts new file mode 100644 index 0000000000..b5c8c25a67 --- /dev/null +++ b/packages/cli/test/unit/Ldap/helpers.test.ts @@ -0,0 +1,40 @@ +import { UserRepository } from '@/databases/repositories/user.repository'; +import { mockInstance } from '../../shared/mocking'; +import * as helpers from '@/Ldap/helpers'; +import { AuthIdentity } from '@/databases/entities/AuthIdentity'; +import { User } from '@/databases/entities/User'; +import { generateNanoId } from '@/databases/utils/generators'; + +const userRepository = mockInstance(UserRepository); + +describe('Ldap/helpers', () => { + describe('updateLdapUserOnLocalDb', () => { + // We need to use `save` so that that the subscriber in + // packages/cli/src/databases/entities/Project.ts receives the full user. + // With `update` it would only receive the updated fields, e.g. the `id` + // would be missing. + test('does not use `Repository.update`, but `Repository.save` instead', async () => { + // + // ARRANGE + // + const user = Object.assign(new User(), { id: generateNanoId() } as User); + const authIdentity = Object.assign(new AuthIdentity(), { + user: { id: user.id }, + } as AuthIdentity); + const data: Partial = { firstName: 'Nathan', lastName: 'Nathaniel' }; + + userRepository.findOneBy.mockResolvedValueOnce(user); + + // + // ACT + // + await helpers.updateLdapUserOnLocalDb(authIdentity, data); + + // + // ASSERT + // + expect(userRepository.save).toHaveBeenCalledWith({ ...user, ...data }, { transaction: true }); + expect(userRepository.update).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/test/unit/PermissionChecker.test.ts b/packages/cli/test/unit/PermissionChecker.test.ts deleted file mode 100644 index 8ddb0754ba..0000000000 --- a/packages/cli/test/unit/PermissionChecker.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { INode } from 'n8n-workflow'; -import { mock } from 'jest-mock-extended'; -import type { User } from '@db/entities/User'; -import type { UserRepository } from '@db/repositories/user.repository'; -import type { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; -import type { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; -import type { License } from '@/License'; -import { PermissionChecker } from '@/UserManagement/PermissionChecker'; - -describe('PermissionChecker', () => { - const user = mock(); - const userRepo = mock(); - const sharedCredentialsRepo = mock(); - const sharedWorkflowRepo = mock(); - const license = mock(); - const permissionChecker = new PermissionChecker( - userRepo, - sharedCredentialsRepo, - sharedWorkflowRepo, - mock(), - license, - ); - - const workflowId = '1'; - const nodes: INode[] = [ - { - id: 'node-id', - name: 'HTTP Request', - type: 'n8n-nodes-base.httpRequest', - parameters: {}, - typeVersion: 1, - position: [0, 0], - credentials: { - oAuth2Api: { - id: 'cred-id', - name: 'Custom oAuth2', - }, - }, - }, - ]; - - beforeEach(() => jest.clearAllMocks()); - - describe('check', () => { - it('should throw if no user is found', async () => { - userRepo.findOneOrFail.mockRejectedValue(new Error('Fail')); - await expect(permissionChecker.check(workflowId, '123', nodes)).rejects.toThrow(); - expect(license.isSharingEnabled).not.toHaveBeenCalled(); - expect(sharedWorkflowRepo.getSharedUserIds).not.toBeCalled(); - expect(sharedCredentialsRepo.getOwnedCredentialIds).not.toHaveBeenCalled(); - expect(sharedCredentialsRepo.getAccessibleCredentialIds).not.toHaveBeenCalled(); - }); - - it('should allow a user if they have a global `workflow:execute` scope', async () => { - userRepo.findOneOrFail.mockResolvedValue(user); - user.hasGlobalScope.calledWith('workflow:execute').mockReturnValue(true); - await expect(permissionChecker.check(workflowId, user.id, nodes)).resolves.not.toThrow(); - expect(license.isSharingEnabled).not.toHaveBeenCalled(); - expect(sharedWorkflowRepo.getSharedUserIds).not.toBeCalled(); - expect(sharedCredentialsRepo.getOwnedCredentialIds).not.toHaveBeenCalled(); - expect(sharedCredentialsRepo.getAccessibleCredentialIds).not.toHaveBeenCalled(); - }); - - describe('When sharing is disabled', () => { - beforeEach(() => { - userRepo.findOneOrFail.mockResolvedValue(user); - user.hasGlobalScope.calledWith('workflow:execute').mockReturnValue(false); - license.isSharingEnabled.mockReturnValue(false); - }); - - it('should validate credential access using only owned credentials', async () => { - sharedCredentialsRepo.getOwnedCredentialIds.mockResolvedValue(['cred-id']); - - await expect(permissionChecker.check(workflowId, user.id, nodes)).resolves.not.toThrow(); - - expect(sharedWorkflowRepo.getSharedUserIds).not.toBeCalled(); - expect(sharedCredentialsRepo.getOwnedCredentialIds).toBeCalledWith([user.id]); - expect(sharedCredentialsRepo.getAccessibleCredentialIds).not.toHaveBeenCalled(); - }); - - it('should throw when the user does not have access to the credential', async () => { - sharedCredentialsRepo.getOwnedCredentialIds.mockResolvedValue(['cred-id2']); - - await expect(permissionChecker.check(workflowId, user.id, nodes)).rejects.toThrow( - 'Node has no access to credential', - ); - - expect(sharedWorkflowRepo.getSharedUserIds).not.toBeCalled(); - expect(sharedCredentialsRepo.getOwnedCredentialIds).toBeCalledWith([user.id]); - expect(sharedCredentialsRepo.getAccessibleCredentialIds).not.toHaveBeenCalled(); - }); - }); - - describe('When sharing is enabled', () => { - beforeEach(() => { - userRepo.findOneOrFail.mockResolvedValue(user); - user.hasGlobalScope.calledWith('workflow:execute').mockReturnValue(false); - license.isSharingEnabled.mockReturnValue(true); - sharedWorkflowRepo.getSharedUserIds.mockResolvedValue([user.id, 'another-user']); - }); - - it('should validate credential access using only owned credentials', async () => { - sharedCredentialsRepo.getAccessibleCredentialIds.mockResolvedValue(['cred-id']); - - await expect(permissionChecker.check(workflowId, user.id, nodes)).resolves.not.toThrow(); - - expect(sharedWorkflowRepo.getSharedUserIds).toBeCalledWith(workflowId); - expect(sharedCredentialsRepo.getAccessibleCredentialIds).toBeCalledWith([ - user.id, - 'another-user', - ]); - expect(sharedCredentialsRepo.getOwnedCredentialIds).not.toHaveBeenCalled(); - }); - - it('should throw when the user does not have access to the credential', async () => { - sharedCredentialsRepo.getAccessibleCredentialIds.mockResolvedValue(['cred-id2']); - - await expect(permissionChecker.check(workflowId, user.id, nodes)).rejects.toThrow( - 'Node has no access to credential', - ); - - expect(sharedWorkflowRepo.find).not.toBeCalled(); - expect(sharedCredentialsRepo.getAccessibleCredentialIds).toBeCalledWith([ - user.id, - 'another-user', - ]); - expect(sharedCredentialsRepo.getOwnedCredentialIds).not.toHaveBeenCalled(); - }); - }); - }); -}); diff --git a/packages/cli/test/unit/databases/entities/user.entity.test.ts b/packages/cli/test/unit/databases/entities/user.entity.test.ts index 005e45df2c..7fac71c5fa 100644 --- a/packages/cli/test/unit/databases/entities/user.entity.test.ts +++ b/packages/cli/test/unit/databases/entities/user.entity.test.ts @@ -17,4 +17,22 @@ describe('User Entity', () => { ); }); }); + + describe('createPersonalProjectName', () => { + test.each([ + ['Nathan', 'Nathaniel', 'nathan@nathaniel.n8n', 'Nathan Nathaniel '], + [undefined, 'Nathaniel', 'nathan@nathaniel.n8n', ''], + ['Nathan', undefined, 'nathan@nathaniel.n8n', ''], + [undefined, undefined, 'nathan@nathaniel.n8n', ''], + [undefined, undefined, undefined, 'Unnamed Project'], + ['Nathan', 'Nathaniel', undefined, 'Unnamed Project'], + ])( + 'given fistName: %s, lastName: %s and email: %s this gives the projectName: "%s"', + async (firstName, lastName, email, projectName) => { + const user = new User(); + Object.assign(user, { firstName, lastName, email }); + expect(user.createPersonalProjectName()).toBe(projectName); + }, + ); + }); }); diff --git a/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts b/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts index 1624cf5e65..8afc8bb121 100644 --- a/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts +++ b/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts @@ -1,4 +1,5 @@ import { Container } from 'typedi'; +import { In } from '@n8n/typeorm'; import { mock } from 'jest-mock-extended'; import { hasScope } from '@n8n/permissions'; @@ -6,7 +7,7 @@ import type { User } from '@db/entities/User'; import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; import { SharedCredentials } from '@db/entities/SharedCredentials'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; -import { memberPermissions, ownerPermissions } from '@/permissions/roles'; +import { GLOBAL_MEMBER_SCOPES, GLOBAL_OWNER_SCOPES } from '@/permissions/global-roles'; import { mockEntityManager } from '../../shared/mocking'; describe('SharedCredentialsRepository', () => { @@ -21,7 +22,7 @@ describe('SharedCredentialsRepository', () => { isOwner: true, hasGlobalScope: (scope) => hasScope(scope, { - global: ownerPermissions, + global: GLOBAL_OWNER_SCOPES, }), }); const member = mock({ @@ -29,7 +30,7 @@ describe('SharedCredentialsRepository', () => { id: 'test', hasGlobalScope: (scope) => hasScope(scope, { - global: memberPermissions, + global: GLOBAL_MEMBER_SCOPES, }), }); @@ -39,9 +40,11 @@ describe('SharedCredentialsRepository', () => { test('should allow instance owner access to all credentials', async () => { entityManager.findOne.mockResolvedValueOnce(sharedCredential); - const credential = await repository.findCredentialForUser(credentialsId, owner); + const credential = await repository.findCredentialForUser(credentialsId, owner, [ + 'credential:read', + ]); expect(entityManager.findOne).toHaveBeenCalledWith(SharedCredentials, { - relations: ['credentials'], + relations: { credentials: { shared: { project: { projectRelations: { user: true } } } } }, where: { credentialsId }, }); expect(credential).toEqual(sharedCredential.credentials); @@ -49,20 +52,42 @@ describe('SharedCredentialsRepository', () => { test('should allow members', async () => { entityManager.findOne.mockResolvedValueOnce(sharedCredential); - const credential = await repository.findCredentialForUser(credentialsId, member); + const credential = await repository.findCredentialForUser(credentialsId, member, [ + 'credential:read', + ]); expect(entityManager.findOne).toHaveBeenCalledWith(SharedCredentials, { - relations: ['credentials'], - where: { credentialsId, userId: member.id }, + relations: { credentials: { shared: { project: { projectRelations: { user: true } } } } }, + where: { + credentialsId, + role: In(['credential:owner', 'credential:user']), + project: { + projectRelations: { + role: In(['project:admin', 'project:personalOwner', 'project:editor']), + userId: member.id, + }, + }, + }, }); expect(credential).toEqual(sharedCredential.credentials); }); test('should return null when no shared credential is found', async () => { entityManager.findOne.mockResolvedValueOnce(null); - const credential = await repository.findCredentialForUser(credentialsId, member); + const credential = await repository.findCredentialForUser(credentialsId, member, [ + 'credential:read', + ]); expect(entityManager.findOne).toHaveBeenCalledWith(SharedCredentials, { - relations: ['credentials'], - where: { credentialsId, userId: member.id }, + relations: { credentials: { shared: { project: { projectRelations: { user: true } } } } }, + where: { + credentialsId, + role: In(['credential:owner', 'credential:user']), + project: { + projectRelations: { + role: In(['project:admin', 'project:personalOwner', 'project:editor']), + userId: member.id, + }, + }, + }, }); expect(credential).toEqual(null); }); diff --git a/packages/cli/test/unit/services/activeWorkflows.service.test.ts b/packages/cli/test/unit/services/activeWorkflows.service.test.ts index 7432d22491..2089c94690 100644 --- a/packages/cli/test/unit/services/activeWorkflows.service.test.ts +++ b/packages/cli/test/unit/services/activeWorkflows.service.test.ts @@ -5,6 +5,7 @@ import type { WorkflowRepository } from '@db/repositories/workflow.repository'; import { ActiveWorkflowsService } from '@/services/activeWorkflows.service'; import { mock } from 'jest-mock-extended'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; describe('ActiveWorkflowsService', () => { const user = mock(); @@ -61,20 +62,24 @@ describe('ActiveWorkflowsService', () => { const workflowId = 'workflowId'; it('should throw a BadRequestError a user does not have access to the workflow id', async () => { - sharedWorkflowRepository.hasAccess.mockResolvedValue(false); + sharedWorkflowRepository.findWorkflowForUser.mockResolvedValue(null); await expect(service.getActivationError(workflowId, user)).rejects.toThrow(BadRequestError); - expect(sharedWorkflowRepository.hasAccess).toHaveBeenCalledWith(workflowId, user); + expect(sharedWorkflowRepository.findWorkflowForUser).toHaveBeenCalledWith(workflowId, user, [ + 'workflow:read', + ]); expect(activationErrorsService.get).not.toHaveBeenCalled(); }); it('should return the error when the user has access', async () => { - sharedWorkflowRepository.hasAccess.mockResolvedValue(true); + sharedWorkflowRepository.findWorkflowForUser.mockResolvedValue(new WorkflowEntity()); activationErrorsService.get.mockResolvedValue('some-error'); const error = await service.getActivationError(workflowId, user); expect(error).toEqual('some-error'); - expect(sharedWorkflowRepository.hasAccess).toHaveBeenCalledWith(workflowId, user); + expect(sharedWorkflowRepository.findWorkflowForUser).toHaveBeenCalledWith(workflowId, user, [ + 'workflow:read', + ]); expect(activationErrorsService.get).toHaveBeenCalledWith(workflowId); }); }); diff --git a/packages/cli/test/unit/services/events.service.test.ts b/packages/cli/test/unit/services/events.service.test.ts index afdd4091d3..7330b619e0 100644 --- a/packages/cli/test/unit/services/events.service.test.ts +++ b/packages/cli/test/unit/services/events.service.test.ts @@ -16,10 +16,12 @@ import { EventsService } from '@/services/events.service'; import { UserService } from '@/services/user.service'; import { OwnershipService } from '@/services/ownership.service'; import { mockInstance } from '../../shared/mocking'; +import type { Project } from '@/databases/entities/Project'; describe('EventsService', () => { const dbType = config.getEnv('database.type'); const fakeUser = mock({ id: 'abcde-fghij' }); + const fakeProject = mock({ id: '12345-67890', type: 'personal' }); const ownershipService = mockInstance(OwnershipService); const userService = mockInstance(UserService); @@ -35,7 +37,8 @@ describe('EventsService', () => { config.set('diagnostics.enabled', true); config.set('deployment.type', 'n8n-testing'); - mocked(ownershipService.getWorkflowOwnerCached).mockResolvedValue(fakeUser); + mocked(ownershipService.getWorkflowProjectCached).mockResolvedValue(fakeProject); + mocked(ownershipService.getProjectOwnerCached).mockResolvedValue(fakeUser); const updateSettingsMock = jest.spyOn(userService, 'updateSettings').mockImplementation(); const eventsService = new EventsService( @@ -89,6 +92,7 @@ describe('EventsService', () => { expect(updateSettingsMock).toHaveBeenCalledTimes(1); expect(onFirstProductionWorkflowSuccess).toBeCalledTimes(1); expect(onFirstProductionWorkflowSuccess).toHaveBeenNthCalledWith(1, { + project_id: fakeProject.id, user_id: fakeUser.id, workflow_id: workflow.id, }); @@ -156,6 +160,7 @@ describe('EventsService', () => { expect(onFirstWorkflowDataLoad).toBeCalledTimes(1); expect(onFirstWorkflowDataLoad).toHaveBeenNthCalledWith(1, { user_id: fakeUser.id, + project_id: fakeProject.id, workflow_id: workflowId, node_type: node.type, node_id: node.id, @@ -183,6 +188,7 @@ describe('EventsService', () => { expect(onFirstWorkflowDataLoad).toBeCalledTimes(1); expect(onFirstWorkflowDataLoad).toHaveBeenNthCalledWith(1, { user_id: fakeUser.id, + project_id: fakeProject.id, workflow_id: workflowId, node_type: node.type, node_id: node.id, diff --git a/packages/cli/test/unit/services/ownership.service.test.ts b/packages/cli/test/unit/services/ownership.service.test.ts index 3fed4b8ce7..d1a722da19 100644 --- a/packages/cli/test/unit/services/ownership.service.test.ts +++ b/packages/cli/test/unit/services/ownership.service.test.ts @@ -7,120 +7,179 @@ import { mockInstance } from '../../shared/mocking'; import { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; import { UserRepository } from '@/databases/repositories/user.repository'; import { mock } from 'jest-mock-extended'; -import { mockCredential, mockUser } from '../shared/mockObjects'; +import { Project } from '@/databases/entities/Project'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; +import { ProjectRelation } from '@/databases/entities/ProjectRelation'; +import { mockCredential, mockProject } from '../shared/mockObjects'; describe('OwnershipService', () => { const userRepository = mockInstance(UserRepository); const sharedWorkflowRepository = mockInstance(SharedWorkflowRepository); - const ownershipService = new OwnershipService(mock(), userRepository, sharedWorkflowRepository); + const projectRelationRepository = mockInstance(ProjectRelationRepository); + const ownershipService = new OwnershipService( + mock(), + userRepository, + mock(), + projectRelationRepository, + sharedWorkflowRepository, + ); beforeEach(() => { jest.clearAllMocks(); }); - describe('getWorkflowOwner()', () => { - test('should retrieve a workflow owner', async () => { - const mockOwner = new User(); - const mockNonOwner = new User(); + describe('getWorkflowProjectCached()', () => { + test('should retrieve a workflow owner project', async () => { + const mockProject = new Project(); const sharedWorkflow = Object.assign(new SharedWorkflow(), { role: 'workflow:owner', - user: mockOwner, + project: mockProject, }); sharedWorkflowRepository.findOneOrFail.mockResolvedValueOnce(sharedWorkflow); - const returnedOwner = await ownershipService.getWorkflowOwnerCached('some-workflow-id'); + const returnedProject = await ownershipService.getWorkflowProjectCached('some-workflow-id'); - expect(returnedOwner).toBe(mockOwner); - expect(returnedOwner).not.toBe(mockNonOwner); + expect(returnedProject).toBe(mockProject); }); - test('should throw if no workflow owner found', async () => { + test('should throw if no workflow owner project found', async () => { sharedWorkflowRepository.findOneOrFail.mockRejectedValue(new Error()); - await expect(ownershipService.getWorkflowOwnerCached('some-workflow-id')).rejects.toThrow(); + await expect(ownershipService.getWorkflowProjectCached('some-workflow-id')).rejects.toThrow(); + }); + }); + + describe('getProjectOwnerCached()', () => { + test('should retrieve a project owner', async () => { + const mockProject = new Project(); + const mockOwner = new User(); + + const projectRelation = Object.assign(new ProjectRelation(), { + role: 'project:personalOwner', + project: mockProject, + user: mockOwner, + }); + + projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([projectRelation]); + + const returnedOwner = await ownershipService.getProjectOwnerCached('some-project-id'); + + expect(returnedOwner).toBe(mockOwner); + }); + + test('should not throw if no project owner found, should return null instead', async () => { + projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([]); + + const owner = await ownershipService.getProjectOwnerCached('some-project-id'); + + expect(owner).toBeNull(); + }); + }); + + describe('getProjectOwnerCached()', () => { + test('should retrieve a project owner', async () => { + const mockProject = new Project(); + const mockOwner = new User(); + + const projectRelation = Object.assign(new ProjectRelation(), { + role: 'project:personalOwner', + project: mockProject, + user: mockOwner, + }); + + projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([projectRelation]); + + const returnedOwner = await ownershipService.getProjectOwnerCached('some-project-id'); + + expect(returnedOwner).toBe(mockOwner); + }); + + test('should not throw if no project owner found, should return null instead', async () => { + projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([]); + + const owner = await ownershipService.getProjectOwnerCached('some-project-id'); + + expect(owner).toBeNull(); }); }); describe('addOwnedByAndSharedWith()', () => { test('should add `ownedBy` and `sharedWith` to credential', async () => { - const owner = mockUser(); - const editor = mockUser(); + const ownerProject = mockProject(); + const editorProject = mockProject(); const credential = mockCredential(); credential.shared = [ - { role: 'credential:owner', user: owner }, - { role: 'credential:editor', user: editor }, + { role: 'credential:owner', project: ownerProject }, + { role: 'credential:editor', project: editorProject }, ] as SharedCredentials[]; - const { ownedBy, sharedWith } = ownershipService.addOwnedByAndSharedWith(credential); + const { homeProject, sharedWithProjects } = + ownershipService.addOwnedByAndSharedWith(credential); - expect(ownedBy).toStrictEqual({ - id: owner.id, - email: owner.email, - firstName: owner.firstName, - lastName: owner.lastName, + expect(homeProject).toMatchObject({ + id: ownerProject.id, + name: ownerProject.name, + type: ownerProject.type, }); - expect(sharedWith).toStrictEqual([ + expect(sharedWithProjects).toMatchObject([ { - id: editor.id, - email: editor.email, - firstName: editor.firstName, - lastName: editor.lastName, + id: editorProject.id, + name: editorProject.name, + type: editorProject.type, }, ]); }); test('should add `ownedBy` and `sharedWith` to workflow', async () => { - const owner = mockUser(); - const editor = mockUser(); + const projectOwner = mockProject(); + const projectEditor = mockProject(); const workflow = new WorkflowEntity(); workflow.shared = [ - { role: 'workflow:owner', user: owner }, - { role: 'workflow:editor', user: editor }, + { role: 'workflow:owner', project: projectOwner }, + { role: 'workflow:editor', project: projectEditor }, ] as SharedWorkflow[]; - const { ownedBy, sharedWith } = ownershipService.addOwnedByAndSharedWith(workflow); + const { homeProject, sharedWithProjects } = + ownershipService.addOwnedByAndSharedWith(workflow); - expect(ownedBy).toStrictEqual({ - id: owner.id, - email: owner.email, - firstName: owner.firstName, - lastName: owner.lastName, + expect(homeProject).toMatchObject({ + id: projectOwner.id, + name: projectOwner.name, + type: projectOwner.type, }); - - expect(sharedWith).toStrictEqual([ + expect(sharedWithProjects).toMatchObject([ { - id: editor.id, - email: editor.email, - firstName: editor.firstName, - lastName: editor.lastName, + id: projectEditor.id, + name: projectEditor.name, + type: projectEditor.type, }, ]); }); test('should produce an empty sharedWith if no sharee', async () => { - const owner = mockUser(); - const credential = mockCredential(); - credential.shared = [{ role: 'credential:owner', user: owner }] as SharedCredentials[]; + const project = mockProject(); - const { ownedBy, sharedWith } = ownershipService.addOwnedByAndSharedWith(credential); + credential.shared = [{ role: 'credential:owner', project }] as SharedCredentials[]; - expect(ownedBy).toStrictEqual({ - id: owner.id, - email: owner.email, - firstName: owner.firstName, - lastName: owner.lastName, + const { homeProject, sharedWithProjects } = + ownershipService.addOwnedByAndSharedWith(credential); + + expect(homeProject).toMatchObject({ + id: project.id, + name: project.name, + type: project.type, }); - expect(sharedWith).toHaveLength(0); + expect(sharedWithProjects).toHaveLength(0); }); }); diff --git a/packages/cli/test/unit/services/user.service.test.ts b/packages/cli/test/unit/services/user.service.test.ts index fe5a7c2a80..5dabdf6646 100644 --- a/packages/cli/test/unit/services/user.service.test.ts +++ b/packages/cli/test/unit/services/user.service.test.ts @@ -4,10 +4,13 @@ import { v4 as uuid } from 'uuid'; import { User } from '@db/entities/User'; import { UserService } from '@/services/user.service'; import { UrlService } from '@/services/url.service'; +import { mockInstance } from '../../shared/mocking'; +import { UserRepository } from '@/databases/repositories/user.repository'; describe('UserService', () => { const urlService = new UrlService(); - const userService = new UserService(mock(), mock(), mock(), urlService); + const userRepository = mockInstance(UserRepository); + const userService = new UserService(mock(), userRepository, mock(), urlService); const commonMockUser = Object.assign(new User(), { id: uuid(), @@ -66,4 +69,28 @@ describe('UserService', () => { expect(url.searchParams.get('inviteeId')).toBe(secondUser.id); }); }); + + describe('update', () => { + // We need to use `save` so that that the subscriber in + // packages/cli/src/databases/entities/Project.ts receives the full user. + // With `update` it would only receive the updated fields, e.g. the `id` + // would be missing. + it('should use `save` instead of `update`', async () => { + const user = new User(); + user.firstName = 'Not Nathan'; + user.lastName = 'Nathaniel'; + + const userId = '1234'; + const data = { + firstName: 'Nathan', + }; + + userRepository.findOneBy.mockResolvedValueOnce(user); + + await userService.update(userId, data); + + expect(userRepository.save).toHaveBeenCalledWith({ ...user, ...data }, { transaction: true }); + expect(userRepository.update).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/test/unit/shared/mockObjects.ts b/packages/cli/test/unit/shared/mockObjects.ts index baa6cf4740..ccc85eb72d 100644 --- a/packages/cli/test/unit/shared/mockObjects.ts +++ b/packages/cli/test/unit/shared/mockObjects.ts @@ -6,7 +6,9 @@ import { randomEmail, randomInteger, randomName, + uniqueId, } from '../../integration/shared/random'; +import { Project } from '@/databases/entities/Project'; export const mockCredential = (): CredentialsEntity => Object.assign(new CredentialsEntity(), randomCredentialPayload()); @@ -18,3 +20,10 @@ export const mockUser = (): User => firstName: randomName(), lastName: randomName(), }); + +export const mockProject = (): Project => + Object.assign(new Project(), { + id: uniqueId(), + type: 'personal', + name: 'Nathan Fillion ', + }); diff --git a/packages/cli/test/unit/sso/saml/samlHelpers.test.ts b/packages/cli/test/unit/sso/saml/samlHelpers.test.ts new file mode 100644 index 0000000000..f6c35ff67e --- /dev/null +++ b/packages/cli/test/unit/sso/saml/samlHelpers.test.ts @@ -0,0 +1,55 @@ +import { User } from '@/databases/entities/User'; +import { generateNanoId } from '@/databases/utils/generators'; +import * as helpers from '@/sso/saml/samlHelpers'; +import type { SamlUserAttributes } from '@/sso/saml/types/samlUserAttributes'; +import { mockInstance } from '../../../shared/mocking'; +import { UserRepository } from '@/databases/repositories/user.repository'; +import type { AuthIdentity } from '@/databases/entities/AuthIdentity'; +import { AuthIdentityRepository } from '@/databases/repositories/authIdentity.repository'; + +const userRepository = mockInstance(UserRepository); +mockInstance(AuthIdentityRepository); + +describe('sso/saml/samlHelpers', () => { + describe('updateUserFromSamlAttributes', () => { + // We need to use `save` so that that the subscriber in + // packages/cli/src/databases/entities/Project.ts receives the full user. + // With `update` it would only receive the updated fields, e.g. the `id` + // would be missing. + test('does not user `Repository.update`, but `Repository.save` instead', async () => { + // + // ARRANGE + // + const user = Object.assign(new User(), { + id: generateNanoId(), + authIdentities: [] as AuthIdentity[], + } as User); + const samlUserAttributes: SamlUserAttributes = { + firstName: 'Nathan', + lastName: 'Nathaniel', + email: 'n@8.n', + userPrincipalName: 'Huh?', + }; + + userRepository.save.mockImplementationOnce(async (user) => user as User); + + // + // ACT + // + await helpers.updateUserFromSamlAttributes(user, samlUserAttributes); + + // + // ASSERT + // + expect(userRepository.save).toHaveBeenCalledWith( + { + ...user, + firstName: samlUserAttributes.firstName, + lastName: samlUserAttributes.lastName, + }, + { transaction: false }, + ); + expect(userRepository.update).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/design-system/src/components/N8nButton/Button.vue b/packages/design-system/src/components/N8nButton/Button.vue index c293deab5d..5c6800e6e0 100644 --- a/packages/design-system/src/components/N8nButton/Button.vue +++ b/packages/design-system/src/components/N8nButton/Button.vue @@ -7,7 +7,10 @@ :aria-busy="ariaBusy" :href="href" aria-live="polite" - v-bind="$attrs" + v-bind="{ + ...$attrs, + ...(props.nativeType ? { type: props.nativeType } : {}), + }" > diff --git a/packages/design-system/src/components/N8nMenu/Menu.vue b/packages/design-system/src/components/N8nMenu/Menu.vue index ebf0a9a0cf..96bc59668f 100644 --- a/packages/design-system/src/components/N8nMenu/Menu.vue +++ b/packages/design-system/src/components/N8nMenu/Menu.vue @@ -133,11 +133,18 @@ const onSelect = (item: IMenuItem): void => { background-color: var(--menu-background, var(--color-background-xlight)); } +.menuHeader { + display: flex; + flex-direction: column; + flex: 0 1 auto; + overflow-y: auto; +} + .menuContent { display: flex; flex-direction: column; justify-content: space-between; - flex-grow: 1; + flex: 1 1 auto; & > div > :global(.el-menu) { background: none; diff --git a/packages/design-system/src/components/N8nMenuItem/MenuItem.vue b/packages/design-system/src/components/N8nMenuItem/MenuItem.vue index 31c2b9af8f..8922525f73 100644 --- a/packages/design-system/src/components/N8nMenuItem/MenuItem.vue +++ b/packages/design-system/src/components/N8nMenuItem/MenuItem.vue @@ -19,9 +19,12 @@ :icon="item.icon" :size="item.customIconSize || 'large'" /> - {{ item.label }} + {{ item.label }} + {{ + getInitials(item.label) + }} - - {{ item.label }} + {{ item.label }} + {{ + getInitials(item.label) + }} + @@ -141,6 +149,16 @@ const isItemActive = (item: IMenuItem): boolean => { Array.isArray(item.children) && item.children.some((child) => isActive(child)); return isActive(item) || hasActiveChild; }; + +const getInitials = (label: string): string => { + const words = label.split(' '); + + if (words.length === 1) { + return words[0].substring(0, 2); + } else { + return words[0].charAt(0) + words[1].charAt(0); + } +}; diff --git a/packages/editor-ui/src/components/layouts/PageViewLayout.vue b/packages/editor-ui/src/components/layouts/PageViewLayout.vue index 7f18b4203a..523fdfc28a 100644 --- a/packages/editor-ui/src/components/layouts/PageViewLayout.vue +++ b/packages/editor-ui/src/components/layouts/PageViewLayout.vue @@ -1,13 +1,9 @@ @@ -31,54 +27,18 @@ export default defineComponent({ diff --git a/packages/editor-ui/src/components/layouts/PageViewLayoutList.vue b/packages/editor-ui/src/components/layouts/PageViewLayoutList.vue index 84246d22dc..263a5b4cab 100644 --- a/packages/editor-ui/src/components/layouts/PageViewLayoutList.vue +++ b/packages/editor-ui/src/components/layouts/PageViewLayoutList.vue @@ -26,8 +26,7 @@ export default defineComponent({ diff --git a/packages/editor-ui/src/components/layouts/ResourcesListLayout.vue b/packages/editor-ui/src/components/layouts/ResourcesListLayout.vue index 81f201c45b..274dbe3058 100644 --- a/packages/editor-ui/src/components/layouts/ResourcesListLayout.vue +++ b/packages/editor-ui/src/components/layouts/ResourcesListLayout.vue @@ -1,35 +1,8 @@