diff --git a/.github/workflows/test-workflows.yml b/.github/workflows/test-workflows.yml index 27b8c622ee21a..9c7cec22e086c 100644 --- a/.github/workflows/test-workflows.yml +++ b/.github/workflows/test-workflows.yml @@ -25,7 +25,7 @@ jobs: - uses: pnpm/action-setup@v2.4.0 with: - version: 8.6.12 + version: 8.7.0 - uses: actions/setup-node@v3.7.0 with: diff --git a/.gitignore b/.gitignore index 3060b870c1692..0c3174e84ee88 100644 --- a/.gitignore +++ b/.gitignore @@ -19,5 +19,6 @@ packages/**/.turbo *.tsbuildinfo cypress/videos/* cypress/screenshots/* +cypress/downloads/* *.swp CHANGELOG-*.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d7700794b38..07b01bdabd039 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,32 @@ +# [1.5.0](https://github.com/n8n-io/n8n/compare/n8n@1.4.0...n8n@1.5.0) (2023-08-31) + + +### Bug Fixes + +* **Agile CRM Node:** Fix issue with company address not working ([#6997](https://github.com/n8n-io/n8n/issues/6997)) ([2f81652](https://github.com/n8n-io/n8n/commit/2f81652400b6a793fa610728519fd992c03c3d0d)) +* **Code Node:** Switch over to vm2 fork ([#7018](https://github.com/n8n-io/n8n/issues/7018)) ([dfe0fa6](https://github.com/n8n-io/n8n/commit/dfe0fa65f8111cd534387e26197cb3836d694e27)) +* **core:** Invalid NODES_INCLUDE should not crash the app ([#7038](https://github.com/n8n-io/n8n/issues/7038)) ([04e3178](https://github.com/n8n-io/n8n/commit/04e31789019aad6fe122ed81b06552a61d7f3a6d)), closes [#6683](https://github.com/n8n-io/n8n/issues/6683) +* **core:** Setup websocket keep-live messages ([#6866](https://github.com/n8n-io/n8n/issues/6866)) ([8bdb07d](https://github.com/n8n-io/n8n/commit/8bdb07d33ded48eab0b8f892a06e18f37bee9372)), closes [#6757](https://github.com/n8n-io/n8n/issues/6757) +* **core:** Throw `NodeSSLError` only for nodes that allow ignoring SSL issues ([#6928](https://github.com/n8n-io/n8n/issues/6928)) ([a01c3fb](https://github.com/n8n-io/n8n/commit/a01c3fbc19d66cf8b1dac3e34e0999dd36d81e7c)) +* **Date & Time Node:** Dont parse date if it's not set (null or undefined) ([#7050](https://github.com/n8n-io/n8n/issues/7050)) ([d72f79f](https://github.com/n8n-io/n8n/commit/d72f79ffb393a096f510f0c41bb66d987fe8cb0d)) +* **editor:** Fix sending of Ask AI tracking events ([#7002](https://github.com/n8n-io/n8n/issues/7002)) ([fb05afa](https://github.com/n8n-io/n8n/commit/fb05afa16560c3c837abf46824f8dc7fa3bb1c83)) +* **Microsoft Excel 365 Node:** Support for more extensions in workbook rlc ([#7020](https://github.com/n8n-io/n8n/issues/7020)) ([d6e1cf2](https://github.com/n8n-io/n8n/commit/d6e1cf232f86ddc69cceb69c8971c3373dab454c)) +* **MongoDB Node:** Stringify response ObjectIDs ([#6990](https://github.com/n8n-io/n8n/issues/6990)) ([9ca990b](https://github.com/n8n-io/n8n/commit/9ca990b9936ee80972952d0a1ad73c2926809ba2)) +* **MongoDB Node:** Upgrade mongodb package to address CVE-2021-32050 ([#7054](https://github.com/n8n-io/n8n/issues/7054)) ([d3f6356](https://github.com/n8n-io/n8n/commit/d3f635657c7514296fd0a473ba13672db2717490)) +* **Postgres Node:** Empty return data fix for Postgres and MySQL ([#7016](https://github.com/n8n-io/n8n/issues/7016)) ([176ccd6](https://github.com/n8n-io/n8n/commit/176ccd62bc1d6f28958c0fc894ee647f1e3a5f6e)) +* **Webhook Node:** Fix URL params for webhooks ([#6986](https://github.com/n8n-io/n8n/issues/6986)) ([596b569](https://github.com/n8n-io/n8n/commit/596b5695cdcca33da02bec428d58de8b2a13297e)) + + +### Features + +* **core:** Add filtering, selection and pagination to users ([#6994](https://github.com/n8n-io/n8n/issues/6994)) ([b716241](https://github.com/n8n-io/n8n/commit/b716241b428ef09cf6bdf32cb3a8680e9ba8f25f)) +* **core:** Add MFA ([#4767](https://github.com/n8n-io/n8n/issues/4767)) ([2b7ba6f](https://github.com/n8n-io/n8n/commit/2b7ba6fdf100ef78b60358648d773e2f200847b8)) +* **editor:** Debug executions in the editor ([#6834](https://github.com/n8n-io/n8n/issues/6834)) ([c833078](https://github.com/n8n-io/n8n/commit/c833078c87adeadb1e701f17d3f380c669eb1460)) +* External Secrets storage for credentials ([#6477](https://github.com/n8n-io/n8n/issues/6477)) ([ed927d3](https://github.com/n8n-io/n8n/commit/ed927d34b25b4ddd7048b622c141e32a8a57b6b7)) +* **RSS Read Node:** Add support for self signed certificates ([#7039](https://github.com/n8n-io/n8n/issues/7039)) ([3b9f0fe](https://github.com/n8n-io/n8n/commit/3b9f0fed7af2d3a234049ab7d50d883ee4608007)) + + + # [1.4.0](https://github.com/n8n-io/n8n/compare/n8n@1.3.0...n8n@1.4.0) (2023-08-23) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 529bab60aaef1..4adfd050d4acd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,7 +65,7 @@ dependencies are installed and the packages get linked correctly. Here's a short #### pnpm -[pnpm](https://pnpm.io/) version 7.18 or newer is required for development purposes. We recommend installing it with [corepack](#corepack). +[pnpm](https://pnpm.io/) version 8.7 or newer is required for development purposes. We recommend installing it with [corepack](#corepack). ##### pnpm workspaces diff --git a/cypress/e2e/27-two-factor-authentication.cy.ts b/cypress/e2e/27-two-factor-authentication.cy.ts new file mode 100644 index 0000000000000..e2aa0d0f9608a --- /dev/null +++ b/cypress/e2e/27-two-factor-authentication.cy.ts @@ -0,0 +1,69 @@ +import { MainSidebar } from './../pages/sidebar/main-sidebar'; +import { INSTANCE_OWNER, BACKEND_BASE_URL } from '../constants'; +import { SigninPage } from '../pages'; +import { PersonalSettingsPage } from '../pages/settings-personal'; +import { MfaLoginPage } from '../pages/mfa-login'; +import generateOTPToken from 'cypress-otp'; + +const MFA_SECRET = 'KVKFKRCPNZQUYMLXOVYDSQKJKZDTSRLD'; + +const RECOVERY_CODE = 'd04ea17f-e8b2-4afa-a9aa-57a2c735b30e'; + +const user = { + email: INSTANCE_OWNER.email, + password: INSTANCE_OWNER.password, + firstName: 'User', + lastName: 'A', + mfaEnabled: false, + mfaSecret: MFA_SECRET, + mfaRecoveryCodes: [RECOVERY_CODE], +}; + +const mfaLoginPage = new MfaLoginPage(); +const signinPage = new SigninPage(); +const personalSettingsPage = new PersonalSettingsPage(); +const mainSidebar = new MainSidebar(); + +describe('Two-factor authentication', () => { + beforeEach(() => { + Cypress.session.clearAllSavedSessions(); + cy.request('POST', `${BACKEND_BASE_URL}/rest/e2e/reset`, { + owner: user, + members: [], + }); + cy.on('uncaught:exception', (err, runnable) => { + expect(err.message).to.include('Not logged in'); + return false; + }); + }); + + it('Should be able to login with MFA token', () => { + const { email, password } = user; + signinPage.actions.loginWithEmailAndPassword(email, password); + personalSettingsPage.actions.enableMfa(); + mainSidebar.actions.signout(); + const token = generateOTPToken(user.mfaSecret) + mfaLoginPage.actions.loginWithMfaToken(email, password, token); + mainSidebar.actions.signout(); + }); + + it('Should be able to login with recovery code', () => { + const { email, password } = user; + signinPage.actions.loginWithEmailAndPassword(email, password); + personalSettingsPage.actions.enableMfa(); + mainSidebar.actions.signout(); + mfaLoginPage.actions.loginWithRecoveryCode(email, password, user.mfaRecoveryCodes[0]); + mainSidebar.actions.signout(); + }); + + it('Should be able to disable MFA in account', () => { + const { email, password } = user; + signinPage.actions.loginWithEmailAndPassword(email, password); + personalSettingsPage.actions.enableMfa(); + mainSidebar.actions.signout(); + const token = generateOTPToken(user.mfaSecret) + mfaLoginPage.actions.loginWithMfaToken(email, password, token); + personalSettingsPage.actions.disableMfa(); + mainSidebar.actions.signout(); + }); +}); diff --git a/cypress/e2e/28-debug.cy.ts b/cypress/e2e/28-debug.cy.ts new file mode 100644 index 0000000000000..ede3b500cb707 --- /dev/null +++ b/cypress/e2e/28-debug.cy.ts @@ -0,0 +1,129 @@ +import { + HTTP_REQUEST_NODE_NAME, IF_NODE_NAME, + INSTANCE_OWNER, + MANUAL_TRIGGER_NODE_NAME, + SET_NODE_NAME, +} from '../constants'; +import { WorkflowPage, NDV, WorkflowExecutionsTab } from '../pages'; + +const workflowPage = new WorkflowPage(); +const ndv = new NDV(); +const executionsTab = new WorkflowExecutionsTab(); + +describe('Debug', () => { + it('should be able to debug executions', () => { + cy.intercept('GET', '/rest/settings', (req) => { + req.on('response', (res) => { + res.send({ + data: { ...res.body.data, enterprise: { debugInEditor: true } }, + }); + }); + }).as('loadSettings'); + cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions'); + cy.intercept('GET', '/rest/executions/*').as('getExecution'); + cy.intercept('GET', '/rest/executions-current?filter=*').as('getCurrentExecutions'); + cy.intercept('POST', '/rest/workflows/run').as('postWorkflowRun'); + + cy.signin({ email: INSTANCE_OWNER.email, password: INSTANCE_OWNER.password }); + + workflowPage.actions.visit(); + + workflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); + workflowPage.actions.addNodeToCanvas(HTTP_REQUEST_NODE_NAME); + workflowPage.actions.openNode(HTTP_REQUEST_NODE_NAME); + ndv.actions.typeIntoParameterInput('url', 'https://foo.bar'); + ndv.actions.close(); + + workflowPage.actions.addNodeToCanvas(SET_NODE_NAME, true); + + workflowPage.actions.saveWorkflowUsingKeyboardShortcut(); + workflowPage.actions.executeWorkflow(); + + cy.wait(['@postWorkflowRun']); + + executionsTab.actions.switchToExecutionsTab(); + + cy.wait(['@getExecutions', '@getCurrentExecutions']); + + executionsTab.getters.executionDebugButton().should('have.text', 'Debug in editor').click(); + cy.get('.el-notification').contains('Execution data imported').should('be.visible'); + cy.get('.matching-pinned-nodes-confirmation').should('not.exist'); + + + workflowPage.actions.openNode(HTTP_REQUEST_NODE_NAME); + ndv.actions.clearParameterInput('url'); + ndv.actions.typeIntoParameterInput('url', 'https://postman-echo.com/get?foo1=bar1&foo2=bar2'); + ndv.actions.close(); + + workflowPage.actions.saveWorkflowUsingKeyboardShortcut(); + workflowPage.actions.executeWorkflow(); + + cy.wait(['@postWorkflowRun']); + + workflowPage.actions.openNode(HTTP_REQUEST_NODE_NAME); + ndv.actions.pinData(); + ndv.actions.close(); + + executionsTab.actions.switchToExecutionsTab(); + + cy.wait(['@getExecutions', '@getCurrentExecutions']); + + executionsTab.getters.executionListItems().should('have.length', 2).first().click(); + cy.wait(['@getExecution']); + + executionsTab.getters.executionDebugButton().should('have.text', 'Copy to editor').click(); + + let confirmDialog = cy.get('.matching-pinned-nodes-confirmation').filter(':visible'); + confirmDialog.find('li').should('have.length', 2); + confirmDialog.get('.btn--cancel').click(); + + cy.wait(['@getExecutions', '@getCurrentExecutions']); + + executionsTab.getters.executionListItems().should('have.length', 2).first().click(); + cy.wait(['@getExecution']); + + executionsTab.getters.executionDebugButton().should('have.text', 'Copy to editor').click(); + + confirmDialog = cy.get('.matching-pinned-nodes-confirmation').filter(':visible'); + confirmDialog.find('li').should('have.length', 2); + confirmDialog.get('.btn--confirm').click(); + + workflowPage.getters.canvasNodes().first().should('have.descendants', '.node-pin-data-icon'); + workflowPage.getters.canvasNodes().not(':first').should('not.have.descendants', '.node-pin-data-icon'); + + cy.reload(true); + cy.wait(['@getExecution']); + + confirmDialog = cy.get('.matching-pinned-nodes-confirmation').filter(':visible'); + confirmDialog.find('li').should('have.length', 1); + confirmDialog.get('.btn--confirm').click(); + + workflowPage.getters.canvasNodePlusEndpointByName(SET_NODE_NAME).click(); + workflowPage.actions.addNodeToCanvas(IF_NODE_NAME, false); + workflowPage.actions.saveWorkflowUsingKeyboardShortcut(); + + executionsTab.actions.switchToExecutionsTab(); + cy.wait(['@getExecutions', '@getCurrentExecutions']); + executionsTab.getters.executionDebugButton().should('have.text', 'Copy to editor').click(); + + confirmDialog = cy.get('.matching-pinned-nodes-confirmation').filter(':visible'); + confirmDialog.find('li').should('have.length', 1); + confirmDialog.get('.btn--confirm').click(); + workflowPage.getters.canvasNodes().last().find('.node-info-icon').should('be.empty'); + + workflowPage.getters.canvasNodes().first().dblclick(); + ndv.getters.pinDataButton().click(); + ndv.actions.close(); + + workflowPage.actions.saveWorkflowUsingKeyboardShortcut(); + workflowPage.actions.executeWorkflow(); + workflowPage.actions.deleteNode(IF_NODE_NAME); + + executionsTab.actions.switchToExecutionsTab(); + cy.wait(['@getExecutions', '@getCurrentExecutions']); + executionsTab.getters.executionListItems().should('have.length', 3).first().click(); + cy.wait(['@getExecution']); + executionsTab.getters.executionDebugButton().should('have.text', 'Copy to editor').click(); + cy.get('.el-notification').contains('Some execution data wasn\'t imported').should('be.visible'); + }); +}); diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index 67010b4a13df3..a729ddffaceda 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -288,4 +288,29 @@ describe('NDV', () => { ndv.getters.parameterInput('value').clear(); }); }); + + it('should flag issues as soon as params are set', () => { + workflowPage.actions.addInitialNodeToCanvas('Webhook'); + workflowPage.getters.canvasNodes().first().dblclick(); + + workflowPage.getters.nodeIssuesByName('Webhook').should('not.exist'); + ndv.getters.nodeExecuteButton().should('not.be.disabled'); + ndv.getters.triggerPanelExecuteButton().should('exist'); + + ndv.getters.parameterInput('path').clear(); + + ndv.getters.nodeExecuteButton().should('be.disabled'); + ndv.getters.triggerPanelExecuteButton().should('not.exist'); + ndv.actions.close(); + workflowPage.getters.nodeIssuesByName('Webhook').should('exist'); + + workflowPage.getters.canvasNodes().first().dblclick(); + ndv.getters.parameterInput('path').type('t') + + ndv.getters.nodeExecuteButton().should('not.be.disabled'); + ndv.getters.triggerPanelExecuteButton().should('exist'); + + ndv.actions.close(); + workflowPage.getters.nodeIssuesByName('Webhook').should('not.exist'); + }); }); diff --git a/cypress/e2e/6-code-node.cy.ts b/cypress/e2e/6-code-node.cy.ts index 0ed9ba5089b6b..bd266562f5d10 100644 --- a/cypress/e2e/6-code-node.cy.ts +++ b/cypress/e2e/6-code-node.cy.ts @@ -110,23 +110,17 @@ describe('Code node', () => { statusCode: 200, body: { data: { - code: 'console.log("Hello World")', - usage: { - prompt_tokens: 15, - completion_tokens: 15, - total_tokens: 30 - } + code: 'console.log("Hello World")' }, } }).as('ask-ai'); + cy.getByTestId('ask-ai-cta').click(); - cy.wait('@ask-ai') - .its('request.body') - .should('deep.include', { - question: prompt, - model: "gpt-3.5-turbo-16k", - context: { schema: [] } - }); + const askAiReq = cy.wait('@ask-ai') + + askAiReq.its('request.body').should('have.keys', ['question', 'model', 'context', 'n8nVersion']); + + askAiReq.its('context').should('have.keys', ['schema', 'ndvSessionId', 'sessionId']); cy.contains('Code generation completed').should('be.visible') cy.getByTestId('code-node-tab-code').should('contain.text', 'console.log("Hello World")'); diff --git a/cypress/pages/index.ts b/cypress/pages/index.ts index 4d95611a12129..18e3649e1acda 100644 --- a/cypress/pages/index.ts +++ b/cypress/pages/index.ts @@ -8,3 +8,5 @@ export * from './settings-log-streaming'; export * from './sidebar'; export * from './ndv'; export * from './bannerStack'; +export * from './workflow-executions-tab'; +export * from './signin'; diff --git a/cypress/pages/mfa-login.ts b/cypress/pages/mfa-login.ts new file mode 100644 index 0000000000000..50ca5adab7f98 --- /dev/null +++ b/cypress/pages/mfa-login.ts @@ -0,0 +1,77 @@ +import { N8N_AUTH_COOKIE } from '../constants'; +import { BasePage } from './base'; +import { SigninPage } from './signin'; +import { WorkflowsPage } from './workflows'; + +export class MfaLoginPage extends BasePage { + url = '/mfa'; + getters = { + form: () => cy.getByTestId('mfa-login-form'), + token: () => cy.getByTestId('token'), + recoveryCode: () => cy.getByTestId('recoveryCode'), + enterRecoveryCodeButton: () => cy.getByTestId('mfa-enter-recovery-code-button'), + }; + + actions = { + loginWithMfaToken: (email: string, password: string, mfaToken: string) => { + const signinPage = new SigninPage(); + const workflowsPage = new WorkflowsPage(); + + cy.session( + [mfaToken], + () => { + cy.visit(signinPage.url); + + signinPage.getters.form().within(() => { + signinPage.getters.email().type(email); + signinPage.getters.password().type(password); + signinPage.getters.submit().click(); + }); + + this.getters.form().within(() => { + this.getters.token().type(mfaToken); + }); + + // we should be redirected to /workflows + cy.url().should('include', workflowsPage.url); + }, + { + validate() { + cy.getCookie(N8N_AUTH_COOKIE).should('exist'); + }, + }, + ); + }, + loginWithRecoveryCode: (email: string, password: string, recoveryCode: string) => { + const signinPage = new SigninPage(); + const workflowsPage = new WorkflowsPage(); + + cy.session( + [recoveryCode], + () => { + cy.visit(signinPage.url); + + signinPage.getters.form().within(() => { + signinPage.getters.email().type(email); + signinPage.getters.password().type(password); + signinPage.getters.submit().click(); + }); + + this.getters.enterRecoveryCodeButton().click(); + + this.getters.form().within(() => { + this.getters.recoveryCode().type(recoveryCode); + }); + + // we should be redirected to /workflows + cy.url().should('include', workflowsPage.url); + }, + { + validate() { + cy.getCookie(N8N_AUTH_COOKIE).should('exist'); + }, + }, + ); + }, + }; +} diff --git a/cypress/pages/modals/mfa-setup-modal.ts b/cypress/pages/modals/mfa-setup-modal.ts new file mode 100644 index 0000000000000..d127731be278d --- /dev/null +++ b/cypress/pages/modals/mfa-setup-modal.ts @@ -0,0 +1,11 @@ +import { BasePage } from './../base'; + +export class MfaSetupModal extends BasePage { + getters = { + modalContainer: () => cy.getByTestId('changePassword-modal').last(), + tokenInput: () => cy.getByTestId('mfa-token-input'), + copySecretToClipboardButton: () => cy.getByTestId('mfa-secret-button'), + downloadRecoveryCodesButton: () => cy.getByTestId('mfa-recovery-codes-button'), + saveButton: () => cy.getByTestId('mfa-save-button'), + }; +} diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index 301de78cfe5a3..0cd8e0913b863 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -8,6 +8,7 @@ export class NDV extends BasePage { copyInput: () => cy.getByTestId('copy-input'), credentialInput: (eq = 0) => cy.getByTestId('node-credentials-select').eq(eq), nodeExecuteButton: () => cy.getByTestId('node-execute-button'), + triggerPanelExecuteButton: () => cy.getByTestId('trigger-execute-button'), inputSelect: () => cy.getByTestId('ndv-input-select'), inputOption: () => cy.getByTestId('ndv-input-option'), inputPanel: () => cy.getByTestId('ndv-input-panel'), diff --git a/cypress/pages/settings-personal.ts b/cypress/pages/settings-personal.ts index 554455ef73420..671b5e21a2129 100644 --- a/cypress/pages/settings-personal.ts +++ b/cypress/pages/settings-personal.ts @@ -1,10 +1,15 @@ import { ChangePasswordModal } from './modals/change-password-modal'; +import { MfaSetupModal } from './modals/mfa-setup-modal'; import { BasePage } from './base'; +import generateOTPToken from 'cypress-otp'; const changePasswordModal = new ChangePasswordModal(); +const mfaSetupModal = new MfaSetupModal(); export class PersonalSettingsPage extends BasePage { url = '/settings/personal'; + secret = ''; + getters = { currentUserName: () => cy.getByTestId('current-user-name'), firstNameInput: () => cy.getByTestId('firstName').find('input').first(), @@ -13,6 +18,8 @@ export class PersonalSettingsPage extends BasePage { emailInput: () => cy.getByTestId('email').find('input').first(), changePasswordLink: () => cy.getByTestId('change-password-link').first(), saveSettingsButton: () => cy.getByTestId('save-settings-button'), + enableMfaButton: () => cy.getByTestId('enable-mfa-button'), + disableMfaButton: () => cy.getByTestId('disable-mfa-button'), }; actions = { loginAndVisit: (email: string, password: string) => { @@ -50,5 +57,21 @@ export class PersonalSettingsPage extends BasePage { this.actions.loginAndVisit(email, password); cy.url().should('match', new RegExp(this.url)); }, + enableMfa: () => { + cy.visit(this.url); + this.getters.enableMfaButton().click(); + mfaSetupModal.getters.copySecretToClipboardButton().realClick(); + cy.readClipboard().then((secret) => { + const token = generateOTPToken(secret) + + mfaSetupModal.getters.tokenInput().type(token); + mfaSetupModal.getters.downloadRecoveryCodesButton().click(); + mfaSetupModal.getters.saveButton().click(); + }); + }, + disableMfa: () => { + cy.visit(this.url); + this.getters.disableMfaButton().click(); + }, }; } diff --git a/cypress/pages/sidebar/main-sidebar.ts b/cypress/pages/sidebar/main-sidebar.ts index 789d63545f6e9..1559d3da65c7f 100644 --- a/cypress/pages/sidebar/main-sidebar.ts +++ b/cypress/pages/sidebar/main-sidebar.ts @@ -1,6 +1,8 @@ import { BasePage } from '../base'; import { WorkflowsPage } from '../workflows'; +const workflowsPage = new WorkflowsPage(); + export class MainSidebar extends BasePage { getters = { menuItem: (menuLabel: string) => @@ -25,7 +27,7 @@ export class MainSidebar extends BasePage { this.getters.credentials().click(); }, openUserMenu: () => { - this.getters.userMenu().find('[role="button"]').last().click(); + this.getters.userMenu().click(); }, openUserMenu: () => { this.getters.userMenu().click(); diff --git a/cypress/pages/signin.ts b/cypress/pages/signin.ts new file mode 100644 index 0000000000000..1b2b35c22fbb4 --- /dev/null +++ b/cypress/pages/signin.ts @@ -0,0 +1,41 @@ +import { N8N_AUTH_COOKIE } from '../constants'; +import { BasePage } from './base'; +import { WorkflowsPage } from './workflows'; + +export class SigninPage extends BasePage { + url = '/signin'; + getters = { + form: () => cy.getByTestId('auth-form'), + email: () => cy.getByTestId('email'), + password: () => cy.getByTestId('password'), + submit: () => cy.get('button'), + }; + + actions = { + loginWithEmailAndPassword: (email: string, password: string) => { + const signinPage = new SigninPage(); + const workflowsPage = new WorkflowsPage(); + + cy.session( + [email, password], + () => { + cy.visit(signinPage.url); + + this.getters.form().within(() => { + this.getters.email().type(email); + this.getters.password().type(password); + this.getters.submit().click(); + }); + + // we should be redirected to /workflows + cy.url().should('include', workflowsPage.url); + }, + { + validate() { + cy.getCookie(N8N_AUTH_COOKIE).should('exist'); + }, + }, + ); + }, + }; +} diff --git a/cypress/pages/workflow-executions-tab.ts b/cypress/pages/workflow-executions-tab.ts index 674ff6d5f3911..eff3fedd3095b 100644 --- a/cypress/pages/workflow-executions-tab.ts +++ b/cypress/pages/workflow-executions-tab.ts @@ -22,6 +22,7 @@ export class WorkflowExecutionsTab extends BasePage { this.getters.executionPreviewDetails().find('[data-test-id="execution-preview-label"]'), executionPreviewId: () => this.getters.executionPreviewDetails().find('[data-test-id="execution-preview-id"]'), + executionDebugButton: () => cy.getByTestId('execution-debug-button'), }; actions = { toggleNodeEnabled: (nodeName: string) => { diff --git a/cypress/pages/workflow.ts b/cypress/pages/workflow.ts index 950c83be9bc9e..a92736e17906c 100644 --- a/cypress/pages/workflow.ts +++ b/cypress/pages/workflow.ts @@ -26,6 +26,10 @@ export class WorkflowPage extends BasePage { canvasNodes: () => cy.getByTestId('canvas-node'), canvasNodeByName: (nodeName: string) => this.getters.canvasNodes().filter(`:contains(${nodeName})`), + nodeIssuesByName: (nodeName: string) => + this.getters.canvasNodes().filter(`:contains(${nodeName})`) + .should('have.length.greaterThan', 0) + .findChildByTestId('node-issues'), getEndpointSelector: (type: 'input' | 'output' | 'plus', nodeName: string, index = 0) => { return `[data-endpoint-name='${nodeName}'][data-endpoint-type='${type}'][data-input-index='${index}']`; }, diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index c215bdf3f7138..c86db382a36e4 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -41,14 +41,13 @@ Cypress.Commands.add('waitForLoad', (waitForIntercepts = true) => { Cypress.Commands.add('signin', ({ email, password }) => { Cypress.session.clearAllSavedSessions(); - cy.session( - [email, password], - () => cy.request('POST', `${BACKEND_BASE_URL}/rest/login`, { email, password }), - { - validate() { - cy.getCookie(N8N_AUTH_COOKIE).should('exist'); - }, - }, + cy.session([email, password], () => + cy.request({ + method: 'POST', + url: `${BACKEND_BASE_URL}/rest/login`, + body: { email, password }, + failOnStatusCode: false, + }), ); }); diff --git a/docker/compose/subfolderWithSSL/docker-compose.yml b/docker/compose/subfolderWithSSL/docker-compose.yml index 9c4cc247c3801..d989329841aa3 100644 --- a/docker/compose/subfolderWithSSL/docker-compose.yml +++ b/docker/compose/subfolderWithSSL/docker-compose.yml @@ -19,6 +19,13 @@ services: volumes: - ${DATA_FOLDER}/letsencrypt:/letsencrypt - /var/run/docker.sock:/var/run/docker.sock:ro + + initContainer: + image: busybox + command: ['sh', '-c', 'chown -R 1000:1000 /home/node/.n8n'] + volumes: + - ${DATA_FOLDER}/.n8n:/home/node/.n8n + n8n: image: docker.n8n.io/n8nio/n8n ports: @@ -50,3 +57,6 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock - ${DATA_FOLDER}/.n8n:/home/node/.n8n + depends_on: + initContainer: + condition: service_completed_successfully diff --git a/package.json b/package.json index da405a61e63bc..b2523cfba098b 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { "name": "n8n", - "version": "1.4.0", + "version": "1.5.0", "private": true, "homepage": "https://n8n.io", "engines": { "node": ">=18.10", - "pnpm": ">=8.6" + "pnpm": ">=8.7" }, - "packageManager": "pnpm@8.6.12", + "packageManager": "pnpm@8.7.0", "scripts": { "preinstall": "node scripts/block-npm-install.js", "build": "turbo run build", @@ -42,6 +42,7 @@ "@types/supertest": "^2.0.12", "@vitest/coverage-v8": "^0.33.0", "cross-env": "^7.0.3", + "cypress-otp": "^1.0.3", "cypress": "^12.17.2", "cypress-real-events": "^1.9.1", "jest": "^29.6.2", @@ -79,7 +80,7 @@ "tslib": "^2.6.1", "tsconfig-paths": "^4.2.0", "ts-node": "^10.9.1", - "typescript": "^5.1.6", + "typescript": "^5.2.2", "xml2js": "^0.5.0", "cpy@8>globby": "^11.1.0", "qqjs>globby": "^11.1.0" @@ -88,7 +89,8 @@ "typedi@0.10.0": "patches/typedi@0.10.0.patch", "@sentry/cli@2.17.0": "patches/@sentry__cli@2.17.0.patch", "pkce-challenge@3.0.0": "patches/pkce-challenge@3.0.0.patch", - "pyodide@0.23.4": "patches/pyodide@0.23.4.patch" + "pyodide@0.23.4": "patches/pyodide@0.23.4.patch", + "@types/ws@8.5.4": "patches/@types__ws@8.5.4.patch" } } } diff --git a/packages/cli/BREAKING-CHANGES.md b/packages/cli/BREAKING-CHANGES.md index 24db177b505da..a60b6e29fa576 100644 --- a/packages/cli/BREAKING-CHANGES.md +++ b/packages/cli/BREAKING-CHANGES.md @@ -2,6 +2,16 @@ This list shows all the versions which include breaking changes and how to upgrade. +## 1.5.0 + +### What changed? + +In the Code node, `console.log` does not output to stdout by default. + +### When is action necessary? + +If you were relying on `console.log` for non-manual executions of a Code node, you need to set the env variable `CODE_ENABLE_STDOUT` to `true` to send Code node logs to process's stdout. + ## 1.2.0 ### What changed? diff --git a/packages/cli/package.json b/packages/cli/package.json index 80e6a29656539..fdbecf3d5cbb9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.4.0", + "version": "1.5.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -139,6 +139,7 @@ "formidable": "^3.5.0", "google-timezones-json": "^1.1.0", "handlebars": "4.7.7", + "infisical-node": "^1.3.0", "inquirer": "^7.0.1", "ioredis": "^5.2.4", "json-diff": "^1.0.6", @@ -159,6 +160,7 @@ "oauth-1.0a": "^2.2.6", "open": "^7.0.0", "openapi-types": "^10.0.0", + "otpauth": "^9.1.1", "p-cancelable": "^2.0.0", "p-lazy": "^3.1.0", "passport": "^0.6.0", diff --git a/packages/cli/src/ActiveWorkflowRunner.ts b/packages/cli/src/ActiveWorkflowRunner.ts index af2d4d75aeec1..c40a103049410 100644 --- a/packages/cli/src/ActiveWorkflowRunner.ts +++ b/packages/cli/src/ActiveWorkflowRunner.ts @@ -194,6 +194,9 @@ export class ActiveWorkflowRunner implements IWebhookManager { Logger.debug(`Received webhook "${httpMethod}" for path "${path}"`); + // Reset request parameters + request.params = {} as WebhookRequest['params']; + // Remove trailing slash if (path.endsWith('/')) { path = path.slice(0, -1); diff --git a/packages/cli/src/CredentialsHelper.ts b/packages/cli/src/CredentialsHelper.ts index 303b7a0fcb632..f8bfc0cc8ad67 100644 --- a/packages/cli/src/CredentialsHelper.ts +++ b/packages/cli/src/CredentialsHelper.ts @@ -31,6 +31,7 @@ import type { IHttpRequestHelper, INodeTypeData, INodeTypes, + IWorkflowExecuteAdditionalData, ICredentialTestFunctions, } from 'n8n-workflow'; import { @@ -342,6 +343,7 @@ export class CredentialsHelper extends ICredentialsHelper { * @param {boolean} [raw] Return the data as supplied without defaults or overwrites */ async getDecrypted( + additionalData: IWorkflowExecuteAdditionalData, nodeCredentials: INodeCredentialsDetails, type: string, mode: WorkflowExecuteMode, @@ -356,12 +358,18 @@ export class CredentialsHelper extends ICredentialsHelper { return decryptedDataOriginal; } + await additionalData?.secretsHelpers?.waitForInit(); + + const canUseSecrets = await this.credentialOwnedByOwner(nodeCredentials); + return this.applyDefaultsAndOverwrites( + additionalData, decryptedDataOriginal, type, mode, defaultTimezone, expressionResolveValues, + canUseSecrets, ); } @@ -369,11 +377,13 @@ export class CredentialsHelper extends ICredentialsHelper { * Applies credential default data and overwrites */ applyDefaultsAndOverwrites( + additionalData: IWorkflowExecuteAdditionalData, decryptedDataOriginal: ICredentialDataDecryptedObject, type: string, mode: WorkflowExecuteMode, defaultTimezone: string, expressionResolveValues?: ICredentialsExpressionResolveValues, + canUseSecrets?: boolean, ): ICredentialDataDecryptedObject { const credentialsProperties = this.getCredentialsProperties(type); @@ -395,6 +405,10 @@ export class CredentialsHelper extends ICredentialsHelper { decryptedData.oauthTokenData = decryptedDataOriginal.oauthTokenData; } + const additionalKeys = NodeExecuteFunctions.getAdditionalKeys(additionalData, mode, null, { + secretsEnabled: canUseSecrets, + }); + if (expressionResolveValues) { const timezone = expressionResolveValues.workflow.settings.timezone ?? defaultTimezone; @@ -408,7 +422,7 @@ export class CredentialsHelper extends ICredentialsHelper { expressionResolveValues.connectionInputData, mode, timezone, - {}, + additionalKeys, undefined, false, decryptedData, @@ -431,7 +445,7 @@ export class CredentialsHelper extends ICredentialsHelper { decryptedData as INodeParameters, mode, defaultTimezone, - {}, + additionalKeys, undefined, undefined, decryptedData, @@ -573,10 +587,24 @@ export class CredentialsHelper extends ICredentialsHelper { } if (credentialsDecrypted.data) { - credentialsDecrypted.data = CredentialsOverwrites().applyOverwrite( - credentialType, - credentialsDecrypted.data, - ); + try { + const additionalData = await WorkflowExecuteAdditionalData.getBase(user.id); + credentialsDecrypted.data = this.applyDefaultsAndOverwrites( + additionalData, + credentialsDecrypted.data, + credentialType, + 'internal' as WorkflowExecuteMode, + additionalData.timezone, + undefined, + user.isOwner, + ); + } catch (error) { + Logger.debug('Credential test failed', error); + return { + status: 'Error', + message: error.message.toString(), + }; + } } if (typeof credentialTestFunction === 'function') { @@ -759,6 +787,36 @@ export class CredentialsHelper extends ICredentialsHelper { message: 'Connection successful!', }; } + + async credentialOwnedByOwner(nodeCredential: INodeCredentialsDetails): Promise { + if (!nodeCredential.id) { + return false; + } + + const credential = await Db.collections.SharedCredentials.findOne({ + where: { + role: { + scope: 'credential', + name: 'owner', + }, + user: { + globalRole: { + scope: 'global', + name: 'owner', + }, + }, + credentials: { + id: nodeCredential.id, + }, + }, + }); + + if (!credential) { + return false; + } + + return true; + } } /** diff --git a/packages/cli/src/ExternalSecrets/ExternalSecrets.controller.ee.ts b/packages/cli/src/ExternalSecrets/ExternalSecrets.controller.ee.ts new file mode 100644 index 0000000000000..10eed808a6eb0 --- /dev/null +++ b/packages/cli/src/ExternalSecrets/ExternalSecrets.controller.ee.ts @@ -0,0 +1,102 @@ +import { Authorized, Get, Post, RestController } from '@/decorators'; +import { ExternalSecretsRequest } from '@/requests'; +import { NotFoundError } from '@/ResponseHelper'; +import { Response } from 'express'; +import { Service } from 'typedi'; +import { ProviderNotFoundError, ExternalSecretsService } from './ExternalSecrets.service.ee'; + +@Service() +@Authorized(['global', 'owner']) +@RestController('/external-secrets') +export class ExternalSecretsController { + constructor(private readonly secretsService: ExternalSecretsService) {} + + @Get('/providers') + async getProviders() { + return this.secretsService.getProviders(); + } + + @Get('/providers/:provider') + async getProvider(req: ExternalSecretsRequest.GetProvider) { + const providerName = req.params.provider; + try { + return this.secretsService.getProvider(providerName); + } catch (e) { + if (e instanceof ProviderNotFoundError) { + throw new NotFoundError(`Could not find provider "${e.providerName}"`); + } + throw e; + } + } + + @Post('/providers/:provider/test') + async testProviderSettings(req: ExternalSecretsRequest.TestProviderSettings, res: Response) { + const providerName = req.params.provider; + try { + const result = await this.secretsService.testProviderSettings(providerName, req.body); + if (result.success) { + res.statusCode = 200; + } else { + res.statusCode = 400; + } + return result; + } catch (e) { + if (e instanceof ProviderNotFoundError) { + throw new NotFoundError(`Could not find provider "${e.providerName}"`); + } + throw e; + } + } + + @Post('/providers/:provider') + async setProviderSettings(req: ExternalSecretsRequest.SetProviderSettings) { + const providerName = req.params.provider; + try { + await this.secretsService.saveProviderSettings(providerName, req.body, req.user.id); + } catch (e) { + if (e instanceof ProviderNotFoundError) { + throw new NotFoundError(`Could not find provider "${e.providerName}"`); + } + throw e; + } + return {}; + } + + @Post('/providers/:provider/connect') + async setProviderConnected(req: ExternalSecretsRequest.SetProviderConnected) { + const providerName = req.params.provider; + try { + await this.secretsService.saveProviderConnected(providerName, req.body.connected); + } catch (e) { + if (e instanceof ProviderNotFoundError) { + throw new NotFoundError(`Could not find provider "${e.providerName}"`); + } + throw e; + } + return {}; + } + + @Post('/providers/:provider/update') + async updateProvider(req: ExternalSecretsRequest.UpdateProvider, res: Response) { + const providerName = req.params.provider; + try { + const resp = await this.secretsService.updateProvider(providerName); + if (resp) { + res.statusCode = 200; + } else { + res.statusCode = 400; + } + return { updated: resp }; + } catch (e) { + if (e instanceof ProviderNotFoundError) { + throw new NotFoundError(`Could not find provider "${e.providerName}"`); + } + throw e; + } + } + + @Get('/secrets') + getSecretNames() { + return this.secretsService.getAllSecrets(); + } +} diff --git a/packages/cli/src/ExternalSecrets/ExternalSecrets.service.ee.ts b/packages/cli/src/ExternalSecrets/ExternalSecrets.service.ee.ts new file mode 100644 index 0000000000000..9952c8a9b8b36 --- /dev/null +++ b/packages/cli/src/ExternalSecrets/ExternalSecrets.service.ee.ts @@ -0,0 +1,154 @@ +import { CREDENTIAL_BLANKING_VALUE } from '@/constants'; +import type { SecretsProvider } from '@/Interfaces'; +import type { ExternalSecretsRequest } from '@/requests'; +import type { IDataObject } from 'n8n-workflow'; +import { deepCopy } from 'n8n-workflow'; +import Container, { Service } from 'typedi'; +import { ExternalSecretsManager } from './ExternalSecretsManager.ee'; + +export class ProviderNotFoundError extends Error { + constructor(public providerName: string) { + super(undefined); + } +} + +@Service() +export class ExternalSecretsService { + getProvider(providerName: string): ExternalSecretsRequest.GetProviderResponse | null { + const providerAndSettings = + Container.get(ExternalSecretsManager).getProviderWithSettings(providerName); + if (!providerAndSettings) { + throw new ProviderNotFoundError(providerName); + } + const { provider, settings } = providerAndSettings; + return { + displayName: provider.displayName, + name: provider.name, + icon: provider.name, + state: provider.state, + connected: settings.connected, + connectedAt: settings.connectedAt, + properties: provider.properties, + data: this.redact(settings.settings, provider), + }; + } + + async getProviders() { + return Container.get(ExternalSecretsManager) + .getProvidersWithSettings() + .map(({ provider, settings }) => ({ + displayName: provider.displayName, + name: provider.name, + icon: provider.name, + state: provider.state, + connected: !!settings.connected, + connectedAt: settings.connectedAt, + data: this.redact(settings.settings, provider), + })); + } + + // Take data and replace all sensitive values with a sentinel value. + // This will replace password fields and oauth data. + redact(data: IDataObject, provider: SecretsProvider): IDataObject { + const copiedData = deepCopy(data || {}); + + const properties = provider.properties; + + for (const dataKey of Object.keys(copiedData)) { + // The frontend only cares that this value isn't falsy. + if (dataKey === 'oauthTokenData') { + copiedData[dataKey] = CREDENTIAL_BLANKING_VALUE; + continue; + } + const prop = properties.find((v) => v.name === dataKey); + if (!prop) { + continue; + } + + if ( + prop.typeOptions?.password && + (!(copiedData[dataKey] as string).startsWith('=') || prop.noDataExpression) + ) { + copiedData[dataKey] = CREDENTIAL_BLANKING_VALUE; + } + } + + return copiedData; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private unredactRestoreValues(unmerged: any, replacement: any) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + for (const [key, value] of Object.entries(unmerged)) { + if (value === CREDENTIAL_BLANKING_VALUE) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + unmerged[key] = replacement[key]; + } else if ( + typeof value === 'object' && + value !== null && + key in replacement && + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + typeof replacement[key] === 'object' && + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + replacement[key] !== null + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + this.unredactRestoreValues(value, replacement[key]); + } + } + } + + // Take unredacted data (probably from the DB) and merge it with + // redacted data to create an unredacted version. + unredact(redactedData: IDataObject, savedData: IDataObject): IDataObject { + // Replace any blank sentinel values with their saved version + const mergedData = deepCopy(redactedData ?? {}); + this.unredactRestoreValues(mergedData, savedData); + return mergedData; + } + + async saveProviderSettings(providerName: string, data: IDataObject, userId: string) { + const providerAndSettings = + Container.get(ExternalSecretsManager).getProviderWithSettings(providerName); + if (!providerAndSettings) { + throw new ProviderNotFoundError(providerName); + } + const { settings } = providerAndSettings; + const newData = this.unredact(data, settings.settings); + await Container.get(ExternalSecretsManager).setProviderSettings(providerName, newData, userId); + } + + async saveProviderConnected(providerName: string, connected: boolean) { + const providerAndSettings = + Container.get(ExternalSecretsManager).getProviderWithSettings(providerName); + if (!providerAndSettings) { + throw new ProviderNotFoundError(providerName); + } + await Container.get(ExternalSecretsManager).setProviderConnected(providerName, connected); + return this.getProvider(providerName); + } + + getAllSecrets(): Record { + return Container.get(ExternalSecretsManager).getAllSecretNames(); + } + + async testProviderSettings(providerName: string, data: IDataObject) { + const providerAndSettings = + Container.get(ExternalSecretsManager).getProviderWithSettings(providerName); + if (!providerAndSettings) { + throw new ProviderNotFoundError(providerName); + } + const { settings } = providerAndSettings; + const newData = this.unredact(data, settings.settings); + return Container.get(ExternalSecretsManager).testProviderSettings(providerName, newData); + } + + async updateProvider(providerName: string) { + const providerAndSettings = + Container.get(ExternalSecretsManager).getProviderWithSettings(providerName); + if (!providerAndSettings) { + throw new ProviderNotFoundError(providerName); + } + return Container.get(ExternalSecretsManager).updateProvider(providerName); + } +} diff --git a/packages/cli/src/ExternalSecrets/ExternalSecretsManager.ee.ts b/packages/cli/src/ExternalSecrets/ExternalSecretsManager.ee.ts new file mode 100644 index 0000000000000..6a0b422377f60 --- /dev/null +++ b/packages/cli/src/ExternalSecrets/ExternalSecretsManager.ee.ts @@ -0,0 +1,381 @@ +import { SettingsRepository } from '@/databases/repositories'; +import type { + ExternalSecretsSettings, + SecretsProvider, + SecretsProviderSettings, +} from '@/Interfaces'; + +import { UserSettings } from 'n8n-core'; +import Container, { Service } from 'typedi'; + +import { AES, enc } from 'crypto-js'; +import { getLogger } from '@/Logger'; + +import type { IDataObject } from 'n8n-workflow'; +import { + EXTERNAL_SECRETS_INITIAL_BACKOFF, + EXTERNAL_SECRETS_MAX_BACKOFF, + EXTERNAL_SECRETS_UPDATE_INTERVAL, +} from './constants'; +import { License } from '@/License'; +import { InternalHooks } from '@/InternalHooks'; +import { ExternalSecretsProviders } from './ExternalSecretsProviders.ee'; + +const logger = getLogger(); + +@Service() +export class ExternalSecretsManager { + private providers: Record = {}; + + private initializingPromise?: Promise; + + private cachedSettings: ExternalSecretsSettings = {}; + + initialized = false; + + updateInterval: NodeJS.Timer; + + initRetryTimeouts: Record = {}; + + constructor( + private settingsRepo: SettingsRepository, + private license: License, + private secretsProviders: ExternalSecretsProviders, + ) {} + + async init(): Promise { + if (!this.initialized) { + if (!this.initializingPromise) { + this.initializingPromise = new Promise(async (resolve) => { + await this.internalInit(); + this.initialized = true; + resolve(); + this.initializingPromise = undefined; + this.updateInterval = setInterval( + async () => this.updateSecrets(), + EXTERNAL_SECRETS_UPDATE_INTERVAL, + ); + }); + } + return this.initializingPromise; + } + } + + shutdown() { + clearInterval(this.updateInterval); + Object.values(this.providers).forEach((p) => { + // Disregard any errors as we're shutting down anyway + void p.disconnect().catch(() => {}); + }); + Object.values(this.initRetryTimeouts).forEach((v) => clearTimeout(v)); + } + + private async getEncryptionKey(): Promise { + return UserSettings.getEncryptionKey(); + } + + private decryptSecretsSettings(value: string, encryptionKey: string): ExternalSecretsSettings { + const decryptedData = AES.decrypt(value, encryptionKey); + + try { + return JSON.parse(decryptedData.toString(enc.Utf8)) as ExternalSecretsSettings; + } catch (e) { + throw new Error( + 'External Secrets Settings could not be decrypted. The likely reason is that a different "encryptionKey" was used to encrypt the data.', + ); + } + } + + private async getDecryptedSettings( + settingsRepo: SettingsRepository, + ): Promise { + const encryptedSettings = await settingsRepo.getEncryptedSecretsProviderSettings(); + if (encryptedSettings === null) { + return null; + } + const encryptionKey = await this.getEncryptionKey(); + return this.decryptSecretsSettings(encryptedSettings, encryptionKey); + } + + private async internalInit() { + const settings = await this.getDecryptedSettings(this.settingsRepo); + if (!settings) { + return; + } + const providers: Array = ( + await Promise.allSettled( + Object.entries(settings).map(async ([name, providerSettings]) => + this.initProvider(name, providerSettings), + ), + ) + ).map((i) => (i.status === 'rejected' ? null : i.value)); + this.providers = Object.fromEntries( + (providers.filter((p) => p !== null) as SecretsProvider[]).map((s) => [s.name, s]), + ); + this.cachedSettings = settings; + await this.updateSecrets(); + } + + private async initProvider( + name: string, + providerSettings: SecretsProviderSettings, + currentBackoff = EXTERNAL_SECRETS_INITIAL_BACKOFF, + ) { + const providerClass = this.secretsProviders.getProvider(name); + if (!providerClass) { + return null; + } + const provider: SecretsProvider = new providerClass(); + + try { + await provider.init(providerSettings); + } catch (e) { + logger.error( + `Error initializing secrets provider ${provider.displayName} (${provider.name}).`, + ); + this.retryInitWithBackoff(name, currentBackoff); + return provider; + } + + try { + if (providerSettings.connected) { + await provider.connect(); + } + } catch (e) { + try { + await provider.disconnect(); + } catch {} + logger.error( + `Error initializing secrets provider ${provider.displayName} (${provider.name}).`, + ); + this.retryInitWithBackoff(name, currentBackoff); + return provider; + } + + return provider; + } + + private retryInitWithBackoff(name: string, currentBackoff: number) { + if (name in this.initRetryTimeouts) { + clearTimeout(this.initRetryTimeouts[name]); + delete this.initRetryTimeouts[name]; + } + this.initRetryTimeouts[name] = setTimeout(() => { + delete this.initRetryTimeouts[name]; + if (this.providers[name] && this.providers[name].state !== 'error') { + return; + } + void this.reloadProvider(name, Math.min(currentBackoff * 2, EXTERNAL_SECRETS_MAX_BACKOFF)); + }, currentBackoff); + } + + async updateSecrets() { + if (!this.license.isExternalSecretsEnabled()) { + return; + } + await Promise.allSettled( + Object.entries(this.providers).map(async ([k, p]) => { + try { + if (this.cachedSettings[k].connected && p.state === 'connected') { + await p.update(); + } + } catch { + logger.error(`Error updating secrets provider ${p.displayName} (${p.name}).`); + } + }), + ); + } + + getProvider(provider: string): SecretsProvider | undefined { + return this.providers[provider]; + } + + hasProvider(provider: string): boolean { + return provider in this.providers; + } + + getProviderNames(): string[] | undefined { + return Object.keys(this.providers); + } + + getSecret(provider: string, name: string): IDataObject | undefined { + return this.getProvider(provider)?.getSecret(name); + } + + hasSecret(provider: string, name: string): boolean { + return this.getProvider(provider)?.hasSecret(name) ?? false; + } + + getSecretNames(provider: string): string[] | undefined { + return this.getProvider(provider)?.getSecretNames(); + } + + getAllSecretNames(): Record { + return Object.fromEntries( + Object.keys(this.providers).map((provider) => [ + provider, + this.getSecretNames(provider) ?? [], + ]), + ); + } + + getProvidersWithSettings(): Array<{ + provider: SecretsProvider; + settings: SecretsProviderSettings; + }> { + return Object.entries(this.secretsProviders.getAllProviders()).map(([k, c]) => ({ + provider: this.getProvider(k) ?? new c(), + settings: this.cachedSettings[k] ?? {}, + })); + } + + getProviderWithSettings(provider: string): + | { + provider: SecretsProvider; + settings: SecretsProviderSettings; + } + | undefined { + const providerConstructor = this.secretsProviders.getProvider(provider); + if (!providerConstructor) { + return undefined; + } + return { + provider: this.getProvider(provider) ?? new providerConstructor(), + settings: this.cachedSettings[provider] ?? {}, + }; + } + + async reloadProvider(provider: string, backoff = EXTERNAL_SECRETS_INITIAL_BACKOFF) { + if (provider in this.providers) { + await this.providers[provider].disconnect(); + delete this.providers[provider]; + } + const newProvider = await this.initProvider(provider, this.cachedSettings[provider], backoff); + if (newProvider) { + this.providers[provider] = newProvider; + } + } + + async setProviderSettings(provider: string, data: IDataObject, userId?: string) { + let isNewProvider = false; + let settings = await this.getDecryptedSettings(this.settingsRepo); + if (!settings) { + settings = {}; + } + if (!(provider in settings)) { + isNewProvider = true; + } + settings[provider] = { + connected: settings[provider]?.connected ?? false, + connectedAt: settings[provider]?.connectedAt ?? new Date(), + settings: data, + }; + + await this.saveAndSetSettings(settings, this.settingsRepo); + this.cachedSettings = settings; + await this.reloadProvider(provider); + + void this.trackProviderSave(provider, isNewProvider, userId); + } + + async setProviderConnected(provider: string, connected: boolean) { + let settings = await this.getDecryptedSettings(this.settingsRepo); + if (!settings) { + settings = {}; + } + settings[provider] = { + connected, + connectedAt: connected ? new Date() : settings[provider]?.connectedAt ?? null, + settings: settings[provider]?.settings ?? {}, + }; + + await this.saveAndSetSettings(settings, this.settingsRepo); + this.cachedSettings = settings; + await this.reloadProvider(provider); + await this.updateSecrets(); + } + + private async trackProviderSave(vaultType: string, isNew: boolean, userId?: string) { + let testResult: [boolean] | [boolean, string] | undefined; + try { + testResult = await this.getProvider(vaultType)?.test(); + } catch {} + void Container.get(InternalHooks).onExternalSecretsProviderSettingsSaved({ + user_id: userId, + vault_type: vaultType, + is_new: isNew, + is_valid: testResult?.[0] ?? false, + error_message: testResult?.[1], + }); + } + + encryptSecretsSettings(settings: ExternalSecretsSettings, encryptionKey: string): string { + return AES.encrypt(JSON.stringify(settings), encryptionKey).toString(); + } + + async saveAndSetSettings(settings: ExternalSecretsSettings, settingsRepo: SettingsRepository) { + const encryptionKey = await this.getEncryptionKey(); + const encryptedSettings = this.encryptSecretsSettings(settings, encryptionKey); + await settingsRepo.saveEncryptedSecretsProviderSettings(encryptedSettings); + } + + async testProviderSettings( + provider: string, + data: IDataObject, + ): Promise<{ + success: boolean; + testState: 'connected' | 'tested' | 'error'; + error?: string; + }> { + let testProvider: SecretsProvider | null = null; + try { + testProvider = await this.initProvider(provider, { + connected: true, + connectedAt: new Date(), + settings: data, + }); + if (!testProvider) { + return { + success: false, + testState: 'error', + }; + } + const [success, error] = await testProvider.test(); + let testState: 'connected' | 'tested' | 'error' = 'error'; + if (success && this.cachedSettings[provider]?.connected) { + testState = 'connected'; + } else if (success) { + testState = 'tested'; + } + return { + success, + testState, + error, + }; + } catch { + return { + success: false, + testState: 'error', + }; + } finally { + if (testProvider) { + await testProvider.disconnect(); + } + } + } + + async updateProvider(provider: string): Promise { + if (!this.license.isExternalSecretsEnabled()) { + return false; + } + if (!this.providers[provider] || this.providers[provider].state !== 'connected') { + return false; + } + try { + await this.providers[provider].update(); + return true; + } catch { + return false; + } + } +} diff --git a/packages/cli/src/ExternalSecrets/ExternalSecretsProviders.ee.ts b/packages/cli/src/ExternalSecrets/ExternalSecretsProviders.ee.ts new file mode 100644 index 0000000000000..a0e9353699af0 --- /dev/null +++ b/packages/cli/src/ExternalSecrets/ExternalSecretsProviders.ee.ts @@ -0,0 +1,24 @@ +import type { SecretsProvider } from '@/Interfaces'; +import { Service } from 'typedi'; +import { InfisicalProvider } from './providers/infisical'; +import { VaultProvider } from './providers/vault'; + +@Service() +export class ExternalSecretsProviders { + providers: Record = { + infisical: InfisicalProvider, + vault: VaultProvider, + }; + + getProvider(name: string): { new (): SecretsProvider } | null { + return this.providers[name] ?? null; + } + + hasProvider(name: string) { + return name in this.providers; + } + + getAllProviders() { + return this.providers; + } +} diff --git a/packages/cli/src/ExternalSecrets/constants.ts b/packages/cli/src/ExternalSecrets/constants.ts new file mode 100644 index 0000000000000..534b4ceb72d0a --- /dev/null +++ b/packages/cli/src/ExternalSecrets/constants.ts @@ -0,0 +1,6 @@ +export const EXTERNAL_SECRETS_DB_KEY = 'feature.externalSecrets'; +export const EXTERNAL_SECRETS_UPDATE_INTERVAL = 5 * 60 * 1000; +export const EXTERNAL_SECRETS_INITIAL_BACKOFF = 10 * 1000; +export const EXTERNAL_SECRETS_MAX_BACKOFF = 5 * 60 * 1000; + +export const EXTERNAL_SECRETS_NAME_REGEX = /^[a-zA-Z0-9_]+$/; diff --git a/packages/cli/src/ExternalSecrets/externalSecretsHelper.ee.ts b/packages/cli/src/ExternalSecrets/externalSecretsHelper.ee.ts new file mode 100644 index 0000000000000..54885664ff97e --- /dev/null +++ b/packages/cli/src/ExternalSecrets/externalSecretsHelper.ee.ts @@ -0,0 +1,7 @@ +import { License } from '@/License'; +import Container from 'typedi'; + +export function isExternalSecretsEnabled() { + const license = Container.get(License); + return license.isExternalSecretsEnabled(); +} diff --git a/packages/cli/src/ExternalSecrets/providers/infisical.ts b/packages/cli/src/ExternalSecrets/providers/infisical.ts new file mode 100644 index 0000000000000..39a0eb92d4b44 --- /dev/null +++ b/packages/cli/src/ExternalSecrets/providers/infisical.ts @@ -0,0 +1,153 @@ +import type { SecretsProvider, SecretsProviderSettings, SecretsProviderState } from '@/Interfaces'; +import InfisicalClient from 'infisical-node'; +import { populateClientWorkspaceConfigsHelper } from 'infisical-node/lib/helpers/key'; +import { getServiceTokenData } from 'infisical-node/lib/api/serviceTokenData'; +import type { IDataObject, INodeProperties } from 'n8n-workflow'; +import { EXTERNAL_SECRETS_NAME_REGEX } from '../constants'; + +export interface InfisicalSettings { + token: string; + siteURL: string; + cacheTTL: number; + debug: boolean; +} + +interface InfisicalSecret { + secretName: string; + secretValue?: string; +} + +interface InfisicalServiceToken { + environment?: string; + scopes?: Array<{ environment: string; path: string }>; +} + +export class InfisicalProvider implements SecretsProvider { + properties: INodeProperties[] = [ + { + displayName: + 'Need help filling out these fields? Open docs', + name: 'notice', + type: 'notice', + default: '', + }, + { + displayName: 'Service Token', + name: 'token', + type: 'string', + hint: 'The Infisical Service Token with read access', + default: '', + required: true, + placeholder: 'e.g. st.64ae963e1874ea.374226a166439dce.39557e4a1b7bdd82', + noDataExpression: true, + typeOptions: { password: true }, + }, + { + displayName: 'Site URL', + name: 'siteURL', + type: 'string', + hint: "The absolute URL of the Infisical instance. Change it only if you're self-hosting Infisical.", + required: true, + noDataExpression: true, + placeholder: 'https://app.infisical.com', + default: 'https://app.infisical.com', + }, + ]; + + displayName = 'Infisical'; + + name = 'infisical'; + + state: SecretsProviderState = 'initializing'; + + private cachedSecrets: Record = {}; + + private client: InfisicalClient; + + private settings: InfisicalSettings; + + private environment: string; + + async init(settings: SecretsProviderSettings): Promise { + this.settings = settings.settings as unknown as InfisicalSettings; + } + + async update(): Promise { + if (!this.client) { + throw new Error('Updated attempted on Infisical when initialization failed'); + } + if (!(await this.test())[0]) { + throw new Error('Infisical provider test failed during update'); + } + const secrets = (await this.client.getAllSecrets({ + environment: this.environment, + path: '/', + attachToProcessEnv: false, + includeImports: true, + })) as InfisicalSecret[]; + const newCache = Object.fromEntries( + secrets.map((s) => [s.secretName, s.secretValue]), + ) as Record; + if (Object.keys(newCache).length === 1 && '' in newCache) { + this.cachedSecrets = {}; + } else { + this.cachedSecrets = newCache; + } + } + + async connect(): Promise { + this.client = new InfisicalClient(this.settings); + if ((await this.test())[0]) { + try { + this.environment = await this.getEnvironment(); + this.state = 'connected'; + } catch { + this.state = 'error'; + } + } else { + this.state = 'error'; + } + } + + async getEnvironment(): Promise { + const serviceTokenData = (await getServiceTokenData( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + this.client.clientConfig, + )) as InfisicalServiceToken; + if (serviceTokenData.environment) { + return serviceTokenData.environment; + } + if (serviceTokenData.scopes) { + return serviceTokenData.scopes[0].environment; + } + throw new Error("Couldn't find environment for Infisical"); + } + + async test(): Promise<[boolean] | [boolean, string]> { + if (!this.client) { + return [false, 'Client not initialized']; + } + try { + await populateClientWorkspaceConfigsHelper(this.client.clientConfig); + return [true]; + } catch (e) { + return [false]; + } + } + + async disconnect(): Promise { + // + } + + getSecret(name: string): IDataObject { + return this.cachedSecrets[name] as unknown as IDataObject; + } + + getSecretNames(): string[] { + return Object.keys(this.cachedSecrets).filter((k) => EXTERNAL_SECRETS_NAME_REGEX.test(k)); + } + + hasSecret(name: string): boolean { + return name in this.cachedSecrets; + } +} diff --git a/packages/cli/src/ExternalSecrets/providers/vault.ts b/packages/cli/src/ExternalSecrets/providers/vault.ts new file mode 100644 index 0000000000000..65ef4a9acf78b --- /dev/null +++ b/packages/cli/src/ExternalSecrets/providers/vault.ts @@ -0,0 +1,559 @@ +import type { SecretsProviderSettings, SecretsProviderState } from '@/Interfaces'; +import { SecretsProvider } from '@/Interfaces'; +import type { IDataObject, INodeProperties } from 'n8n-workflow'; +import type { AxiosInstance, AxiosResponse } from 'axios'; +import axios from 'axios'; +import { getLogger } from '@/Logger'; +import { EXTERNAL_SECRETS_NAME_REGEX } from '../constants'; + +const logger = getLogger(); + +type VaultAuthMethod = 'token' | 'usernameAndPassword' | 'appRole'; + +interface VaultSettings { + url: string; + namespace?: string; + authMethod: VaultAuthMethod; + + // Token + token: string; + renewToken: boolean; + + // Username and Password + username: string; + password: string; + + // AppRole + roleId: string; + secretId: string; +} + +interface VaultResponse { + data: T; +} + +interface VaultTokenInfo { + accessor: string; + creation_time: number; + creation_ttl: number; + display_name: string; + entity_id: string; + expire_time: string | null; + explicit_max_ttl: number; + id: string; + issue_time: string; + meta: Record; + num_uses: number; + orphan: boolean; + path: string; + policies: string[]; + ttl: number; + renewable: boolean; + type: 'kv' | string; +} + +interface VaultMount { + accessor: string; + config: Record; + description: string; + external_entropy_access: boolean; + local: boolean; + options: Record; + plugin_version: string; + running_plugin_version: string; + running_sha256: string; + seal_wrap: number; + type: string; + uuid: string; +} + +interface VaultMountsResp { + [path: string]: VaultMount; +} + +interface VaultUserPassLoginResp { + auth: { + client_token: string; + }; +} + +type VaultAppRoleResp = VaultUserPassLoginResp; + +interface VaultSecretList { + keys: string[]; +} + +export class VaultProvider extends SecretsProvider { + properties: INodeProperties[] = [ + { + displayName: + 'Need help filling out these fields? Open docs', + name: 'notice', + type: 'notice', + default: '', + }, + { + displayName: 'Vault URL', + name: 'url', + type: 'string', + required: true, + noDataExpression: true, + placeholder: 'e.g. https://example.com/v1/', + default: '', + }, + { + displayName: 'Vault Namespace (optional)', + name: 'namespace', + type: 'string', + hint: 'Leave blank if not using namespaces', + required: false, + noDataExpression: true, + placeholder: 'e.g. admin', + default: '', + }, + { + displayName: 'Authentication Method', + name: 'authMethod', + type: 'options', + required: true, + noDataExpression: true, + options: [ + { name: 'Token', value: 'token' }, + { name: 'Username and Password', value: 'usernameAndPassword' }, + { name: 'AppRole', value: 'appRole' }, + ], + default: 'token', + }, + + // Token Auth + { + displayName: 'Token', + name: 'token', + type: 'string', + default: '', + required: true, + noDataExpression: true, + placeholder: 'e.g. hvs.2OCsZxZA6Z9lChbt0janOOZI', + typeOptions: { password: true }, + displayOptions: { + show: { + authMethod: ['token'], + }, + }, + }, + // { + // displayName: 'Renew Token', + // name: 'renewToken', + // description: + // 'Try to renew Vault token. This will update the settings on this provider when doing so.', + // type: 'boolean', + // noDataExpression: true, + // default: true, + // displayOptions: { + // show: { + // authMethod: ['token'], + // }, + // }, + // }, + + // Username and Password + { + displayName: 'Username', + name: 'username', + type: 'string', + default: '', + required: true, + noDataExpression: true, + placeholder: 'Username', + displayOptions: { + show: { + authMethod: ['usernameAndPassword'], + }, + }, + }, + { + displayName: 'Password', + name: 'password', + type: 'string', + default: '', + required: true, + noDataExpression: true, + placeholder: '***************', + typeOptions: { password: true }, + displayOptions: { + show: { + authMethod: ['usernameAndPassword'], + }, + }, + }, + + // Username and Password + { + displayName: 'Role ID', + name: 'roleId', + type: 'string', + default: '', + required: true, + noDataExpression: true, + placeholder: '59d6d1ca-47bb-4e7e-a40b-8be3bc5a0ba8', + displayOptions: { + show: { + authMethod: ['appRole'], + }, + }, + }, + { + displayName: 'Secret ID', + name: 'secretId', + type: 'string', + default: '', + required: true, + noDataExpression: true, + placeholder: '84896a0c-1347-aa90-a4f6-aca8b7558780', + typeOptions: { password: true }, + displayOptions: { + show: { + authMethod: ['appRole'], + }, + }, + }, + ]; + + displayName = 'HashiCorp Vault'; + + name = 'vault'; + + state: SecretsProviderState = 'initializing'; + + private cachedSecrets: Record = {}; + + private settings: VaultSettings; + + #currentToken: string | null = null; + + #tokenInfo: VaultTokenInfo | null = null; + + #http: AxiosInstance; + + private refreshTimeout: NodeJS.Timer | null; + + private refreshAbort = new AbortController(); + + async init(settings: SecretsProviderSettings): Promise { + this.settings = settings.settings as unknown as VaultSettings; + + const baseURL = new URL(this.settings.url); + + this.#http = axios.create({ baseURL: baseURL.toString() }); + if (this.settings.namespace) { + this.#http.interceptors.request.use((config) => { + return { + ...config, + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-unsafe-assignment + headers: { ...config.headers, 'X-Vault-Namespace': this.settings.namespace }, + }; + }); + } + this.#http.interceptors.request.use((config) => { + if (!this.#currentToken) { + return config; + } + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-unsafe-assignment + return { ...config, headers: { ...config.headers, 'X-Vault-Token': this.#currentToken } }; + }); + } + + async connect(): Promise { + if (this.settings.authMethod === 'token') { + this.#currentToken = this.settings.token; + } else if (this.settings.authMethod === 'usernameAndPassword') { + try { + this.#currentToken = await this.authUsernameAndPassword( + this.settings.username, + this.settings.password, + ); + } catch { + this.state = 'error'; + logger.error('Failed to connect to Vault using Username and Password credentials.'); + return; + } + } else if (this.settings.authMethod === 'appRole') { + try { + this.#currentToken = await this.authAppRole(this.settings.roleId, this.settings.secretId); + } catch { + this.state = 'error'; + logger.error('Failed to connect to Vault using AppRole credentials.'); + return; + } + } + try { + if (!(await this.test())[0]) { + this.state = 'error'; + } else { + this.state = 'connected'; + + [this.#tokenInfo] = await this.getTokenInfo(); + this.setupTokenRefresh(); + } + } catch (e) { + this.state = 'error'; + logger.error('Failed credentials test on Vault connect.'); + } + + try { + await this.update(); + } catch { + logger.warn('Failed to update Vault secrets'); + } + } + + async disconnect(): Promise { + if (this.refreshTimeout !== null) { + clearTimeout(this.refreshTimeout); + } + this.refreshAbort.abort(); + } + + private setupTokenRefresh() { + if (!this.#tokenInfo) { + return; + } + // Token never expires + if (this.#tokenInfo.expire_time === null) { + return; + } + // Token can't be renewed + if (!this.#tokenInfo.renewable) { + return; + } + + const expireDate = new Date(this.#tokenInfo.expire_time); + setTimeout(this.tokenRefresh, (expireDate.valueOf() - Date.now()) / 2); + } + + private tokenRefresh = async () => { + if (this.refreshAbort.signal.aborted) { + return; + } + try { + // We don't actually care about the result of this since it doesn't + // return an expire_time + await this.#http.post('auth/token/renew-self'); + + [this.#tokenInfo] = await this.getTokenInfo(); + + if (!this.#tokenInfo) { + logger.error('Failed to fetch token info during renewal. Cancelling all future renewals.'); + return; + } + + if (this.refreshAbort.signal.aborted) { + return; + } + + this.setupTokenRefresh(); + } catch { + logger.error('Failed to renew Vault token. Attempting to reconnect.'); + void this.connect(); + } + }; + + private async authUsernameAndPassword( + username: string, + password: string, + ): Promise { + try { + const resp = await this.#http.request({ + method: 'POST', + url: `auth/userpass/login/${username}`, + responseType: 'json', + data: { password }, + }); + + return resp.data.auth.client_token; + } catch { + return null; + } + } + + private async authAppRole(roleId: string, secretId: string): Promise { + try { + const resp = await this.#http.request({ + method: 'POST', + url: 'auth/approle/login', + responseType: 'json', + data: { role_id: roleId, secret_id: secretId }, + }); + + return resp.data.auth.client_token; + } catch (e) { + return null; + } + } + + private async getTokenInfo(): Promise<[VaultTokenInfo | null, AxiosResponse]> { + const resp = await this.#http.request>({ + method: 'GET', + url: 'auth/token/lookup-self', + responseType: 'json', + validateStatus: () => true, + }); + + if (resp.status !== 200 || !resp.data.data) { + return [null, resp]; + } + return [resp.data.data, resp]; + } + + private async getKVSecrets( + mountPath: string, + kvVersion: string, + path: string, + ): Promise<[string, IDataObject] | null> { + let listPath = mountPath; + if (kvVersion === '2') { + listPath += 'metadata/'; + } + listPath += path; + let listResp: AxiosResponse>; + try { + listResp = await this.#http.request>({ + url: listPath, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any + method: 'LIST' as any, + }); + } catch { + return null; + } + const data = Object.fromEntries( + ( + await Promise.allSettled( + listResp.data.data.keys.map(async (key): Promise<[string, IDataObject] | null> => { + if (key.endsWith('/')) { + return this.getKVSecrets(mountPath, kvVersion, path + key); + } + let secretPath = mountPath; + if (kvVersion === '2') { + secretPath += 'data/'; + } + secretPath += path + key; + try { + const secretResp = await this.#http.get>(secretPath); + return [ + key, + kvVersion === '2' + ? (secretResp.data.data.data as IDataObject) + : secretResp.data.data, + ]; + } catch { + return null; + } + }), + ) + ) + .map((i) => (i.status === 'rejected' ? null : i.value)) + .filter((v) => v !== null) as Array<[string, IDataObject]>, + ); + const name = path.substring(0, path.length - 1); + return [name, data]; + } + + async update(): Promise { + const mounts = await this.#http.get>('sys/mounts'); + + const kvs = Object.entries(mounts.data.data).filter(([, v]) => v.type === 'kv'); + + const secrets = Object.fromEntries( + ( + await Promise.all( + kvs.map(async ([basePath, data]): Promise<[string, IDataObject] | null> => { + const value = await this.getKVSecrets(basePath, data.options.version as string, ''); + if (value === null) { + return null; + } + return [basePath.substring(0, basePath.length - 1), value[1]]; + }), + ) + ).filter((v) => v !== null) as Array<[string, IDataObject]>, + ); + this.cachedSecrets = secrets; + } + + async test(): Promise<[boolean] | [boolean, string]> { + try { + const [token, tokenResp] = await this.getTokenInfo(); + + if (token === null) { + if (tokenResp.status === 404) { + return [false, 'Could not find auth path. Try adding /v1/ to the end of your base URL.']; + } + return [false, 'Invalid credentials']; + } + + const resp = await this.#http.request>({ + method: 'GET', + url: 'sys/mounts', + responseType: 'json', + validateStatus: () => true, + }); + + if (resp.status === 403) { + return [ + false, + "Couldn't list mounts. Please give these credentials 'read' access to sys/mounts.", + ]; + } else if (resp.status !== 200) { + return [ + false, + "Couldn't list mounts but wasn't a permissions issue. Please consult your Vault admin.", + ]; + } + + return [true]; + } catch (e) { + if (axios.isAxiosError(e)) { + if (e.code === 'ECONNREFUSED') { + return [ + false, + 'Connection refused. Please check the host and port of the server are correct.', + ]; + } + } + + return [false]; + } + } + + getSecret(name: string): IDataObject { + return this.cachedSecrets[name]; + } + + hasSecret(name: string): boolean { + return name in this.cachedSecrets; + } + + getSecretNames(): string[] { + const getKeys = ([k, v]: [string, IDataObject]): string[] => { + if (!EXTERNAL_SECRETS_NAME_REGEX.test(k)) { + return []; + } + if (typeof v === 'object') { + const keys: string[] = []; + for (const key of Object.keys(v)) { + if (!EXTERNAL_SECRETS_NAME_REGEX.test(key)) { + continue; + } + const value = v[key]; + if (typeof value === 'object' && value !== null) { + keys.push(...getKeys([key, value as IDataObject]).map((ok) => `${k}.${ok}`)); + } else { + keys.push(`${k}.${key}`); + } + } + return keys; + } + return [k]; + }; + return Object.entries(this.cachedSecrets).flatMap(getKeys); + } +} diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 41ca4088f1d01..b6bd20270dbc2 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -21,6 +21,7 @@ import type { ExecutionStatus, IExecutionsSummary, FeatureFlags, + INodeProperties, IUserSettings, IHttpRequestMethods, } from 'n8n-workflow'; @@ -460,6 +461,13 @@ export interface IInternalHooksClass { onApiKeyCreated(apiKeyDeletedData: { user: User; public_api: boolean }): Promise; onApiKeyDeleted(apiKeyDeletedData: { user: User; public_api: boolean }): Promise; onVariableCreated(createData: { variable_type: string }): Promise; + onExternalSecretsProviderSettingsSaved(saveData: { + user_id?: string; + vault_type: string; + is_valid: boolean; + is_new: boolean; + error_message?: string; + }): Promise; } export interface IVersionNotificationSettings { @@ -758,14 +766,13 @@ export interface PublicUser { passwordResetToken?: string; createdAt: Date; isPending: boolean; + hasRecoveryCodesLeft: boolean; globalRole?: Role; signInType: AuthProviderType; disabled: boolean; settings?: IUserSettings | null; inviteAcceptUrl?: string; -} - -export interface CurrentUser extends PublicUser { + isOwner?: boolean; featureFlags?: FeatureFlags; } @@ -778,4 +785,35 @@ export interface N8nApp { export type UserSettings = Pick; +export interface SecretsProviderSettings { + connected: boolean; + connectedAt: Date | null; + settings: T; +} + +export interface ExternalSecretsSettings { + [key: string]: SecretsProviderSettings; +} + +export type SecretsProviderState = 'initializing' | 'connected' | 'error'; + +export abstract class SecretsProvider { + displayName: string; + + name: string; + + properties: INodeProperties[]; + + state: SecretsProviderState; + + abstract init(settings: SecretsProviderSettings): Promise; + abstract connect(): Promise; + abstract disconnect(): Promise; + abstract update(): Promise; + abstract test(): Promise<[boolean] | [boolean, string]>; + abstract getSecret(name: string): IDataObject | undefined; + abstract hasSecret(name: string): boolean; + abstract getSecretNames(): string[]; +} + export type N8nInstanceType = 'main' | 'webhook' | 'worker'; diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index ebd3bc2570d86..a099d6fd76e89 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -1086,4 +1086,14 @@ export class InternalHooks implements IInternalHooksClass { }): Promise { return this.telemetry.track('User finished push via UI', data); } + + async onExternalSecretsProviderSettingsSaved(saveData: { + user_id?: string | undefined; + vault_type: string; + is_valid: boolean; + is_new: boolean; + error_message?: string | undefined; + }): Promise { + return this.telemetry.track('User updated external secrets settings', saveData); + } } diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index 267419da9fd27..b296c3a333860 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -140,6 +140,10 @@ export class License { return this.isFeatureEnabled(LICENSE_FEATURES.SOURCE_CONTROL); } + isExternalSecretsEnabled() { + return this.isFeatureEnabled(LICENSE_FEATURES.EXTERNAL_SECRETS); + } + isWorkflowHistoryLicensed() { return this.isFeatureEnabled(LICENSE_FEATURES.WORKFLOW_HISTORY); } diff --git a/packages/cli/src/Mfa/constants.ts b/packages/cli/src/Mfa/constants.ts new file mode 100644 index 0000000000000..bf5e34c3457dd --- /dev/null +++ b/packages/cli/src/Mfa/constants.ts @@ -0,0 +1 @@ +export const MFA_FEATURE_ENABLED = 'mfa.enabled'; diff --git a/packages/cli/src/Mfa/helpers.ts b/packages/cli/src/Mfa/helpers.ts new file mode 100644 index 0000000000000..8484572533d97 --- /dev/null +++ b/packages/cli/src/Mfa/helpers.ts @@ -0,0 +1,21 @@ +import config from '@/config'; +import * as Db from '@/Db'; +import { MFA_FEATURE_ENABLED } from './constants'; + +export const isMfaFeatureEnabled = () => config.get(MFA_FEATURE_ENABLED); + +const isMfaFeatureDisabled = () => !isMfaFeatureEnabled(); + +const getUsersWithMfaEnabled = async () => + Db.collections.User.count({ where: { mfaEnabled: true } }); + +export const handleMfaDisable = async () => { + if (isMfaFeatureDisabled()) { + // check for users with MFA enabled, and if there are + // users, then keep the feature enabled + const users = await getUsersWithMfaEnabled(); + if (users) { + config.set(MFA_FEATURE_ENABLED, true); + } + } +}; diff --git a/packages/cli/src/Mfa/mfa.service.ts b/packages/cli/src/Mfa/mfa.service.ts new file mode 100644 index 0000000000000..50b0d29f89c44 --- /dev/null +++ b/packages/cli/src/Mfa/mfa.service.ts @@ -0,0 +1,79 @@ +import { v4 as uuid } from 'uuid'; +import { AES, enc } from 'crypto-js'; +import { TOTPService } from './totp.service'; +import { Service } from 'typedi'; +import { UserRepository } from '@/databases/repositories'; + +@Service() +export class MfaService { + constructor( + private userRepository: UserRepository, + public totp: TOTPService, + private encryptionKey: string, + ) {} + + public generateRecoveryCodes(n = 10) { + return Array.from(Array(n)).map(() => uuid()); + } + + public generateEncryptedRecoveryCodes() { + return this.generateRecoveryCodes().map((code) => + AES.encrypt(code, this.encryptionKey).toString(), + ); + } + + public async saveSecretAndRecoveryCodes(userId: string, secret: string, recoveryCodes: string[]) { + const { encryptedSecret, encryptedRecoveryCodes } = this.encryptSecretAndRecoveryCodes( + secret, + recoveryCodes, + ); + return this.userRepository.update(userId, { + mfaSecret: encryptedSecret, + mfaRecoveryCodes: encryptedRecoveryCodes, + }); + } + + public encryptSecretAndRecoveryCodes(rawSecret: string, rawRecoveryCodes: string[]) { + const encryptedSecret = AES.encrypt(rawSecret, this.encryptionKey).toString(), + encryptedRecoveryCodes = rawRecoveryCodes.map((code) => + AES.encrypt(code, this.encryptionKey).toString(), + ); + return { + encryptedRecoveryCodes, + encryptedSecret, + }; + } + + private decryptSecretAndRecoveryCodes(mfaSecret: string, mfaRecoveryCodes: string[]) { + return { + decryptedSecret: AES.decrypt(mfaSecret, this.encryptionKey).toString(enc.Utf8), + decryptedRecoveryCodes: mfaRecoveryCodes.map((code) => + AES.decrypt(code, this.encryptionKey).toString(enc.Utf8), + ), + }; + } + + public async getSecretAndRecoveryCodes(userId: string) { + const { mfaSecret, mfaRecoveryCodes } = await this.userRepository.findOneOrFail({ + where: { id: userId }, + select: ['id', 'mfaSecret', 'mfaRecoveryCodes'], + }); + return this.decryptSecretAndRecoveryCodes(mfaSecret ?? '', mfaRecoveryCodes ?? []); + } + + public async enableMfa(userId: string) { + await this.userRepository.update(userId, { mfaEnabled: true }); + } + + public encryptRecoveryCodes(mfaRecoveryCodes: string[]) { + return mfaRecoveryCodes.map((code) => AES.encrypt(code, this.encryptionKey).toString()); + } + + public async disableMfa(userId: string) { + await this.userRepository.update(userId, { + mfaEnabled: false, + mfaSecret: null, + mfaRecoveryCodes: [], + }); + } +} diff --git a/packages/cli/src/Mfa/totp.service.ts b/packages/cli/src/Mfa/totp.service.ts new file mode 100644 index 0000000000000..ee85c3f100c55 --- /dev/null +++ b/packages/cli/src/Mfa/totp.service.ts @@ -0,0 +1,36 @@ +import OTPAuth from 'otpauth'; +export class TOTPService { + generateSecret(): string { + return new OTPAuth.Secret()?.base32; + } + + generateTOTPUri({ + issuer = 'n8n', + secret, + label, + }: { + secret: string; + label: string; + issuer?: string; + }) { + return new OTPAuth.TOTP({ + secret: OTPAuth.Secret.fromBase32(secret), + issuer, + label, + }).toString(); + } + + verifySecret({ secret, token, window = 2 }: { secret: string; token: string; window?: number }) { + return new OTPAuth.TOTP({ + secret: OTPAuth.Secret.fromBase32(secret), + }).validate({ token, window }) === null + ? false + : true; + } + + generateTOTP(secret: string) { + return OTPAuth.TOTP.generate({ + secret: OTPAuth.Secret.fromBase32(secret), + }); + } +} diff --git a/packages/cli/src/NodeTypes.ts b/packages/cli/src/NodeTypes.ts index 8bb2335355df9..0f1d6ebddca08 100644 --- a/packages/cli/src/NodeTypes.ts +++ b/packages/cli/src/NodeTypes.ts @@ -10,6 +10,9 @@ import { NodeHelpers } from 'n8n-workflow'; import { Service } from 'typedi'; import { RESPONSE_ERROR_MESSAGES } from './constants'; import { LoadNodesAndCredentials } from './LoadNodesAndCredentials'; +import { join, dirname } from 'path'; +import { readdir } from 'fs/promises'; +import type { Dirent } from 'fs'; @Service() export class NodeTypes implements INodeTypes { @@ -78,4 +81,46 @@ export class NodeTypes implements INodeTypes { private get knownNodes() { return this.nodesAndCredentials.known.nodes; } + + async getNodeTranslationPath({ + nodeSourcePath, + longNodeType, + locale, + }: { + nodeSourcePath: string; + longNodeType: string; + locale: string; + }) { + const nodeDir = dirname(nodeSourcePath); + const maxVersion = await this.getMaxVersion(nodeDir); + const nodeType = longNodeType.replace('n8n-nodes-base.', ''); + + return maxVersion + ? join(nodeDir, `v${maxVersion}`, 'translations', locale, `${nodeType}.json`) + : join(nodeDir, 'translations', locale, `${nodeType}.json`); + } + + private async getMaxVersion(dir: string) { + const entries = await readdir(dir, { withFileTypes: true }); + + const dirnames = entries.reduce((acc, cur) => { + if (this.isVersionedDirname(cur)) acc.push(cur.name); + return acc; + }, []); + + if (!dirnames.length) return null; + + return Math.max(...dirnames.map((d) => parseInt(d.charAt(1), 10))); + } + + private isVersionedDirname(dirent: Dirent) { + if (!dirent.isDirectory()) return false; + + const ALLOWED_VERSIONED_DIRNAME_LENGTH = [2, 3]; // e.g. v1, v10 + + return ( + ALLOWED_VERSIONED_DIRNAME_LENGTH.includes(dirent.name.length) && + dirent.name.toLowerCase().startsWith('v') + ); + } } diff --git a/packages/cli/src/ResponseHelper.ts b/packages/cli/src/ResponseHelper.ts index 968b637fe2d4b..0d87b77f644f1 100644 --- a/packages/cli/src/ResponseHelper.ts +++ b/packages/cli/src/ResponseHelper.ts @@ -45,8 +45,8 @@ export class BadRequestError extends ResponseError { } export class AuthError extends ResponseError { - constructor(message: string) { - super(message, 401); + constructor(message: string, errorCode?: number) { + super(message, 401, errorCode); } } diff --git a/packages/cli/src/SecretsHelpers.ts b/packages/cli/src/SecretsHelpers.ts new file mode 100644 index 0000000000000..d90a0e84c8ca7 --- /dev/null +++ b/packages/cli/src/SecretsHelpers.ts @@ -0,0 +1,41 @@ +import type { IDataObject, SecretsHelpersBase } from 'n8n-workflow'; +import { Service } from 'typedi'; +import { ExternalSecretsManager } from './ExternalSecrets/ExternalSecretsManager.ee'; + +@Service() +export class SecretsHelper implements SecretsHelpersBase { + constructor(private service: ExternalSecretsManager) {} + + async update() { + if (!this.service.initialized) { + await this.service.init(); + } + await this.service.updateSecrets(); + } + + async waitForInit() { + if (!this.service.initialized) { + await this.service.init(); + } + } + + getSecret(provider: string, name: string): IDataObject | undefined { + return this.service.getSecret(provider, name); + } + + hasSecret(provider: string, name: string): boolean { + return this.service.hasSecret(provider, name); + } + + hasProvider(provider: string): boolean { + return this.service.hasProvider(provider); + } + + listProviders(): string[] { + return this.service.getProviderNames() ?? []; + } + + listSecrets(provider: string): string[] { + return this.service.getSecretNames(provider) ?? []; + } +} diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index dd627551a0bc0..6114c40701034 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -88,6 +88,7 @@ import { AuthController, LdapController, MeController, + MFAController, NodesController, NodeTypesController, OwnerController, @@ -98,6 +99,7 @@ import { WorkflowStatisticsController, } from '@/controllers'; +import { ExternalSecretsController } from '@/ExternalSecrets/ExternalSecrets.controller.ee'; import { executionsController } from '@/executions/executions.controller'; import { isApiEnabled, loadPublicApiVersions } from '@/PublicApi'; import { @@ -162,11 +164,18 @@ import { isLdapCurrentAuthenticationMethod, isSamlCurrentAuthenticationMethod, } from './sso/ssoHelpers'; +import { isExternalSecretsEnabled } from './ExternalSecrets/externalSecretsHelper.ee'; import { isSourceControlLicensed } from '@/environments/sourceControl/sourceControlHelper.ee'; import { SourceControlService } from '@/environments/sourceControl/sourceControl.service.ee'; import { SourceControlController } from '@/environments/sourceControl/sourceControl.controller.ee'; -import { ExecutionRepository } from '@db/repositories'; +import { ExecutionRepository, SettingsRepository } from '@db/repositories'; import type { ExecutionEntity } from '@db/entities/ExecutionEntity'; +import { TOTPService } from './Mfa/totp.service'; +import { MfaService } from './Mfa/mfa.service'; +import { handleMfaDisable, isMfaFeatureEnabled } from './Mfa/helpers'; +import { JwtService } from './services/jwt.service'; +import { RoleService } from './services/role.service'; +import { UserService } from './services/user.service'; const exec = promisify(callbackExec); @@ -310,9 +319,13 @@ export class Server extends AbstractServer { variables: false, sourceControl: false, auditLogs: false, + externalSecrets: false, showNonProdBanner: false, debugInEditor: false, }, + mfa: { + enabled: false, + }, hideUsagePage: config.getEnv('hideUsagePage'), license: { environment: config.getEnv('license.tenantId') === 1 ? 'production' : 'staging', @@ -444,6 +457,7 @@ export class Server extends AbstractServer { advancedExecutionFilters: isAdvancedExecutionFiltersEnabled(), variables: isVariablesEnabled(), sourceControl: isSourceControlLicensed(), + externalSecrets: isExternalSecretsEnabled(), showNonProdBanner: Container.get(License).isFeatureEnabled( LICENSE_FEATURES.SHOW_NON_PROD_BANNER, ), @@ -471,6 +485,9 @@ export class Server extends AbstractServer { if (config.get('nodes.packagesMissing').length > 0) { this.frontendSettings.missingPackages = true; } + + this.frontendSettings.mfa.enabled = isMfaFeatureEnabled(); + return this.frontendSettings; } @@ -479,54 +496,59 @@ export class Server extends AbstractServer { const repositories = Db.collections; setupAuthMiddlewares(app, ignoredEndpoints, this.restEndpoint); + const encryptionKey = await UserSettings.getEncryptionKey(); + const logger = LoggerProxy; const internalHooks = Container.get(InternalHooks); const mailer = Container.get(UserManagementMailer); + const userService = Container.get(UserService); + const jwtService = Container.get(JwtService); const postHog = this.postHog; + const mfaService = new MfaService(repositories.User, new TOTPService(), encryptionKey); const controllers: object[] = [ new EventBusController(), - new AuthController({ + new AuthController(config, logger, internalHooks, mfaService, userService, postHog), + new OwnerController( config, - internalHooks, - repositories, logger, + internalHooks, + Container.get(SettingsRepository), + userService, postHog, - }), - new OwnerController({ + ), + new MeController(logger, externalHooks, internalHooks, userService), + new NodeTypesController(config, nodeTypes), + new PasswordResetController( config, - internalHooks, - repositories, - logger, - }), - new MeController({ - externalHooks, - internalHooks, logger, - }), - new NodeTypesController({ config, nodeTypes }), - new PasswordResetController({ - config, externalHooks, internalHooks, mailer, - logger, - }), + userService, + jwtService, + mfaService, + ), Container.get(TagsController), new TranslationController(config, this.credentialTypes), - new UsersController({ + new UsersController( config, - mailer, + logger, externalHooks, internalHooks, - repositories, + repositories.SharedCredentials, + repositories.SharedWorkflow, activeWorkflowRunner, - logger, + mailer, + jwtService, + Container.get(RoleService), + userService, postHog, - }), + ), Container.get(SamlController), Container.get(SourceControlController), Container.get(WorkflowStatisticsController), + Container.get(ExternalSecretsController), ]; if (isLdapEnabled()) { @@ -546,6 +568,10 @@ export class Server extends AbstractServer { controllers.push(Container.get(E2EController)); } + if (isMfaFeatureEnabled()) { + controllers.push(new MFAController(mfaService)); + } + controllers.forEach((controller) => registerController(app, config, controller)); } @@ -623,6 +649,8 @@ export class Server extends AbstractServer { await handleLdapInit(); + await handleMfaDisable(); + await this.registerControllers(ignoredEndpoints); this.app.use(`/${this.restEndpoint}/credentials`, credentialsController); @@ -924,10 +952,13 @@ export class Server extends AbstractServer { throw new ResponseHelper.InternalServerError(error.message); } + const additionalData = await WorkflowExecuteAdditionalData.getBase(req.user.id); + const mode: WorkflowExecuteMode = 'internal'; const timezone = config.getEnv('generic.timezone'); const credentialsHelper = new CredentialsHelper(encryptionKey); const decryptedDataOriginal = await credentialsHelper.getDecrypted( + additionalData, credential as INodeCredentialsDetails, credential.type, mode, @@ -936,6 +967,7 @@ export class Server extends AbstractServer { ); const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites( + additionalData, decryptedDataOriginal, credential.type, mode, @@ -1070,10 +1102,13 @@ export class Server extends AbstractServer { throw new ResponseHelper.InternalServerError(error.message); } + const additionalData = await WorkflowExecuteAdditionalData.getBase(req.user.id); + const mode: WorkflowExecuteMode = 'internal'; const timezone = config.getEnv('generic.timezone'); const credentialsHelper = new CredentialsHelper(encryptionKey); const decryptedDataOriginal = await credentialsHelper.getDecrypted( + additionalData, credential as INodeCredentialsDetails, credential.type, mode, @@ -1081,6 +1116,7 @@ export class Server extends AbstractServer { true, ); const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites( + additionalData, decryptedDataOriginal, credential.type, mode, diff --git a/packages/cli/src/TestWebhooks.ts b/packages/cli/src/TestWebhooks.ts index a16d47099bfa1..8ffa9ad7ad863 100644 --- a/packages/cli/src/TestWebhooks.ts +++ b/packages/cli/src/TestWebhooks.ts @@ -56,6 +56,9 @@ export class TestWebhooks implements IWebhookManager { const httpMethod = request.method; let path = request.params.path; + // Reset request parameters + request.params = {} as WebhookRequest['params']; + // Remove trailing slash if (path.endsWith('/')) { path = path.slice(0, -1); diff --git a/packages/cli/src/TranslationHelpers.ts b/packages/cli/src/TranslationHelpers.ts deleted file mode 100644 index dd829534a6d78..0000000000000 --- a/packages/cli/src/TranslationHelpers.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { join, dirname } from 'path'; -import { readdir } from 'fs/promises'; -import type { Dirent } from 'fs'; - -const ALLOWED_VERSIONED_DIRNAME_LENGTH = [2, 3]; // e.g. v1, v10 - -function isVersionedDirname(dirent: Dirent) { - if (!dirent.isDirectory()) return false; - - return ( - ALLOWED_VERSIONED_DIRNAME_LENGTH.includes(dirent.name.length) && - dirent.name.toLowerCase().startsWith('v') - ); -} - -async function getMaxVersion(from: string) { - const entries = await readdir(from, { withFileTypes: true }); - - const dirnames = entries.reduce((acc, cur) => { - if (isVersionedDirname(cur)) acc.push(cur.name); - return acc; - }, []); - - if (!dirnames.length) return null; - - return Math.max(...dirnames.map((d) => parseInt(d.charAt(1), 10))); -} - -/** - * Get the full path to a node translation file in `/dist`. - */ -export async function getNodeTranslationPath({ - nodeSourcePath, - longNodeType, - locale, -}: { - nodeSourcePath: string; - longNodeType: string; - locale: string; -}): Promise { - const nodeDir = dirname(nodeSourcePath); - const maxVersion = await getMaxVersion(nodeDir); - const nodeType = longNodeType.replace('n8n-nodes-base.', ''); - - return maxVersion - ? join(nodeDir, `v${maxVersion}`, 'translations', locale, `${nodeType}.json`) - : join(nodeDir, 'translations', locale, `${nodeType}.json`); -} diff --git a/packages/cli/src/UserManagement/UserManagementHelper.ts b/packages/cli/src/UserManagement/UserManagementHelper.ts index 5c119df0deac9..6ec733e675863 100644 --- a/packages/cli/src/UserManagement/UserManagementHelper.ts +++ b/packages/cli/src/UserManagement/UserManagementHelper.ts @@ -4,13 +4,12 @@ import { Container } from 'typedi'; import * as Db from '@/Db'; import * as ResponseHelper from '@/ResponseHelper'; -import type { CurrentUser, PublicUser, WhereClause } from '@/Interfaces'; +import type { WhereClause } from '@/Interfaces'; import type { User } from '@db/entities/User'; import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '@db/entities/User'; import config from '@/config'; import { License } from '@/License'; import { getWebhookBaseUrl } from '@/WebhookHelpers'; -import type { PostHogClient } from '@/posthog'; import { RoleService } from '@/services/role.service'; export function isEmailSetUp(): boolean { @@ -84,59 +83,6 @@ export function validatePassword(password?: string): string { return password; } -/** - * Remove sensitive properties from the user to return to the client. - */ -export function sanitizeUser(user: User, withoutKeys?: string[]): PublicUser { - const { password, updatedAt, apiKey, authIdentities, ...rest } = user; - if (withoutKeys) { - withoutKeys.forEach((key) => { - // @ts-ignore - delete rest[key]; - }); - } - const sanitizedUser: PublicUser = { - ...rest, - signInType: 'email', - }; - const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap'); - if (ldapIdentity) { - sanitizedUser.signInType = 'ldap'; - } - return sanitizedUser; -} - -export async function withFeatureFlags( - postHog: PostHogClient | undefined, - user: CurrentUser, -): Promise { - if (!postHog) { - return user; - } - - // native PostHog implementation has default 10s timeout and 3 retries.. which cannot be updated without affecting other functionality - // https://github.com/PostHog/posthog-js-lite/blob/a182de80a433fb0ffa6859c10fb28084d0f825c2/posthog-core/src/index.ts#L67 - const timeoutPromise = new Promise((resolve) => { - setTimeout(() => { - resolve(user); - }, 1500); - }); - - const fetchPromise = new Promise(async (resolve) => { - user.featureFlags = await postHog.getFeatureFlags(user); - resolve(user); - }); - - return Promise.race([fetchPromise, timeoutPromise]); -} - -export function addInviteLinkToUser(user: PublicUser, inviterId: string): PublicUser { - if (user.isPending) { - user.inviteAcceptUrl = generateUserInviteUrl(inviterId, user.id); - } - return user; -} - export async function getUserById(userId: string): Promise { const user = await Db.collections.User.findOneOrFail({ where: { id: userId }, diff --git a/packages/cli/src/WaitingWebhooks.ts b/packages/cli/src/WaitingWebhooks.ts index 617dc98ed659a..f67fe1de37274 100644 --- a/packages/cli/src/WaitingWebhooks.ts +++ b/packages/cli/src/WaitingWebhooks.ts @@ -32,6 +32,9 @@ export class WaitingWebhooks implements IWebhookManager { const { path: executionId, suffix } = req.params; Logger.debug(`Received waiting-webhook "${req.method}" for execution "${executionId}"`); + // Reset request parameters + req.params = {} as WaitingWebhookRequest['params']; + const execution = await this.executionRepository.findSingleExecution(executionId, { includeData: true, unflattenData: true, diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 4c8dacf76b7ba..5760cce3d1a6d 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -65,6 +65,7 @@ import { InternalHooks } from '@/InternalHooks'; import type { ExecutionMetadata } from '@db/entities/ExecutionMetadata'; import { ExecutionRepository } from '@db/repositories'; import { EventsService } from '@/services/events.service'; +import { SecretsHelper } from './SecretsHelpers'; import { OwnershipService } from './services/ownership.service'; const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType'); @@ -1167,6 +1168,7 @@ export async function getBase( userId, setExecutionStatus, variables, + secretsHelpers: Container.get(SecretsHelper), }; } diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index d748d06f65e6b..78be7cc23f04c 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -20,9 +20,7 @@ import type { IExternalHooksClass } from '@/Interfaces'; import { InternalHooks } from '@/InternalHooks'; import { PostHogClient } from '@/posthog'; import { License } from '@/License'; - -export const UM_FIX_INSTRUCTION = - 'Please fix the database by running ./packages/cli/bin/n8n user-management:reset'; +import { ExternalSecretsManager } from '@/ExternalSecrets/ExternalSecretsManager.ee'; export abstract class BaseCommand extends Command { protected logger = LoggerProxy.init(getLogger()); @@ -137,6 +135,11 @@ export abstract class BaseCommand extends Command { } } + async initExternalSecrets() { + const secretsManager = Container.get(ExternalSecretsManager); + await secretsManager.init(); + } + async finally(error: Error | undefined) { if (inTest || this.id === 'start') return; if (Db.connectionState.connected) { diff --git a/packages/cli/src/commands/import/credentials.ts b/packages/cli/src/commands/import/credentials.ts index 6eb87aebd2293..942d5c518b12e 100644 --- a/packages/cli/src/commands/import/credentials.ts +++ b/packages/cli/src/commands/import/credentials.ts @@ -10,10 +10,11 @@ import { SharedCredentials } from '@db/entities/SharedCredentials'; import type { Role } from '@db/entities/Role'; import { CredentialsEntity } from '@db/entities/CredentialsEntity'; import { disableAutoGeneratedIds } from '@db/utils/commandHelpers'; -import { BaseCommand, UM_FIX_INSTRUCTION } from '../BaseCommand'; +import { BaseCommand } from '../BaseCommand'; import type { ICredentialsEncrypted } from 'n8n-workflow'; import { jsonParse } from 'n8n-workflow'; import { RoleService } from '@/services/role.service'; +import { UM_FIX_INSTRUCTION } from '@/constants'; export class ImportCredentialsCommand extends BaseCommand { static description = 'Import credentials'; diff --git a/packages/cli/src/commands/import/workflow.ts b/packages/cli/src/commands/import/workflow.ts index 5ae3a529d73b8..177014e460d2a 100644 --- a/packages/cli/src/commands/import/workflow.ts +++ b/packages/cli/src/commands/import/workflow.ts @@ -14,10 +14,11 @@ import type { User } from '@db/entities/User'; import { disableAutoGeneratedIds } from '@db/utils/commandHelpers'; import type { ICredentialsDb, IWorkflowToImport } from '@/Interfaces'; import { replaceInvalidCredentials } from '@/WorkflowHelpers'; -import { BaseCommand, UM_FIX_INSTRUCTION } from '../BaseCommand'; +import { BaseCommand } from '../BaseCommand'; import { generateNanoId } from '@db/utils/generators'; import { RoleService } from '@/services/role.service'; import { TagService } from '@/services/tag.service'; +import { UM_FIX_INSTRUCTION } from '@/constants'; function assertHasWorkflowsToImport(workflows: unknown): asserts workflows is IWorkflowToImport[] { if (!Array.isArray(workflows)) { diff --git a/packages/cli/src/commands/mfa/disable.ts b/packages/cli/src/commands/mfa/disable.ts new file mode 100644 index 0000000000000..6c5eb2fb9ce3b --- /dev/null +++ b/packages/cli/src/commands/mfa/disable.ts @@ -0,0 +1,55 @@ +import { flags } from '@oclif/command'; +import * as Db from '@/Db'; +import { BaseCommand } from '../BaseCommand'; + +export class DisableMFACommand extends BaseCommand { + static description = 'Disable MFA authentication for a user'; + + static examples = ['$ n8n mfa:disable --email=johndoe@example.com']; + + static flags = { + help: flags.help({ char: 'h' }), + email: flags.string({ + description: 'The email of the user to disable the MFA authentication', + }), + }; + + async init() { + await super.init(); + } + + async run(): Promise { + // eslint-disable-next-line @typescript-eslint/no-shadow + const { flags } = this.parse(DisableMFACommand); + + if (!flags.email) { + this.logger.info('An email with --email must be provided'); + return; + } + + const updateOperationResult = await Db.collections.User.update( + { email: flags.email }, + { mfaSecret: null, mfaRecoveryCodes: [], mfaEnabled: false }, + ); + + if (!updateOperationResult.affected) { + this.reportUserDoesNotExistError(flags.email); + return; + } + + this.reportSuccess(flags.email); + } + + async catch(error: Error) { + this.logger.error('An error occurred while disabling MFA in account'); + this.logger.error(error.message); + } + + private reportSuccess(email: string) { + this.logger.info(`Successfully disabled MFA for user with email: ${email}`); + } + + private reportUserDoesNotExistError(email: string) { + this.logger.info(`User with email: ${email} does not exist`); + } +} diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 84afe4770c5ea..a21a93e21f1ea 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -195,6 +195,7 @@ export class Start extends BaseCommand { await this.initLicense(); await this.initBinaryManager(); await this.initExternalHooks(); + await this.initExternalSecrets(); if (!config.getEnv('endpoints.disableUi')) { await this.generateStaticAssets(); diff --git a/packages/cli/src/commands/webhook.ts b/packages/cli/src/commands/webhook.ts index c627d0d32831b..7d5bf4630232e 100644 --- a/packages/cli/src/commands/webhook.ts +++ b/packages/cli/src/commands/webhook.ts @@ -80,6 +80,7 @@ export class Webhook extends BaseCommand { await this.initLicense(); await this.initBinaryManager(); await this.initExternalHooks(); + await this.initExternalSecrets(); } async run() { diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index 5702dc2d35339..dfaf0e34c0c62 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -239,6 +239,7 @@ export class Worker extends BaseCommand { await this.initLicense(); await this.initBinaryManager(); await this.initExternalHooks(); + await this.initExternalSecrets(); } async run() { diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 567777a39b457..742407853c89f 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -929,6 +929,15 @@ export const schema = { }, }, + mfa: { + enabled: { + format: Boolean, + default: true, + doc: 'Whether to enable MFA feature in instance.', + env: 'N8N_MFA_ENABLED', + }, + }, + sso: { justInTimeProvisioning: { format: Boolean, diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index caef03bcb2324..8d9bbb9e14f2b 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -77,6 +77,7 @@ export const LICENSE_FEATURES = { VARIABLES: 'feat:variables', SOURCE_CONTROL: 'feat:sourceControl', API_DISABLED: 'feat:apiDisabled', + EXTERNAL_SECRETS: 'feat:externalSecrets', SHOW_NON_PROD_BANNER: 'feat:showNonProdBanner', WORKFLOW_HISTORY: 'feat:workflowHistory', DEBUG_IN_EDITOR: 'feat:debugInEditor', @@ -90,3 +91,6 @@ export const LICENSE_QUOTAS = { export const UNLIMITED_LICENSE_QUOTA = -1; export const CREDENTIAL_BLANKING_VALUE = '__n8n_BLANK_VALUE_e5362baf-c777-4d57-a609-6eaf1f9e87f6'; + +export const UM_FIX_INSTRUCTION = + 'Please fix the database by running ./packages/cli/bin/n8n user-management:reset'; diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 0ee765760b4b8..c7beb64a222f2 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -8,22 +8,17 @@ import { InternalServerError, UnauthorizedError, } from '@/ResponseHelper'; -import { sanitizeUser, withFeatureFlags } from '@/UserManagement/UserManagementHelper'; import { issueCookie, resolveJwt } from '@/auth/jwt'; import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from '@/constants'; import { Request, Response } from 'express'; -import type { ILogger } from 'n8n-workflow'; +import { ILogger } from 'n8n-workflow'; import type { User } from '@db/entities/User'; import { LoginRequest, UserRequest } from '@/requests'; -import type { Config } from '@/config'; -import type { - PublicUser, - IDatabaseCollections, - IInternalHooksClass, - CurrentUser, -} from '@/Interfaces'; +import type { PublicUser } from '@/Interfaces'; +import { Config } from '@/config'; +import { IInternalHooksClass } from '@/Interfaces'; import { handleEmailLogin, handleLdapLogin } from '@/auth'; -import type { PostHogClient } from '@/posthog'; +import { PostHogClient } from '@/posthog'; import { getCurrentAuthenticationMethod, isLdapCurrentAuthenticationMethod, @@ -32,44 +27,25 @@ import { import { InternalHooks } from '../InternalHooks'; import { License } from '@/License'; import { UserService } from '@/services/user.service'; +import { MfaService } from '@/Mfa/mfa.service'; @RestController() export class AuthController { - private readonly config: Config; - - private readonly logger: ILogger; - - private readonly internalHooks: IInternalHooksClass; - - private readonly userService: UserService; - - private readonly postHog?: PostHogClient; - - constructor({ - config, - logger, - internalHooks, - postHog, - }: { - config: Config; - logger: ILogger; - internalHooks: IInternalHooksClass; - repositories: Pick; - postHog?: PostHogClient; - }) { - this.config = config; - this.logger = logger; - this.internalHooks = internalHooks; - this.postHog = postHog; - this.userService = Container.get(UserService); - } + constructor( + private readonly config: Config, + private readonly logger: ILogger, + private readonly internalHooks: IInternalHooksClass, + private readonly mfaService: MfaService, + private readonly userService: UserService, + private readonly postHog?: PostHogClient, + ) {} /** * Log in a user. */ @Post('/login') async login(req: LoginRequest, res: Response): Promise { - const { email, password } = req.body; + const { email, password, mfaToken, mfaRecoveryCode } = req.body; if (!email) throw new Error('Email is required to log in'); if (!password) throw new Error('Password is required to log in'); @@ -94,13 +70,35 @@ export class AuthController { } else { user = await handleEmailLogin(email, password); } + if (user) { + if (user.mfaEnabled) { + if (!mfaToken && !mfaRecoveryCode) { + throw new AuthError('MFA Error', 998); + } + + const { decryptedRecoveryCodes, decryptedSecret } = + await this.mfaService.getSecretAndRecoveryCodes(user.id); + + user.mfaSecret = decryptedSecret; + user.mfaRecoveryCodes = decryptedRecoveryCodes; + + const isMFATokenValid = + (await this.validateMfaToken(user, mfaToken)) || + (await this.validateMfaRecoveryCode(user, mfaRecoveryCode)); + + if (!isMFATokenValid) { + throw new AuthError('Invalid mfa token or recovery code'); + } + } + await issueCookie(res, user); void Container.get(InternalHooks).onUserLoginSuccess({ user, authenticationMethod: usedAuthenticationMethod, }); - return withFeatureFlags(this.postHog, sanitizeUser(user)); + + return this.userService.toPublic(user, { posthog: this.postHog }); } void Container.get(InternalHooks).onUserLoginFailed({ user: email, @@ -114,7 +112,7 @@ export class AuthController { * Manually check the `n8n-auth` cookie. */ @Get('/login') - async currentUser(req: Request, res: Response): Promise { + async currentUser(req: Request, res: Response): Promise { // Manually check the existing cookie. // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const cookieContents = req.cookies?.[AUTH_COOKIE_NAME] as string | undefined; @@ -125,7 +123,7 @@ export class AuthController { try { user = await resolveJwt(cookieContents); - return await withFeatureFlags(this.postHog, sanitizeUser(user)); + return await this.userService.toPublic(user, { posthog: this.postHog }); } catch (error) { res.clearCookie(AUTH_COOKIE_NAME); } @@ -148,7 +146,7 @@ export class AuthController { } await issueCookie(res, user); - return withFeatureFlags(this.postHog, sanitizeUser(user)); + return this.userService.toPublic(user, { posthog: this.postHog }); } /** @@ -185,7 +183,10 @@ export class AuthController { } } - const users = await this.userService.findMany({ where: { id: In([inviterId, inviteeId]) } }); + const users = await this.userService.findMany({ + where: { id: In([inviterId, inviteeId]) }, + relations: ['globalRole'], + }); if (users.length !== 2) { this.logger.debug( 'Request to resolve signup token failed because the ID of the inviter and/or the ID of the invitee were not found in database', @@ -229,4 +230,27 @@ export class AuthController { res.clearCookie(AUTH_COOKIE_NAME); return { loggedOut: true }; } + + private async validateMfaToken(user: User, token?: string) { + if (!!!token) return false; + return this.mfaService.totp.verifySecret({ + secret: user.mfaSecret ?? '', + token, + }); + } + + private async validateMfaRecoveryCode(user: User, mfaRecoveryCode?: string) { + if (!!!mfaRecoveryCode) return false; + const index = user.mfaRecoveryCodes.indexOf(mfaRecoveryCode); + if (index === -1) return false; + + // remove used recovery code + user.mfaRecoveryCodes.splice(index, 1); + + await this.userService.update(user.id, { + mfaRecoveryCodes: this.mfaService.encryptRecoveryCodes(user.mfaRecoveryCodes), + }); + + return true; + } } diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index d2497eddca24f..0957ea95ee773 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -12,6 +12,9 @@ import { LICENSE_FEATURES, inE2ETests } from '@/constants'; import { NoAuthRequired, Patch, Post, RestController } from '@/decorators'; import type { UserSetupPayload } from '@/requests'; import type { BooleanLicenseFeature } from '@/Interfaces'; +import { UserSettings } from 'n8n-core'; +import { MfaService } from '@/Mfa/mfa.service'; +import { TOTPService } from '@/Mfa/totp.service'; if (!inE2ETests) { console.error('E2E endpoints only allowed during E2E tests'); @@ -61,6 +64,7 @@ export class E2EController { [LICENSE_FEATURES.SOURCE_CONTROL]: false, [LICENSE_FEATURES.VARIABLES]: false, [LICENSE_FEATURES.API_DISABLED]: false, + [LICENSE_FEATURES.EXTERNAL_SECRETS]: false, [LICENSE_FEATURES.SHOW_NON_PROD_BANNER]: false, [LICENSE_FEATURES.WORKFLOW_HISTORY]: false, [LICENSE_FEATURES.DEBUG_IN_EDITOR]: false, @@ -136,13 +140,30 @@ export class E2EController { roles.map(([name, scope], index) => ({ name, scope, id: (index + 1).toString() })), ); - const users = []; - users.push({ + const encryptionKey = await UserSettings.getEncryptionKey(); + + const mfaService = new MfaService(this.userRepo, new TOTPService(), encryptionKey); + + const instanceOwner = { id: uuid(), ...owner, password: await hashPassword(owner.password), globalRoleId: globalOwnerRoleId, - }); + }; + + if (owner?.mfaSecret && owner.mfaRecoveryCodes?.length) { + const { encryptedRecoveryCodes, encryptedSecret } = mfaService.encryptSecretAndRecoveryCodes( + owner.mfaSecret, + owner.mfaRecoveryCodes, + ); + instanceOwner.mfaSecret = encryptedSecret; + instanceOwner.mfaRecoveryCodes = encryptedRecoveryCodes; + } + + const users = []; + + users.push(instanceOwner); + for (const { password, ...payload } of members) { users.push( this.userRepo.create({ @@ -154,8 +175,6 @@ export class E2EController { ); } - console.log('users', users); - await this.userRepo.insert(users); await this.settingsRepo.update( diff --git a/packages/cli/src/controllers/index.ts b/packages/cli/src/controllers/index.ts index c3e742924e998..2091b95a30edc 100644 --- a/packages/cli/src/controllers/index.ts +++ b/packages/cli/src/controllers/index.ts @@ -1,6 +1,7 @@ export { AuthController } from './auth.controller'; export { LdapController } from './ldap.controller'; export { MeController } from './me.controller'; +export { MFAController } from './mfa.controller'; export { NodesController } from './nodes.controller'; export { NodeTypesController } from './nodeTypes.controller'; export { OwnerController } from './owner.controller'; diff --git a/packages/cli/src/controllers/me.controller.ts b/packages/cli/src/controllers/me.controller.ts index bfebaf0a716f5..8026e67bdb4ea 100644 --- a/packages/cli/src/controllers/me.controller.ts +++ b/packages/cli/src/controllers/me.controller.ts @@ -1,55 +1,34 @@ import validator from 'validator'; import { plainToInstance } from 'class-transformer'; import { Authorized, Delete, Get, Patch, Post, RestController } from '@/decorators'; -import { - compareHash, - hashPassword, - sanitizeUser, - validatePassword, -} from '@/UserManagement/UserManagementHelper'; +import { compareHash, hashPassword, validatePassword } from '@/UserManagement/UserManagementHelper'; import { BadRequestError } from '@/ResponseHelper'; import { validateEntity } from '@/GenericHelpers'; import { issueCookie } from '@/auth/jwt'; import type { User } from '@db/entities/User'; import { Response } from 'express'; -import type { ILogger } from 'n8n-workflow'; +import { ILogger } from 'n8n-workflow'; import { AuthenticatedRequest, MeRequest, UserSettingsUpdatePayload, UserUpdatePayload, } from '@/requests'; -import type { PublicUser, IExternalHooksClass, IInternalHooksClass } from '@/Interfaces'; +import { IExternalHooksClass, IInternalHooksClass } from '@/Interfaces'; +import type { PublicUser } from '@/Interfaces'; import { randomBytes } from 'crypto'; import { isSamlLicensedAndEnabled } from '../sso/saml/samlHelpers'; import { UserService } from '@/services/user.service'; -import Container from 'typedi'; @Authorized() @RestController('/me') export class MeController { - private readonly logger: ILogger; - - private readonly externalHooks: IExternalHooksClass; - - private readonly internalHooks: IInternalHooksClass; - - private readonly userService: UserService; - - constructor({ - logger, - externalHooks, - internalHooks, - }: { - logger: ILogger; - externalHooks: IExternalHooksClass; - internalHooks: IInternalHooksClass; - }) { - this.logger = logger; - this.externalHooks = externalHooks; - this.internalHooks = internalHooks; - this.userService = Container.get(UserService); - } + constructor( + private readonly logger: ILogger, + private readonly externalHooks: IExternalHooksClass, + private readonly internalHooks: IInternalHooksClass, + private readonly userService: UserService, + ) {} /** * Update the logged-in user's properties, except password. @@ -105,9 +84,11 @@ export class MeController { fields_changed: updatedKeys, }); - await this.externalHooks.run('user.profile.update', [currentEmail, sanitizeUser(user)]); + const publicUser = await this.userService.toPublic(user); + + await this.externalHooks.run('user.profile.update', [currentEmail, publicUser]); - return sanitizeUser(user); + return publicUser; } /** diff --git a/packages/cli/src/controllers/mfa.controller.ts b/packages/cli/src/controllers/mfa.controller.ts new file mode 100644 index 0000000000000..70e3444851224 --- /dev/null +++ b/packages/cli/src/controllers/mfa.controller.ts @@ -0,0 +1,96 @@ +import { Authorized, Delete, Get, Post, RestController } from '@/decorators'; +import { AuthenticatedRequest, MFA } from '@/requests'; +import { BadRequestError } from '@/ResponseHelper'; +import { MfaService } from '@/Mfa/mfa.service'; +@Authorized() +@RestController('/mfa') +export class MFAController { + constructor(private mfaService: MfaService) {} + + @Get('/qr') + async getQRCode(req: AuthenticatedRequest) { + const { email, id, mfaEnabled } = req.user; + + if (mfaEnabled) + throw new BadRequestError( + 'MFA already enabled. Disable it to generate new secret and recovery codes', + ); + + const { decryptedSecret: secret, decryptedRecoveryCodes: recoveryCodes } = + await this.mfaService.getSecretAndRecoveryCodes(id); + + if (secret && recoveryCodes.length) { + const qrCode = this.mfaService.totp.generateTOTPUri({ + secret, + label: email, + }); + + return { + secret, + recoveryCodes, + qrCode, + }; + } + + const newRecoveryCodes = this.mfaService.generateRecoveryCodes(); + + const newSecret = this.mfaService.totp.generateSecret(); + + const qrCode = this.mfaService.totp.generateTOTPUri({ secret: newSecret, label: email }); + + await this.mfaService.saveSecretAndRecoveryCodes(id, newSecret, newRecoveryCodes); + + return { + secret: newSecret, + qrCode, + recoveryCodes: newRecoveryCodes, + }; + } + + @Post('/enable') + async activateMFA(req: MFA.Activate) { + const { token = null } = req.body; + const { id, mfaEnabled } = req.user; + + const { decryptedSecret: secret, decryptedRecoveryCodes: recoveryCodes } = + await this.mfaService.getSecretAndRecoveryCodes(id); + + if (!token) throw new BadRequestError('Token is required to enable MFA feature'); + + if (mfaEnabled) throw new BadRequestError('MFA already enabled'); + + if (!secret || !recoveryCodes.length) { + throw new BadRequestError('Cannot enable MFA without generating secret and recovery codes'); + } + + const verified = this.mfaService.totp.verifySecret({ secret, token, window: 10 }); + + if (!verified) + throw new BadRequestError('MFA token expired. Close the modal and enable MFA again', 997); + + await this.mfaService.enableMfa(id); + } + + @Delete('/disable') + async disableMFA(req: AuthenticatedRequest) { + const { id } = req.user; + + await this.mfaService.disableMfa(id); + } + + @Post('/verify') + async verifyMFA(req: MFA.Verify) { + const { id } = req.user; + const { token } = req.body; + + const { decryptedSecret: secret } = await this.mfaService.getSecretAndRecoveryCodes(id); + + if (!token) throw new BadRequestError('Token is required to enable MFA feature'); + + if (!secret) throw new BadRequestError('No MFA secret se for this user'); + + const verified = this.mfaService.totp.verifySecret({ secret, token }); + + if (!verified) throw new BadRequestError('MFA secret could not be verified'); + } +} diff --git a/packages/cli/src/controllers/nodeTypes.controller.ts b/packages/cli/src/controllers/nodeTypes.controller.ts index 49dec0b674d7d..029f4d879017a 100644 --- a/packages/cli/src/controllers/nodeTypes.controller.ts +++ b/packages/cli/src/controllers/nodeTypes.controller.ts @@ -3,21 +3,16 @@ import get from 'lodash/get'; import { Request } from 'express'; import type { INodeTypeDescription, INodeTypeNameVersion } from 'n8n-workflow'; import { Authorized, Post, RestController } from '@/decorators'; -import { getNodeTranslationPath } from '@/TranslationHelpers'; -import type { Config } from '@/config'; -import type { NodeTypes } from '@/NodeTypes'; +import { Config } from '@/config'; +import { NodeTypes } from '@/NodeTypes'; @Authorized() @RestController('/node-types') export class NodeTypesController { - private readonly config: Config; - - private readonly nodeTypes: NodeTypes; - - constructor({ config, nodeTypes }: { config: Config; nodeTypes: NodeTypes }) { - this.config = config; - this.nodeTypes = nodeTypes; - } + constructor( + private readonly config: Config, + private readonly nodeTypes: NodeTypes, + ) {} @Post('/') async getNodeInfo(req: Request) { @@ -39,7 +34,7 @@ export class NodeTypesController { nodeTypes: INodeTypeDescription[], ) => { const { description, sourcePath } = this.nodeTypes.getWithSourcePath(name, version); - const translationPath = await getNodeTranslationPath({ + const translationPath = await this.nodeTypes.getNodeTranslationPath({ nodeSourcePath: sourcePath, longNodeType: description.name, locale: defaultLocale, diff --git a/packages/cli/src/controllers/owner.controller.ts b/packages/cli/src/controllers/owner.controller.ts index 3437884f1f736..3a33388fd5ec3 100644 --- a/packages/cli/src/controllers/owner.controller.ts +++ b/packages/cli/src/controllers/owner.controller.ts @@ -2,58 +2,28 @@ import validator from 'validator'; import { validateEntity } from '@/GenericHelpers'; import { Authorized, Post, RestController } from '@/decorators'; import { BadRequestError } from '@/ResponseHelper'; -import { - hashPassword, - sanitizeUser, - validatePassword, - withFeatureFlags, -} from '@/UserManagement/UserManagementHelper'; +import { hashPassword, validatePassword } from '@/UserManagement/UserManagementHelper'; import { issueCookie } from '@/auth/jwt'; import { Response } from 'express'; -import type { ILogger } from 'n8n-workflow'; -import type { Config } from '@/config'; +import { ILogger } from 'n8n-workflow'; +import { Config } from '@/config'; import { OwnerRequest } from '@/requests'; -import type { IDatabaseCollections, IInternalHooksClass } from '@/Interfaces'; -import type { SettingsRepository } from '@db/repositories'; +import { IInternalHooksClass } from '@/Interfaces'; +import { SettingsRepository } from '@db/repositories'; +import { PostHogClient } from '@/posthog'; import { UserService } from '@/services/user.service'; -import Container from 'typedi'; -import type { PostHogClient } from '@/posthog'; @Authorized(['global', 'owner']) @RestController('/owner') export class OwnerController { - private readonly config: Config; - - private readonly logger: ILogger; - - private readonly internalHooks: IInternalHooksClass; - - private readonly userService: UserService; - - private readonly settingsRepository: SettingsRepository; - - private readonly postHog?: PostHogClient; - - constructor({ - config, - logger, - internalHooks, - repositories, - postHog, - }: { - config: Config; - logger: ILogger; - internalHooks: IInternalHooksClass; - repositories: Pick; - postHog?: PostHogClient; - }) { - this.config = config; - this.logger = logger; - this.internalHooks = internalHooks; - this.userService = Container.get(UserService); - this.settingsRepository = repositories.Settings; - this.postHog = postHog; - } + constructor( + private readonly config: Config, + private readonly logger: ILogger, + private readonly internalHooks: IInternalHooksClass, + private readonly settingsRepository: SettingsRepository, + private readonly userService: UserService, + private readonly postHog?: PostHogClient, + ) {} /** * Promote a shell into the owner of the n8n instance, @@ -131,7 +101,7 @@ export class OwnerController { void this.internalHooks.onInstanceOwnerSetup({ user_id: userId }); - return withFeatureFlags(this.postHog, sanitizeUser(owner)); + return this.userService.toPublic(owner, { posthog: this.postHog }); } @Post('/dismiss-banner') diff --git a/packages/cli/src/controllers/passwordReset.controller.ts b/packages/cli/src/controllers/passwordReset.controller.ts index e012f0409a327..838413c31933b 100644 --- a/packages/cli/src/controllers/passwordReset.controller.ts +++ b/packages/cli/src/controllers/passwordReset.controller.ts @@ -13,13 +13,13 @@ import { hashPassword, validatePassword, } from '@/UserManagement/UserManagementHelper'; -import type { UserManagementMailer } from '@/UserManagement/email'; +import { UserManagementMailer } from '@/UserManagement/email'; import { Response } from 'express'; -import type { ILogger } from 'n8n-workflow'; -import type { Config } from '@/config'; +import { ILogger } from 'n8n-workflow'; +import { Config } from '@/config'; import { PasswordResetRequest } from '@/requests'; -import type { IExternalHooksClass, IInternalHooksClass } from '@/Interfaces'; +import { IExternalHooksClass, IInternalHooksClass } from '@/Interfaces'; import { issueCookie } from '@/auth/jwt'; import { isLdapEnabled } from '@/Ldap/helpers'; import { isSamlCurrentAuthenticationMethod } from '@/sso/ssoHelpers'; @@ -30,44 +30,20 @@ import { RESPONSE_ERROR_MESSAGES } from '@/constants'; import { TokenExpiredError } from 'jsonwebtoken'; import type { JwtPayload } from '@/services/jwt.service'; import { JwtService } from '@/services/jwt.service'; +import { MfaService } from '@/Mfa/mfa.service'; @RestController() export class PasswordResetController { - private readonly config: Config; - - private readonly logger: ILogger; - - private readonly externalHooks: IExternalHooksClass; - - private readonly internalHooks: IInternalHooksClass; - - private readonly mailer: UserManagementMailer; - - private readonly jwtService: JwtService; - - private readonly userService: UserService; - - constructor({ - config, - logger, - externalHooks, - internalHooks, - mailer, - }: { - config: Config; - logger: ILogger; - externalHooks: IExternalHooksClass; - internalHooks: IInternalHooksClass; - mailer: UserManagementMailer; - }) { - this.config = config; - this.logger = logger; - this.externalHooks = externalHooks; - this.internalHooks = internalHooks; - this.mailer = mailer; - this.jwtService = Container.get(JwtService); - this.userService = Container.get(UserService); - } + constructor( + private readonly config: Config, + private readonly logger: ILogger, + private readonly externalHooks: IExternalHooksClass, + private readonly internalHooks: IInternalHooksClass, + private readonly mailer: UserManagementMailer, + private readonly userService: UserService, + private readonly jwtService: JwtService, + private readonly mfaService: MfaService, + ) {} /** * Send a password reset email. @@ -150,7 +126,11 @@ export class PasswordResetController { }, ); - const url = this.userService.generatePasswordResetUrl(baseUrl, resetPasswordToken); + const url = this.userService.generatePasswordResetUrl( + baseUrl, + resetPasswordToken, + user.mfaEnabled, + ); try { await this.mailer.passwordReset({ @@ -233,7 +213,7 @@ export class PasswordResetController { */ @Post('/change-password') async changePassword(req: PasswordResetRequest.NewPassword, res: Response) { - const { token: resetPasswordToken, password } = req.body; + const { token: resetPasswordToken, password, mfaToken } = req.body; if (!resetPasswordToken || !password) { this.logger.debug( @@ -264,6 +244,16 @@ export class PasswordResetController { throw new NotFoundError(''); } + if (user.mfaEnabled) { + if (!mfaToken) throw new BadRequestError('If MFA enabled, mfaToken is required.'); + + const { decryptedSecret: secret } = await this.mfaService.getSecretAndRecoveryCodes(user.id); + + const validToken = this.mfaService.totp.verifySecret({ secret, token: mfaToken }); + + if (!validToken) throw new BadRequestError('Invalid MFA token.'); + } + const passwordHash = await hashPassword(validPassword); await this.userService.update(user.id, { password: passwordHash }); diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index 93c1a61ff4c2c..e77774c9ecfdf 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -1,20 +1,17 @@ import validator from 'validator'; -import { In } from 'typeorm'; -import type { ILogger } from 'n8n-workflow'; -import { ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; +import type { FindManyOptions } from 'typeorm'; +import { In, Not } from 'typeorm'; +import { ILogger, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; import { User } from '@db/entities/User'; import { SharedCredentials } from '@db/entities/SharedCredentials'; import { SharedWorkflow } from '@db/entities/SharedWorkflow'; import { Authorized, NoAuthRequired, Delete, Get, Post, RestController, Patch } from '@/decorators'; import { - addInviteLinkToUser, generateUserInviteUrl, getInstanceBaseUrl, hashPassword, isEmailSetUp, - sanitizeUser, validatePassword, - withFeatureFlags, } from '@/UserManagement/UserManagementHelper'; import { issueCookie } from '@/auth/jwt'; import { @@ -24,21 +21,16 @@ import { UnauthorizedError, } from '@/ResponseHelper'; import { Response } from 'express'; -import type { Config } from '@/config'; -import { UserRequest, UserSettingsUpdatePayload } from '@/requests'; -import type { UserManagementMailer } from '@/UserManagement/email'; -import type { - PublicUser, - IDatabaseCollections, - IExternalHooksClass, - IInternalHooksClass, - ITelemetryUserDeletionData, -} from '@/Interfaces'; -import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; +import { ListQuery, UserRequest, UserSettingsUpdatePayload } from '@/requests'; +import { UserManagementMailer } from '@/UserManagement/email'; +import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; +import { Config } from '@/config'; +import { IExternalHooksClass, IInternalHooksClass } from '@/Interfaces'; +import type { PublicUser, ITelemetryUserDeletionData } from '@/Interfaces'; import { AuthIdentity } from '@db/entities/AuthIdentity'; -import type { PostHogClient } from '@/posthog'; +import { PostHogClient } from '@/posthog'; import { isSamlLicensedAndEnabled } from '../sso/saml/samlHelpers'; -import type { SharedCredentialsRepository, SharedWorkflowRepository } from '@db/repositories'; +import { SharedCredentialsRepository, SharedWorkflowRepository } from '@db/repositories'; import { plainToInstance } from 'class-transformer'; import { License } from '@/License'; import { Container } from 'typedi'; @@ -46,66 +38,25 @@ import { RESPONSE_ERROR_MESSAGES } from '@/constants'; import { JwtService } from '@/services/jwt.service'; import { RoleService } from '@/services/role.service'; import { UserService } from '@/services/user.service'; +import { listQueryMiddleware } from '@/middlewares'; @Authorized(['global', 'owner']) @RestController('/users') export class UsersController { - private config: Config; - - private logger: ILogger; - - private externalHooks: IExternalHooksClass; - - private internalHooks: IInternalHooksClass; - - private sharedCredentialsRepository: SharedCredentialsRepository; - - private sharedWorkflowRepository: SharedWorkflowRepository; - - private activeWorkflowRunner: ActiveWorkflowRunner; - - private mailer: UserManagementMailer; - - private jwtService: JwtService; - - private postHog?: PostHogClient; - - private roleService: RoleService; - - private userService: UserService; - - constructor({ - config, - logger, - externalHooks, - internalHooks, - repositories, - activeWorkflowRunner, - mailer, - postHog, - }: { - config: Config; - logger: ILogger; - externalHooks: IExternalHooksClass; - internalHooks: IInternalHooksClass; - repositories: Pick; - activeWorkflowRunner: ActiveWorkflowRunner; - mailer: UserManagementMailer; - postHog?: PostHogClient; - }) { - this.config = config; - this.logger = logger; - this.externalHooks = externalHooks; - this.internalHooks = internalHooks; - this.sharedCredentialsRepository = repositories.SharedCredentials; - this.sharedWorkflowRepository = repositories.SharedWorkflow; - this.activeWorkflowRunner = activeWorkflowRunner; - this.mailer = mailer; - this.jwtService = Container.get(JwtService); - this.postHog = postHog; - this.roleService = Container.get(RoleService); - this.userService = Container.get(UserService); - } + constructor( + private readonly config: Config, + private readonly logger: ILogger, + private readonly externalHooks: IExternalHooksClass, + private readonly internalHooks: IInternalHooksClass, + private readonly sharedCredentialsRepository: SharedCredentialsRepository, + private readonly sharedWorkflowRepository: SharedWorkflowRepository, + private readonly activeWorkflowRunner: ActiveWorkflowRunner, + private readonly mailer: UserManagementMailer, + private readonly jwtService: JwtService, + private readonly roleService: RoleService, + private readonly userService: UserService, + private readonly postHog?: PostHogClient, + ) {} /** * Send email invite(s) to one or multiple users and create user shell(s). @@ -179,6 +130,7 @@ export class UsersController { // remove/exclude existing users from creation const existingUsers = await this.userService.findMany({ where: { email: In(Object.keys(createUsers)) }, + relations: ['globalRole'], }); existingUsers.forEach((user) => { if (user.password) { @@ -354,20 +306,98 @@ export class UsersController { was_disabled_ldap_user: false, }); - await this.externalHooks.run('user.profile.update', [invitee.email, sanitizeUser(invitee)]); + const publicInvitee = await this.userService.toPublic(invitee); + + await this.externalHooks.run('user.profile.update', [invitee.email, publicInvitee]); await this.externalHooks.run('user.password.update', [invitee.email, invitee.password]); - return withFeatureFlags(this.postHog, sanitizeUser(updatedUser)); + return this.userService.toPublic(updatedUser, { posthog: this.postHog }); + } + + private async toFindManyOptions(listQueryOptions?: ListQuery.Options) { + const findManyOptions: FindManyOptions = {}; + + if (!listQueryOptions) { + findManyOptions.relations = ['globalRole', 'authIdentities']; + return findManyOptions; + } + + const { filter, select, take, skip } = listQueryOptions; + + if (select) findManyOptions.select = select; + if (take) findManyOptions.take = take; + if (skip) findManyOptions.skip = skip; + + if (take && !select) { + findManyOptions.relations = ['globalRole', 'authIdentities']; + } + + if (take && select && !select?.id) { + findManyOptions.select = { ...findManyOptions.select, id: true }; // pagination requires id + } + + if (filter) { + const { isOwner, ...otherFilters } = filter; + + findManyOptions.where = otherFilters; + + if (isOwner !== undefined) { + const ownerRole = await this.roleService.findGlobalOwnerRole(); + + findManyOptions.relations = ['globalRole']; + findManyOptions.where.globalRole = { id: isOwner ? ownerRole.id : Not(ownerRole.id) }; + } + } + + return findManyOptions; + } + + removeSupplementaryFields( + publicUsers: Array>, + listQueryOptions: ListQuery.Options, + ) { + const { take, select, filter } = listQueryOptions; + + // remove fields added to satisfy query + + if (take && select && !select?.id) { + for (const user of publicUsers) delete user.id; + } + + if (filter?.isOwner) { + for (const user of publicUsers) delete user.globalRole; + } + + // remove computed fields (unselectable) + + if (select) { + for (const user of publicUsers) { + delete user.isOwner; + delete user.isPending; + delete user.signInType; + delete user.hasRecoveryCodesLeft; + } + } + + return publicUsers; } @Authorized('any') - @Get('/') - async listUsers(req: UserRequest.List) { - const users = await this.userService.findMany({ relations: ['globalRole', 'authIdentities'] }); - return users.map( - (user): PublicUser => - addInviteLinkToUser(sanitizeUser(user, ['personalizationAnswers']), req.user.id), + @Get('/', { middlewares: listQueryMiddleware }) + async listUsers(req: ListQuery.Request) { + const { listQueryOptions } = req; + + const findManyOptions = await this.toFindManyOptions(listQueryOptions); + + const users = await this.userService.findMany(findManyOptions); + + const publicUsers: Array> = await Promise.all( + users.map(async (u) => this.userService.toPublic(u, { withInviteUrl: true })), ); + + return listQueryOptions + ? this.removeSupplementaryFields(publicUsers, listQueryOptions) + : publicUsers; } @Authorized(['global', 'owner']) @@ -389,7 +419,11 @@ export class UsersController { const baseUrl = getInstanceBaseUrl(); - const link = this.userService.generatePasswordResetUrl(baseUrl, resetPasswordToken); + const link = this.userService.generatePasswordResetUrl( + baseUrl, + resetPasswordToken, + user.mfaEnabled, + ); return { link, }; @@ -437,6 +471,7 @@ export class UsersController { const users = await this.userService.findMany({ where: { id: In([transferId, idToDelete]) }, + relations: ['globalRole'], }); if (!users.length || (transferId && users.length !== 2)) { @@ -527,7 +562,7 @@ export class UsersController { telemetryData, publicApi: false, }); - await this.externalHooks.run('user.deleted', [sanitizeUser(userToDelete)]); + await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]); return { success: true }; } @@ -565,7 +600,7 @@ export class UsersController { publicApi: false, }); - await this.externalHooks.run('user.deleted', [sanitizeUser(userToDelete)]); + await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]); return { success: true }; } diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index 94be5b9fb6743..0d6c67c554f5e 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -309,7 +309,10 @@ export class CredentialsService { if (!prop) { continue; } - if (prop.typeOptions?.password) { + if ( + prop.typeOptions?.password && + (!(copiedData[dataKey] as string).startsWith('={{') || prop.noDataExpression) + ) { if (copiedData[dataKey].toString().length > 0) { copiedData[dataKey] = CREDENTIAL_BLANKING_VALUE; } else { diff --git a/packages/cli/src/credentials/oauth2Credential.api.ts b/packages/cli/src/credentials/oauth2Credential.api.ts index bcb3892ddf455..dda9b486e957d 100644 --- a/packages/cli/src/credentials/oauth2Credential.api.ts +++ b/packages/cli/src/credentials/oauth2Credential.api.ts @@ -34,6 +34,8 @@ import config from '@/config'; import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; import { Container } from 'typedi'; +import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; + export const oauth2CredentialController = express.Router(); /** @@ -81,12 +83,15 @@ oauth2CredentialController.get( throw new ResponseHelper.InternalServerError((error as Error).message); } + const additionalData = await WorkflowExecuteAdditionalData.getBase(req.user.id); + const credentialType = (credential as unknown as ICredentialsEncrypted).type; const mode: WorkflowExecuteMode = 'internal'; const timezone = config.getEnv('generic.timezone'); const credentialsHelper = new CredentialsHelper(encryptionKey); const decryptedDataOriginal = await credentialsHelper.getDecrypted( + additionalData, credential as INodeCredentialsDetails, credentialType, mode, @@ -107,6 +112,7 @@ oauth2CredentialController.get( } const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites( + additionalData, decryptedDataOriginal, credentialType, mode, @@ -223,11 +229,13 @@ oauth2CredentialController.get( } const encryptionKey = await UserSettings.getEncryptionKey(); + const additionalData = await WorkflowExecuteAdditionalData.getBase(state.cid); const mode: WorkflowExecuteMode = 'internal'; const timezone = config.getEnv('generic.timezone'); const credentialsHelper = new CredentialsHelper(encryptionKey); const decryptedDataOriginal = await credentialsHelper.getDecrypted( + additionalData, credential as INodeCredentialsDetails, (credential as unknown as ICredentialsEncrypted).type, mode, @@ -235,6 +243,7 @@ oauth2CredentialController.get( true, ); const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites( + additionalData, decryptedDataOriginal, (credential as unknown as ICredentialsEncrypted).type, mode, diff --git a/packages/cli/src/databases/dsl/Column.ts b/packages/cli/src/databases/dsl/Column.ts index 7d2257a8697ae..54442c69d8e3c 100644 --- a/packages/cli/src/databases/dsl/Column.ts +++ b/packages/cli/src/databases/dsl/Column.ts @@ -93,6 +93,9 @@ export class Column { options.type = isPostgres ? 'timestamptz' : 'datetime'; } else if (type === 'json' && isSqlite) { options.type = 'text'; + } else if (type === 'uuid' && isMysql) { + // mysql does not support uuid type + options.type = 'varchar(36)'; } if ((type === 'varchar' || type === 'timestamp') && length !== 'auto') { diff --git a/packages/cli/src/databases/entities/User.ts b/packages/cli/src/databases/entities/User.ts index 15370aade6ba9..a32c9f2dc06e9 100644 --- a/packages/cli/src/databases/entities/User.ts +++ b/packages/cli/src/databases/entities/User.ts @@ -96,6 +96,15 @@ export class User extends WithTimestamps implements IUser { @Index({ unique: true }) apiKey?: string | null; + @Column({ type: Boolean, default: false }) + mfaEnabled: boolean; + + @Column({ type: String, nullable: true, select: false }) + mfaSecret?: string | null; + + @Column({ type: 'simple-array', default: '', select: false }) + mfaRecoveryCodes: string[]; + /** * Whether the user is pending setup completion. */ diff --git a/packages/cli/src/databases/migrations/common/1690000000040-AddMfaColumns.ts b/packages/cli/src/databases/migrations/common/1690000000040-AddMfaColumns.ts new file mode 100644 index 0000000000000..2c044f6dce9d8 --- /dev/null +++ b/packages/cli/src/databases/migrations/common/1690000000040-AddMfaColumns.ts @@ -0,0 +1,35 @@ +import type { MigrationContext, ReversibleMigration } from '@/databases/types'; +import { TableColumn } from 'typeorm'; + +export class AddMfaColumns1690000000030 implements ReversibleMigration { + async up({ queryRunner, tablePrefix }: MigrationContext) { + await queryRunner.addColumns(`${tablePrefix}user`, [ + new TableColumn({ + name: 'mfaEnabled', + type: 'boolean', + isNullable: false, + default: false, + }), + new TableColumn({ + name: 'mfaSecret', + type: 'text', + isNullable: true, + default: null, + }), + new TableColumn({ + name: 'mfaRecoveryCodes', + type: 'text', + isNullable: true, + default: null, + }), + ]); + } + + async down({ queryRunner, tablePrefix }: MigrationContext) { + await queryRunner.dropColumns(`${tablePrefix}user`, [ + 'mfaEnabled', + 'mfaSecret', + 'mfaRecoveryCodes', + ]); + } +} diff --git a/packages/cli/src/databases/migrations/common/1692967111175-CreateWorkflowHistoryTable.ts b/packages/cli/src/databases/migrations/common/1692967111175-CreateWorkflowHistoryTable.ts new file mode 100644 index 0000000000000..5f047ccb8dbf2 --- /dev/null +++ b/packages/cli/src/databases/migrations/common/1692967111175-CreateWorkflowHistoryTable.ts @@ -0,0 +1,27 @@ +import type { MigrationContext, ReversibleMigration } from '@db/types'; + +const tableName = 'workflow_history'; + +export class CreateWorkflowHistoryTable1692967111175 implements ReversibleMigration { + async up({ schemaBuilder: { createTable, column }, queryRunner }: MigrationContext) { + await createTable(tableName) + .withColumns( + column('versionId').varchar(36).primary.notNull, + column('workflowId').varchar(36).notNull, + column('nodes').text.notNull, + column('connections').text.notNull, + column('authors').varchar(255).notNull, + ) + .withTimestamps.withIndexOn('workflowId') + .withForeignKey('workflowId', { + tableName: 'workflow_entity', + columnName: 'id', + onDelete: 'CASCADE', + }) + .execute(queryRunner); + } + + async down({ schemaBuilder: { dropTable } }: MigrationContext) { + await dropTable(tableName); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index ca606867ae335..ce89cb312cb18 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -44,6 +44,8 @@ import { FixExecutionDataType1690000000031 } from './1690000000031-FixExecutionD import { RemoveSkipOwnerSetup1681134145997 } from './1681134145997-RemoveSkipOwnerSetup'; import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns'; import { CreateWorkflowNameIndex1691088862123 } from '../common/1691088862123-CreateWorkflowNameIndex'; +import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns'; +import { CreateWorkflowHistoryTable1692967111175 } from '../common/1692967111175-CreateWorkflowHistoryTable'; export const mysqlMigrations: Migration[] = [ InitialMigration1588157391238, @@ -91,4 +93,6 @@ export const mysqlMigrations: Migration[] = [ RemoveSkipOwnerSetup1681134145997, RemoveResetPasswordColumns1690000000030, CreateWorkflowNameIndex1691088862123, + AddMfaColumns1690000000030, + CreateWorkflowHistoryTable1692967111175, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index 554bb1311483a..89249058e6633 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -42,6 +42,8 @@ import { RemoveSkipOwnerSetup1681134145997 } from './1681134145997-RemoveSkipOwn import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns'; import { AddMissingPrimaryKeyOnExecutionData1690787606731 } from './1690787606731-AddMissingPrimaryKeyOnExecutionData'; import { CreateWorkflowNameIndex1691088862123 } from '../common/1691088862123-CreateWorkflowNameIndex'; +import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns'; +import { CreateWorkflowHistoryTable1692967111175 } from '../common/1692967111175-CreateWorkflowHistoryTable'; export const postgresMigrations: Migration[] = [ InitialMigration1587669153312, @@ -87,4 +89,6 @@ export const postgresMigrations: Migration[] = [ RemoveResetPasswordColumns1690000000030, AddMissingPrimaryKeyOnExecutionData1690787606731, CreateWorkflowNameIndex1691088862123, + AddMfaColumns1690000000030, + CreateWorkflowHistoryTable1692967111175, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/1690000000040-AddMfaColumns.ts b/packages/cli/src/databases/migrations/sqlite/1690000000040-AddMfaColumns.ts new file mode 100644 index 0000000000000..1047f44f5fc73 --- /dev/null +++ b/packages/cli/src/databases/migrations/sqlite/1690000000040-AddMfaColumns.ts @@ -0,0 +1,5 @@ +import { AddMfaColumns1690000000030 as BaseMigration } from '../common/1690000000040-AddMfaColumns'; + +export class AddMfaColumns1690000000030 extends BaseMigration { + transaction = false as const; +} diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index eeb0cc4995b00..9bac59767b0e3 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -41,6 +41,8 @@ import { RemoveSkipOwnerSetup1681134145997 } from './1681134145997-RemoveSkipOwn import { FixMissingIndicesFromStringIdMigration1690000000020 } from './1690000000020-FixMissingIndicesFromStringIdMigration'; import { RemoveResetPasswordColumns1690000000030 } from './1690000000030-RemoveResetPasswordColumns'; import { CreateWorkflowNameIndex1691088862123 } from '../common/1691088862123-CreateWorkflowNameIndex'; +import { AddMfaColumns1690000000030 } from './1690000000040-AddMfaColumns'; +import { CreateWorkflowHistoryTable1692967111175 } from '../common/1692967111175-CreateWorkflowHistoryTable'; const sqliteMigrations: Migration[] = [ InitialMigration1588102412422, @@ -85,6 +87,8 @@ const sqliteMigrations: Migration[] = [ FixMissingIndicesFromStringIdMigration1690000000020, RemoveResetPasswordColumns1690000000030, CreateWorkflowNameIndex1691088862123, + AddMfaColumns1690000000030, + CreateWorkflowHistoryTable1692967111175, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/databases/repositories/settings.repository.ts b/packages/cli/src/databases/repositories/settings.repository.ts index 82630bffdb1b1..a213ee78a3018 100644 --- a/packages/cli/src/databases/repositories/settings.repository.ts +++ b/packages/cli/src/databases/repositories/settings.repository.ts @@ -1,3 +1,4 @@ +import { EXTERNAL_SECRETS_DB_KEY } from '@/ExternalSecrets/constants'; import { Service } from 'typedi'; import { DataSource, Repository } from 'typeorm'; import { ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; @@ -10,6 +11,21 @@ export class SettingsRepository extends Repository { super(Settings, dataSource.manager); } + async getEncryptedSecretsProviderSettings(): Promise { + return (await this.findOne({ where: { key: EXTERNAL_SECRETS_DB_KEY } }))?.value ?? null; + } + + async saveEncryptedSecretsProviderSettings(data: string): Promise { + await this.upsert( + { + key: EXTERNAL_SECRETS_DB_KEY, + value: data, + loadOnStartup: false, + }, + ['key'], + ); + } + async dismissBanner({ bannerName }: { bannerName: string }): Promise<{ success: boolean }> { const key = 'ui.banners.dismissed'; const dismissedBannersSetting = await this.findOneBy({ key }); diff --git a/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts index a35d18d2f34ac..85a6ad83fdc8e 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts @@ -15,7 +15,6 @@ import { Credentials, UserSettings } from 'n8n-core'; import type { IWorkflowToImport } from '@/Interfaces'; import type { ExportableCredential } from './types/exportableCredential'; import type { Variables } from '@db/entities/Variables'; -import { UM_FIX_INSTRUCTION } from '@/commands/BaseCommand'; import { SharedCredentials } from '@db/entities/SharedCredentials'; import type { WorkflowTagMapping } from '@db/entities/WorkflowTagMapping'; import type { TagEntity } from '@db/entities/TagEntity'; @@ -28,6 +27,7 @@ import type { SourceControlledFile } from './types/sourceControlledFile'; import { RoleService } from '@/services/role.service'; import { VariablesService } from '../variables/variables.service'; import { TagRepository } from '@/databases/repositories'; +import { UM_FIX_INSTRUCTION } from '@/constants'; @Service() export class SourceControlImportService { diff --git a/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationWebhook.ee.ts b/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationWebhook.ee.ts index 2a97f4471d09c..6af27c1b76b56 100644 --- a/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationWebhook.ee.ts +++ b/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationWebhook.ee.ts @@ -15,6 +15,7 @@ import type { MessageEventBusDestinationOptions, MessageEventBusDestinationWebhookParameterItem, MessageEventBusDestinationWebhookParameterOptions, + IWorkflowExecuteAdditionalData, } from 'n8n-workflow'; import { CredentialsHelper } from '@/CredentialsHelper'; import { UserSettings } from 'n8n-core'; @@ -24,6 +25,7 @@ import { isLogStreamingEnabled } from '../MessageEventBus/MessageEventBusHelper' import { eventMessageGenericDestinationTestEvent } from '../EventMessageClasses/EventMessageGeneric'; import { MessageEventBus } from '../MessageEventBus/MessageEventBus'; import type { MessageWithCallback } from '../MessageEventBus/MessageEventBus'; +import * as SecretsHelpers from '@/ExternalSecrets/externalSecretsHelper.ee'; export const isMessageEventBusDestinationWebhookOptions = ( candidate: unknown, @@ -108,6 +110,7 @@ export class MessageEventBusDestinationWebhook if (foundCredential) { const timezone = config.getEnv('generic.timezone'); const credentialsDecrypted = await this.credentialsHelper?.getDecrypted( + { secretsHelpers: SecretsHelpers } as unknown as IWorkflowExecuteAdditionalData, foundCredential[1], foundCredential[0], 'internal', diff --git a/packages/cli/src/middlewares/listQuery/dtos/base.filter.dto.ts b/packages/cli/src/middlewares/listQuery/dtos/base.filter.dto.ts new file mode 100644 index 0000000000000..47a7273b5b2df --- /dev/null +++ b/packages/cli/src/middlewares/listQuery/dtos/base.filter.dto.ts @@ -0,0 +1,30 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +import { isObjectLiteral } from '@/utils'; +import { plainToInstance, instanceToPlain } from 'class-transformer'; +import { validate } from 'class-validator'; +import { jsonParse } from 'n8n-workflow'; + +export class BaseFilter { + protected static async toFilter(rawFilter: string, Filter: typeof BaseFilter) { + const dto = jsonParse(rawFilter, { errorMessage: 'Failed to parse filter JSON' }); + + if (!isObjectLiteral(dto)) throw new Error('Filter must be an object literal'); + + const instance = plainToInstance(Filter, dto, { + excludeExtraneousValues: true, // remove fields not in class + }); + + await instance.validate(); + + return instanceToPlain(instance, { + exposeUnsetFields: false, // remove in-class undefined fields + }); + } + + private async validate() { + const result = await validate(this); + + if (result.length > 0) throw new Error('Parsed filter does not fit the schema'); + } +} diff --git a/packages/cli/src/middlewares/listQuery/dtos/base.select.dto.ts b/packages/cli/src/middlewares/listQuery/dtos/base.select.dto.ts new file mode 100644 index 0000000000000..da7ba6752d17b --- /dev/null +++ b/packages/cli/src/middlewares/listQuery/dtos/base.select.dto.ts @@ -0,0 +1,19 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +import { isStringArray } from '@/utils'; +import { jsonParse } from 'n8n-workflow'; + +export class BaseSelect { + static selectableFields: Set; + + protected static toSelect(rawFilter: string, Select: typeof BaseSelect) { + const dto = jsonParse(rawFilter, { errorMessage: 'Failed to parse filter JSON' }); + + if (!isStringArray(dto)) throw new Error('Parsed select is not a string array'); + + return dto.reduce>((acc, field) => { + if (!Select.selectableFields.has(field)) return acc; + return (acc[field] = true), acc; + }, {}); + } +} diff --git a/packages/cli/src/middlewares/listQuery/dtos/user.filter.dto.ts b/packages/cli/src/middlewares/listQuery/dtos/user.filter.dto.ts new file mode 100644 index 0000000000000..6c7e8a01312ba --- /dev/null +++ b/packages/cli/src/middlewares/listQuery/dtos/user.filter.dto.ts @@ -0,0 +1,29 @@ +import { IsOptional, IsString, IsBoolean } from 'class-validator'; +import { Expose } from 'class-transformer'; +import { BaseFilter } from './base.filter.dto'; + +export class UserFilter extends BaseFilter { + @IsString() + @IsOptional() + @Expose() + email?: string; + + @IsString() + @IsOptional() + @Expose() + firstName?: string; + + @IsString() + @IsOptional() + @Expose() + lastName?: string; + + @IsBoolean() + @IsOptional() + @Expose() + isOwner?: boolean; + + static async fromString(rawFilter: string) { + return this.toFilter(rawFilter, UserFilter); + } +} diff --git a/packages/cli/src/middlewares/listQuery/dtos/user.select.dto.ts b/packages/cli/src/middlewares/listQuery/dtos/user.select.dto.ts new file mode 100644 index 0000000000000..7da40be3e8975 --- /dev/null +++ b/packages/cli/src/middlewares/listQuery/dtos/user.select.dto.ts @@ -0,0 +1,11 @@ +import { BaseSelect } from './base.select.dto'; + +export class UserSelect extends BaseSelect { + static get selectableFields() { + return new Set(['id', 'email', 'firstName', 'lastName']); + } + + static fromString(rawFilter: string) { + return this.toSelect(rawFilter, UserSelect); + } +} 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 4eb9da41e0c94..389246cf6cf42 100644 --- a/packages/cli/src/middlewares/listQuery/dtos/workflow.filter.dto.ts +++ b/packages/cli/src/middlewares/listQuery/dtos/workflow.filter.dto.ts @@ -1,9 +1,9 @@ -import { IsOptional, IsString, IsBoolean, IsArray, validate } from 'class-validator'; -import { Expose, instanceToPlain, plainToInstance } from 'class-transformer'; -import { jsonParse } from 'n8n-workflow'; -import { isObjectLiteral } from '@/utils'; +import { IsOptional, IsString, IsBoolean, IsArray } from 'class-validator'; +import { Expose } from 'class-transformer'; -export class WorkflowFilter { +import { BaseFilter } from './base.filter.dto'; + +export class WorkflowFilter extends BaseFilter { @IsString() @IsOptional() @Expose() @@ -21,23 +21,6 @@ export class WorkflowFilter { tags?: string[]; static async fromString(rawFilter: string) { - const dto = jsonParse(rawFilter, { errorMessage: 'Failed to parse filter JSON' }); - - if (!isObjectLiteral(dto)) throw new Error('Filter must be an object literal'); - - const instance = plainToInstance(WorkflowFilter, dto, { - excludeExtraneousValues: true, // remove fields not in class - exposeUnsetFields: false, // remove in-class undefined fields - }); - - await instance.validate(); - - return instanceToPlain(instance); - } - - private async validate() { - const result = await validate(this); - - if (result.length > 0) throw new Error('Parsed filter does not fit the schema'); + return this.toFilter(rawFilter, WorkflowFilter); } } diff --git a/packages/cli/src/middlewares/listQuery/dtos/workflow.select.dto.ts b/packages/cli/src/middlewares/listQuery/dtos/workflow.select.dto.ts index dd74e81269d7a..0a905604623c0 100644 --- a/packages/cli/src/middlewares/listQuery/dtos/workflow.select.dto.ts +++ b/packages/cli/src/middlewares/listQuery/dtos/workflow.select.dto.ts @@ -1,9 +1,6 @@ -import { isStringArray } from '@/utils'; -import { jsonParse } from 'n8n-workflow'; - -export class WorkflowSelect { - fields: string[]; +import { BaseSelect } from './base.select.dto'; +export class WorkflowSelect extends BaseSelect { static get selectableFields() { return new Set([ 'id', // always included downstream @@ -18,13 +15,6 @@ export class WorkflowSelect { } static fromString(rawFilter: string) { - const dto = jsonParse(rawFilter, { errorMessage: 'Failed to parse filter JSON' }); - - if (!isStringArray(dto)) throw new Error('Parsed select is not a string array'); - - return dto.reduce>((acc, field) => { - if (!WorkflowSelect.selectableFields.has(field)) return acc; - return (acc[field] = true), acc; - }, {}); + return this.toSelect(rawFilter, WorkflowSelect); } } diff --git a/packages/cli/src/middlewares/listQuery/filter.ts b/packages/cli/src/middlewares/listQuery/filter.ts index 6ca297ef909fb..cb189a5042cc5 100644 --- a/packages/cli/src/middlewares/listQuery/filter.ts +++ b/packages/cli/src/middlewares/listQuery/filter.ts @@ -2,6 +2,7 @@ import * as ResponseHelper from '@/ResponseHelper'; import { WorkflowFilter } from './dtos/workflow.filter.dto'; +import { UserFilter } from './dtos/user.filter.dto'; import { toError } from '@/utils'; import type { NextFunction, Response } from 'express'; @@ -20,6 +21,8 @@ export const filterListQueryMiddleware = async ( if (req.baseUrl.endsWith('workflows')) { Filter = WorkflowFilter; + } else if (req.baseUrl.endsWith('users')) { + Filter = UserFilter; } else { return next(); } diff --git a/packages/cli/src/middlewares/listQuery/pagination.ts b/packages/cli/src/middlewares/listQuery/pagination.ts index 9a16ee3a5a28e..2438292be7517 100644 --- a/packages/cli/src/middlewares/listQuery/pagination.ts +++ b/packages/cli/src/middlewares/listQuery/pagination.ts @@ -11,9 +11,13 @@ export const paginationListQueryMiddleware: RequestHandler = ( ) => { const { take: rawTake, skip: rawSkip = '0' } = req.query; - if (!rawTake) return next(); - try { + if (!rawTake && req.query.skip) { + throw new Error('Please specify `take` when using `skip`'); + } + + if (!rawTake) return next(); + const { take, skip } = Pagination.fromString(rawTake, rawSkip); req.listQueryOptions = { ...req.listQueryOptions, skip, take }; diff --git a/packages/cli/src/middlewares/listQuery/select.ts b/packages/cli/src/middlewares/listQuery/select.ts index e5813bbc0e0a8..4ff4ac525af66 100644 --- a/packages/cli/src/middlewares/listQuery/select.ts +++ b/packages/cli/src/middlewares/listQuery/select.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { WorkflowSelect } from './dtos/workflow.select.dto'; +import { UserSelect } from './dtos/user.select.dto'; import * as ResponseHelper from '@/ResponseHelper'; import { toError } from '@/utils'; @@ -16,6 +17,8 @@ export const selectListQueryMiddleware: RequestHandler = (req: ListQuery.Request if (req.baseUrl.endsWith('workflows')) { Select = WorkflowSelect; + } else if (req.baseUrl.endsWith('users')) { + Select = UserSelect; } else { return next(); } diff --git a/packages/cli/src/push/index.ts b/packages/cli/src/push/index.ts index d5225b34893a9..752f90e5a977b 100644 --- a/packages/cli/src/push/index.ts +++ b/packages/cli/src/push/index.ts @@ -26,7 +26,7 @@ export class Push extends EventEmitter { } else if (!useWebSockets) { (this.backend as SSEPush).add(req.query.sessionId, { req, res }); } else { - res.status(1008).send('Unauthorized'); + res.status(401).send('Unauthorized'); } this.emit('editorUiConnected', req.query.sessionId); } @@ -88,7 +88,7 @@ export const setupPushHandler = (restEndpoint: string, app: Application) => { ws.send(`Unauthorized: ${(error as Error).message}`); ws.close(1008); } else { - res.status(1008).send('Unauthorized'); + res.status(401).send('Unauthorized'); } return; } diff --git a/packages/cli/src/push/websocket.push.ts b/packages/cli/src/push/websocket.push.ts index 93c9a24220759..46c38394df16f 100644 --- a/packages/cli/src/push/websocket.push.ts +++ b/packages/cli/src/push/websocket.push.ts @@ -1,12 +1,29 @@ import type WebSocket from 'ws'; import { AbstractPush } from './abstract.push'; +function heartbeat(this: WebSocket) { + this.isAlive = true; +} + export class WebSocketPush extends AbstractPush { + constructor() { + super(); + + // Ping all connected clients every 60 seconds + setInterval(() => this.pingAll(), 60 * 1000); + } + add(sessionId: string, connection: WebSocket) { + connection.isAlive = true; + connection.on('pong', heartbeat); + super.add(sessionId, connection); // Makes sure to remove the session if the connection is closed - connection.once('close', () => this.remove(sessionId)); + connection.once('close', () => { + connection.off('pong', heartbeat); + this.remove(sessionId); + }); } protected close(connection: WebSocket): void { @@ -16,4 +33,18 @@ export class WebSocketPush extends AbstractPush { protected sendToOne(connection: WebSocket, data: string): void { connection.send(data); } + + private pingAll() { + for (const sessionId in this.connections) { + const connection = this.connections[sessionId]; + // If a connection did not respond with a `PONG` in the last 60 seconds, disconnect + if (!connection.isAlive) { + delete this.connections[sessionId]; + return connection.terminate(); + } + + connection.isAlive = false; + connection.ping(); + } + } } diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 7fc0bb94f6f04..fe7bb2c421897 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -4,6 +4,7 @@ import type { IConnections, ICredentialDataDecryptedObject, ICredentialNodeAccess, + IDataObject, INode, INodeCredentialTestRequest, IPinData, @@ -14,7 +15,13 @@ import type { import { IsBoolean, IsEmail, IsOptional, IsString, Length } from 'class-validator'; import { NoXss } from '@db/utils/customValidators'; -import type { PublicUser, IExecutionDeleteFilter, IWorkflowDb } from '@/Interfaces'; +import type { + PublicUser, + IExecutionDeleteFilter, + IWorkflowDb, + SecretsProvider, + SecretsProviderState, +} from '@/Interfaces'; import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; import type { UserManagementMailer } from '@/UserManagement/email'; @@ -227,7 +234,7 @@ export declare namespace MeRequest { export type Password = AuthenticatedRequest< {}, {}, - { currentPassword: string; newPassword: string } + { currentPassword: string; newPassword: string; token?: string } >; export type SurveyAnswers = AuthenticatedRequest<{}, {}, Record | {}>; } @@ -237,6 +244,9 @@ export interface UserSetupPayload { password: string; firstName: string; lastName: string; + mfaEnabled?: boolean; + mfaSecret?: string; + mfaRecoveryCodes?: string[]; } // ---------------------------------- @@ -261,7 +271,7 @@ export declare namespace PasswordResetRequest { export type NewPassword = AuthlessRequest< {}, {}, - Pick & { token?: string; userId?: string } + Pick & { token?: string; userId?: string; mfaToken?: string } >; } @@ -270,8 +280,6 @@ export declare namespace PasswordResetRequest { // ---------------------------------- export declare namespace UserRequest { - export type List = AuthenticatedRequest; - export type Invite = AuthenticatedRequest<{}, {}, Array<{ email: string }>>; export type ResolveSignUp = AuthlessRequest< @@ -332,9 +340,27 @@ export type LoginRequest = AuthlessRequest< { email: string; password: string; + mfaToken?: string; + mfaRecoveryCode?: string; } >; +// ---------------------------------- +// MFA endpoints +// ---------------------------------- + +export declare namespace MFA { + type Verify = AuthenticatedRequest<{}, {}, { token: string }, {}>; + type Activate = AuthenticatedRequest<{}, {}, { token: string }, {}>; + type Config = AuthenticatedRequest<{}, {}, { login: { enabled: boolean } }, {}>; + type ValidateRecoveryCode = AuthenticatedRequest< + {}, + {}, + { recoveryCode: { enabled: boolean } }, + {} + >; +} + // ---------------------------------- // oauth endpoints // ---------------------------------- @@ -476,3 +502,25 @@ export declare namespace VariablesRequest { type Update = AuthenticatedRequest<{ id: string }, {}, CreateUpdatePayload, {}>; type Delete = Get; } + +export declare namespace ExternalSecretsRequest { + type GetProviderResponse = Pick & { + icon: string; + connected: boolean; + connectedAt: Date | null; + state: SecretsProviderState; + data: IDataObject; + }; + + type GetProviders = AuthenticatedRequest; + type GetProvider = AuthenticatedRequest<{ provider: string }, GetProviderResponse>; + type SetProviderSettings = AuthenticatedRequest<{ provider: string }, {}, IDataObject>; + type TestProviderSettings = SetProviderSettings; + type SetProviderConnected = AuthenticatedRequest< + { provider: string }, + {}, + { connected: boolean } + >; + + type UpdateProvider = AuthenticatedRequest<{ provider: string }>; +} diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index 678482a340ca4..6d6c9c15f528b 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -4,6 +4,9 @@ import { In } from 'typeorm'; import { User } from '@db/entities/User'; import type { IUserSettings } from 'n8n-workflow'; import { UserRepository } from '@/databases/repositories'; +import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; +import type { PublicUser } from '@/Interfaces'; +import type { PostHogClient } from '@/posthog'; @Service() export class UserService { @@ -18,7 +21,7 @@ export class UserService { } async findMany(options: FindManyOptions) { - return this.userRepository.find({ relations: ['globalRole'], ...options }); + return this.userRepository.find(options); } async findOneBy(options: FindOptionsWhere) { @@ -51,11 +54,62 @@ export class UserService { return this.userRepository.update(userId, { settings: { ...settings, ...newSettings } }); } - generatePasswordResetUrl(instanceBaseUrl: string, token: string) { + generatePasswordResetUrl(instanceBaseUrl: string, token: string, mfaEnabled: boolean) { const url = new URL(`${instanceBaseUrl}/change-password`); url.searchParams.append('token', token); + url.searchParams.append('mfaEnabled', mfaEnabled.toString()); return url.toString(); } + + async toPublic(user: User, options?: { withInviteUrl?: boolean; posthog?: PostHogClient }) { + const { password, updatedAt, apiKey, authIdentities, ...rest } = user; + + const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap'); + + let publicUser: PublicUser = { + ...rest, + signInType: ldapIdentity ? 'ldap' : 'email', + hasRecoveryCodesLeft: !!user.mfaRecoveryCodes?.length, + }; + + if (options?.withInviteUrl && publicUser.isPending) { + publicUser = this.addInviteUrl(publicUser, user.id); + } + + if (options?.posthog) { + publicUser = await this.addFeatureFlags(publicUser, options.posthog); + } + + return publicUser; + } + + private addInviteUrl(user: PublicUser, inviterId: string) { + const url = new URL(getInstanceBaseUrl()); + url.pathname = '/signup'; + url.searchParams.set('inviterId', inviterId); + url.searchParams.set('inviteeId', user.id); + + user.inviteAcceptUrl = url.toString(); + + return user; + } + + private async addFeatureFlags(publicUser: PublicUser, posthog: PostHogClient) { + // native PostHog implementation has default 10s timeout and 3 retries.. which cannot be updated without affecting other functionality + // https://github.com/PostHog/posthog-js-lite/blob/a182de80a433fb0ffa6859c10fb28084d0f825c2/posthog-core/src/index.ts#L67 + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => { + resolve(publicUser); + }, 1500); + }); + + const fetchPromise = new Promise(async (resolve) => { + publicUser.featureFlags = await posthog.getFeatureFlags(publicUser); + resolve(publicUser); + }); + + return Promise.race([fetchPromise, timeoutPromise]); + } } diff --git a/packages/cli/test/integration/ExternalSecrets/externalSecrets.api.test.ts b/packages/cli/test/integration/ExternalSecrets/externalSecrets.api.test.ts new file mode 100644 index 0000000000000..8ea55e7e7317b --- /dev/null +++ b/packages/cli/test/integration/ExternalSecrets/externalSecrets.api.test.ts @@ -0,0 +1,370 @@ +import type { SuperAgentTest } from 'supertest'; +import { License } from '@/License'; +import * as testDb from '../shared/testDb'; +import * as utils from '../shared/utils/'; +import type { ExternalSecretsSettings, SecretsProviderState } from '@/Interfaces'; +import { UserSettings } from 'n8n-core'; +import { SettingsRepository } from '@/databases/repositories/settings.repository'; +import Container from 'typedi'; +import { AES, enc } from 'crypto-js'; +import { ExternalSecretsProviders } from '@/ExternalSecrets/ExternalSecretsProviders.ee'; +import { + DummyProvider, + FailedProvider, + MockProviders, + TestFailProvider, +} from '../../shared/ExternalSecrets/utils'; +import config from '@/config'; +import { ExternalSecretsManager } from '@/ExternalSecrets/ExternalSecretsManager.ee'; +import { CREDENTIAL_BLANKING_VALUE } from '@/constants'; +import type { IDataObject } from 'n8n-workflow'; + +let authOwnerAgent: SuperAgentTest; +let authMemberAgent: SuperAgentTest; + +const licenseLike = utils.mockInstance(License, { + isExternalSecretsEnabled: jest.fn().mockReturnValue(true), + isWithinUsersLimit: jest.fn().mockReturnValue(true), +}); + +const mockProvidersInstance = new MockProviders(); +let providersMock: ExternalSecretsProviders = utils.mockInstance( + ExternalSecretsProviders, + mockProvidersInstance, +); + +const testServer = utils.setupTestServer({ endpointGroups: ['externalSecrets'] }); + +const connectedDate = '2023-08-01T12:32:29.000Z'; + +async function setExternalSecretsSettings(settings: ExternalSecretsSettings) { + const encryptionKey = await UserSettings.getEncryptionKey(); + return Container.get(SettingsRepository).saveEncryptedSecretsProviderSettings( + AES.encrypt(JSON.stringify(settings), encryptionKey).toString(), + ); +} + +async function getExternalSecretsSettings(): Promise { + const encryptionKey = await UserSettings.getEncryptionKey(); + const encSettings = await Container.get(SettingsRepository).getEncryptedSecretsProviderSettings(); + if (encSettings === null) { + return null; + } + return JSON.parse(AES.decrypt(encSettings, encryptionKey).toString(enc.Utf8)); +} + +const resetManager = async () => { + Container.get(ExternalSecretsManager).shutdown(); + Container.set( + ExternalSecretsManager, + new ExternalSecretsManager( + Container.get(SettingsRepository), + licenseLike, + mockProvidersInstance, + ), + ); + + await Container.get(ExternalSecretsManager).init(); +}; + +const getDummyProviderData = ({ + data, + includeProperties, + connected, + state, + connectedAt, + displayName, +}: { + data?: IDataObject; + includeProperties?: boolean; + connected?: boolean; + state?: SecretsProviderState; + connectedAt?: string | null; + displayName?: string; +} = {}) => { + const dummy: IDataObject = { + connected: connected ?? true, + connectedAt: connectedAt === undefined ? connectedDate : connectedAt, + data: data ?? {}, + name: 'dummy', + displayName: displayName ?? 'Dummy Provider', + icon: 'dummy', + state: state ?? 'connected', + }; + + if (includeProperties) { + dummy.properties = new DummyProvider().properties; + } + + return dummy; +}; + +beforeAll(async () => { + await utils.initEncryptionKey(); + + const owner = await testDb.createOwner(); + authOwnerAgent = testServer.authAgentFor(owner); + const member = await testDb.createUser(); + authMemberAgent = testServer.authAgentFor(member); + config.set('userManagement.isInstanceOwnerSetUp', true); +}); + +beforeEach(async () => { + licenseLike.isExternalSecretsEnabled.mockReturnValue(true); + + mockProvidersInstance.setProviders({ + dummy: DummyProvider, + }); + + await setExternalSecretsSettings({ + dummy: { + connected: true, + connectedAt: new Date(connectedDate), + settings: {}, + }, + }); + + await resetManager(); +}); + +afterEach(async () => { + Container.get(ExternalSecretsManager).shutdown(); +}); + +describe('GET /external-secrets/providers', () => { + test('can retrieve providers as owner', async () => { + const resp = await authOwnerAgent.get('/external-secrets/providers'); + expect(resp.body).toEqual({ + data: [getDummyProviderData()], + }); + }); + + test('can not retrieve providers as non-owner', async () => { + const resp = await authMemberAgent.get('/external-secrets/providers'); + expect(resp.status).toBe(403); + }); + + test('does obscure passwords', async () => { + await setExternalSecretsSettings({ + dummy: { + connected: true, + connectedAt: new Date(connectedDate), + settings: { + username: 'testuser', + password: 'testpass', + }, + }, + }); + + await resetManager(); + + const resp = await authOwnerAgent.get('/external-secrets/providers'); + expect(resp.body).toEqual({ + data: [ + getDummyProviderData({ + data: { + username: 'testuser', + password: CREDENTIAL_BLANKING_VALUE, + }, + }), + ], + }); + }); +}); + +describe('GET /external-secrets/providers/:provider', () => { + test('can retrieve provider as owner', async () => { + const resp = await authOwnerAgent.get('/external-secrets/providers/dummy'); + expect(resp.body.data).toEqual(getDummyProviderData({ includeProperties: true })); + }); + + test('can not retrieve provider as non-owner', async () => { + const resp = await authMemberAgent.get('/external-secrets/providers/dummy'); + expect(resp.status).toBe(403); + }); + + test('does obscure passwords', async () => { + await setExternalSecretsSettings({ + dummy: { + connected: true, + connectedAt: new Date(connectedDate), + settings: { + username: 'testuser', + password: 'testpass', + }, + }, + }); + + await resetManager(); + + const resp = await authOwnerAgent.get('/external-secrets/providers/dummy'); + expect(resp.body.data).toEqual( + getDummyProviderData({ + data: { + username: 'testuser', + password: CREDENTIAL_BLANKING_VALUE, + }, + includeProperties: true, + }), + ); + }); +}); + +describe('POST /external-secrets/providers/:provider', () => { + test('can update provider settings', async () => { + const testData = { + username: 'testuser', + other: 'testother', + }; + const resp = await authOwnerAgent.post('/external-secrets/providers/dummy').send(testData); + expect(resp.status).toBe(200); + + const confirmResp = await authOwnerAgent.get('/external-secrets/providers/dummy'); + expect(confirmResp.body.data).toEqual( + getDummyProviderData({ data: testData, includeProperties: true }), + ); + }); + + test('can update provider settings with blanking value', async () => { + await setExternalSecretsSettings({ + dummy: { + connected: true, + connectedAt: new Date(connectedDate), + settings: { + username: 'testuser', + password: 'testpass', + }, + }, + }); + + await resetManager(); + + const testData = { + username: 'newuser', + password: CREDENTIAL_BLANKING_VALUE, + }; + const resp = await authOwnerAgent.post('/external-secrets/providers/dummy').send(testData); + expect(resp.status).toBe(200); + + const confirmResp = await authOwnerAgent.get('/external-secrets/providers/dummy'); + expect((await getExternalSecretsSettings())?.dummy.settings).toEqual({ + username: 'newuser', + password: 'testpass', + }); + }); +}); + +describe('POST /external-secrets/providers/:provider/connect', () => { + test('can change provider connected state', async () => { + const testData = { + connected: false, + }; + const resp = await authOwnerAgent + .post('/external-secrets/providers/dummy/connect') + .send(testData); + expect(resp.status).toBe(200); + + const confirmResp = await authOwnerAgent.get('/external-secrets/providers/dummy'); + expect(confirmResp.body.data).toEqual( + getDummyProviderData({ + includeProperties: true, + connected: false, + state: 'initializing', + }), + ); + }); +}); + +describe('POST /external-secrets/providers/:provider/test', () => { + test('can test provider', async () => { + const testData = { + username: 'testuser', + other: 'testother', + }; + const resp = await authOwnerAgent.post('/external-secrets/providers/dummy/test').send(testData); + expect(resp.status).toBe(200); + expect(resp.body.data.success).toBe(true); + expect(resp.body.data.testState).toBe('connected'); + }); + + test('can test provider fail', async () => { + mockProvidersInstance.setProviders({ + dummy: TestFailProvider, + }); + + await resetManager(); + + const testData = { + username: 'testuser', + other: 'testother', + }; + const resp = await authOwnerAgent.post('/external-secrets/providers/dummy/test').send(testData); + expect(resp.status).toBe(400); + expect(resp.body.data.success).toBe(false); + expect(resp.body.data.testState).toBe('error'); + }); +}); + +describe('POST /external-secrets/providers/:provider/update', () => { + test('can update provider', async () => { + const updateSpy = jest.spyOn( + Container.get(ExternalSecretsManager).getProvider('dummy')!, + 'update', + ); + + const resp = await authOwnerAgent.post('/external-secrets/providers/dummy/update'); + expect(resp.status).toBe(200); + expect(resp.body.data).toEqual({ updated: true }); + expect(updateSpy).toBeCalled(); + }); + + test('can not update errored provider', async () => { + mockProvidersInstance.setProviders({ + dummy: FailedProvider, + }); + + await resetManager(); + + const updateSpy = jest.spyOn( + Container.get(ExternalSecretsManager).getProvider('dummy')!, + 'update', + ); + + const resp = await authOwnerAgent.post('/external-secrets/providers/dummy/update'); + expect(resp.status).toBe(400); + expect(resp.body.data).toEqual({ updated: false }); + expect(updateSpy).not.toBeCalled(); + }); + + test('can not update provider without a valid license', async () => { + const updateSpy = jest.spyOn( + Container.get(ExternalSecretsManager).getProvider('dummy')!, + 'update', + ); + + licenseLike.isExternalSecretsEnabled.mockReturnValue(false); + + const resp = await authOwnerAgent.post('/external-secrets/providers/dummy/update'); + expect(resp.status).toBe(400); + expect(resp.body.data).toEqual({ updated: false }); + expect(updateSpy).not.toBeCalled(); + }); +}); + +describe('GET /external-secrets/secrets', () => { + test('can get secret names as owner', async () => { + const resp = await authOwnerAgent.get('/external-secrets/secrets'); + expect(resp.status).toBe(200); + expect(resp.body.data).toEqual({ + dummy: ['test1', 'test2'], + }); + }); + + test('can not get secret names as non-owner', async () => { + const resp = await authMemberAgent.get('/external-secrets/secrets'); + expect(resp.status).toBe(403); + expect(resp.body.data).not.toEqual({ + dummy: ['test1', 'test2'], + }); + }); +}); diff --git a/packages/cli/test/integration/ldap/ldap.api.test.ts b/packages/cli/test/integration/ldap/ldap.api.test.ts index 65d1c080d5b7d..8b00566d1187d 100644 --- a/packages/cli/test/integration/ldap/ldap.api.test.ts +++ b/packages/cli/test/integration/ldap/ldap.api.test.ts @@ -11,7 +11,6 @@ import { LdapManager } from '@/Ldap/LdapManager.ee'; import { LdapService } from '@/Ldap/LdapService.ee'; import { encryptPassword, saveLdapSynchronization } from '@/Ldap/helpers'; import type { LdapConfig } from '@/Ldap/types'; -import { sanitizeUser } from '@/UserManagement/UserManagementHelper'; import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from '@/sso/ssoHelpers'; import { randomEmail, randomName, uniqueId } from './../shared/random'; @@ -570,24 +569,3 @@ describe('Instance owner should able to delete LDAP users', () => { await authOwnerAgent.post(`/users/${member.id}?transferId=${owner.id}`); }); }); - -test('Sign-type should be returned when listing users', async () => { - const ldapConfig = await createLdapConfig(); - LdapManager.updateConfig(ldapConfig); - - await testDb.createLdapUser( - { - globalRole: globalMemberRole, - }, - uniqueId(), - ); - - const allUsers = await testDb.getAllUsers(); - expect(allUsers.length).toBe(2); - - const ownerUser = allUsers.find((u) => u.email === owner.email)!; - expect(sanitizeUser(ownerUser).signInType).toStrictEqual('email'); - - const memberUser = allUsers.find((u) => u.email !== owner.email)!; - expect(sanitizeUser(memberUser).signInType).toStrictEqual('ldap'); -}); diff --git a/packages/cli/test/integration/mfa/mfa.api.test.ts b/packages/cli/test/integration/mfa/mfa.api.test.ts new file mode 100644 index 0000000000000..3914a428436b3 --- /dev/null +++ b/packages/cli/test/integration/mfa/mfa.api.test.ts @@ -0,0 +1,405 @@ +import config from '@/config'; +import * as Db from '@/Db'; +import type { Role } from '@db/entities/Role'; +import type { User } from '@db/entities/User'; +import * as testDb from './../shared/testDb'; +import * as utils from '../shared/utils'; +import { randomPassword } from '@/Ldap/helpers'; +import { randomDigit, randomString, randomValidPassword, uniqueId } from '../shared/random'; +import { TOTPService } from '@/Mfa/totp.service'; +import Container from 'typedi'; +import { JwtService } from '@/services/jwt.service'; + +jest.mock('@/telemetry'); + +let globalOwnerRole: Role; +let owner: User; + +const testServer = utils.setupTestServer({ + endpointGroups: ['mfa', 'auth', 'me', 'passwordReset'], +}); + +beforeEach(async () => { + await testDb.truncate(['User']); + + owner = await testDb.createUser({ globalRole: globalOwnerRole }); + + config.set('userManagement.disabled', false); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +describe('Enable MFA setup', () => { + describe('Step one', () => { + test('GET /qr should fail due to unauthenticated user', async () => { + const response = await testServer.authlessAgent.get('/mfa/qr'); + + expect(response.statusCode).toBe(401); + }); + + test('GET /qr should reuse secret and recovery codes until setup is complete', async () => { + const firstCall = await testServer.authAgentFor(owner).get('/mfa/qr'); + + const secondCall = await testServer.authAgentFor(owner).get('/mfa/qr'); + + expect(firstCall.body.data.secret).toBe(secondCall.body.data.secret); + expect(firstCall.body.data.recoveryCodes.join('')).toBe( + secondCall.body.data.recoveryCodes.join(''), + ); + + await testServer.authAgentFor(owner).delete('/mfa/disable'); + + const thirdCall = await testServer.authAgentFor(owner).get('/mfa/qr'); + + expect(firstCall.body.data.secret).not.toBe(thirdCall.body.data.secret); + expect(firstCall.body.data.recoveryCodes.join('')).not.toBe( + thirdCall.body.data.recoveryCodes.join(''), + ); + }); + + test('GET /qr should return qr, secret and recovery codes', async () => { + const response = await testServer.authAgentFor(owner).get('/mfa/qr'); + + expect(response.statusCode).toBe(200); + + const { data } = response.body; + + expect(data.secret).toBeDefined(); + expect(data.qrCode).toBeDefined(); + expect(data.recoveryCodes).toBeDefined(); + expect(data.recoveryCodes).not.toBeEmptyArray(); + expect(data.recoveryCodes.length).toBe(10); + }); + }); + + describe('Step two', () => { + test('POST /verify should fail due to unauthenticated user', async () => { + const response = await testServer.authlessAgent.post('/mfa/verify'); + + expect(response.statusCode).toBe(401); + }); + + test('POST /verify should fail due to invalid MFA token', async () => { + const response = await testServer + .authAgentFor(owner) + .post('/mfa/verify') + .send({ token: '123' }); + + expect(response.statusCode).toBe(400); + }); + + test('POST /verify should fail due to missing token parameter', async () => { + await testServer.authAgentFor(owner).get('/mfa/qr'); + + const response = await testServer.authAgentFor(owner).post('/mfa/verify').send({ token: '' }); + + expect(response.statusCode).toBe(400); + }); + + test('POST /verify should validate MFA token', async () => { + const response = await testServer.authAgentFor(owner).get('/mfa/qr'); + + const { secret } = response.body.data; + + const token = new TOTPService().generateTOTP(secret); + + const { statusCode } = await testServer + .authAgentFor(owner) + .post('/mfa/verify') + .send({ token }); + + expect(statusCode).toBe(200); + }); + }); + + describe('Step three', () => { + test('POST /enable should fail due to unauthenticated user', async () => { + const response = await testServer.authlessAgent.post('/mfa/enable'); + + expect(response.statusCode).toBe(401); + }); + + test('POST /verify should fail due to missing token parameter', async () => { + const response = await testServer.authAgentFor(owner).post('/mfa/verify').send({ token: '' }); + + expect(response.statusCode).toBe(400); + }); + + test('POST /enable should fail due to invalid MFA token', async () => { + await testServer.authAgentFor(owner).get('/mfa/qr'); + + const response = await testServer + .authAgentFor(owner) + .post('/mfa/enable') + .send({ token: '123' }); + + expect(response.statusCode).toBe(400); + }); + + test('POST /enable should fail due to empty secret and recovery codes', async () => { + const response = await testServer.authAgentFor(owner).post('/mfa/enable'); + + expect(response.statusCode).toBe(400); + }); + + test('POST /enable should enable MFA in account', async () => { + const response = await testServer.authAgentFor(owner).get('/mfa/qr'); + + const { secret } = response.body.data; + + const token = new TOTPService().generateTOTP(secret); + + await testServer.authAgentFor(owner).post('/mfa/verify').send({ token }); + + const { statusCode } = await testServer + .authAgentFor(owner) + .post('/mfa/enable') + .send({ token }); + + expect(statusCode).toBe(200); + + const user = await Db.collections.User.findOneOrFail({ + where: {}, + select: ['mfaEnabled', 'mfaRecoveryCodes', 'mfaSecret'], + }); + + expect(user.mfaEnabled).toBe(true); + expect(user.mfaRecoveryCodes).toBeDefined(); + expect(user.mfaSecret).toBeDefined(); + }); + }); +}); + +describe('Disable MFA setup', () => { + test('POST /disable should disable login with MFA', async () => { + const { user } = await testDb.createUserWithMfaEnabled(); + + const response = await testServer.authAgentFor(user).delete('/mfa/disable'); + + expect(response.statusCode).toBe(200); + + const dbUser = await Db.collections.User.findOneOrFail({ + where: { id: user.id }, + select: ['mfaEnabled', 'mfaRecoveryCodes', 'mfaSecret'], + }); + + expect(dbUser.mfaEnabled).toBe(false); + expect(dbUser.mfaSecret).toBe(null); + expect(dbUser.mfaRecoveryCodes.length).toBe(0); + }); +}); + +describe('Change password with MFA enabled', () => { + test('PATCH /me/password should fail due to missing MFA token', async () => { + const { user, rawPassword } = await testDb.createUserWithMfaEnabled(); + + const newPassword = randomPassword(); + + const response = await testServer + .authAgentFor(user) + .patch('/me/password') + .send({ currentPassword: rawPassword, newPassword }); + + expect(response.statusCode).toBe(400); + }); + + test('POST /change-password should fail due to missing MFA token', async () => { + const { user } = await testDb.createUserWithMfaEnabled(); + + const newPassword = randomValidPassword(); + + const resetPasswordToken = uniqueId(); + + const response = await testServer.authlessAgent + .post('/change-password') + .send({ password: newPassword, token: resetPasswordToken }); + + expect(response.statusCode).toBe(400); + }); + + test('POST /change-password should fail due to invalid MFA token', async () => { + const { user } = await testDb.createUserWithMfaEnabled(); + + const newPassword = randomValidPassword(); + + const resetPasswordToken = uniqueId(); + + const response = await testServer.authlessAgent.post('/change-password').send({ + password: newPassword, + token: resetPasswordToken, + mfaToken: randomDigit(), + }); + + expect(response.statusCode).toBe(400); + }); + + test('POST /change-password should update password', async () => { + const { user, rawSecret } = await testDb.createUserWithMfaEnabled(); + + const newPassword = randomValidPassword(); + + config.set('userManagement.jwtSecret', randomString(5, 10)); + + const jwtService = Container.get(JwtService); + + const resetPasswordToken = jwtService.signData({ sub: user.id }); + + const mfaToken = new TOTPService().generateTOTP(rawSecret); + + const response = await testServer.authlessAgent.post('/change-password').send({ + password: newPassword, + token: resetPasswordToken, + mfaToken, + }); + + expect(response.statusCode).toBe(200); + + const loginResponse = await testServer + .authAgentFor(user) + .post('/login') + .send({ + email: user.email, + password: newPassword, + mfaToken: new TOTPService().generateTOTP(rawSecret), + }); + + expect(loginResponse.statusCode).toBe(200); + expect(loginResponse.body).toHaveProperty('data'); + }); +}); + +describe('Login', () => { + test('POST /login with email/password should succeed when mfa is disabled', async () => { + const password = randomPassword(); + + const user = await testDb.createUser({ password }); + + const response = await testServer.authlessAgent + .post('/login') + .send({ email: user.email, password }); + + expect(response.statusCode).toBe(200); + }); + + test('GET /login should include hasRecoveryCodesLeft property in response', async () => { + const response = await testServer.authAgentFor(owner).get('/login'); + + const { data } = response.body; + + expect(response.statusCode).toBe(200); + + expect(data.hasRecoveryCodesLeft).toBeDefined(); + }); + + test('GET /login should not include mfaSecret and mfaRecoveryCodes property in response', async () => { + const response = await testServer.authAgentFor(owner).get('/login'); + + const { data } = response.body; + + expect(response.statusCode).toBe(200); + + expect(data.recoveryCodes).not.toBeDefined(); + expect(data.mfaSecret).not.toBeDefined(); + }); + + test('POST /login with email/password should fail when mfa is enabled', async () => { + const { user, rawPassword } = await testDb.createUserWithMfaEnabled(); + + const response = await testServer.authlessAgent + .post('/login') + .send({ email: user.email, password: rawPassword }); + + expect(response.statusCode).toBe(401); + }); + + describe('Login with MFA token', () => { + test('POST /login should fail due to invalid MFA token', async () => { + const { user, rawPassword } = await testDb.createUserWithMfaEnabled(); + + const response = await testServer.authlessAgent + .post('/login') + .send({ email: user.email, password: rawPassword, mfaToken: 'wrongvalue' }); + + expect(response.statusCode).toBe(401); + }); + + test('POST /login should fail due two MFA step needed', async () => { + const { user, rawPassword } = await testDb.createUserWithMfaEnabled(); + + const response = await testServer.authlessAgent + .post('/login') + .send({ email: user.email, password: rawPassword }); + + expect(response.statusCode).toBe(401); + expect(response.body.code).toBe(998); + }); + + test('POST /login should succeed with MFA token', async () => { + const { user, rawSecret, rawPassword } = await testDb.createUserWithMfaEnabled(); + + const token = new TOTPService().generateTOTP(rawSecret); + + const response = await testServer.authlessAgent + .post('/login') + .send({ email: user.email, password: rawPassword, mfaToken: token }); + + const data = response.body.data; + + expect(response.statusCode).toBe(200); + expect(data.mfaEnabled).toBe(true); + }); + }); + + describe('Login with recovery code', () => { + test('POST /login should fail due to invalid MFA recovery code', async () => { + const { user, rawPassword } = await testDb.createUserWithMfaEnabled(); + + const response = await testServer.authlessAgent + .post('/login') + .send({ email: user.email, password: rawPassword, mfaRecoveryCode: 'wrongvalue' }); + + expect(response.statusCode).toBe(401); + }); + + test('POST /login should succeed with MFA recovery code', async () => { + const { user, rawPassword, rawRecoveryCodes } = await testDb.createUserWithMfaEnabled(); + + const response = await testServer.authlessAgent + .post('/login') + .send({ email: user.email, password: rawPassword, mfaRecoveryCode: rawRecoveryCodes[0] }); + + const data = response.body.data; + + expect(response.statusCode).toBe(200); + expect(data.mfaEnabled).toBe(true); + expect(data.hasRecoveryCodesLeft).toBe(true); + + const dbUser = await Db.collections.User.findOneOrFail({ + where: { id: user.id }, + select: ['mfaEnabled', 'mfaRecoveryCodes', 'mfaSecret'], + }); + + // Make sure the recovery code used was removed + expect(dbUser.mfaRecoveryCodes.length).toBe(rawRecoveryCodes.length - 1); + expect(dbUser.mfaRecoveryCodes.includes(rawRecoveryCodes[0])).toBe(false); + }); + + test('POST /login with MFA recovery code should update hasRecoveryCodesLeft property', async () => { + const { user, rawPassword, rawRecoveryCodes } = await testDb.createUserWithMfaEnabled({ + numberOfRecoveryCodes: 1, + }); + + const response = await testServer.authlessAgent + .post('/login') + .send({ email: user.email, password: rawPassword, mfaRecoveryCode: rawRecoveryCodes[0] }); + + const data = response.body.data; + + expect(response.statusCode).toBe(200); + expect(data.mfaEnabled).toBe(true); + expect(data.hasRecoveryCodesLeft).toBe(false); + }); + }); +}); diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts index 58ae9cef6017a..6d1d572473965 100644 --- a/packages/cli/test/integration/shared/testDb.ts +++ b/packages/cli/test/integration/shared/testDb.ts @@ -21,7 +21,6 @@ import type { TagEntity } from '@db/entities/TagEntity'; import type { User } from '@db/entities/User'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { ICredentialsDb } from '@/Interfaces'; - import { DB_INITIALIZATION_TIMEOUT } from './constants'; import { randomApiKey, randomEmail, randomName, randomString, randomValidPassword } from './random'; import type { @@ -38,6 +37,10 @@ import { VariablesService } from '@/environments/variables/variables.service'; import { TagRepository, WorkflowTagMappingRepository } from '@/databases/repositories'; import { separate } from '@/utils'; +import { randomPassword } from '@/Ldap/helpers'; +import { TOTPService } from '@/Mfa/totp.service'; +import { MfaService } from '@/Mfa/mfa.service'; + export type TestDBType = 'postgres' | 'mysql'; export const testDbPrefix = 'n8n_test_'; @@ -95,7 +98,7 @@ export async function init() { await Db.init(getDBOptions('postgres', testDbName)); } else if (dbType === 'mysqldb' || dbType === 'mariadb') { const bootstrapMysql = await new Connection(getBootstrapDBOptions('mysql')).initialize(); - await bootstrapMysql.query(`CREATE DATABASE ${testDbName}`); + await bootstrapMysql.query(`CREATE DATABASE ${testDbName} DEFAULT CHARACTER SET utf8mb4`); await bootstrapMysql.destroy(); await Db.init(getDBOptions('mysql', testDbName)); @@ -204,10 +207,49 @@ export async function createLdapUser(attributes: Partial, ldapId: string): return user; } +export async function createUserWithMfaEnabled( + data: { numberOfRecoveryCodes: number } = { numberOfRecoveryCodes: 10 }, +) { + const encryptionKey = await UserSettings.getEncryptionKey(); + + const email = randomEmail(); + const password = randomPassword(); + + const toptService = new TOTPService(); + + const secret = toptService.generateSecret(); + + const mfaService = new MfaService(Db.collections.User, toptService, encryptionKey); + + const recoveryCodes = mfaService.generateRecoveryCodes(data.numberOfRecoveryCodes); + + const { encryptedSecret, encryptedRecoveryCodes } = mfaService.encryptSecretAndRecoveryCodes( + secret, + recoveryCodes, + ); + + return { + user: await createUser({ + mfaEnabled: true, + password, + email, + mfaSecret: encryptedSecret, + mfaRecoveryCodes: encryptedRecoveryCodes, + }), + rawPassword: password, + rawSecret: secret, + rawRecoveryCodes: recoveryCodes, + }; +} + export async function createOwner() { return createUser({ globalRole: await getGlobalOwnerRole() }); } +export async function createMember() { + return createUser({ globalRole: await getGlobalMemberRole() }); +} + export async function createUserShell(globalRole: Role): Promise { if (globalRole.scope !== 'global') { throw new Error(`Invalid role received: ${JSON.stringify(globalRole)}`); @@ -592,13 +634,12 @@ const baseOptions = (type: TestDBType) => ({ /** * Generate options for a bootstrap DB connection, to create and drop test databases. */ -export const getBootstrapDBOptions = (type: TestDBType) => - ({ - type, - name: type, - database: type, - ...baseOptions(type), - }) as const; +export const getBootstrapDBOptions = (type: TestDBType) => ({ + type, + name: type, + database: type, + ...baseOptions(type), +}); const getDBOptions = (type: TestDBType, name: string) => ({ type, diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 2b483d4032c13..1e4e20c6f07b6 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -26,6 +26,8 @@ export type EndpointGroup = | 'license' | 'variables' | 'tags' + | 'externalSecrets' + | 'mfa' | 'metrics'; export interface SetupProps { diff --git a/packages/cli/test/integration/shared/utils/testServer.ts b/packages/cli/test/integration/shared/utils/testServer.ts index ab098e5c64ef5..b00273eb66958 100644 --- a/packages/cli/test/integration/shared/utils/testServer.ts +++ b/packages/cli/test/integration/shared/utils/testServer.ts @@ -23,6 +23,7 @@ import { registerController } from '@/decorators'; import { AuthController, LdapController, + MFAController, MeController, NodesController, OwnerController, @@ -49,8 +50,19 @@ import * as testDb from '../../shared/testDb'; import { AUTHLESS_ENDPOINTS, PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from '../constants'; import type { EndpointGroup, SetupProps, TestServer } from '../types'; import { mockInstance } from './mocking'; -import { JwtService } from '@/services/jwt.service'; +import { ExternalSecretsController } from '@/ExternalSecrets/ExternalSecrets.controller.ee'; +import { MfaService } from '@/Mfa/mfa.service'; +import { TOTPService } from '@/Mfa/totp.service'; +import { UserSettings } from 'n8n-core'; import { MetricsService } from '@/services/metrics.service'; +import { + SettingsRepository, + SharedCredentialsRepository, + SharedWorkflowRepository, +} from '@/databases/repositories'; +import { JwtService } from '@/services/jwt.service'; +import { RoleService } from '@/services/role.service'; +import { UserService } from '@/services/user.service'; /** * Plugin to prefix a path segment into a request URL pathname. @@ -179,11 +191,13 @@ export const setupTestServer = ({ } if (functionEndpoints.length) { + const encryptionKey = await UserSettings.getEncryptionKey(); + const repositories = Db.collections; const externalHooks = Container.get(ExternalHooks); const internalHooks = Container.get(InternalHooks); const mailer = Container.get(UserManagementMailer); - const jwtService = Container.get(JwtService); - const repositories = Db.collections; + const mfaService = new MfaService(repositories.User, new TOTPService(), encryptionKey); + const userService = Container.get(UserService); for (const group of functionEndpoints) { switch (group) { @@ -197,14 +211,11 @@ export const setupTestServer = ({ registerController( app, config, - new AuthController({ - config, - logger, - internalHooks, - repositories, - }), + new AuthController(config, logger, internalHooks, mfaService, userService), ); break; + case 'mfa': + registerController(app, config, new MFAController(mfaService)); case 'ldap': Container.get(License).isLdapEnabled = () => true; await handleLdapInit(); @@ -233,56 +244,63 @@ export const setupTestServer = ({ registerController( app, config, - new MeController({ - logger, - externalHooks, - internalHooks, - }), + new MeController(logger, externalHooks, internalHooks, userService), ); break; case 'passwordReset': registerController( app, config, - new PasswordResetController({ + new PasswordResetController( config, logger, externalHooks, internalHooks, mailer, - }), + userService, + Container.get(JwtService), + mfaService, + ), ); break; case 'owner': registerController( app, config, - new OwnerController({ + new OwnerController( config, logger, internalHooks, - repositories, - }), + Container.get(SettingsRepository), + userService, + ), ); break; case 'users': registerController( app, config, - new UsersController({ + new UsersController( config, - mailer, + logger, externalHooks, internalHooks, - repositories, - activeWorkflowRunner: Container.get(ActiveWorkflowRunner), - logger, - }), + Container.get(SharedCredentialsRepository), + Container.get(SharedWorkflowRepository), + Container.get(ActiveWorkflowRunner), + mailer, + Container.get(JwtService), + Container.get(RoleService), + userService, + ), ); break; case 'tags': registerController(app, config, Container.get(TagsController)); break; + case 'externalSecrets': + registerController(app, config, Container.get(ExternalSecretsController)); + break; } } } diff --git a/packages/cli/test/integration/users.api.test.ts b/packages/cli/test/integration/users.api.test.ts index 0f56258883a15..3f3457d5cb194 100644 --- a/packages/cli/test/integration/users.api.test.ts +++ b/packages/cli/test/integration/users.api.test.ts @@ -60,49 +60,6 @@ beforeEach(async () => { config.set('userManagement.emails.smtp.host', ''); }); -describe('GET /users', () => { - test('should return all users (for owner)', async () => { - await testDb.createUser({ globalRole: globalMemberRole }); - - const response = await authOwnerAgent.get('/users'); - - expect(response.statusCode).toBe(200); - expect(response.body.data.length).toBe(2); - - response.body.data.map((user: User) => { - const { - id, - email, - firstName, - lastName, - personalizationAnswers, - globalRole, - password, - isPending, - apiKey, - } = user; - - expect(validator.isUUID(id)).toBe(true); - expect(email).toBeDefined(); - expect(firstName).toBeDefined(); - expect(lastName).toBeDefined(); - expect(personalizationAnswers).toBeUndefined(); - expect(password).toBeUndefined(); - expect(isPending).toBe(false); - expect(globalRole).toBeDefined(); - expect(apiKey).not.toBeDefined(); - }); - }); - - test('should return all users (for member)', async () => { - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const response = await testServer.authAgentFor(member).get('/users'); - - expect(response.statusCode).toBe(200); - expect(response.body.data.length).toBe(2); - }); -}); - describe('DELETE /users/:id', () => { test('should delete the user', async () => { const userToDelete = await testDb.createUser({ globalRole: globalMemberRole }); diff --git a/packages/cli/test/integration/users.controller.test.ts b/packages/cli/test/integration/users.controller.test.ts new file mode 100644 index 0000000000000..0dc5d41a28daa --- /dev/null +++ b/packages/cli/test/integration/users.controller.test.ts @@ -0,0 +1,248 @@ +import * as testDb from './shared/testDb'; +import { setupTestServer } from './shared/utils/'; +import type { User } from '@/databases/entities/User'; +import type { PublicUser } from '@/Interfaces'; + +const { any } = expect; + +const testServer = setupTestServer({ endpointGroups: ['users'] }); + +let owner: User; +let member: User; + +beforeEach(async () => { + await testDb.truncate(['User']); + owner = await testDb.createOwner(); + member = await testDb.createMember(); +}); + +const validatePublicUser = (user: PublicUser) => { + expect(typeof user.id).toBe('string'); + expect(user.email).toBeDefined(); + expect(user.firstName).toBeDefined(); + expect(user.lastName).toBeDefined(); + expect(typeof user.isOwner).toBe('boolean'); + expect(user.isPending).toBe(false); + expect(user.signInType).toBe('email'); + expect(user.settings).toBe(null); + expect(user.personalizationAnswers).toBeNull(); + expect(user.password).toBeUndefined(); + expect(user.globalRole).toBeDefined(); +}; + +describe('GET /users', () => { + test('should return all users', async () => { + const response = await testServer.authAgentFor(owner).get('/users').expect(200); + + expect(response.body.data).toHaveLength(2); + + response.body.data.forEach(validatePublicUser); + + const _response = await testServer.authAgentFor(member).get('/users').expect(200); + + expect(_response.body.data).toHaveLength(2); + + _response.body.data.forEach(validatePublicUser); + }); + + describe('filter', () => { + test('should filter users by field: email', async () => { + const secondMember = await testDb.createMember(); + + const response = await testServer + .authAgentFor(owner) + .get('/users') + .query(`filter={ "email": "${secondMember.email}" }`) + .expect(200); + + expect(response.body.data).toHaveLength(1); + + const [user] = response.body.data; + + expect(user.email).toBe(secondMember.email); + + const _response = await testServer + .authAgentFor(owner) + .get('/users') + .query('filter={ "email": "non@existing.com" }') + .expect(200); + + expect(_response.body.data).toHaveLength(0); + }); + + test('should filter users by field: firstName', async () => { + const secondMember = await testDb.createMember(); + + const response = await testServer + .authAgentFor(owner) + .get('/users') + .query(`filter={ "firstName": "${secondMember.firstName}" }`) + .expect(200); + + expect(response.body.data).toHaveLength(1); + + const [user] = response.body.data; + + expect(user.email).toBe(secondMember.email); + + const _response = await testServer + .authAgentFor(owner) + .get('/users') + .query('filter={ "firstName": "Non-Existing" }') + .expect(200); + + expect(_response.body.data).toHaveLength(0); + }); + + test('should filter users by field: lastName', async () => { + const secondMember = await testDb.createMember(); + + const response = await testServer + .authAgentFor(owner) + .get('/users') + .query(`filter={ "lastName": "${secondMember.lastName}" }`) + .expect(200); + + expect(response.body.data).toHaveLength(1); + + const [user] = response.body.data; + + expect(user.email).toBe(secondMember.email); + + const _response = await testServer + .authAgentFor(owner) + .get('/users') + .query('filter={ "lastName": "Non-Existing" }') + .expect(200); + + expect(_response.body.data).toHaveLength(0); + }); + + test('should filter users by computed field: isOwner', async () => { + const response = await testServer + .authAgentFor(owner) + .get('/users') + .query('filter={ "isOwner": true }') + .expect(200); + + expect(response.body.data).toHaveLength(1); + + const [user] = response.body.data; + + expect(user.isOwner).toBe(true); + + const _response = await testServer + .authAgentFor(owner) + .get('/users') + .query('filter={ "isOwner": false }') + .expect(200); + + expect(_response.body.data).toHaveLength(1); + + const [_user] = _response.body.data; + + expect(_user.isOwner).toBe(false); + }); + }); + + describe('select', () => { + test('should select user field: id', async () => { + const response = await testServer + .authAgentFor(owner) + .get('/users') + .query('select=["id"]') + .expect(200); + + expect(response.body).toEqual({ + data: [{ id: any(String) }, { id: any(String) }], + }); + }); + + test('should select user field: email', async () => { + const response = await testServer + .authAgentFor(owner) + .get('/users') + .query('select=["email"]') + .expect(200); + + expect(response.body).toEqual({ + data: [{ email: any(String) }, { email: any(String) }], + }); + }); + + test('should select user field: firstName', async () => { + const response = await testServer + .authAgentFor(owner) + .get('/users') + .query('select=["firstName"]') + .expect(200); + + expect(response.body).toEqual({ + data: [{ firstName: any(String) }, { firstName: any(String) }], + }); + }); + + test('should select user field: lastName', async () => { + const response = await testServer + .authAgentFor(owner) + .get('/users') + .query('select=["lastName"]') + .expect(200); + + expect(response.body).toEqual({ + data: [{ lastName: any(String) }, { lastName: any(String) }], + }); + }); + }); + + describe('take', () => { + test('should return n users or less, without skip', async () => { + const response = await testServer + .authAgentFor(owner) + .get('/users') + .query('take=2') + .expect(200); + + expect(response.body.data).toHaveLength(2); + + response.body.data.forEach(validatePublicUser); + + const _response = await testServer + .authAgentFor(owner) + .get('/users') + .query('take=1') + .expect(200); + + expect(_response.body.data).toHaveLength(1); + + _response.body.data.forEach(validatePublicUser); + }); + + test('should return n users or less, with skip', async () => { + const response = await testServer + .authAgentFor(owner) + .get('/users') + .query('take=1&skip=1') + .expect(200); + + expect(response.body.data).toHaveLength(1); + + response.body.data.forEach(validatePublicUser); + }); + }); + + describe('combinations', () => { + test('should support options that require auxiliary fields', async () => { + // isOwner requires globalRole + // id-less select with take requires id + + const response = await testServer + .authAgentFor(owner) + .get('/users') + .query('filter={ "isOwner": true }&select=["firstName"]&take=10') + .expect(200); + + expect(response.body).toEqual({ data: [{ firstName: any(String) }] }); + }); + }); +}); diff --git a/packages/cli/test/integration/webhooks.api.test.ts b/packages/cli/test/integration/webhooks.api.test.ts index b47613a6964c1..a5ec923f836f3 100644 --- a/packages/cli/test/integration/webhooks.api.test.ts +++ b/packages/cli/test/integration/webhooks.api.test.ts @@ -10,6 +10,7 @@ import { InternalHooks } from '@/InternalHooks'; import { getLogger } from '@/Logger'; import { NodeTypes } from '@/NodeTypes'; import { Push } from '@/push'; +import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { mockInstance, initActiveWorkflowRunner } from './shared/utils'; import * as testDb from './shared/testDb'; @@ -22,47 +23,43 @@ describe('Webhook API', () => { let agent: SuperAgentTest; + beforeAll(async () => { + await testDb.init(); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + describe('Content-Type support', () => { beforeAll(async () => { - await testDb.init(); - const node = new WebhookTestingNode(); const user = await testDb.createUser(); - await testDb.createWorkflow( - { - active: true, - nodes: [ - { - name: 'Webhook', - type: node.description.name, - typeVersion: 1, - parameters: { - httpMethod: 'POST', - path: 'abcd', - }, - id: '74786112-fb73-4d80-bd9a-43982939b801', - webhookId: '5ccef736-be16-4d10-b7fb-feed7a61ff22', - position: [740, 420], - }, - ], - }, - user, - ); + await testDb.createWorkflow(createWebhookWorkflow(node), user); const nodeTypes = mockInstance(NodeTypes); nodeTypes.getByName.mockReturnValue(node); nodeTypes.getByNameAndVersion.mockReturnValue(node); await initActiveWorkflowRunner(); + const server = new (class extends AbstractServer {})(); await server.start(); agent = testAgent(server.app); }); + afterAll(async () => { + await testDb.truncate(['Workflow']); + }); + test('should handle JSON', async () => { const response = await agent.post('/webhook/abcd').send({ test: true }); expect(response.statusCode).toEqual(200); - expect(response.body).toEqual({ type: 'application/json', body: { test: true } }); + expect(response.body).toEqual({ + type: 'application/json', + body: { test: true }, + params: {}, + }); }); test('should handle XML', async () => { @@ -83,6 +80,7 @@ describe('Webhook API', () => { inner: 'value', }, }, + params: {}, }); }); @@ -95,6 +93,7 @@ describe('Webhook API', () => { expect(response.body).toEqual({ type: 'application/x-www-form-urlencoded', body: { x: '5', y: 'str', z: 'false' }, + params: {}, }); }); @@ -107,6 +106,7 @@ describe('Webhook API', () => { expect(response.body).toEqual({ type: 'text/plain', body: '{"key": "value"}', + params: {}, }); }); @@ -133,6 +133,44 @@ describe('Webhook API', () => { }); }); + describe('Params support', () => { + beforeAll(async () => { + const node = new WebhookTestingNode(); + const user = await testDb.createUser(); + await testDb.createWorkflow(createWebhookWorkflow(node, ':variable', 'PATCH'), user); + + const nodeTypes = mockInstance(NodeTypes); + nodeTypes.getByName.mockReturnValue(node); + nodeTypes.getByNameAndVersion.mockReturnValue(node); + + await initActiveWorkflowRunner(); + + const server = new (class extends AbstractServer {})(); + await server.start(); + agent = testAgent(server.app); + }); + + afterAll(async () => { + await testDb.truncate(['Workflow']); + }); + + test('should handle params', async () => { + const response = await agent + .patch('/webhook/5ccef736-be16-4d10-b7fb-feed7a61ff22/test') + .send({ test: true }); + expect(response.statusCode).toEqual(200); + expect(response.body).toEqual({ + type: 'application/json', + body: { test: true }, + params: { + variable: 'test', + }, + }); + + await agent.post('/webhook/abcd').send({ test: true }).expect(404); + }); + }); + class WebhookTestingNode implements INodeType { description: INodeTypeDescription = { displayName: 'Webhook Testing Node', @@ -173,8 +211,28 @@ describe('Webhook API', () => { webhookResponse: { type: req.contentType, body: req.body, + params: req.params, }, }; } } + + const createWebhookWorkflow = ( + node: WebhookTestingNode, + path = 'abcd', + httpMethod = 'POST', + ): Partial => ({ + active: true, + nodes: [ + { + name: 'Webhook', + type: node.description.name, + typeVersion: 1, + parameters: { httpMethod, path }, + id: '74786112-fb73-4d80-bd9a-43982939b801', + webhookId: '5ccef736-be16-4d10-b7fb-feed7a61ff22', + position: [740, 420], + }, + ], + }); }); diff --git a/packages/cli/test/shared/ExternalSecrets/utils.ts b/packages/cli/test/shared/ExternalSecrets/utils.ts new file mode 100644 index 0000000000000..068e22576e454 --- /dev/null +++ b/packages/cli/test/shared/ExternalSecrets/utils.ts @@ -0,0 +1,215 @@ +import { SecretsProvider } from '@/Interfaces'; +import type { SecretsProviderSettings, SecretsProviderState } from '@/Interfaces'; +import type { IDataObject, INodeProperties } from 'n8n-workflow'; + +export class MockProviders { + providers: Record = { + dummy: DummyProvider, + }; + + setProviders(providers: Record) { + this.providers = providers; + } + + getProvider(name: string): { new (): SecretsProvider } | null { + return this.providers[name] ?? null; + } + + hasProvider(name: string) { + return name in this.providers; + } + + getAllProviders() { + return this.providers; + } +} + +export class DummyProvider extends SecretsProvider { + properties: INodeProperties[] = [ + { + name: 'username', + displayName: 'Username', + type: 'string', + default: '', + required: true, + }, + { + name: 'other', + displayName: 'Other', + type: 'string', + default: '', + }, + { + name: 'password', + displayName: 'Password', + type: 'string', + default: '', + typeOptions: { + password: true, + }, + }, + ]; + + secrets: Record = {}; + + displayName = 'Dummy Provider'; + + name = 'dummy'; + + state: SecretsProviderState = 'initializing'; + + _updateSecrets: Record = { + test1: 'value1', + test2: 'value2', + }; + + async init(settings: SecretsProviderSettings): Promise {} + + async connect(): Promise { + this.state = 'connected'; + } + + async disconnect(): Promise {} + + async update(): Promise { + this.secrets = this._updateSecrets; + } + + async test(): Promise<[boolean] | [boolean, string]> { + return [true]; + } + + getSecret(name: string): IDataObject | undefined { + return this.secrets[name] as unknown as IDataObject | undefined; + } + + hasSecret(name: string): boolean { + return name in this.secrets; + } + + getSecretNames(): string[] { + return Object.keys(this.secrets); + } +} + +export class ErrorProvider extends SecretsProvider { + secrets: Record = {}; + + displayName = 'Error Provider'; + + name = 'dummy'; + + state: SecretsProviderState = 'initializing'; + + async init(settings: SecretsProviderSettings): Promise { + throw new Error(); + } + + async connect(): Promise { + this.state = 'error'; + throw new Error(); + } + + async disconnect(): Promise { + throw new Error(); + } + + async update(): Promise { + throw new Error(); + } + + async test(): Promise<[boolean] | [boolean, string]> { + throw new Error(); + } + + getSecret(name: string): IDataObject | undefined { + throw new Error(); + } + + hasSecret(name: string): boolean { + throw new Error(); + } + + getSecretNames(): string[] { + throw new Error(); + } +} + +export class FailedProvider extends SecretsProvider { + secrets: Record = {}; + + displayName = 'Failed Provider'; + + name = 'dummy'; + + state: SecretsProviderState = 'initializing'; + + async init(settings: SecretsProviderSettings): Promise {} + + async connect(): Promise { + this.state = 'error'; + } + + async disconnect(): Promise {} + + async update(): Promise {} + + async test(): Promise<[boolean] | [boolean, string]> { + return [true]; + } + + getSecret(name: string): IDataObject | undefined { + return this.secrets[name] as unknown as IDataObject | undefined; + } + + hasSecret(name: string): boolean { + return name in this.secrets; + } + + getSecretNames(): string[] { + return Object.keys(this.secrets); + } +} + +export class TestFailProvider extends SecretsProvider { + secrets: Record = {}; + + displayName = 'Test Failed Provider'; + + name = 'dummy'; + + state: SecretsProviderState = 'initializing'; + + _updateSecrets: Record = { + test1: 'value1', + test2: 'value2', + }; + + async init(settings: SecretsProviderSettings): Promise {} + + async connect(): Promise { + this.state = 'connected'; + } + + async disconnect(): Promise {} + + async update(): Promise { + this.secrets = this._updateSecrets; + } + + async test(): Promise<[boolean] | [boolean, string]> { + return [false]; + } + + getSecret(name: string): IDataObject | undefined { + return this.secrets[name] as unknown as IDataObject | undefined; + } + + hasSecret(name: string): boolean { + return name in this.secrets; + } + + getSecretNames(): string[] { + return Object.keys(this.secrets); + } +} diff --git a/packages/cli/test/unit/ActiveWorkflowRunner.test.ts b/packages/cli/test/unit/ActiveWorkflowRunner.test.ts index c9b3e6ad09d0e..0622dac9a2044 100644 --- a/packages/cli/test/unit/ActiveWorkflowRunner.test.ts +++ b/packages/cli/test/unit/ActiveWorkflowRunner.test.ts @@ -24,6 +24,7 @@ import { mockInstance } from '../integration/shared/utils/'; import { Push } from '@/push'; import { ActiveExecutions } from '@/ActiveExecutions'; import { NodeTypes } from '@/NodeTypes'; +import { SecretsHelper } from '@/SecretsHelpers'; import { WebhookService } from '@/services/webhook.service'; import { VariablesService } from '../../src/environments/variables/variables.service'; @@ -159,6 +160,7 @@ describe('ActiveWorkflowRunner', () => { Container.set(LoadNodesAndCredentials, nodesAndCredentials); Container.set(VariablesService, mockVariablesService); mockInstance(Push); + mockInstance(SecretsHelper); }); beforeEach(() => { diff --git a/packages/cli/test/unit/ExternalSecrets/ExternalSecretsManager.test.ts b/packages/cli/test/unit/ExternalSecrets/ExternalSecretsManager.test.ts new file mode 100644 index 0000000000000..0789b87d67caf --- /dev/null +++ b/packages/cli/test/unit/ExternalSecrets/ExternalSecretsManager.test.ts @@ -0,0 +1,194 @@ +import type { SettingsRepository } from '@/databases/repositories'; +import type { ExternalSecretsSettings } from '@/Interfaces'; +import { License } from '@/License'; +import { ExternalSecretsManager } from '@/ExternalSecrets/ExternalSecretsManager.ee'; +import { ExternalSecretsProviders } from '@/ExternalSecrets/ExternalSecretsProviders.ee'; +import { mock } from 'jest-mock-extended'; +import { UserSettings } from 'n8n-core'; +import Container from 'typedi'; +import { mockInstance } from '../../integration/shared/utils'; +import { + DummyProvider, + ErrorProvider, + FailedProvider, + MockProviders, +} from '../../shared/ExternalSecrets/utils'; +import { AES, enc } from 'crypto-js'; +import { InternalHooks } from '@/InternalHooks'; + +const connectedDate = '2023-08-01T12:32:29.000Z'; +const encryptionKey = 'testkey'; +let settings: string | null = null; +const mockProvidersInstance = new MockProviders(); +const settingsRepo = mock({ + async getEncryptedSecretsProviderSettings() { + return settings; + }, + async saveEncryptedSecretsProviderSettings(data) { + settings = data; + }, +}); +let licenseMock: License; +let providersMock: ExternalSecretsProviders; +let manager: ExternalSecretsManager | undefined; + +const createMockSettings = (settings: ExternalSecretsSettings): string => { + return AES.encrypt(JSON.stringify(settings), encryptionKey).toString(); +}; + +const decryptSettings = (settings: string) => { + return JSON.parse(AES.decrypt(settings ?? '', encryptionKey).toString(enc.Utf8)); +}; + +describe('External Secrets Manager', () => { + beforeAll(() => { + jest + .spyOn(UserSettings, 'getEncryptionKey') + .mockReturnValue(new Promise((resolve) => resolve(encryptionKey))); + providersMock = mockInstance(ExternalSecretsProviders, mockProvidersInstance); + licenseMock = mockInstance(License, { + isExternalSecretsEnabled() { + return true; + }, + }); + mockInstance(InternalHooks); + }); + + beforeEach(() => { + mockProvidersInstance.setProviders({ + dummy: DummyProvider, + }); + settings = createMockSettings({ + dummy: { connected: true, connectedAt: new Date(connectedDate), settings: {} }, + }); + + Container.remove(ExternalSecretsManager); + }); + + afterEach(() => { + manager?.shutdown(); + jest.useRealTimers(); + }); + + test('should get secret', async () => { + manager = new ExternalSecretsManager(settingsRepo, licenseMock, providersMock); + + await manager.init(); + + expect(manager.getSecret('dummy', 'test1')).toBe('value1'); + }); + + test('should not throw errors during init', async () => { + mockProvidersInstance.setProviders({ + dummy: ErrorProvider, + }); + manager = new ExternalSecretsManager(settingsRepo, licenseMock, providersMock); + + expect(async () => manager!.init()).not.toThrow(); + }); + + test('should not throw errors during shutdown', async () => { + mockProvidersInstance.setProviders({ + dummy: ErrorProvider, + }); + manager = new ExternalSecretsManager(settingsRepo, licenseMock, providersMock); + + await manager.init(); + expect(() => manager!.shutdown()).not.toThrow(); + manager = undefined; + }); + + test('should save provider settings', async () => { + manager = new ExternalSecretsManager(settingsRepo, licenseMock, providersMock); + + const settingsSpy = jest.spyOn(settingsRepo, 'saveEncryptedSecretsProviderSettings'); + + await manager.init(); + + await manager.setProviderSettings('dummy', { + test: 'value', + }); + + expect(decryptSettings(settingsSpy.mock.calls[0][0])).toEqual({ + dummy: { + connected: true, + connectedAt: connectedDate, + settings: { + test: 'value', + }, + }, + }); + }); + + test('should call provider update functions on a timer', async () => { + jest.useFakeTimers(); + manager = new ExternalSecretsManager(settingsRepo, licenseMock, providersMock); + + await manager.init(); + + const updateSpy = jest.spyOn(manager.getProvider('dummy')!, 'update'); + + expect(updateSpy).toBeCalledTimes(0); + + jest.runOnlyPendingTimers(); + + expect(updateSpy).toBeCalledTimes(1); + }); + + test('should not call provider update functions if the not licensed', async () => { + jest.useFakeTimers(); + + manager = new ExternalSecretsManager( + settingsRepo, + mock({ + isExternalSecretsEnabled() { + return false; + }, + }), + providersMock, + ); + + await manager.init(); + + const updateSpy = jest.spyOn(manager.getProvider('dummy')!, 'update'); + + expect(updateSpy).toBeCalledTimes(0); + + jest.runOnlyPendingTimers(); + + expect(updateSpy).toBeCalledTimes(0); + }); + + test('should not call provider update functions if the provider has an error', async () => { + jest.useFakeTimers(); + + mockProvidersInstance.setProviders({ + dummy: FailedProvider, + }); + manager = new ExternalSecretsManager(settingsRepo, licenseMock, providersMock); + + await manager.init(); + + const updateSpy = jest.spyOn(manager.getProvider('dummy')!, 'update'); + + expect(updateSpy).toBeCalledTimes(0); + + jest.runOnlyPendingTimers(); + + expect(updateSpy).toBeCalledTimes(0); + }); + + test('should reinitialize a provider when save provider settings', async () => { + manager = new ExternalSecretsManager(settingsRepo, licenseMock, providersMock); + + await manager.init(); + + const dummyInitSpy = jest.spyOn(DummyProvider.prototype, 'init'); + + await manager.setProviderSettings('dummy', { + test: 'value', + }); + + expect(dummyInitSpy).toBeCalledTimes(1); + }); +}); diff --git a/packages/cli/test/unit/controllers/me.controller.test.ts b/packages/cli/test/unit/controllers/me.controller.test.ts index 950957c12c331..e2970be7d7f8b 100644 --- a/packages/cli/test/unit/controllers/me.controller.test.ts +++ b/packages/cli/test/unit/controllers/me.controller.test.ts @@ -2,27 +2,21 @@ import type { CookieOptions, Response } from 'express'; import jwt from 'jsonwebtoken'; import { mock, anyObject, captor } from 'jest-mock-extended'; import type { ILogger } from 'n8n-workflow'; -import type { IExternalHooksClass, IInternalHooksClass } from '@/Interfaces'; +import type { IExternalHooksClass, IInternalHooksClass, PublicUser } from '@/Interfaces'; import type { User } from '@db/entities/User'; import { MeController } from '@/controllers'; import { AUTH_COOKIE_NAME } from '@/constants'; import { BadRequestError } from '@/ResponseHelper'; import type { AuthenticatedRequest, MeRequest } from '@/requests'; import { badPasswords } from '../shared/testData'; -import { UserService } from '@/services/user.service'; -import Container from 'typedi'; +import type { UserService } from '@/services/user.service'; describe('MeController', () => { const logger = mock(); const externalHooks = mock(); const internalHooks = mock(); const userService = mock(); - Container.set(UserService, userService); - const controller = new MeController({ - logger, - externalHooks, - internalHooks, - }); + const controller = new MeController(logger, externalHooks, internalHooks, userService); describe('updateCurrentUser', () => { it('should throw BadRequestError if email is missing in the payload', async () => { @@ -51,6 +45,7 @@ describe('MeController', () => { const res = mock(); userService.findOneOrFail.mockResolvedValue(user); jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token'); + userService.toPublic.mockResolvedValue({} as unknown as PublicUser); await controller.updateCurrentUser(req, res); diff --git a/packages/cli/test/unit/controllers/owner.controller.test.ts b/packages/cli/test/unit/controllers/owner.controller.test.ts index 5c48c1a1d39bd..ee9ca3ac0b74c 100644 --- a/packages/cli/test/unit/controllers/owner.controller.test.ts +++ b/packages/cli/test/unit/controllers/owner.controller.test.ts @@ -12,7 +12,6 @@ import { OwnerController } from '@/controllers'; import { badPasswords } from '../shared/testData'; import { AUTH_COOKIE_NAME } from '@/constants'; import { UserService } from '@/services/user.service'; -import Container from 'typedi'; import { mockInstance } from '../../integration/shared/utils'; describe('OwnerController', () => { @@ -20,16 +19,14 @@ describe('OwnerController', () => { const logger = mock(); const internalHooks = mock(); const userService = mockInstance(UserService); - Container.set(UserService, userService); const settingsRepository = mock(); - const controller = new OwnerController({ + const controller = new OwnerController( config, logger, internalHooks, - repositories: { - Settings: settingsRepository, - }, - }); + settingsRepository, + userService, + ); describe('setupOwner', () => { it('should throw a BadRequestError if the instance owner is already setup', async () => { diff --git a/packages/cli/test/unit/middleware/listQuery.test.ts b/packages/cli/test/unit/middleware/listQuery.test.ts index dc119d1ed4ff6..218c7d25123f7 100644 --- a/packages/cli/test/unit/middleware/listQuery.test.ts +++ b/packages/cli/test/unit/middleware/listQuery.test.ts @@ -134,12 +134,12 @@ describe('List query middleware', () => { expect(nextFn).toBeCalledTimes(1); }); - test('should ignore skip without take', () => { + test('should throw on skip without take', () => { mockReq.query = { skip: '1' }; paginationListQueryMiddleware(...args); expect(mockReq.listQueryOptions).toBeUndefined(); - expect(nextFn).toBeCalledTimes(1); + expect(sendErrorResponse).toHaveBeenCalledTimes(1); }); test('should default skip to 0', () => { diff --git a/packages/core/package.json b/packages/core/package.json index d1332149b3360..a0886df9010b3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "1.4.0", + "version": "1.5.0", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/core/src/DirectoryLoader.ts b/packages/core/src/DirectoryLoader.ts index 4b55f6b24d147..6a5c195d8cffa 100644 --- a/packages/core/src/DirectoryLoader.ts +++ b/packages/core/src/DirectoryLoader.ts @@ -371,7 +371,9 @@ export class LazyPackageDirectoryLoader extends PackageDirectoryLoader { if (this.includeNodes.length) { const allowedNodes: typeof this.known.nodes = {}; for (const nodeName of this.includeNodes) { - allowedNodes[nodeName] = this.known.nodes[nodeName]; + if (nodeName in this.known.nodes) { + allowedNodes[nodeName] = this.known.nodes[nodeName]; + } } this.known.nodes = allowedNodes; diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 7de0fe5f01025..af000e91cc006 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -136,6 +136,7 @@ import { setAllWorkflowExecutionMetadata, setWorkflowExecutionMetadata, } from './WorkflowExecutionMetadata'; +import { getSecretsProxy } from './Secrets'; import { getUserN8nFolderPath } from './UserSettings'; axios.defaults.timeout = 300000; @@ -720,7 +721,7 @@ export async function proxyRequestToAxios( error: responseData, response: pick(response, ['headers', 'status', 'statusText']), }); - } else if (error instanceof Error && error.message.includes('SSL routines')) { + } else if ('rejectUnauthorized' in configObject && error.code?.includes('CERT')) { throw new NodeSSLError(error); } } @@ -1683,6 +1684,7 @@ export function getAdditionalKeys( additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode, runExecutionData: IRunExecutionData | null, + options?: { secretsEnabled?: boolean }, ): IWorkflowDataProxyAdditionalKeys { const executionId = additionalData.executionId || PLACEHOLDER_EMPTY_EXECUTION_ID; const resumeUrl = `${additionalData.webhookWaitingBaseUrl}/${executionId}`; @@ -1723,6 +1725,7 @@ export function getAdditionalKeys( : undefined, }, $vars: additionalData.variables, + $secrets: options?.secretsEnabled ? getSecretsProxy(additionalData) : undefined, // deprecated $executionId: executionId, @@ -1858,6 +1861,7 @@ export async function getCredentials( // } const decryptedDataObject = await additionalData.credentialsHelper.getDecrypted( + additionalData, nodeCredentials, type, mode, diff --git a/packages/core/src/Secrets.ts b/packages/core/src/Secrets.ts new file mode 100644 index 0000000000000..508af6ada302c --- /dev/null +++ b/packages/core/src/Secrets.ts @@ -0,0 +1,76 @@ +import type { IDataObject, IWorkflowExecuteAdditionalData } from 'n8n-workflow'; +import { ExpressionError } from 'n8n-workflow'; + +function buildSecretsValueProxy(value: IDataObject): unknown { + return new Proxy(value, { + get(target, valueName) { + if (typeof valueName !== 'string') { + return; + } + if (!(valueName in value)) { + throw new ExpressionError('Could not load secrets', { + description: + 'The credential in use tries to use secret from an external store that could not be found', + }); + } + const retValue = value[valueName]; + if (typeof retValue === 'object' && retValue !== null) { + return buildSecretsValueProxy(retValue as IDataObject); + } + return retValue; + }, + }); +} + +export function getSecretsProxy(additionalData: IWorkflowExecuteAdditionalData): IDataObject { + const secretsHelpers = additionalData.secretsHelpers; + return new Proxy( + {}, + { + get(target, providerName) { + if (typeof providerName !== 'string') { + return {}; + } + if (secretsHelpers.hasProvider(providerName)) { + return new Proxy( + {}, + { + get(target2, secretName): IDataObject | undefined { + if (typeof secretName !== 'string') { + return; + } + if (!secretsHelpers.hasSecret(providerName, secretName)) { + throw new ExpressionError('Could not load secrets', { + description: + 'The credential in use tries to use secret from an external store that could not be found', + }); + } + const retValue = secretsHelpers.getSecret(providerName, secretName); + if (typeof retValue === 'object' && retValue !== null) { + return buildSecretsValueProxy(retValue) as IDataObject; + } + return retValue; + }, + set() { + return false; + }, + ownKeys() { + return secretsHelpers.listSecrets(providerName); + }, + }, + ); + } + throw new ExpressionError('Could not load secrets', { + description: + 'The credential in use pulls secrets from an external store that is not reachable', + }); + }, + set() { + return false; + }, + ownKeys() { + return secretsHelpers.listProviders(); + }, + }, + ); +} diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 7c1b1f5154c4b..65c42b6a951ba 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -1,6 +1,6 @@ { "name": "n8n-design-system", - "version": "1.4.0", + "version": "1.5.0", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", "author": { diff --git a/packages/design-system/src/components/N8nCallout/Callout.vue b/packages/design-system/src/components/N8nCallout/Callout.vue index 53545563ab690..639380773f9bd 100644 --- a/packages/design-system/src/components/N8nCallout/Callout.vue +++ b/packages/design-system/src/components/N8nCallout/Callout.vue @@ -24,7 +24,7 @@ const CALLOUT_DEFAULT_ICONS: { [key: string]: string } = { info: 'info-circle', success: 'check-circle', warning: 'exclamation-triangle', - danger: 'times-circle', + danger: 'exclamation-triangle', }; export default defineComponent({ diff --git a/packages/design-system/src/components/N8nCallout/__tests__/__snapshots__/Callout.spec.ts.snap b/packages/design-system/src/components/N8nCallout/__tests__/__snapshots__/Callout.spec.ts.snap index 3e04a43141d65..a2f82a3ef90c6 100644 --- a/packages/design-system/src/components/N8nCallout/__tests__/__snapshots__/Callout.spec.ts.snap +++ b/packages/design-system/src/components/N8nCallout/__tests__/__snapshots__/Callout.spec.ts.snap @@ -27,7 +27,7 @@ exports[`components > N8nCallout > should render danger theme correctly 1`] = ` "
- +
 
diff --git a/packages/design-system/src/css/loading.scss b/packages/design-system/src/css/loading.scss index 92442916407a3..d13db8be39264 100644 --- a/packages/design-system/src/css/loading.scss +++ b/packages/design-system/src/css/loading.scss @@ -26,7 +26,7 @@ position: fixed; .el-loading-spinner { - margin-top: #{- var.$loading-fullscreen-spinner-size * 0.5}; + transform: translateY(-50%); .circular { height: var.$loading-fullscreen-spinner-size; @@ -38,7 +38,7 @@ @include mixins.b(loading-spinner) { top: 50%; - margin-top: #{- var.$loading-spinner-size * 0.5}; + transform: translateY(-50%); width: 100%; text-align: center; position: absolute; diff --git a/packages/design-system/src/css/utilities/_link.scss b/packages/design-system/src/css/utilities/_link.scss new file mode 100644 index 0000000000000..4a65d43ae81fb --- /dev/null +++ b/packages/design-system/src/css/utilities/_link.scss @@ -0,0 +1,10 @@ +.overlay-link::after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1; + pointer-events: auto; +} diff --git a/packages/design-system/src/css/utilities/index.scss b/packages/design-system/src/css/utilities/index.scss index 73ac65908a51e..e79fb2651a395 100644 --- a/packages/design-system/src/css/utilities/index.scss +++ b/packages/design-system/src/css/utilities/index.scss @@ -1,4 +1,5 @@ @import 'float'; +@import 'link'; @import 'list'; @import 'spacing'; @import 'typography'; diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 5e9d3a06ac400..65fd3195b976d 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "1.4.0", + "version": "1.5.0", "description": "Workflow Editor UI for n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -67,6 +67,7 @@ "pinia": "^2.1.6", "prettier": "^3.0.0", "stream-browserify": "^3.0.0", + "qrcode.vue": "^3.3.4", "timeago.js": "^4.0.2", "uuid": "^8.3.2", "v3-infinite-loading": "^1.2.2", diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 02814e992ffba..8b0a607d60e61 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -30,10 +30,10 @@ import type { FeatureFlags, ExecutionStatus, ITelemetryTrackProperties, - IN8nUISettings, IUserManagementSettings, WorkflowSettings, IUserSettings, + IN8nUISettings, BannerName, } from 'n8n-workflow'; import type { SignInType } from './constants'; @@ -44,6 +44,7 @@ import type { } from './constants'; import type { BulkCommand, Undoable } from '@/models/history'; import type { PartialBy } from '@/utils/typeHelpers'; +import type { INodeProperties } from 'n8n-workflow'; export * from 'n8n-design-system/types'; @@ -583,9 +584,12 @@ export interface CurrentUserResponse extends IUserResponse { export interface IUser extends IUserResponse { isDefaultUser: boolean; isPendingUser: boolean; + hasRecoveryCodesLeft: boolean; isOwner: boolean; inviteAcceptUrl?: string; fullName?: string; + createdAt?: string; + mfaEnabled: boolean; } export interface IVersionNotificationSettings { @@ -722,7 +726,7 @@ export interface ITimeoutHMS { seconds: number; } -export type WorkflowTitleStatus = 'EXECUTING' | 'IDLE' | 'ERROR'; +export type WorkflowTitleStatus = 'EXECUTING' | 'IDLE' | 'ERROR' | 'DEBUG'; export type ExtractActionKeys = T extends SimplifiedNodeType ? T['name'] : never; @@ -894,6 +898,7 @@ export interface WorkflowsState { workflowExecutionData: IExecutionResponse | null; workflowExecutionPairedItemMappings: { [itemId: string]: Set }; workflowsById: IWorkflowsMap; + isInDebugMode?: boolean; } export interface RootState { @@ -1142,6 +1147,9 @@ export interface ISettingsState { loginLabel: string; loginEnabled: boolean; }; + mfa: { + enabled: boolean; + }; onboardingCallPromptEnabled: boolean; saveDataErrorExecution: string; saveDataSuccessExecution: string; @@ -1535,6 +1543,26 @@ export interface InstanceUsage { export type CloudPlanAndUsageData = Cloud.PlanData & { usage: InstanceUsage }; +export interface ExternalSecretsProviderSecret { + key: string; +} + +export type ExternalSecretsProviderData = Record; + +export interface ExternalSecretsProvider { + icon: string; + name: string; + displayName: string; + connected: boolean; + connectedAt: string | false; + state: 'connected' | 'tested' | 'initializing' | 'error'; + data?: ExternalSecretsProviderData; +} + +export interface ExternalSecretsProviderWithProperties extends ExternalSecretsProvider { + properties: INodeProperties[]; +} + export type CloudUpdateLinkSourceType = | 'canvas-nav' | 'custom-data-filter' diff --git a/packages/editor-ui/src/__tests__/router.test.ts b/packages/editor-ui/src/__tests__/router.test.ts new file mode 100644 index 0000000000000..04e264474674b --- /dev/null +++ b/packages/editor-ui/src/__tests__/router.test.ts @@ -0,0 +1,31 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { createComponentRenderer } from '@/__tests__/render'; +import router from '@/router'; +import { VIEWS } from '@/constants'; + +const App = { + template: '
', +}; +const renderComponent = createComponentRenderer(App); + +describe('router', () => { + beforeAll(() => { + const pinia = createPinia(); + setActivePinia(pinia); + renderComponent({ pinia }); + }); + + test.each([ + ['/', VIEWS.WORKFLOWS], + ['/workflow', VIEWS.NEW_WORKFLOW], + ['/workflow/new', VIEWS.NEW_WORKFLOW], + ['/workflow/R9JFXwkUCL1jZBuw', VIEWS.WORKFLOW], + ['/workflow/R9JFXwkUCL1jZBuw/executions/29021', VIEWS.EXECUTION_PREVIEW], + ['/workflow/R9JFXwkUCL1jZBuw/debug/29021', VIEWS.EXECUTION_DEBUG], + ['/workflows/templates/R9JFXwkUCL1jZBuw', VIEWS.TEMPLATE_IMPORT], + ['/workflows/demo', VIEWS.DEMO], + ])('should resolve %s to %s', async (path, name) => { + await router.push(path); + expect(router.currentRoute.value.name).toBe(name); + }); +}); diff --git a/packages/editor-ui/src/api/ai.ts b/packages/editor-ui/src/api/ai.ts index 95ace358fbdfe..294d3523ac783 100644 --- a/packages/editor-ui/src/api/ai.ts +++ b/packages/editor-ui/src/api/ai.ts @@ -2,12 +2,6 @@ import type { IRestApiContext, Schema } from '@/Interface'; import { makeRestApiRequest } from '@/utils/apiUtils'; import type { IDataObject } from 'n8n-workflow'; -type Usage = { - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; -}; - export async function generateCodeForPrompt( ctx: IRestApiContext, { @@ -20,11 +14,13 @@ export async function generateCodeForPrompt( context: { schema: Array<{ nodeName: string; schema: Schema }>; inputSchema: { nodeName: string; schema: Schema }; + sessionId: string; + ndvSessionId: string; }; model: string; n8nVersion: string; }, -): Promise<{ code: string; usage: Usage }> { +): Promise<{ code: string }> { return makeRestApiRequest(ctx, 'POST', '/ask-ai', { question, context, diff --git a/packages/editor-ui/src/api/externalSecrets.ee.ts b/packages/editor-ui/src/api/externalSecrets.ee.ts new file mode 100644 index 0000000000000..76a1e189f9016 --- /dev/null +++ b/packages/editor-ui/src/api/externalSecrets.ee.ts @@ -0,0 +1,58 @@ +import type { + IRestApiContext, + ExternalSecretsProvider, + ExternalSecretsProviderWithProperties, +} from '@/Interface'; +import { makeRestApiRequest } from '@/utils'; + +export const getExternalSecrets = async ( + context: IRestApiContext, +): Promise> => { + return makeRestApiRequest(context, 'GET', '/external-secrets/secrets'); +}; + +export const getExternalSecretsProviders = async ( + context: IRestApiContext, +): Promise => { + return makeRestApiRequest(context, 'GET', '/external-secrets/providers'); +}; + +export const getExternalSecretsProvider = async ( + context: IRestApiContext, + id: string, +): Promise => { + return makeRestApiRequest(context, 'GET', `/external-secrets/providers/${id}`); +}; + +export const testExternalSecretsProviderConnection = async ( + context: IRestApiContext, + id: string, + data: ExternalSecretsProvider['data'], +): Promise<{ testState: ExternalSecretsProvider['state'] }> => { + return makeRestApiRequest(context, 'POST', `/external-secrets/providers/${id}/test`, data); +}; + +export const updateProvider = async ( + context: IRestApiContext, + id: string, + data: ExternalSecretsProvider['data'], +): Promise => { + return makeRestApiRequest(context, 'POST', `/external-secrets/providers/${id}`, data); +}; + +export const reloadProvider = async ( + context: IRestApiContext, + id: string, +): Promise<{ updated: boolean }> => { + return makeRestApiRequest(context, 'POST', `/external-secrets/providers/${id}/update`); +}; + +export const connectProvider = async ( + context: IRestApiContext, + id: string, + connected: boolean, +): Promise => { + return makeRestApiRequest(context, 'POST', `/external-secrets/providers/${id}/connect`, { + connected, + }); +}; diff --git a/packages/editor-ui/src/api/mfa.ts b/packages/editor-ui/src/api/mfa.ts new file mode 100644 index 0000000000000..5909a6ceb8876 --- /dev/null +++ b/packages/editor-ui/src/api/mfa.ts @@ -0,0 +1,23 @@ +import type { IRestApiContext } from '@/Interface'; +import { makeRestApiRequest } from '@/utils/apiUtils'; + +export async function getMfaQR( + context: IRestApiContext, +): Promise<{ qrCode: string; secret: string; recoveryCodes: string[] }> { + return makeRestApiRequest(context, 'GET', '/mfa/qr'); +} + +export async function enableMfa(context: IRestApiContext, data: { token: string }): Promise { + return makeRestApiRequest(context, 'POST', '/mfa/enable', data); +} + +export async function verifyMfaToken( + context: IRestApiContext, + data: { token: string }, +): Promise { + return makeRestApiRequest(context, 'POST', '/mfa/verify', data); +} + +export async function disableMfa(context: IRestApiContext): Promise { + return makeRestApiRequest(context, 'DELETE', '/mfa/disable'); +} diff --git a/packages/editor-ui/src/api/users.ts b/packages/editor-ui/src/api/users.ts index 6f6d9bd86797d..5ce56d26dd7bd 100644 --- a/packages/editor-ui/src/api/users.ts +++ b/packages/editor-ui/src/api/users.ts @@ -16,7 +16,7 @@ export async function loginCurrentUser( export async function login( context: IRestApiContext, - params: { email: string; password: string }, + params: { email: string; password: string; mfaToken?: string; mfaRecoveryToken?: string }, ): Promise { return makeRestApiRequest(context, 'POST', '/login', params); } @@ -74,7 +74,7 @@ export async function validatePasswordToken( export async function changePassword( context: IRestApiContext, - params: { token: string; password: string }, + params: { token: string; password: string; mfaToken?: string }, ): Promise { await makeRestApiRequest(context, 'POST', '/change-password', params); } diff --git a/packages/editor-ui/src/assets/images/doppler.webp b/packages/editor-ui/src/assets/images/doppler.webp new file mode 100644 index 0000000000000..7bd34406375ca Binary files /dev/null and b/packages/editor-ui/src/assets/images/doppler.webp differ diff --git a/packages/editor-ui/src/assets/images/hashicorp.webp b/packages/editor-ui/src/assets/images/hashicorp.webp new file mode 100644 index 0000000000000..ca52275380f54 Binary files /dev/null and b/packages/editor-ui/src/assets/images/hashicorp.webp differ diff --git a/packages/editor-ui/src/assets/images/infisical.webp b/packages/editor-ui/src/assets/images/infisical.webp new file mode 100644 index 0000000000000..abe9a20de36b6 Binary files /dev/null and b/packages/editor-ui/src/assets/images/infisical.webp differ diff --git a/packages/editor-ui/src/components/ChangePasswordModal.vue b/packages/editor-ui/src/components/ChangePasswordModal.vue index 9337bb4b0577a..601c7aa5e3265 100644 --- a/packages/editor-ui/src/components/ChangePasswordModal.vue +++ b/packages/editor-ui/src/components/ChangePasswordModal.vue @@ -31,10 +31,10 @@ + + + + diff --git a/packages/editor-ui/src/components/ExecutionsView/ExecutionPreview.vue b/packages/editor-ui/src/components/ExecutionsView/ExecutionPreview.vue index b9a4d4c845f7c..5468f6831fded 100644 --- a/packages/editor-ui/src/components/ExecutionsView/ExecutionPreview.vue +++ b/packages/editor-ui/src/components/ExecutionsView/ExecutionPreview.vue @@ -79,6 +79,29 @@
+ + + {{ + debugButtonData.text + }} + + + import { defineComponent } from 'vue'; - -import { useMessage } from '@/composables'; +import { ElDropdown } from 'element-plus'; +import { useExecutionDebugging, useMessage } from '@/composables'; import WorkflowPreview from '@/components/WorkflowPreview.vue'; import type { IExecutionUIData } from '@/mixins/executionsHelpers'; import { executionHelpers } from '@/mixins/executionsHelpers'; import { MODAL_CONFIRM, VIEWS } from '@/constants'; -import { ElDropdown } from 'element-plus'; type RetryDropdownRef = InstanceType & { hide: () => void }; @@ -153,6 +175,7 @@ export default defineComponent({ setup() { return { ...useMessage(), + ...useExecutionDebugging(), }; }, computed: { @@ -162,6 +185,17 @@ export default defineComponent({ executionMode(): string { return this.activeExecution?.mode || ''; }, + debugButtonData(): Record { + return this.activeExecution?.status === 'success' + ? { + text: this.$locale.baseText('executionsList.debug.button.copyToEditor'), + type: 'secondary', + } + : { + text: this.$locale.baseText('executionsList.debug.button.debugInEditor'), + type: 'primary', + }; + }, }, methods: { async onDeleteExecution(): Promise { @@ -212,9 +246,15 @@ export default defineComponent({ width: 100%; display: flex; justify-content: space-between; + align-items: center; transition: all 150ms ease-in-out; pointer-events: none; + > div:last-child { + display: flex; + align-items: center; + } + & * { pointer-events: all; } @@ -254,4 +294,21 @@ export default defineComponent({ margin-top: var(--spacing-l); text-align: center; } + +.debugLink { + padding: 0; + margin-right: var(--spacing-xs); + + &.secondary { + a span { + color: var(--color-primary-shade-1); + } + } + + a span { + display: block; + padding: var(--spacing-xs) var(--spacing-m); + color: var(--color-text-xlight); + } +} diff --git a/packages/editor-ui/src/components/ExecutionsView/__tests__/ExecutionPreview.test.ts b/packages/editor-ui/src/components/ExecutionsView/__tests__/ExecutionPreview.test.ts new file mode 100644 index 0000000000000..fb13c06a34958 --- /dev/null +++ b/packages/editor-ui/src/components/ExecutionsView/__tests__/ExecutionPreview.test.ts @@ -0,0 +1,112 @@ +import { vi, describe, expect } from 'vitest'; +import { render } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { faker } from '@faker-js/faker'; +import { createRouter, createWebHistory } from 'vue-router'; +import { createPinia, PiniaVuePlugin, setActivePinia } from 'pinia'; +import type { IExecutionsSummary } from 'n8n-workflow'; +import { useSettingsStore, useWorkflowsStore } from '@/stores'; +import ExecutionPreview from '@/components/ExecutionsView/ExecutionPreview.vue'; +import { VIEWS } from '@/constants'; +import { i18nInstance, I18nPlugin } from '@/plugins/i18n'; +import { FontAwesomePlugin } from '@/plugins/icons'; +import { GlobalComponentsPlugin } from '@/plugins/components'; + +let pinia: ReturnType; + +const routes = [ + { path: '/', name: 'home', component: { template: '
' } }, + { + path: '/workflow/:name/debug/:executionId', + name: VIEWS.EXECUTION_DEBUG, + component: { template: '
' }, + }, +]; + +const router = createRouter({ + history: createWebHistory(), + routes, +}); + +const $route = { + params: {}, +}; + +const generateUndefinedNullOrString = () => { + switch (Math.floor(Math.random() * 4)) { + case 0: + return undefined; + case 1: + return null; + case 2: + return faker.string.uuid(); + case 3: + return ''; + default: + return undefined; + } +}; + +const executionDataFactory = (): IExecutionsSummary => ({ + id: faker.string.uuid(), + finished: faker.datatype.boolean(), + mode: faker.helpers.arrayElement(['manual', 'trigger']), + startedAt: faker.date.past(), + stoppedAt: faker.date.past(), + workflowId: faker.number.int().toString(), + workflowName: faker.string.sample(), + status: faker.helpers.arrayElement(['failed', 'success']), + nodeExecutionStatus: {}, + retryOf: generateUndefinedNullOrString(), + retrySuccessId: generateUndefinedNullOrString(), +}); + +describe('ExecutionPreview.vue', () => { + let workflowsStore: ReturnType; + let settingsStore: ReturnType; + const executionData: IExecutionsSummary = executionDataFactory(); + + beforeEach(() => { + pinia = createPinia(); + setActivePinia(pinia); + + workflowsStore = useWorkflowsStore(); + settingsStore = useSettingsStore(); + + vi.spyOn(workflowsStore, 'activeWorkflowExecution', 'get').mockReturnValue(executionData); + }); + + test.each([ + [false, '/'], + [true, `/workflow/${executionData.workflowId}/debug/${executionData.id}`], + ])( + 'when debug enterprise feature is %s it should handle debug link click accordingly', + async (availability, path) => { + vi.spyOn(settingsStore, 'isEnterpriseFeatureEnabled', 'get').mockReturnValue( + () => availability, + ); + + // Not using createComponentRenderer helper here because this component should not stub `router-link` + const { getByTestId } = render(ExecutionPreview, { + global: { + plugins: [ + I18nPlugin, + i18nInstance, + PiniaVuePlugin, + FontAwesomePlugin, + GlobalComponentsPlugin, + pinia, + router, + ], + mocks: { + $route, + }, + }, + }); + + await userEvent.click(getByTestId('execution-debug-button')); + + expect(router.currentRoute.value.path).toBe(path); + }, + ); +}); diff --git a/packages/editor-ui/src/components/ExpressionParameterInput.vue b/packages/editor-ui/src/components/ExpressionParameterInput.vue index 8567f95123d5e..ad9093f180027 100644 --- a/packages/editor-ui/src/components/ExpressionParameterInput.vue +++ b/packages/editor-ui/src/components/ExpressionParameterInput.vue @@ -19,6 +19,7 @@ :isReadOnly="isReadOnly" :targetItem="hoveringItem" :isSingleLine="isForRecordLocator" + :additionalData="additionalExpressionData" :path="path" @focus="onFocus" @blur="onBlur" @@ -34,7 +35,6 @@ data-test-id="expander" />
- import { mapStores } from 'pinia'; +import type { PropType } from 'vue'; import { defineComponent } from 'vue'; import { useNDVStore } from '@/stores/ndv.store'; @@ -57,6 +58,7 @@ import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils'; import type { Segment } from '@/types/expressions'; import type { TargetItem } from '@/Interface'; +import type { IDataObject } from 'n8n-workflow'; type InlineExpressionEditorInputRef = InstanceType; @@ -88,6 +90,10 @@ export default defineComponent({ type: Boolean, default: false, }, + additionalExpressionData: { + type: Object as PropType, + default: () => ({}), + }, }, computed: { ...mapStores(useNDVStore, useWorkflowsStore), diff --git a/packages/editor-ui/src/components/ExternalSecretsProviderCard.ee.vue b/packages/editor-ui/src/components/ExternalSecretsProviderCard.ee.vue new file mode 100644 index 0000000000000..ed4934733c287 --- /dev/null +++ b/packages/editor-ui/src/components/ExternalSecretsProviderCard.ee.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/packages/editor-ui/src/components/ExternalSecretsProviderConnectionSwitch.ee.vue b/packages/editor-ui/src/components/ExternalSecretsProviderConnectionSwitch.ee.vue new file mode 100644 index 0000000000000..d56ead9a86997 --- /dev/null +++ b/packages/editor-ui/src/components/ExternalSecretsProviderConnectionSwitch.ee.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/packages/editor-ui/src/components/ExternalSecretsProviderImage.ee.vue b/packages/editor-ui/src/components/ExternalSecretsProviderImage.ee.vue new file mode 100644 index 0000000000000..e70742a4a7548 --- /dev/null +++ b/packages/editor-ui/src/components/ExternalSecretsProviderImage.ee.vue @@ -0,0 +1,28 @@ + + + diff --git a/packages/editor-ui/src/components/ExternalSecretsProviderModal.ee.vue b/packages/editor-ui/src/components/ExternalSecretsProviderModal.ee.vue new file mode 100644 index 0000000000000..64c2d54d7b748 --- /dev/null +++ b/packages/editor-ui/src/components/ExternalSecretsProviderModal.ee.vue @@ -0,0 +1,331 @@ + + + + + + + diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue index 902729dbe1567..fc6434aa632f8 100644 --- a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue +++ b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue @@ -4,6 +4,7 @@ + + diff --git a/packages/editor-ui/src/components/Modals.vue b/packages/editor-ui/src/components/Modals.vue index d39a3c2c9c91f..a7c1d2be28b77 100644 --- a/packages/editor-ui/src/components/Modals.vue +++ b/packages/editor-ui/src/components/Modals.vue @@ -65,6 +65,10 @@ + + + + + + + + + + + +
@@ -143,6 +159,9 @@ import { LOG_STREAM_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY, SOURCE_CONTROL_PULL_MODAL_KEY, + EXTERNAL_SECRETS_PROVIDER_MODAL_KEY, + DEBUG_PAYWALL_MODAL_KEY, + MFA_SETUP_MODAL_KEY, } from '@/constants'; import AboutModal from './AboutModal.vue'; @@ -164,10 +183,13 @@ import WorkflowSettings from './WorkflowSettings.vue'; import DeleteUserModal from './DeleteUserModal.vue'; import ActivationModal from './ActivationModal.vue'; import ImportCurlModal from './ImportCurlModal.vue'; +import MfaSetupModal from './MfaSetupModal.vue'; import WorkflowShareModal from './WorkflowShareModal.ee.vue'; import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue'; import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue'; import SourceControlPullModal from '@/components/SourceControlPullModal.ee.vue'; +import ExternalSecretsProviderModal from '@/components/ExternalSecretsProviderModal.ee.vue'; +import DebugPaywallModal from '@/components/DebugPaywallModal.vue'; export default defineComponent({ name: 'Modals', @@ -195,6 +217,9 @@ export default defineComponent({ EventDestinationSettingsModal, SourceControlPushModal, SourceControlPullModal, + ExternalSecretsProviderModal, + DebugPaywallModal, + MfaSetupModal, }, data: () => ({ COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, @@ -219,6 +244,9 @@ export default defineComponent({ LOG_STREAM_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY, SOURCE_CONTROL_PULL_MODAL_KEY, + EXTERNAL_SECRETS_PROVIDER_MODAL_KEY, + DEBUG_PAYWALL_MODAL_KEY, + MFA_SETUP_MODAL_KEY, }), }); diff --git a/packages/editor-ui/src/components/Node.vue b/packages/editor-ui/src/components/Node.vue index 08ca458cc0972..594dd01864ed8 100644 --- a/packages/editor-ui/src/components/Node.vue +++ b/packages/editor-ui/src/components/Node.vue @@ -34,7 +34,7 @@ v-if="!data.disabled" :class="{ 'node-info-icon': true, 'shift-icon': shiftOutputCount }" > -
+