diff --git a/cypress/composables/projects.ts b/cypress/composables/projects.ts new file mode 100644 index 000000000..dd25c3f20 --- /dev/null +++ b/cypress/composables/projects.ts @@ -0,0 +1,18 @@ +export const getHomeButton = () => cy.getByTestId('project-home-menu-item'); +export const getMenuItems = () => cy.getByTestId('project-menu-item'); +export const getAddProjectButton = () => cy.getByTestId('add-project-menu-item'); +export const getProjectTabs = () => cy.getByTestId('project-tabs').find('a'); +export const getProjectTabWorkflows = () => getProjectTabs().filter('a[href$="/workflows"]'); +export const getProjectTabCredentials = () => getProjectTabs().filter('a[href$="/credentials"]'); +export const getProjectTabSettings = () => getProjectTabs().filter('a[href$="/settings"]'); +export const getProjectSettingsSaveButton = () => cy.getByTestId('project-settings-save-button'); +export const getProjectSettingsCancelButton = () => + cy.getByTestId('project-settings-cancel-button'); +export const getProjectSettingsDeleteButton = () => + cy.getByTestId('project-settings-delete-button'); +export const getProjectMembersSelect = () => cy.getByTestId('project-members-select'); + +export const addProjectMember = (email: string) => { + getProjectMembersSelect().click(); + getProjectMembersSelect().get('.el-select-dropdown__item').contains(email.toLowerCase()).click(); +}; diff --git a/cypress/e2e/17-sharing.cy.ts b/cypress/e2e/17-sharing.cy.ts index 71f41250e..7908e8d12 100644 --- a/cypress/e2e/17-sharing.cy.ts +++ b/cypress/e2e/17-sharing.cy.ts @@ -30,7 +30,7 @@ const workflowSharingModal = new WorkflowSharingModal(); const ndv = new NDV(); describe('Sharing', { disableAutoLogin: true }, () => { - before(() => cy.enableFeature('sharing', true)); + before(() => cy.enableFeature('sharing')); let workflowW2Url = ''; it('should create C1, W1, W2, share W1 with U3, as U2', () => { @@ -171,11 +171,11 @@ describe('Sharing', { disableAutoLogin: true }, () => { cy.get('input').should('not.have.length'); credentialsModal.actions.changeTab('Sharing'); cy.contains( - 'You can view this credential because you have permission to read and share', + 'Sharing a credential allows people to use it in their workflows. They cannot access credential details.', ).should('be.visible'); credentialsModal.getters.usersSelect().click(); - cy.getByTestId('user-email') + cy.getByTestId('project-sharing-info') .filter(':visible') .should('have.length', 3) .contains(INSTANCE_ADMIN.email) diff --git a/cypress/e2e/19-execution.cy.ts b/cypress/e2e/19-execution.cy.ts index 1855bdb43..98c0909b4 100644 --- a/cypress/e2e/19-execution.cy.ts +++ b/cypress/e2e/19-execution.cy.ts @@ -501,7 +501,7 @@ describe('Execution', () => { workflowPage.getters.clearExecutionDataButton().should('be.visible'); - cy.intercept('POST', '/rest/workflows/run').as('workflowRun'); + cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun'); workflowPage.getters .canvasNodeByName('do something with them') @@ -525,7 +525,7 @@ describe('Execution', () => { workflowPage.getters.zoomToFitButton().click(); - cy.intercept('POST', '/rest/workflows/run').as('workflowRun'); + cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun'); workflowPage.getters .canvasNodeByName('If') @@ -545,7 +545,7 @@ describe('Execution', () => { workflowPage.getters.clearExecutionDataButton().should('be.visible'); - cy.intercept('POST', '/rest/workflows/run').as('workflowRun'); + cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun'); workflowPage.getters .canvasNodeByName('NoOp2') @@ -576,7 +576,7 @@ describe('Execution', () => { 'My test workflow', ); - cy.intercept('POST', '/rest/workflows/run').as('workflowRun'); + cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun'); workflowPage.getters.zoomToFitButton().click(); workflowPage.getters.executeWorkflowButton().click(); @@ -599,7 +599,7 @@ describe('Execution', () => { 'My test workflow', ); - cy.intercept('POST', '/rest/workflows/run').as('workflowRun'); + cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun'); workflowPage.getters.zoomToFitButton().click(); workflowPage.getters.executeWorkflowButton().click(); diff --git a/cypress/e2e/23-variables.cy.ts b/cypress/e2e/23-variables.cy.ts index ce6a49fb9..c481f2512 100644 --- a/cypress/e2e/23-variables.cy.ts +++ b/cypress/e2e/23-variables.cy.ts @@ -4,7 +4,7 @@ const variablesPage = new VariablesPage(); describe('Variables', () => { it('should show the unlicensed action box when the feature is disabled', () => { - cy.disableFeature('variables', false); + cy.disableFeature('variables'); cy.visit(variablesPage.url); variablesPage.getters.unavailableResourcesList().should('be.visible'); @@ -18,14 +18,15 @@ describe('Variables', () => { beforeEach(() => { cy.intercept('GET', '/rest/variables').as('loadVariables'); + cy.intercept('GET', '/rest/login').as('login'); cy.visit(variablesPage.url); - cy.wait(['@loadVariables', '@loadSettings']); + cy.wait(['@loadVariables', '@loadSettings', '@login']); }); it('should show the licensed action box when the feature is enabled', () => { variablesPage.getters.emptyResourcesList().should('be.visible'); - variablesPage.getters.createVariableButton().should('be.visible'); + variablesPage.getters.emptyResourcesListNewVariableButton().should('be.visible'); }); it('should create a new variable using empty state row', () => { diff --git a/cypress/e2e/28-debug.cy.ts b/cypress/e2e/28-debug.cy.ts index 955d33ce2..71c733c25 100644 --- a/cypress/e2e/28-debug.cy.ts +++ b/cypress/e2e/28-debug.cy.ts @@ -19,7 +19,7 @@ describe('Debug', () => { it('should be able to debug executions', () => { cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions'); cy.intercept('GET', '/rest/executions/*').as('getExecution'); - cy.intercept('POST', '/rest/workflows/run').as('postWorkflowRun'); + cy.intercept('POST', '/rest/workflows/**/run').as('postWorkflowRun'); cy.signin({ email: INSTANCE_OWNER.email, password: INSTANCE_OWNER.password }); diff --git a/cypress/e2e/29-templates.cy.ts b/cypress/e2e/29-templates.cy.ts index 34762b12f..d5f0a67f7 100644 --- a/cypress/e2e/29-templates.cy.ts +++ b/cypress/e2e/29-templates.cy.ts @@ -10,7 +10,7 @@ describe('Workflow templates', () => { beforeEach(() => { cy.intercept('GET', '**/rest/settings', (req) => { // Disable cache - delete req.headers['if-none-match'] + delete req.headers['if-none-match']; req.reply((res) => { if (res.body.data) { // Disable custom templates host if it has been overridden by another intercept @@ -22,18 +22,27 @@ describe('Workflow templates', () => { it('Opens website when clicking templates sidebar link', () => { cy.visit(workflowsPage.url); - mainSidebar.getters.menuItem('Templates').should('be.visible'); + mainSidebar.getters.templates().should('be.visible'); // Templates should be a link to the website - mainSidebar.getters.templates().parent('a').should('have.attr', 'href').and('include', 'https://n8n.io/workflows'); + mainSidebar.getters + .templates() + .parent('a') + .should('have.attr', 'href') + .and('include', 'https://n8n.io/workflows'); // Link should contain instance address and n8n version - mainSidebar.getters.templates().parent('a').then(($a) => { - const href = $a.attr('href'); - const params = new URLSearchParams(href); - // Link should have all mandatory parameters expected on the website - expect(decodeURIComponent(`${params.get('utm_instance')}`)).to.include(window.location.origin); - expect(params.get('utm_n8n_version')).to.match(/[0-9]+\.[0-9]+\.[0-9]+/); - expect(params.get('utm_awc')).to.match(/[0-9]+/); - }); + mainSidebar.getters + .templates() + .parent('a') + .then(($a) => { + const href = $a.attr('href'); + const params = new URLSearchParams(href); + // Link should have all mandatory parameters expected on the website + expect(decodeURIComponent(`${params.get('utm_instance')}`)).to.include( + window.location.origin, + ); + expect(params.get('utm_n8n_version')).to.match(/[0-9]+\.[0-9]+\.[0-9]+/); + expect(params.get('utm_awc')).to.match(/[0-9]+/); + }); mainSidebar.getters.templates().parent('a').should('have.attr', 'target', '_blank'); }); @@ -41,6 +50,6 @@ describe('Workflow templates', () => { cy.visit(templatesPage.url); cy.origin('https://n8n.io', () => { cy.url().should('include', 'https://n8n.io/workflows'); - }) + }); }); }); diff --git a/cypress/e2e/30-editor-after-route-changes.cy.ts b/cypress/e2e/30-editor-after-route-changes.cy.ts index 727078e73..a502d3577 100644 --- a/cypress/e2e/30-editor-after-route-changes.cy.ts +++ b/cypress/e2e/30-editor-after-route-changes.cy.ts @@ -148,7 +148,7 @@ describe('Editor actions should work', () => { it('after switching between Editor and Debug', () => { cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions'); cy.intercept('GET', '/rest/executions/*').as('getExecution'); - cy.intercept('POST', '/rest/workflows/run').as('postWorkflowRun'); + cy.intercept('POST', '/rest/workflows/**/run').as('postWorkflowRun'); editWorkflowAndDeactivate(); workflowPage.actions.executeWorkflow(); @@ -196,9 +196,9 @@ describe('Editor zoom should work after route changes', () => { cy.intercept('GET', '/rest/workflow-history/workflow/*/version/*').as('getVersion'); cy.intercept('GET', '/rest/workflow-history/workflow/*').as('getHistory'); cy.intercept('GET', '/rest/users').as('getUsers'); - cy.intercept('GET', '/rest/workflows').as('getWorkflows'); + cy.intercept('GET', '/rest/workflows?*').as('getWorkflows'); cy.intercept('GET', '/rest/active-workflows').as('getActiveWorkflows'); - cy.intercept('GET', '/rest/credentials').as('getCredentials'); + cy.intercept('GET', '/rest/credentials?*').as('getCredentials'); switchBetweenEditorAndHistory(); zoomInAndCheckNodes(); diff --git a/cypress/e2e/39-projects.cy.ts b/cypress/e2e/39-projects.cy.ts new file mode 100644 index 000000000..5cf1ac1fd --- /dev/null +++ b/cypress/e2e/39-projects.cy.ts @@ -0,0 +1,151 @@ +import { INSTANCE_ADMIN, INSTANCE_MEMBERS } from '../constants'; +import { WorkflowsPage, WorkflowPage, CredentialsModal, CredentialsPage } from '../pages'; +import * as projects from '../composables/projects'; + +const workflowsPage = new WorkflowsPage(); +const workflowPage = new WorkflowPage(); +const credentialsPage = new CredentialsPage(); +const credentialsModal = new CredentialsModal(); + +describe('Projects', () => { + beforeEach(() => { + cy.resetDatabase(); + cy.enableFeature('advancedPermissions'); + cy.enableFeature('projectRole:admin'); + cy.enableFeature('projectRole:editor'); + cy.changeQuota('maxTeamProjects', -1); + }); + + it('should handle workflows and credentials', () => { + cy.signin(INSTANCE_ADMIN); + cy.visit(workflowsPage.url); + workflowsPage.getters.workflowCards().should('not.have.length'); + + workflowsPage.getters.newWorkflowButtonCard().click(); + + cy.intercept('POST', '/rest/workflows').as('workflowSave'); + workflowPage.actions.saveWorkflowOnButtonClick(); + + cy.wait('@workflowSave').then((interception) => { + expect(interception.request.body).not.to.have.property('projectId'); + }); + + projects.getHomeButton().click(); + projects.getProjectTabs().should('have.length', 2); + + projects.getProjectTabCredentials().click(); + credentialsPage.getters.credentialCards().should('not.have.length'); + + credentialsPage.getters.emptyListCreateCredentialButton().click(); + credentialsModal.getters.newCredentialModal().should('be.visible'); + credentialsModal.getters.newCredentialTypeSelect().should('be.visible'); + credentialsModal.getters.newCredentialTypeOption('Notion API').click(); + credentialsModal.getters.newCredentialTypeButton().click(); + credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890'); + credentialsModal.actions.setName('My awesome Notion account'); + + cy.intercept('POST', '/rest/credentials').as('credentialSave'); + credentialsModal.actions.save(); + cy.wait('@credentialSave').then((interception) => { + expect(interception.request.body).not.to.have.property('projectId'); + }); + + credentialsModal.actions.close(); + credentialsPage.getters.credentialCards().should('have.length', 1); + + projects.getProjectTabWorkflows().click(); + workflowsPage.getters.workflowCards().should('have.length', 1); + + projects.getMenuItems().should('not.have.length'); + + cy.intercept('POST', '/rest/projects').as('projectCreate'); + projects.getAddProjectButton().click(); + cy.wait('@projectCreate'); + projects.getMenuItems().should('have.length', 1); + projects.getProjectTabs().should('have.length', 3); + + cy.get('input[name="name"]').type('Development'); + projects.addProjectMember(INSTANCE_MEMBERS[0].email); + + cy.intercept('PATCH', '/rest/projects/*').as('projectSettingsSave'); + projects.getProjectSettingsSaveButton().click(); + cy.wait('@projectSettingsSave').then((interception) => { + expect(interception.request.body).to.have.property('name').and.to.equal('Development'); + expect(interception.request.body).to.have.property('relations').to.have.lengthOf(2); + }); + + projects.getMenuItems().first().click(); + workflowsPage.getters.workflowCards().should('not.have.length'); + projects.getProjectTabs().should('have.length', 3); + + workflowsPage.getters.newWorkflowButtonCard().click(); + + cy.intercept('POST', '/rest/workflows').as('workflowSave'); + workflowPage.actions.saveWorkflowOnButtonClick(); + + cy.wait('@workflowSave').then((interception) => { + expect(interception.request.body).to.have.property('projectId'); + }); + + projects.getMenuItems().first().click(); + + projects.getProjectTabCredentials().click(); + credentialsPage.getters.credentialCards().should('not.have.length'); + + credentialsPage.getters.emptyListCreateCredentialButton().click(); + credentialsModal.getters.newCredentialModal().should('be.visible'); + credentialsModal.getters.newCredentialTypeSelect().should('be.visible'); + credentialsModal.getters.newCredentialTypeOption('Notion API').click(); + credentialsModal.getters.newCredentialTypeButton().click(); + credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890'); + credentialsModal.actions.setName('My awesome Notion account'); + + cy.intercept('POST', '/rest/credentials').as('credentialSave'); + credentialsModal.actions.save(); + cy.wait('@credentialSave').then((interception) => { + expect(interception.request.body).to.have.property('projectId'); + }); + credentialsModal.actions.close(); + + projects.getAddProjectButton().click(); + projects.getMenuItems().should('have.length', 2); + + let projectId: string; + projects.getMenuItems().first().click(); + cy.intercept('GET', '/rest/credentials*').as('credentialsList'); + projects.getProjectTabCredentials().click(); + cy.wait('@credentialsList').then((interception) => { + const url = new URL(interception.request.url); + const queryParams = new URLSearchParams(url.search); + const filter = queryParams.get('filter'); + expect(filter).to.be.a('string').and.to.contain('projectId'); + + if (filter) { + projectId = JSON.parse(filter).projectId; + } + }); + + projects.getMenuItems().last().click(); + cy.intercept('GET', '/rest/credentials*').as('credentialsList'); + projects.getProjectTabCredentials().click(); + cy.wait('@credentialsList').then((interception) => { + const url = new URL(interception.request.url); + const queryParams = new URLSearchParams(url.search); + const filter = queryParams.get('filter'); + expect(filter).to.be.a('string').and.to.contain('projectId'); + + if (filter) { + expect(JSON.parse(filter).projectId).not.to.equal(projectId); + } + }); + + projects.getHomeButton().click(); + workflowsPage.getters.workflowCards().should('have.length', 2); + + cy.intercept('GET', '/rest/credentials*').as('credentialsList'); + projects.getProjectTabCredentials().click(); + cy.wait('@credentialsList').then((interception) => { + expect(interception.request.url).not.to.contain('filter'); + }); + }); +}); diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index cdac202f4..6513a80cb 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -697,7 +697,7 @@ describe('NDV', () => { }); it('Stop listening for trigger event from NDV', () => { - cy.intercept('POST', '/rest/workflows/run').as('workflowRun'); + cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun'); workflowPage.actions.addInitialNodeToCanvas('Local File Trigger', { keepNdvOpen: true, action: 'On Changes To A Specific File', diff --git a/cypress/pages/credentials.ts b/cypress/pages/credentials.ts index 24ec88565..7ae2d0f3b 100644 --- a/cypress/pages/credentials.ts +++ b/cypress/pages/credentials.ts @@ -1,7 +1,7 @@ import { BasePage } from './base'; export class CredentialsPage extends BasePage { - url = '/credentials'; + url = '/home/credentials'; getters = { emptyListCreateCredentialButton: () => cy.getByTestId('empty-resources-list').find('button'), createCredentialButton: () => cy.getByTestId('resources-list-add'), diff --git a/cypress/pages/modals/credentials-modal.ts b/cypress/pages/modals/credentials-modal.ts index 08a258a05..2275ea5e4 100644 --- a/cypress/pages/modals/credentials-modal.ts +++ b/cypress/pages/modals/credentials-modal.ts @@ -25,7 +25,7 @@ export class CredentialsModal extends BasePage { credentialInputs: () => cy.getByTestId('credential-connection-parameter'), menu: () => this.getters.editCredentialModal().get('.menu-container'), menuItem: (name: string) => this.getters.menu().get('.n8n-menu-item').contains(name), - usersSelect: () => cy.getByTestId('credential-sharing-modal-users-select'), + usersSelect: () => cy.getByTestId('project-sharing-select').filter(':visible'), testSuccessTag: () => cy.getByTestId('credentials-config-container-test-success'), }; actions = { diff --git a/cypress/pages/modals/workflow-sharing-modal.ts b/cypress/pages/modals/workflow-sharing-modal.ts index c01309328..fc4ba8dad 100644 --- a/cypress/pages/modals/workflow-sharing-modal.ts +++ b/cypress/pages/modals/workflow-sharing-modal.ts @@ -3,7 +3,7 @@ import { BasePage } from '../base'; export class WorkflowSharingModal extends BasePage { getters = { modal: () => cy.getByTestId('workflowShare-modal', { timeout: 5000 }), - usersSelect: () => cy.getByTestId('workflow-sharing-modal-users-select'), + usersSelect: () => cy.getByTestId('project-sharing-select'), saveButton: () => cy.getByTestId('workflow-sharing-modal-save-button'), closeButton: () => this.getters.modal().find('.el-dialog__close').first(), }; diff --git a/cypress/pages/settings-users.ts b/cypress/pages/settings-users.ts index e3c80e5bc..a16eb4ab6 100644 --- a/cypress/pages/settings-users.ts +++ b/cypress/pages/settings-users.ts @@ -41,10 +41,10 @@ export class SettingsUsersPage extends BasePage { workflowPage.actions.visit(); mainSidebar.actions.goToSettings(); if (isOwner) { - settingsSidebar.getters.menuItem('Users').click(); + settingsSidebar.getters.users().click(); cy.url().should('match', new RegExp(this.url)); } else { - settingsSidebar.getters.menuItem('Users').should('not.exist'); + settingsSidebar.getters.users().should('not.exist'); // Should be redirected to workflows page if trying to access UM url cy.visit('/settings/users'); cy.url().should('match', new RegExp(workflowsPage.url)); diff --git a/cypress/pages/sidebar/main-sidebar.ts b/cypress/pages/sidebar/main-sidebar.ts index 5379b1f88..348d4aa14 100644 --- a/cypress/pages/sidebar/main-sidebar.ts +++ b/cypress/pages/sidebar/main-sidebar.ts @@ -5,14 +5,13 @@ const workflowsPage = new WorkflowsPage(); export class MainSidebar extends BasePage { getters = { - menuItem: (menuLabel: string) => - cy.getByTestId('menu-item').filter(`:contains("${menuLabel}")`), - settings: () => this.getters.menuItem('Settings'), - templates: () => this.getters.menuItem('Templates'), - workflows: () => this.getters.menuItem('Workflows'), - credentials: () => this.getters.menuItem('Credentials'), - executions: () => this.getters.menuItem('Executions'), - adminPanel: () => this.getters.menuItem('Admin Panel'), + menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id), + settings: () => this.getters.menuItem('settings'), + templates: () => this.getters.menuItem('templates'), + workflows: () => this.getters.menuItem('workflows'), + credentials: () => this.getters.menuItem('credentials'), + executions: () => this.getters.menuItem('executions'), + adminPanel: () => this.getters.menuItem('cloud-admin'), userMenu: () => cy.get('div[class="action-dropdown-container"]'), logo: () => cy.getByTestId('n8n-logo'), }; diff --git a/cypress/pages/sidebar/settings-sidebar.ts b/cypress/pages/sidebar/settings-sidebar.ts index 6d519d6c3..886a0a3c1 100644 --- a/cypress/pages/sidebar/settings-sidebar.ts +++ b/cypress/pages/sidebar/settings-sidebar.ts @@ -2,9 +2,8 @@ import { BasePage } from '../base'; export class SettingsSidebar extends BasePage { getters = { - menuItem: (menuLabel: string) => - cy.getByTestId('menu-item').filter(`:contains("${menuLabel}")`), - users: () => this.getters.menuItem('Users'), + menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id), + users: () => this.getters.menuItem('settings-users'), back: () => cy.getByTestId('settings-back'), }; actions = { diff --git a/cypress/pages/variables.ts b/cypress/pages/variables.ts index 6091e5cf1..6d9e9eb13 100644 --- a/cypress/pages/variables.ts +++ b/cypress/pages/variables.ts @@ -35,7 +35,7 @@ export class VariablesPage extends BasePage { deleteVariable: (key: string) => { const row = this.getters.variableRow(key); row.within(() => { - cy.getByTestId('variable-row-delete-button').click(); + cy.getByTestId('variable-row-delete-button').should('not.be.disabled').click(); }); const modal = cy.get('[role="dialog"]'); @@ -53,7 +53,7 @@ export class VariablesPage extends BasePage { editRow: (key: string) => { const row = this.getters.variableRow(key); row.within(() => { - cy.getByTestId('variable-row-edit-button').click(); + cy.getByTestId('variable-row-edit-button').should('not.be.disabled').click(); }); }, setRowValue: (row: Chainable>, field: 'key' | 'value', value: string) => { diff --git a/cypress/pages/workflow-executions-tab.ts b/cypress/pages/workflow-executions-tab.ts index eb855f026..cf9665a8b 100644 --- a/cypress/pages/workflow-executions-tab.ts +++ b/cypress/pages/workflow-executions-tab.ts @@ -32,7 +32,7 @@ export class WorkflowExecutionsTab extends BasePage { }, createManualExecutions: (count: number) => { for (let i = 0; i < count; i++) { - cy.intercept('POST', '/rest/workflows/run').as('workflowExecution'); + cy.intercept('POST', '/rest/workflows/**/run').as('workflowExecution'); workflowPage.actions.executeWorkflow(); cy.wait('@workflowExecution'); } diff --git a/cypress/pages/workflows.ts b/cypress/pages/workflows.ts index 56a3c4492..fd65a426a 100644 --- a/cypress/pages/workflows.ts +++ b/cypress/pages/workflows.ts @@ -1,7 +1,7 @@ import { BasePage } from './base'; export class WorkflowsPage extends BasePage { - url = '/workflows'; + url = '/home/workflows'; getters = { newWorkflowButtonCard: () => cy.getByTestId('new-workflow-card'), newWorkflowTemplateCard: () => cy.getByTestId('new-workflow-template-card'), diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index a92dc2ce0..bd33a8f21 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -65,7 +65,7 @@ Cypress.Commands.add('signout', () => { cy.request({ method: 'POST', url: `${BACKEND_BASE_URL}/rest/logout`, - headers: { 'browser-id': localStorage.getItem('n8n-browserId') } + headers: { 'browser-id': localStorage.getItem('n8n-browserId') }, }); cy.getCookie(N8N_AUTH_COOKIE).should('not.exist'); }); @@ -80,12 +80,19 @@ const setFeature = (feature: string, enabled: boolean) => enabled, }); +const setQuota = (feature: string, value: number) => + cy.request('PATCH', `${BACKEND_BASE_URL}/rest/e2e/quota`, { + feature: `quota:${feature}`, + value, + }); + const setQueueMode = (enabled: boolean) => cy.request('PATCH', `${BACKEND_BASE_URL}/rest/e2e/queue-mode`, { enabled, }); Cypress.Commands.add('enableFeature', (feature: string) => setFeature(feature, true)); +Cypress.Commands.add('changeQuota', (feature: string, value: number) => setQuota(feature, value)); Cypress.Commands.add('disableFeature', (feature: string) => setFeature(feature, false)); Cypress.Commands.add('enableQueueMode', () => setQueueMode(true)); Cypress.Commands.add('disableQueueMode', () => setQueueMode(false)); diff --git a/cypress/support/index.ts b/cypress/support/index.ts index f31e50c57..411b73225 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -30,6 +30,7 @@ declare global { disableFeature(feature: string): void; enableQueueMode(): void; disableQueueMode(): void; + changeQuota(feature: string, value: number): void; waitForLoad(waitForIntercepts?: boolean): void; grantBrowserPermissions(...permissions: string[]): void; readClipboard(): Chainable; diff --git a/cypress/utils/executions.ts b/cypress/utils/executions.ts index 81748af50..d88b58ea9 100644 --- a/cypress/utils/executions.ts +++ b/cypress/utils/executions.ts @@ -29,7 +29,7 @@ export function createMockNodeExecutionData( ]; return acc; - }, {}) + }, {}) : data, source: [null], ...rest, @@ -88,7 +88,7 @@ export function runMockWorkflowExcution({ }) { const executionId = Math.random().toString(36).substring(4); - cy.intercept('POST', '/rest/workflows/run', { + cy.intercept('POST', '/rest/workflows/**/run', { statusCode: 201, body: { data: { diff --git a/packages/@n8n/permissions/src/combineScopes.ts b/packages/@n8n/permissions/src/combineScopes.ts new file mode 100644 index 000000000..23da64d83 --- /dev/null +++ b/packages/@n8n/permissions/src/combineScopes.ts @@ -0,0 +1,23 @@ +import type { Scope, ScopeLevels, GlobalScopes, MaskLevels } from './types'; + +export function combineScopes(userScopes: GlobalScopes, masks?: MaskLevels): Set; +export function combineScopes(userScopes: ScopeLevels, masks?: MaskLevels): Set; +export function combineScopes( + userScopes: GlobalScopes | ScopeLevels, + masks?: MaskLevels, +): Set { + const maskedScopes: GlobalScopes | ScopeLevels = Object.fromEntries( + Object.entries(userScopes).map((e) => [e[0], [...e[1]]]), + ) as GlobalScopes | ScopeLevels; + + if (masks?.sharing) { + if ('project' in maskedScopes) { + maskedScopes.project = maskedScopes.project.filter((v) => masks.sharing.includes(v)); + } + if ('resource' in maskedScopes) { + maskedScopes.resource = maskedScopes.resource.filter((v) => masks.sharing.includes(v)); + } + } + + return new Set(Object.values(maskedScopes).flat()); +} diff --git a/packages/@n8n/permissions/src/hasScope.ts b/packages/@n8n/permissions/src/hasScope.ts index 76c22f7b1..d44928349 100644 --- a/packages/@n8n/permissions/src/hasScope.ts +++ b/packages/@n8n/permissions/src/hasScope.ts @@ -1,25 +1,29 @@ -import type { Scope, ScopeLevels, GlobalScopes, ScopeOptions } from './types'; +import { combineScopes } from './combineScopes'; +import type { Scope, ScopeLevels, GlobalScopes, ScopeOptions, MaskLevels } from './types'; export function hasScope( scope: Scope | Scope[], userScopes: GlobalScopes, + masks?: MaskLevels, options?: ScopeOptions, ): boolean; export function hasScope( scope: Scope | Scope[], userScopes: ScopeLevels, + masks?: MaskLevels, options?: ScopeOptions, ): boolean; export function hasScope( scope: Scope | Scope[], userScopes: GlobalScopes | ScopeLevels, + masks?: MaskLevels, options: ScopeOptions = { mode: 'oneOf' }, ): boolean { if (!Array.isArray(scope)) { scope = [scope]; } - const userScopeSet = new Set(Object.values(userScopes).flat()); + const userScopeSet = combineScopes(userScopes, masks); if (options.mode === 'allOf') { return !!scope.length && scope.every((s) => userScopeSet.has(s)); diff --git a/packages/@n8n/permissions/src/index.ts b/packages/@n8n/permissions/src/index.ts index 5934473ce..0d3e510ab 100644 --- a/packages/@n8n/permissions/src/index.ts +++ b/packages/@n8n/permissions/src/index.ts @@ -1,2 +1,3 @@ export type * from './types'; export * from './hasScope'; +export * from './combineScopes'; diff --git a/packages/@n8n/permissions/src/types.ts b/packages/@n8n/permissions/src/types.ts index 1707d1c35..817d6321a 100644 --- a/packages/@n8n/permissions/src/types.ts +++ b/packages/@n8n/permissions/src/types.ts @@ -12,8 +12,10 @@ export type Resource = | 'license' | 'logStreaming' | 'orchestration' - | 'sourceControl' + | 'project' | 'saml' + | 'securityAudit' + | 'sourceControl' | 'tag' | 'user' | 'variable' @@ -48,7 +50,9 @@ export type LdapScope = ResourceScope<'ldap', 'manage' | 'sync'>; export type LicenseScope = ResourceScope<'license', 'manage'>; export type LogStreamingScope = ResourceScope<'logStreaming', 'manage'>; export type OrchestrationScope = ResourceScope<'orchestration', 'read' | 'list'>; +export type ProjectScope = ResourceScope<'project'>; export type SamlScope = ResourceScope<'saml', 'manage'>; +export type SecurityAuditScope = ResourceScope<'securityAudit', 'generate'>; export type SourceControlScope = ResourceScope<'sourceControl', 'pull' | 'push' | 'manage'>; export type TagScope = ResourceScope<'tag'>; export type UserScope = ResourceScope<'user', DefaultOperations | 'resetPassword' | 'changeRole'>; @@ -69,7 +73,9 @@ export type Scope = | LicenseScope | LogStreamingScope | OrchestrationScope + | ProjectScope | SamlScope + | SecurityAuditScope | SourceControlScope | TagScope | UserScope @@ -84,5 +90,10 @@ export type ProjectScopes = GetScopeLevel<'project'>; export type ResourceScopes = GetScopeLevel<'resource'>; export type ScopeLevels = GlobalScopes & (ProjectScopes | (ProjectScopes & ResourceScopes)); +export type MaskLevel = 'sharing'; +export type GetMaskLevel = Record; +export type SharingMasks = GetMaskLevel<'sharing'>; +export type MaskLevels = SharingMasks; + export type ScopeMode = 'oneOf' | 'allOf'; export type ScopeOptions = { mode: ScopeMode }; diff --git a/packages/@n8n/permissions/test/hasScope.test.ts b/packages/@n8n/permissions/test/hasScope.test.ts index 22137d632..0e43bc8dc 100644 --- a/packages/@n8n/permissions/test/hasScope.test.ts +++ b/packages/@n8n/permissions/test/hasScope.test.ts @@ -33,6 +33,7 @@ describe('hasScope', () => { { global: memberPermissions, }, + undefined, { mode: 'oneOf' }, ), ).toBe(true); @@ -43,6 +44,7 @@ describe('hasScope', () => { { global: memberPermissions, }, + undefined, { mode: 'allOf' }, ), ).toBe(true); @@ -53,6 +55,7 @@ describe('hasScope', () => { { global: memberPermissions, }, + undefined, { mode: 'oneOf' }, ), ).toBe(false); @@ -63,6 +66,7 @@ describe('hasScope', () => { { global: memberPermissions, }, + undefined, { mode: 'allOf' }, ), ).toBe(false); @@ -95,6 +99,7 @@ describe('hasScope', () => { { global: ownerPermissions, }, + undefined, { mode: 'allOf' }, ), ).toBe(true); @@ -105,6 +110,7 @@ describe('hasScope', () => { { global: memberPermissions, }, + undefined, { mode: 'allOf' }, ), ).toBe(false); @@ -115,6 +121,7 @@ describe('hasScope', () => { { global: memberPermissions, }, + undefined, { mode: 'allOf' }, ), ).toBe(false); @@ -125,8 +132,127 @@ describe('hasScope', () => { { global: memberPermissions, }, + undefined, { mode: 'allOf' }, ), ).toBe(false); }); }); + +describe('hasScope masking', () => { + test('should return true without mask when scopes present', () => { + expect( + hasScope('workflow:read', { + global: ['user:list'], + project: ['workflow:read'], + resource: [], + }), + ).toBe(true); + }); + + test('should return false without mask when scopes are not present', () => { + expect( + hasScope('workflow:update', { + global: ['user:list'], + project: ['workflow:read'], + resource: [], + }), + ).toBe(false); + }); + + test('should return false when mask does not include scope but scopes list does contain required scope', () => { + expect( + hasScope( + 'workflow:update', + { + global: ['user:list'], + project: ['workflow:read', 'workflow:update'], + resource: [], + }, + { + sharing: ['workflow:read'], + }, + ), + ).toBe(false); + }); + + test('should return true when mask does include scope and scope list includes scope', () => { + expect( + hasScope( + 'workflow:update', + { + global: ['user:list'], + project: ['workflow:read', 'workflow:update'], + resource: [], + }, + { + sharing: ['workflow:read', 'workflow:update'], + }, + ), + ).toBe(true); + }); + + test('should return true when mask does include scope and scopes list includes scope on multiple levels', () => { + expect( + hasScope( + 'workflow:update', + { + global: ['user:list'], + project: ['workflow:read', 'workflow:update'], + resource: ['workflow:update'], + }, + { + sharing: ['workflow:read', 'workflow:update'], + }, + ), + ).toBe(true); + }); + + test('should not mask out global scopes', () => { + expect( + hasScope( + 'workflow:update', + { + global: ['workflow:read', 'workflow:update'], + project: ['workflow:read'], + resource: ['workflow:read'], + }, + { + sharing: ['workflow:read'], + }, + ), + ).toBe(true); + }); + + test('should return false when scope is not in mask or scope list', () => { + expect( + hasScope( + 'workflow:update', + { + global: ['workflow:read'], + project: ['workflow:read'], + resource: ['workflow:read'], + }, + { + sharing: ['workflow:read'], + }, + ), + ).toBe(false); + }); + + test('should return false when scope is in mask or not scope list', () => { + expect( + hasScope( + 'workflow:update', + { + global: ['workflow:read'], + project: ['workflow:read'], + resource: ['workflow:read'], + }, + { + sharing: ['workflow:read', 'workflow:update'], + }, + ), + ).toBe(false); + }); +}); diff --git a/packages/cli/.eslintrc.js b/packages/cli/.eslintrc.js index 736003d7b..9eaf0128f 100644 --- a/packages/cli/.eslintrc.js +++ b/packages/cli/.eslintrc.js @@ -35,4 +35,20 @@ module.exports = { '@typescript-eslint/no-unsafe-enum-comparison': 'warn', '@typescript-eslint/no-unsafe-declaration-merging': 'warn', }, + + overrides: [ + { + files: ['./src/decorators/**/*.ts'], + rules: { + '@typescript-eslint/ban-types': [ + 'warn', + { + types: { + Function: false, + }, + }, + ], + }, + }, + ], }; diff --git a/packages/cli/src/ActiveWebhooks.ts b/packages/cli/src/ActiveWebhooks.ts index 6b9717341..79626df02 100644 --- a/packages/cli/src/ActiveWebhooks.ts +++ b/packages/cli/src/ActiveWebhooks.ts @@ -84,7 +84,7 @@ export class ActiveWebhooks implements IWebhookManager { const workflowData = await this.workflowRepository.findOne({ where: { id: webhook.workflowId }, - relations: ['shared', 'shared.user'], + relations: { shared: { project: { projectRelations: true } } }, }); if (workflowData === null) { @@ -102,9 +102,7 @@ export class ActiveWebhooks implements IWebhookManager { settings: workflowData.settings, }); - const additionalData = await WorkflowExecuteAdditionalData.getBase( - workflowData.shared[0].user.id, - ); + const additionalData = await WorkflowExecuteAdditionalData.getBase(); const webhookData = NodeHelpers.getNodeWebhooks( workflow, diff --git a/packages/cli/src/ActiveWorkflowManager.ts b/packages/cli/src/ActiveWorkflowManager.ts index f0d1f2fbf..5e0bd66ed 100644 --- a/packages/cli/src/ActiveWorkflowManager.ts +++ b/packages/cli/src/ActiveWorkflowManager.ts @@ -229,7 +229,6 @@ export class ActiveWorkflowManager { async clearWebhooks(workflowId: string) { const workflowData = await this.workflowRepository.findOne({ where: { id: workflowId }, - relations: ['shared', 'shared.user'], }); if (workflowData === null) { @@ -249,9 +248,7 @@ export class ActiveWorkflowManager { const mode = 'internal'; - const additionalData = await WorkflowExecuteAdditionalData.getBase( - workflowData.shared[0].user.id, - ); + const additionalData = await WorkflowExecuteAdditionalData.getBase(); const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData, undefined, true); @@ -570,13 +567,7 @@ export class ActiveWorkflowManager { ); } - const sharing = dbWorkflow.shared.find((shared) => shared.role === 'workflow:owner'); - - if (!sharing) { - throw new WorkflowActivationError(`Workflow ${dbWorkflow.display()} has no owner`); - } - - const additionalData = await WorkflowExecuteAdditionalData.getBase(sharing.user.id); + const additionalData = await WorkflowExecuteAdditionalData.getBase(); if (shouldAddWebhooks) { await this.addWebhooks(workflow, additionalData, 'trigger', activationMode); @@ -711,6 +702,7 @@ export class ActiveWorkflowManager { * @param {string} workflowId The id of the workflow to deactivate */ // TODO: this should happen in a transaction + // maybe, see: https://github.com/n8n-io/n8n/pull/8904#discussion_r1530150510 async remove(workflowId: string) { if (this.orchestrationService.isMultiMainSetupEnabled) { try { diff --git a/packages/cli/src/CredentialsHelper.ts b/packages/cli/src/CredentialsHelper.ts index c20ddd479..670b944e4 100644 --- a/packages/cli/src/CredentialsHelper.ts +++ b/packages/cli/src/CredentialsHelper.ts @@ -30,15 +30,15 @@ import { ICredentialsHelper, NodeHelpers, Workflow, ApplicationError } from 'n8n import type { ICredentialsDb } from '@/Interfaces'; import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; -import { NodeTypes } from '@/NodeTypes'; import { CredentialTypes } from '@/CredentialTypes'; import { CredentialsOverwrites } from '@/CredentialsOverwrites'; import { RESPONSE_ERROR_MESSAGES } from './constants'; -import { Logger } from '@/Logger'; import { CredentialsRepository } from '@db/repositories/credentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { CredentialNotFoundError } from './errors/credential-not-found.error'; +import { In } from '@n8n/typeorm'; +import { CacheService } from './services/cache/cache.service'; const mockNode = { name: '', @@ -77,12 +77,11 @@ const mockNodeTypes: INodeTypes = { @Service() export class CredentialsHelper extends ICredentialsHelper { constructor( - private readonly logger: Logger, private readonly credentialTypes: CredentialTypes, - private readonly nodeTypes: NodeTypes, private readonly credentialsOverwrites: CredentialsOverwrites, private readonly credentialsRepository: CredentialsRepository, private readonly sharedCredentialsRepository: SharedCredentialsRepository, + private readonly cacheService: CacheService, ) { super(); } @@ -245,7 +244,6 @@ export class CredentialsHelper extends ICredentialsHelper { async getCredentials( nodeCredential: INodeCredentialsDetails, type: string, - userId?: string, ): Promise { if (!nodeCredential.id) { throw new ApplicationError('Found credential with no ID.', { @@ -257,14 +255,10 @@ export class CredentialsHelper extends ICredentialsHelper { let credential: CredentialsEntity; try { - credential = userId - ? await this.sharedCredentialsRepository - .findOneOrFail({ - relations: ['credentials'], - where: { credentials: { id: nodeCredential.id, type }, userId }, - }) - .then((shared) => shared.credentials) - : await this.credentialsRepository.findOneByOrFail({ id: nodeCredential.id, type }); + credential = await this.credentialsRepository.findOneByOrFail({ + id: nodeCredential.id, + type, + }); } catch (error) { throw new CredentialNotFoundError(nodeCredential.id, type); } @@ -338,7 +332,7 @@ export class CredentialsHelper extends ICredentialsHelper { await additionalData?.secretsHelpers?.waitForInit(); - const canUseSecrets = await this.credentialOwnedByOwner(nodeCredentials); + const canUseSecrets = await this.credentialCanUseExternalSecrets(nodeCredentials); return this.applyDefaultsAndOverwrites( additionalData, @@ -457,28 +451,39 @@ export class CredentialsHelper extends ICredentialsHelper { await this.credentialsRepository.update(findQuery, newCredentialsData); } - async credentialOwnedByOwner(nodeCredential: INodeCredentialsDetails): Promise { + async credentialCanUseExternalSecrets(nodeCredential: INodeCredentialsDetails): Promise { if (!nodeCredential.id) { return false; } - const credential = await this.sharedCredentialsRepository.findOne({ - where: { - role: 'credential:owner', - user: { - role: 'global:owner', - }, - credentials: { - id: nodeCredential.id, - }, - }, - }); + return ( + (await this.cacheService.get(`credential-can-use-secrets:${nodeCredential.id}`, { + refreshFn: async () => { + const credential = await this.sharedCredentialsRepository.findOne({ + where: { + role: 'credential:owner', + project: { + projectRelations: { + role: In(['project:personalOwner', 'project:admin']), + user: { + role: In(['global:owner', 'global:admin']), + }, + }, + }, + credentials: { + id: nodeCredential.id!, + }, + }, + }); - if (!credential) { - return false; - } + if (!credential) { + return false; + } - return true; + return true; + }, + })) ?? false + ); } } diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 9c56982c9..32825f2ab 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -535,7 +535,8 @@ export interface IWorkflowExecutionDataProcess { pushRef?: string; startNodes?: StartNodeData[]; workflowData: IWorkflowBase; - userId: string; + userId?: string; + projectId?: string; } export interface IWorkflowExecuteProcess { diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index 70b3030f2..61a119b9f 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -34,6 +34,10 @@ import { License } from '@/License'; import { EventsService } from '@/services/events.service'; import { NodeTypes } from '@/NodeTypes'; import { Telemetry } from '@/telemetry'; +import type { Project } from '@db/entities/Project'; +import type { ProjectRole } from '@db/entities/ProjectRelation'; +import { ProjectRelationRepository } from './databases/repositories/projectRelation.repository'; +import { SharedCredentialsRepository } from './databases/repositories/sharedCredentials.repository'; function userToPayload(user: User): { userId: string; @@ -62,6 +66,8 @@ export class InternalHooks { private readonly instanceSettings: InstanceSettings, private readonly eventBus: MessageEventBus, private readonly license: License, + private readonly projectRelationRepository: ProjectRelationRepository, + private readonly sharedCredentialsRepository: SharedCredentialsRepository, ) { eventsService.on( 'telemetry.onFirstProductionWorkflowSuccess', @@ -164,7 +170,12 @@ export class InternalHooks { ); } - async onWorkflowCreated(user: User, workflow: IWorkflowBase, publicApi: boolean): Promise { + async onWorkflowCreated( + user: User, + workflow: IWorkflowBase, + project: Project, + publicApi: boolean, + ): Promise { const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); void Promise.all([ this.eventBus.sendAuditEvent({ @@ -180,6 +191,8 @@ export class InternalHooks { workflow_id: workflow.id, node_graph_string: JSON.stringify(nodeGraph), public_api: publicApi, + project_id: project.id, + project_type: project.type, }), ]); } @@ -208,19 +221,32 @@ export class InternalHooks { isCloudDeployment, }); + let userRole: 'owner' | 'sharee' | 'member' | undefined = undefined; + const role = await this.sharedWorkflowRepository.findSharingRole(user.id, workflow.id); + if (role) { + userRole = role === 'workflow:owner' ? 'owner' : 'sharee'; + } else { + const workflowOwner = await this.sharedWorkflowRepository.getWorkflowOwningProject( + workflow.id, + ); + + if (workflowOwner) { + const projectRole = await this.projectRelationRepository.findProjectRole({ + userId: user.id, + projectId: workflowOwner.id, + }); + + if (projectRole && projectRole !== 'project:personalOwner') { + userRole = 'member'; + } + } + } + const notesCount = Object.keys(nodeGraph.notes).length; const overlappingCount = Object.values(nodeGraph.notes).filter( (note) => note.overlapping, ).length; - let userRole: 'owner' | 'sharee' | undefined = undefined; - if (user.id && workflow.id) { - const role = await this.sharedWorkflowRepository.findSharingRole(user.id, workflow.id); - if (role) { - userRole = role === 'workflow:owner' ? 'owner' : 'sharee'; - } - } - void Promise.all([ this.eventBus.sendAuditEvent({ eventName: 'n8n.audit.workflow.updated', @@ -865,6 +891,9 @@ export class InternalHooks { credential_id: string; public_api: boolean; }): Promise { + const project = await this.sharedCredentialsRepository.findCredentialOwningProject( + userCreatedCredentialsData.credential_id, + ); void Promise.all([ this.eventBus.sendAuditEvent({ eventName: 'n8n.audit.user.credentials.created', @@ -880,6 +909,8 @@ export class InternalHooks { credential_type: userCreatedCredentialsData.credential_type, credential_id: userCreatedCredentialsData.credential_id, instance_id: this.instanceSettings.instanceId, + project_id: project?.id, + project_type: project?.type, }), ]); } @@ -1207,4 +1238,27 @@ export class InternalHooks { }): Promise { return await this.telemetry.track('User updated external secrets settings', saveData); } + + async onTeamProjectCreated(data: { user_id: string; role: GlobalRole }) { + return await this.telemetry.track('User created project', data); + } + + async onTeamProjectDeleted(data: { + user_id: string; + role: GlobalRole; + project_id: string; + removal_type: 'delete' | 'transfer'; + target_project_id?: string; + }) { + return await this.telemetry.track('User deleted project', data); + } + + async onTeamProjectUpdated(data: { + user_id: string; + role: GlobalRole; + project_id: string; + members: Array<{ user_id: string; role: ProjectRole }>; + }) { + return await this.telemetry.track('Project settings updated', data); + } } diff --git a/packages/cli/src/Ldap/helpers.ts b/packages/cli/src/Ldap/helpers.ts index 12f1b402e..567031d01 100644 --- a/packages/cli/src/Ldap/helpers.ts +++ b/packages/cli/src/Ldap/helpers.ts @@ -93,7 +93,7 @@ export const getAuthIdentityByLdapId = async ( idAttributeValue: string, ): Promise => { return await Container.get(AuthIdentityRepository).findOne({ - relations: ['user'], + relations: { user: true }, where: { providerId: idAttributeValue, providerType: 'ldap', @@ -140,7 +140,7 @@ export const getLdapIds = async (): Promise => { export const getLdapUsers = async (): Promise => { const identities = await Container.get(AuthIdentityRepository).find({ - relations: ['user'], + relations: { user: true }, where: { providerType: 'ldap', }, @@ -179,10 +179,15 @@ export const processUsers = async ( toUpdateUsers: Array<[string, User]>, toDisableUsers: string[], ): Promise => { + const userRepository = Container.get(UserRepository); await Db.transaction(async (transactionManager) => { return await Promise.all([ ...toCreateUsers.map(async ([ldapId, user]) => { - const authIdentity = AuthIdentity.create(await transactionManager.save(user), ldapId); + const { user: savedUser } = await userRepository.createUserWithProject( + user, + transactionManager, + ); + const authIdentity = AuthIdentity.create(savedUser, ldapId); return await transactionManager.save(authIdentity); }), ...toUpdateUsers.map(async ([ldapId, user]) => { @@ -202,7 +207,13 @@ export const processUsers = async ( providerId: ldapId, }); if (authIdentity?.userId) { - await transactionManager.update(User, { id: authIdentity?.userId }, { disabled: true }); + const user = await transactionManager.findOneBy(User, { id: authIdentity.userId }); + + if (user) { + user.disabled = true; + await transactionManager.save(user); + } + await transactionManager.delete(AuthIdentity, { userId: authIdentity?.userId }); } }), @@ -266,14 +277,11 @@ export const createLdapAuthIdentity = async (user: User, ldapId: string) => { }; export const createLdapUserOnLocalDb = async (data: Partial, ldapId: string) => { - const user = await Container.get(UserRepository).save( - { - password: randomPassword(), - role: 'global:member', - ...data, - }, - { transaction: false }, - ); + const { user } = await Container.get(UserRepository).createUserWithProject({ + password: randomPassword(), + role: 'global:member', + ...data, + }); await createLdapAuthIdentity(user, ldapId); return user; }; @@ -281,7 +289,11 @@ export const createLdapUserOnLocalDb = async (data: Partial, ldapId: strin export const updateLdapUserOnLocalDb = async (identity: AuthIdentity, data: Partial) => { const userId = identity?.user?.id; if (userId) { - await Container.get(UserRepository).update({ id: userId }, data); + const user = await Container.get(UserRepository).findOneBy({ id: userId }); + + if (user) { + await Container.get(UserRepository).save({ id: userId, ...data }, { transaction: true }); + } } }; diff --git a/packages/cli/src/Ldap/ldap.service.ts b/packages/cli/src/Ldap/ldap.service.ts index 0d7f45e58..c13a31ecc 100644 --- a/packages/cli/src/Ldap/ldap.service.ts +++ b/packages/cli/src/Ldap/ldap.service.ts @@ -349,7 +349,7 @@ export class LdapService { localAdUsers, ); - this.logger.debug('LDAP - Users processed', { + this.logger.debug('LDAP - Users to process', { created: usersToCreate.length, updated: usersToUpdate.length, disabled: usersToDisable.length, diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index 1e3122257..979320404 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -289,6 +289,18 @@ export class License { return this.isFeatureEnabled(LICENSE_FEATURES.WORKER_VIEW); } + isProjectRoleAdminLicensed() { + return this.isFeatureEnabled(LICENSE_FEATURES.PROJECT_ROLE_ADMIN); + } + + isProjectRoleEditorLicensed() { + return this.isFeatureEnabled(LICENSE_FEATURES.PROJECT_ROLE_EDITOR); + } + + isProjectRoleViewerLicensed() { + return this.isFeatureEnabled(LICENSE_FEATURES.PROJECT_ROLE_VIEWER); + } + getCurrentEntitlements() { return this.manager?.getCurrentEntitlements() ?? []; } @@ -341,6 +353,10 @@ export class License { ); } + getTeamProjectLimit() { + return this.getFeatureValue(LICENSE_QUOTAS.TEAM_PROJECT_LIMIT) ?? 0; + } + getPlanName(): string { return this.getFeatureValue('planName') ?? 'Community'; } diff --git a/packages/cli/src/Mfa/mfa.service.ts b/packages/cli/src/Mfa/mfa.service.ts index 019005054..53f1229a4 100644 --- a/packages/cli/src/Mfa/mfa.service.ts +++ b/packages/cli/src/Mfa/mfa.service.ts @@ -25,10 +25,16 @@ export class MfaService { secret, recoveryCodes, ); - return await this.userRepository.update(userId, { - mfaSecret: encryptedSecret, - mfaRecoveryCodes: encryptedRecoveryCodes, - }); + + const user = await this.userRepository.findOneBy({ id: userId }); + if (user) { + Object.assign(user, { + mfaSecret: encryptedSecret, + mfaRecoveryCodes: encryptedRecoveryCodes, + }); + + await this.userRepository.save(user); + } } public encryptSecretAndRecoveryCodes(rawSecret: string, rawRecoveryCodes: string[]) { @@ -56,7 +62,12 @@ export class MfaService { } public async enableMfa(userId: string) { - await this.userRepository.update(userId, { mfaEnabled: true }); + const user = await this.userRepository.findOneBy({ id: userId }); + if (user) { + user.mfaEnabled = true; + + await this.userRepository.save(user); + } } public encryptRecoveryCodes(mfaRecoveryCodes: string[]) { @@ -64,10 +75,15 @@ export class MfaService { } public async disableMfa(userId: string) { - await this.userRepository.update(userId, { - mfaEnabled: false, - mfaSecret: null, - mfaRecoveryCodes: [], - }); + const user = await this.userRepository.findOneBy({ id: userId }); + + if (user) { + Object.assign(user, { + mfaEnabled: false, + mfaSecret: null, + mfaRecoveryCodes: [], + }); + await this.userRepository.save(user); + } } } diff --git a/packages/cli/src/PublicApi/types.ts b/packages/cli/src/PublicApi/types.ts index c7e1bddad..2e80deb1a 100644 --- a/packages/cli/src/PublicApi/types.ts +++ b/packages/cli/src/PublicApi/types.ts @@ -1,4 +1,5 @@ -import type { IDataObject, ExecutionStatus } from 'n8n-workflow'; +import type { ExecutionStatus, ICredentialDataDecryptedObject } from 'n8n-workflow'; + import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { TagEntity } from '@db/entities/TagEntity'; import type { Risk } from '@/security-audit/types'; @@ -127,7 +128,14 @@ export declare namespace UserRequest { } export declare namespace CredentialRequest { - type Create = AuthenticatedRequest<{}, {}, { type: string; name: string; data: IDataObject }, {}>; + type Create = AuthenticatedRequest< + {}, + {}, + { type: string; name: string; data: ICredentialDataDecryptedObject }, + {} + >; + + type Delete = AuthenticatedRequest<{ id: string }, {}, {}, Record>; } export type OperationID = 'getUsers' | 'getUser'; diff --git a/packages/cli/src/PublicApi/v1/handlers/audit/audit.handler.ts b/packages/cli/src/PublicApi/v1/handlers/audit/audit.handler.ts index 9fd2d028f..caf3750ad 100644 --- a/packages/cli/src/PublicApi/v1/handlers/audit/audit.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/audit/audit.handler.ts @@ -1,11 +1,11 @@ -import { authorize } from '@/PublicApi/v1/shared/middlewares/global.middleware'; +import { globalScope } from '@/PublicApi/v1/shared/middlewares/global.middleware'; import type { Response } from 'express'; import type { AuditRequest } from '@/PublicApi/types'; import Container from 'typedi'; export = { generateAudit: [ - authorize(['global:owner', 'global:admin']), + globalScope('securityAudit:generate'), async (req: AuditRequest.Generate, res: Response): Promise => { try { const { SecurityAuditService } = await import('@/security-audit/SecurityAudit.service'); diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts index 165c7f911..4da763583 100644 --- a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts @@ -4,9 +4,8 @@ import type express from 'express'; import { CredentialsHelper } from '@/CredentialsHelper'; import { CredentialTypes } from '@/CredentialTypes'; import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; -import type { CredentialRequest } from '@/requests'; -import type { CredentialTypeRequest } from '../../../types'; -import { authorize } from '../../shared/middlewares/global.middleware'; +import type { CredentialTypeRequest, CredentialRequest } from '../../../types'; +import { projectScope } from '../../shared/middlewares/global.middleware'; import { validCredentialsProperties, validCredentialType } from './credentials.middleware'; import { @@ -23,7 +22,6 @@ import { Container } from 'typedi'; export = { createCredential: [ - authorize(['global:owner', 'global:admin', 'global:member']), validCredentialType, validCredentialsProperties, async ( @@ -47,7 +45,7 @@ export = { }, ], deleteCredential: [ - authorize(['global:owner', 'global:admin', 'global:member']), + projectScope('credential:delete', 'credential'), async ( req: CredentialRequest.Delete, res: express.Response, @@ -75,7 +73,6 @@ export = { ], getCredentialType: [ - authorize(['global:owner', 'global:admin', 'global:member']), async (req: CredentialTypeRequest.Get, res: express.Response): Promise => { const { credentialTypeName } = req.params; diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts index 935956df2..6a7cfa208 100644 --- a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts @@ -16,6 +16,7 @@ import type { CredentialRequest } from '@/requests'; import { Container } from 'typedi'; import { CredentialsRepository } from '@db/repositories/credentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; import { InternalHooks } from '@/InternalHooks'; export async function getCredentials(credentialId: string): Promise { @@ -28,7 +29,7 @@ export async function getSharedCredentials( ): Promise { return await Container.get(SharedCredentialsRepository).findOne({ where: { - userId, + project: { projectRelations: { userId } }, credentialsId: credentialId, }, relations: ['credentials'], @@ -66,10 +67,14 @@ export async function saveCredential( const newSharedCredential = new SharedCredentials(); + const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + user.id, + ); + Object.assign(newSharedCredential, { role: 'credential:owner', - user, credentials: savedCredential, + projectId: personalProject.id, }); await transactionManager.save(newSharedCredential); diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts index 3a63fb9aa..d7b9e1cb2 100644 --- a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts @@ -3,7 +3,7 @@ import { Container } from 'typedi'; import { replaceCircularReferences } from 'n8n-workflow'; import { ActiveExecutions } from '@/ActiveExecutions'; -import { authorize, validCursor } from '../../shared/middlewares/global.middleware'; +import { validCursor } from '../../shared/middlewares/global.middleware'; import type { ExecutionRequest } from '../../../types'; import { getSharedWorkflowIds } from '../workflows/workflows.service'; import { encodeNextCursor } from '../../shared/services/pagination.service'; @@ -12,9 +12,8 @@ import { ExecutionRepository } from '@db/repositories/execution.repository'; export = { deleteExecution: [ - authorize(['global:owner', 'global:admin', 'global:member']), async (req: ExecutionRequest.Delete, res: express.Response): Promise => { - const sharedWorkflowsIds = await getSharedWorkflowIds(req.user); + const sharedWorkflowsIds = await getSharedWorkflowIds(req.user, ['workflow:delete']); // user does not have workflows hence no executions // or the execution they are trying to access belongs to a workflow they do not own @@ -44,9 +43,8 @@ export = { }, ], getExecution: [ - authorize(['global:owner', 'global:admin', 'global:member']), async (req: ExecutionRequest.Get, res: express.Response): Promise => { - const sharedWorkflowsIds = await getSharedWorkflowIds(req.user); + const sharedWorkflowsIds = await getSharedWorkflowIds(req.user, ['workflow:read']); // user does not have workflows hence no executions // or the execution they are trying to access belongs to a workflow they do not own @@ -75,7 +73,6 @@ export = { }, ], getExecutions: [ - authorize(['global:owner', 'global:admin', 'global:member']), validCursor, async (req: ExecutionRequest.GetAll, res: express.Response): Promise => { const { @@ -86,7 +83,7 @@ export = { workflowId = undefined, } = req.query; - const sharedWorkflowsIds = await getSharedWorkflowIds(req.user); + const sharedWorkflowsIds = await getSharedWorkflowIds(req.user, ['workflow:read']); // user does not have workflows hence no executions // or the execution they are trying to access belongs to a workflow they do not own diff --git a/packages/cli/src/PublicApi/v1/handlers/sourceControl/sourceControl.handler.ts b/packages/cli/src/PublicApi/v1/handlers/sourceControl/sourceControl.handler.ts index 66233867d..a413290c5 100644 --- a/packages/cli/src/PublicApi/v1/handlers/sourceControl/sourceControl.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/sourceControl/sourceControl.handler.ts @@ -2,7 +2,7 @@ import type express from 'express'; import { Container } from 'typedi'; import type { StatusResult } from 'simple-git'; import type { PublicSourceControlRequest } from '../../../types'; -import { authorize } from '../../shared/middlewares/global.middleware'; +import { globalScope } from '../../shared/middlewares/global.middleware'; import type { ImportResult } from '@/environments/sourceControl/types/importResult'; import { SourceControlService } from '@/environments/sourceControl/sourceControl.service.ee'; import { SourceControlPreferencesService } from '@/environments/sourceControl/sourceControlPreferences.service.ee'; @@ -14,7 +14,7 @@ import { InternalHooks } from '@/InternalHooks'; export = { pull: [ - authorize(['global:owner', 'global:admin']), + globalScope('sourceControl:pull'), async ( req: PublicSourceControlRequest.Pull, res: express.Response, diff --git a/packages/cli/src/PublicApi/v1/handlers/tags/tags.handler.ts b/packages/cli/src/PublicApi/v1/handlers/tags/tags.handler.ts index 56e8f3b4b..3711aa36e 100644 --- a/packages/cli/src/PublicApi/v1/handlers/tags/tags.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/tags/tags.handler.ts @@ -1,7 +1,7 @@ import type express from 'express'; import type { TagEntity } from '@db/entities/TagEntity'; -import { authorize, validCursor } from '../../shared/middlewares/global.middleware'; +import { globalScope, validCursor } from '../../shared/middlewares/global.middleware'; import type { TagRequest } from '../../../types'; import { encodeNextCursor } from '../../shared/services/pagination.service'; @@ -12,7 +12,7 @@ import { TagService } from '@/services/tag.service'; export = { createTag: [ - authorize(['global:owner', 'global:admin', 'global:member']), + globalScope('tag:create'), async (req: TagRequest.Create, res: express.Response): Promise => { const { name } = req.body; @@ -27,7 +27,7 @@ export = { }, ], updateTag: [ - authorize(['global:owner', 'global:admin', 'global:member']), + globalScope('tag:update'), async (req: TagRequest.Update, res: express.Response): Promise => { const { id } = req.params; const { name } = req.body; @@ -49,7 +49,7 @@ export = { }, ], deleteTag: [ - authorize(['global:owner', 'global:admin']), + globalScope('tag:delete'), async (req: TagRequest.Delete, res: express.Response): Promise => { const { id } = req.params; @@ -65,7 +65,7 @@ export = { }, ], getTags: [ - authorize(['global:owner', 'global:admin', 'global:member']), + globalScope('tag:read'), validCursor, async (req: TagRequest.GetAll, res: express.Response): Promise => { const { offset = 0, limit = 100 } = req.query; @@ -88,7 +88,7 @@ export = { }, ], getTag: [ - authorize(['global:owner', 'global:admin', 'global:member']), + globalScope('tag:read'), async (req: TagRequest.Get, res: express.Response): Promise => { const { id } = req.params; diff --git a/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts b/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts index 8fd36b1db..96b2d5723 100644 --- a/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts +++ b/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts @@ -5,7 +5,7 @@ import { clean, getAllUsersAndCount, getUser } from './users.service.ee'; import { encodeNextCursor } from '../../shared/services/pagination.service'; import { - authorize, + globalScope, validCursor, validLicenseWithUserQuota, } from '../../shared/middlewares/global.middleware'; @@ -15,7 +15,7 @@ import { InternalHooks } from '@/InternalHooks'; export = { getUser: [ validLicenseWithUserQuota, - authorize(['global:owner', 'global:admin']), + globalScope('user:read'), async (req: UserRequest.Get, res: express.Response) => { const { includeRole = false } = req.query; const { id } = req.params; @@ -41,7 +41,7 @@ export = { getUsers: [ validLicenseWithUserQuota, validCursor, - authorize(['global:owner', 'global:admin']), + globalScope(['user:list', 'user:read']), async (req: UserRequest.Get, res: express.Response) => { const { offset = 0, limit = 100, includeRole = false } = req.query; diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts index 1083d33ed..6daab565c 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts @@ -11,11 +11,10 @@ import { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { ExternalHooks } from '@/ExternalHooks'; import { addNodeIds, replaceInvalidCredentials } from '@/WorkflowHelpers'; import type { WorkflowRequest } from '../../../types'; -import { authorize, validCursor } from '../../shared/middlewares/global.middleware'; +import { projectScope, validCursor } from '../../shared/middlewares/global.middleware'; import { encodeNextCursor } from '../../shared/services/pagination.service'; import { getWorkflowById, - getSharedWorkflow, setWorkflowAsActive, setWorkflowAsInactive, updateWorkflow, @@ -30,10 +29,10 @@ import { WorkflowHistoryService } from '@/workflows/workflowHistory/workflowHist import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; import { TagRepository } from '@/databases/repositories/tag.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; export = { createWorkflow: [ - authorize(['global:owner', 'global:admin', 'global:member']), async (req: WorkflowRequest.Create, res: express.Response): Promise => { const workflow = req.body; @@ -44,7 +43,10 @@ export = { addNodeIds(workflow); - const createdWorkflow = await createWorkflow(workflow, req.user, 'workflow:owner'); + const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + req.user.id, + ); + const createdWorkflow = await createWorkflow(workflow, req.user, project, 'workflow:owner'); await Container.get(WorkflowHistoryService).saveVersion( req.user, @@ -53,13 +55,13 @@ export = { ); await Container.get(ExternalHooks).run('workflow.afterCreate', [createdWorkflow]); - void Container.get(InternalHooks).onWorkflowCreated(req.user, createdWorkflow, true); + void Container.get(InternalHooks).onWorkflowCreated(req.user, createdWorkflow, project, true); return res.json(createdWorkflow); }, ], deleteWorkflow: [ - authorize(['global:owner', 'global:admin', 'global:member']), + projectScope('workflow:delete', 'workflow'), async (req: WorkflowRequest.Get, res: express.Response): Promise => { const { id: workflowId } = req.params; @@ -74,15 +76,21 @@ export = { }, ], getWorkflow: [ - authorize(['global:owner', 'global:admin', 'global:member']), + projectScope('workflow:read', 'workflow'), async (req: WorkflowRequest.Get, res: express.Response): Promise => { const { id } = req.params; - const sharedWorkflow = await getSharedWorkflow(req.user, id); + const workflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser( + id, + req.user, + ['workflow:read'], + { includeTags: !config.getEnv('workflowTagsDisabled') }, + ); - if (!sharedWorkflow) { + if (!workflow) { // user trying to access a workflow they do not own - // or workflow does not exist + // and was not shared to them + // Or does not exist. return res.status(404).json({ message: 'Not Found' }); } @@ -91,11 +99,10 @@ export = { public_api: true, }); - return res.json(sharedWorkflow.workflow); + return res.json(workflow); }, ], getWorkflows: [ - authorize(['global:owner', 'global:admin', 'global:member']), validCursor, async (req: WorkflowRequest.GetAll, res: express.Response): Promise => { const { offset = 0, limit = 100, active, tags, name } = req.query; @@ -121,19 +128,24 @@ export = { ); } - const sharedWorkflows = await Container.get(SharedWorkflowRepository).getSharedWorkflows( + let workflows = await Container.get(SharedWorkflowRepository).findAllWorkflowsForUser( req.user, - options, + ['workflow:read'], ); - if (!sharedWorkflows.length) { + if (options.workflowIds) { + const workflowIds = options.workflowIds; + workflows = workflows.filter((wf) => workflowIds.includes(wf.id)); + } + + if (!workflows.length) { return res.status(200).json({ data: [], nextCursor: null, }); } - const workflowsIds = sharedWorkflows.map((shareWorkflow) => shareWorkflow.workflowId); + const workflowsIds = workflows.map((wf) => wf.id); where.id = In(workflowsIds); } @@ -160,7 +172,7 @@ export = { }, ], updateWorkflow: [ - authorize(['global:owner', 'global:admin', 'global:member']), + projectScope('workflow:update', 'workflow'), async (req: WorkflowRequest.Update, res: express.Response): Promise => { const { id } = req.params; const updateData = new WorkflowEntity(); @@ -168,9 +180,13 @@ export = { updateData.id = id; updateData.versionId = uuid(); - const sharedWorkflow = await getSharedWorkflow(req.user, id); + const workflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser( + id, + req.user, + ['workflow:update'], + ); - if (!sharedWorkflow) { + if (!workflow) { // user trying to access a workflow they do not own // or workflow does not exist return res.status(404).json({ message: 'Not Found' }); @@ -181,23 +197,23 @@ export = { const workflowManager = Container.get(ActiveWorkflowManager); - if (sharedWorkflow.workflow.active) { + if (workflow.active) { // When workflow gets saved always remove it as the triggers could have been // changed and so the changes would not take effect await workflowManager.remove(id); } try { - await updateWorkflow(sharedWorkflow.workflowId, updateData); + await updateWorkflow(workflow.id, updateData); } catch (error) { if (error instanceof Error) { return res.status(400).json({ message: error.message }); } } - if (sharedWorkflow.workflow.active) { + if (workflow.active) { try { - await workflowManager.add(sharedWorkflow.workflowId, 'update'); + await workflowManager.add(workflow.id, 'update'); } catch (error) { if (error instanceof Error) { return res.status(400).json({ message: error.message }); @@ -205,13 +221,13 @@ export = { } } - const updatedWorkflow = await getWorkflowById(sharedWorkflow.workflowId); + const updatedWorkflow = await getWorkflowById(workflow.id); if (updatedWorkflow) { await Container.get(WorkflowHistoryService).saveVersion( req.user, updatedWorkflow, - sharedWorkflow.workflowId, + workflow.id, ); } @@ -222,21 +238,25 @@ export = { }, ], activateWorkflow: [ - authorize(['global:owner', 'global:admin', 'global:member']), + projectScope('workflow:update', 'workflow'), async (req: WorkflowRequest.Activate, res: express.Response): Promise => { const { id } = req.params; - const sharedWorkflow = await getSharedWorkflow(req.user, id); + const workflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser( + id, + req.user, + ['workflow:update'], + ); - if (!sharedWorkflow) { + if (!workflow) { // user trying to access a workflow they do not own // or workflow does not exist return res.status(404).json({ message: 'Not Found' }); } - if (!sharedWorkflow.workflow.active) { + if (!workflow.active) { try { - await Container.get(ActiveWorkflowManager).add(sharedWorkflow.workflowId, 'activate'); + await Container.get(ActiveWorkflowManager).add(workflow.id, 'activate'); } catch (error) { if (error instanceof Error) { return res.status(400).json({ message: error.message }); @@ -244,25 +264,29 @@ export = { } // change the status to active in the DB - await setWorkflowAsActive(sharedWorkflow.workflow); + await setWorkflowAsActive(workflow); - sharedWorkflow.workflow.active = true; + workflow.active = true; - return res.json(sharedWorkflow.workflow); + return res.json(workflow); } // nothing to do as the workflow is already active - return res.json(sharedWorkflow.workflow); + return res.json(workflow); }, ], deactivateWorkflow: [ - authorize(['global:owner', 'global:admin', 'global:member']), + projectScope('workflow:update', 'workflow'), async (req: WorkflowRequest.Activate, res: express.Response): Promise => { const { id } = req.params; - const sharedWorkflow = await getSharedWorkflow(req.user, id); + const workflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser( + id, + req.user, + ['workflow:update'], + ); - if (!sharedWorkflow) { + if (!workflow) { // user trying to access a workflow they do not own // or workflow does not exist return res.status(404).json({ message: 'Not Found' }); @@ -270,22 +294,22 @@ export = { const activeWorkflowManager = Container.get(ActiveWorkflowManager); - if (sharedWorkflow.workflow.active) { - await activeWorkflowManager.remove(sharedWorkflow.workflowId); + if (workflow.active) { + await activeWorkflowManager.remove(workflow.id); - await setWorkflowAsInactive(sharedWorkflow.workflow); + await setWorkflowAsInactive(workflow); - sharedWorkflow.workflow.active = false; + workflow.active = false; - return res.json(sharedWorkflow.workflow); + return res.json(workflow); } // nothing to do as the workflow is already inactive - return res.json(sharedWorkflow.workflow); + return res.json(workflow); }, ], getWorkflowTags: [ - authorize(['global:owner', 'global:admin', 'global:member']), + projectScope('workflow:read', 'workflow'), async (req: WorkflowRequest.GetTags, res: express.Response): Promise => { const { id } = req.params; @@ -293,9 +317,13 @@ export = { return res.status(400).json({ message: 'Workflow Tags Disabled' }); } - const sharedWorkflow = await getSharedWorkflow(req.user, id); + const workflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser( + id, + req.user, + ['workflow:read'], + ); - if (!sharedWorkflow) { + if (!workflow) { // user trying to access a workflow he does not own // or workflow does not exist return res.status(404).json({ message: 'Not Found' }); @@ -307,7 +335,7 @@ export = { }, ], updateWorkflowTags: [ - authorize(['global:owner', 'global:admin', 'global:member']), + projectScope('workflow:update', 'workflow'), async (req: WorkflowRequest.UpdateTags, res: express.Response): Promise => { const { id } = req.params; const newTags = req.body.map((newTag) => newTag.id); @@ -316,7 +344,11 @@ export = { return res.status(400).json({ message: 'Workflow Tags Disabled' }); } - const sharedWorkflow = await getSharedWorkflow(req.user, id); + const sharedWorkflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser( + id, + req.user, + ['workflow:update'], + ); if (!sharedWorkflow) { // user trying to access a workflow he does not own diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts index 39116eb7a..bb7b8bebd 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts @@ -4,23 +4,31 @@ import type { User } from '@db/entities/User'; import { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { WorkflowTagMapping } from '@db/entities/WorkflowTagMapping'; import { SharedWorkflow, type WorkflowSharingRole } from '@db/entities/SharedWorkflow'; -import config from '@/config'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; +import type { Project } from '@/databases/entities/Project'; import { WorkflowTagMappingRepository } from '@db/repositories/workflowTagMapping.repository'; import { TagRepository } from '@db/repositories/tag.repository'; +import { License } from '@/License'; +import { WorkflowSharingService } from '@/workflows/workflowSharing.service'; +import type { Scope } from '@n8n/permissions'; +import config from '@/config'; function insertIf(condition: boolean, elements: string[]): string[] { return condition ? elements : []; } -export async function getSharedWorkflowIds(user: User): Promise { - const where = ['global:owner', 'global:admin'].includes(user.role) ? {} : { userId: user.id }; - const sharedWorkflows = await Container.get(SharedWorkflowRepository).find({ - where, - select: ['workflowId'], - }); - return sharedWorkflows.map(({ workflowId }) => workflowId); +export async function getSharedWorkflowIds(user: User, scopes: Scope[]): Promise { + if (Container.get(License).isSharingEnabled()) { + return await Container.get(WorkflowSharingService).getSharedWorkflowIds(user, { + scopes, + }); + } else { + return await Container.get(WorkflowSharingService).getSharedWorkflowIds(user, { + workflowRoles: ['workflow:owner'], + projectRoles: ['project:personalOwner'], + }); + } } export async function getSharedWorkflow( @@ -45,6 +53,7 @@ export async function getWorkflowById(id: string): Promise { return await Db.transaction(async (transactionManager) => { @@ -56,6 +65,7 @@ export async function createWorkflow( Object.assign(newSharedWorkflow, { role, user, + project: personalProject, workflow: savedWorkflow, }); await transactionManager.save(newSharedWorkflow); diff --git a/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts b/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts index 6baf96607..6fa9bed11 100644 --- a/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts +++ b/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts @@ -3,27 +3,48 @@ import type express from 'express'; import { Container } from 'typedi'; import { License } from '@/License'; -import type { GlobalRole } from '@db/entities/User'; import type { AuthenticatedRequest } from '@/requests'; import type { PaginatedRequest } from '../../../types'; import { decodeCursor } from '../services/pagination.service'; +import type { Scope } from '@n8n/permissions'; +import { userHasScope } from '@/permissions/checkAccess'; const UNLIMITED_USERS_QUOTA = -1; -export const authorize = - (authorizedRoles: readonly GlobalRole[]) => - ( - req: AuthenticatedRequest, +export type ProjectScopeResource = 'workflow' | 'credential'; + +const buildScopeMiddleware = ( + scopes: Scope[], + resource?: ProjectScopeResource, + { globalOnly } = { globalOnly: false }, +) => { + return async ( + req: AuthenticatedRequest<{ id?: string }>, res: express.Response, next: express.NextFunction, - ): express.Response | void => { - if (!authorizedRoles.includes(req.user.role)) { + ): Promise => { + const params: { credentialId?: string; workflowId?: string } = {}; + if (req.params.id) { + if (resource === 'workflow') { + params.workflowId = req.params.id; + } else if (resource === 'credential') { + params.credentialId = req.params.id; + } + } + if (!(await userHasScope(req.user, scopes, globalOnly, params))) { return res.status(403).json({ message: 'Forbidden' }); } return next(); }; +}; + +export const globalScope = (scopes: Scope | Scope[]) => + buildScopeMiddleware(Array.isArray(scopes) ? scopes : [scopes], undefined, { globalOnly: true }); + +export const projectScope = (scopes: Scope | Scope[], resource: ProjectScopeResource) => + buildScopeMiddleware(Array.isArray(scopes) ? scopes : [scopes], resource, { globalOnly: false }); export const validCursor = ( req: PaginatedRequest, diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 4b2f3ce19..c8054a78b 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -71,6 +71,8 @@ import { InvitationController } from './controllers/invitation.controller'; // import { CollaborationService } from './collaboration/collaboration.service'; import { BadRequestError } from './errors/response-errors/bad-request.error'; import { OrchestrationService } from '@/services/orchestration.service'; +import { ProjectController } from './controllers/project.controller'; +import { RoleController } from './controllers/role.controller'; const exec = promisify(callbackExec); @@ -146,6 +148,8 @@ export class Server extends AbstractServer { ExecutionsController, CredentialsController, AIController, + ProjectController, + RoleController, ]; if ( diff --git a/packages/cli/src/UserManagement/PermissionChecker.ts b/packages/cli/src/UserManagement/PermissionChecker.ts index 9cd9590ec..6d2a8e04b 100644 --- a/packages/cli/src/UserManagement/PermissionChecker.ts +++ b/packages/cli/src/UserManagement/PermissionChecker.ts @@ -5,64 +5,47 @@ import { CredentialAccessError, NodeOperationError, WorkflowOperationError } fro import config from '@/config'; import { License } from '@/License'; import { OwnershipService } from '@/services/ownership.service'; -import { UserRepository } from '@db/repositories/user.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; -import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; +import { ProjectService } from '@/services/project.service'; @Service() export class PermissionChecker { constructor( - private readonly userRepository: UserRepository, private readonly sharedCredentialsRepository: SharedCredentialsRepository, - private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly ownershipService: OwnershipService, private readonly license: License, + private readonly projectService: ProjectService, ) {} /** - * Check if a user is permitted to execute a workflow. + * Check if a workflow has the ability to execute based on the projects it's apart of. */ - async check(workflowId: string, userId: string, nodes: INode[]) { - // allow if no nodes in this workflow use creds - + async check(workflowId: string, nodes: INode[]) { + const homeProject = await this.ownershipService.getWorkflowProjectCached(workflowId); + const homeProjectOwner = await this.ownershipService.getProjectOwnerCached(homeProject.id); + if (homeProject.type === 'personal' && homeProjectOwner?.hasGlobalScope('credential:list')) { + // Workflow belongs to a project by a user with privileges + // so all credentials are usable. Skip credential checks. + return; + } + const projectIds = await this.projectService.findProjectsWorkflowIsIn(workflowId); const credIdsToNodes = this.mapCredIdsToNodes(nodes); const workflowCredIds = Object.keys(credIdsToNodes); if (workflowCredIds.length === 0) return; - // allow if requesting user is instance owner + const accessible = await this.sharedCredentialsRepository.getFilteredAccessibleCredentials( + projectIds, + workflowCredIds, + ); - const user = await this.userRepository.findOneOrFail({ - where: { id: userId }, - }); - - if (user.hasGlobalScope('workflow:execute')) return; - - const isSharingEnabled = this.license.isSharingEnabled(); - - // allow if all creds used in this workflow are a subset of - // all creds accessible to users who have access to this workflow - - let workflowUserIds = [userId]; - - if (workflowId && isSharingEnabled) { - workflowUserIds = await this.sharedWorkflowRepository.getSharedUserIds(workflowId); + for (const credentialsId of workflowCredIds) { + if (!accessible.includes(credentialsId)) { + const nodeToFlag = credIdsToNodes[credentialsId][0]; + throw new CredentialAccessError(nodeToFlag, credentialsId, workflowId); + } } - - const accessibleCredIds = isSharingEnabled - ? await this.sharedCredentialsRepository.getAccessibleCredentialIds(workflowUserIds) - : await this.sharedCredentialsRepository.getOwnedCredentialIds(workflowUserIds); - - const inaccessibleCredIds = workflowCredIds.filter((id) => !accessibleCredIds.includes(id)); - - if (inaccessibleCredIds.length === 0) return; - - // if disallowed, flag only first node using first inaccessible cred - const inaccessibleCredId = inaccessibleCredIds[0]; - const nodeToFlag = credIdsToNodes[inaccessibleCredId][0]; - - throw new CredentialAccessError(nodeToFlag, inaccessibleCredId, workflowId); } async checkSubworkflowExecutePolicy( @@ -91,14 +74,14 @@ export class PermissionChecker { } const parentWorkflowOwner = - await this.ownershipService.getWorkflowOwnerCached(parentWorkflowId); + await this.ownershipService.getWorkflowProjectCached(parentWorkflowId); - const subworkflowOwner = await this.ownershipService.getWorkflowOwnerCached(subworkflow.id); + const subworkflowOwner = await this.ownershipService.getWorkflowProjectCached(subworkflow.id); const description = subworkflowOwner.id === parentWorkflowOwner.id ? 'Change the settings of the sub-workflow so it can be called by this one.' - : `${subworkflowOwner.firstName} (${subworkflowOwner.email}) can make this change. You may need to tell them the ID of the sub-workflow, which is ${subworkflow.id}`; + : `An admin for the ${subworkflowOwner.name} project can make this change. You may need to tell them the ID of the sub-workflow, which is ${subworkflow.id}`; const errorToThrow = new WorkflowOperationError( `Target workflow ID ${subworkflow.id} may not be called`, diff --git a/packages/cli/src/WaitTracker.ts b/packages/cli/src/WaitTracker.ts index 6b79d10a9..ae6dbf9a6 100644 --- a/packages/cli/src/WaitTracker.ts +++ b/packages/cli/src/WaitTracker.ts @@ -173,13 +173,13 @@ export class WaitTracker { throw new ApplicationError('Only saved workflows can be resumed.'); } const workflowId = fullExecutionData.workflowData.id; - const user = await this.ownershipService.getWorkflowOwnerCached(workflowId); + const project = await this.ownershipService.getWorkflowProjectCached(workflowId); const data: IWorkflowExecutionDataProcess = { executionMode: fullExecutionData.mode, executionData: fullExecutionData.data, workflowData: fullExecutionData.workflowData, - userId: user.id, + projectId: project.id, }; // Start the execution again diff --git a/packages/cli/src/WaitingWebhooks.ts b/packages/cli/src/WaitingWebhooks.ts index c745644db..cf16569b9 100644 --- a/packages/cli/src/WaitingWebhooks.ts +++ b/packages/cli/src/WaitingWebhooks.ts @@ -88,19 +88,12 @@ export class WaitingWebhooks implements IWebhookManager { settings: workflowData.settings, }); - let workflowOwner; - try { - workflowOwner = await this.ownershipService.getWorkflowOwnerCached(workflowData.id); - } catch (error) { - throw new NotFoundError('Could not find workflow'); - } - const workflowStartNode = workflow.getNode(lastNodeExecuted); if (workflowStartNode === null) { throw new NotFoundError('Could not find node to process webhook.'); } - const additionalData = await WorkflowExecuteAdditionalData.getBase(workflowOwner.id); + const additionalData = await WorkflowExecuteAdditionalData.getBase(); const webhookData = NodeHelpers.getNodeWebhooks( workflow, workflowStartNode, diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index 2682e928e..ec3d18157 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -56,8 +56,6 @@ import * as WorkflowHelpers from '@/WorkflowHelpers'; import { WorkflowRunner } from '@/WorkflowRunner'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; import { ActiveExecutions } from '@/ActiveExecutions'; -import type { User } from '@db/entities/User'; -import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { EventsService } from '@/services/events.service'; import { OwnershipService } from './services/ownership.service'; import { parseBody } from './middlewares'; @@ -65,6 +63,7 @@ import { Logger } from './Logger'; import { NotFoundError } from './errors/response-errors/not-found.error'; import { InternalServerError } from './errors/response-errors/internal-server.error'; import { UnprocessableRequestError } from './errors/response-errors/unprocessable.error'; +import type { Project } from './databases/entities/Project'; export const WEBHOOK_METHODS: IHttpRequestMethods[] = [ 'DELETE', @@ -248,22 +247,15 @@ export async function executeWebhook( $executionId: executionId, }; - let user: User; - if ( - (workflowData as WorkflowEntity).shared?.length && - (workflowData as WorkflowEntity).shared[0].user - ) { - user = (workflowData as WorkflowEntity).shared[0].user; - } else { - try { - user = await Container.get(OwnershipService).getWorkflowOwnerCached(workflowData.id); - } catch (error) { - throw new NotFoundError('Cannot find workflow'); - } + let project: Project | undefined = undefined; + try { + project = await Container.get(OwnershipService).getWorkflowProjectCached(workflowData.id); + } catch (error) { + throw new NotFoundError('Cannot find workflow'); } // Prepare everything that is needed to run the workflow - const additionalData = await WorkflowExecuteAdditionalData.getBase(user.id); + const additionalData = await WorkflowExecuteAdditionalData.getBase(); // Get the responseMode const responseMode = workflow.expression.getSimpleParameterValue( @@ -546,7 +538,7 @@ export async function executeWebhook( pushRef, workflowData, pinData, - userId: user.id, + projectId: project?.id, }; let responsePromise: IDeferredPromise | undefined; diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 88374f0c6..5fb6a3de0 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -195,12 +195,12 @@ export function executeErrorWorkflow( } Container.get(OwnershipService) - .getWorkflowOwnerCached(workflowId) - .then((user) => { + .getWorkflowProjectCached(workflowId) + .then((project) => { void Container.get(WorkflowExecutionService).executeErrorWorkflow( errorWorkflow, workflowErrorData, - user, + project, ); }) .catch((error: Error) => { @@ -223,12 +223,12 @@ export function executeErrorWorkflow( ) { logger.verbose('Start internal error workflow', { executionId, workflowId }); void Container.get(OwnershipService) - .getWorkflowOwnerCached(workflowId) - .then((user) => { + .getWorkflowProjectCached(workflowId) + .then((project) => { void Container.get(WorkflowExecutionService).executeErrorWorkflow( workflowId, workflowErrorData, - user, + project, ); }); } @@ -655,7 +655,6 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { export async function getRunData( workflowData: IWorkflowBase, - userId: string, inputData?: INodeExecutionData[], ): Promise { const mode = 'integrated'; @@ -698,7 +697,6 @@ export async function getRunData( executionData: runExecutionData, // @ts-ignore workflowData, - userId, }; return runData; @@ -784,9 +782,7 @@ async function executeWorkflow( settings: workflowData.settings, }); - const runData = - options.loadedRunData ?? - (await getRunData(workflowData, additionalData.userId, options.inputData)); + const runData = options.loadedRunData ?? (await getRunData(workflowData, options.inputData)); let executionId; @@ -800,11 +796,7 @@ async function executeWorkflow( let data; try { - await Container.get(PermissionChecker).check( - workflowData.id, - additionalData.userId, - workflowData.nodes, - ); + await Container.get(PermissionChecker).check(workflowData.id, workflowData.nodes); await Container.get(PermissionChecker).checkSubworkflowExecutePolicy( workflow, options.parentWorkflowId, @@ -813,7 +805,7 @@ async function executeWorkflow( // Create new additionalData to have different workflow loaded and to call // different webhooks - const additionalDataIntegrated = await getBase(additionalData.userId); + const additionalDataIntegrated = await getBase(); additionalDataIntegrated.hooks = getWorkflowHooksIntegrated( runData.executionMode, executionId, @@ -966,7 +958,7 @@ export function sendDataToUI(type: string, data: IDataObject | IDataObject[]) { * Returns the base additional data without webhooks */ export async function getBase( - userId: string, + userId?: string, currentNodeParameters?: INodeParameters, executionTimeoutTimestamp?: number, ): Promise { diff --git a/packages/cli/src/WorkflowRunner.ts b/packages/cli/src/WorkflowRunner.ts index 10ca743fa..703474724 100644 --- a/packages/cli/src/WorkflowRunner.ts +++ b/packages/cli/src/WorkflowRunner.ts @@ -161,7 +161,7 @@ export class WorkflowRunner { const { id: workflowId, nodes } = data.workflowData; try { - await this.permissionChecker.check(workflowId, data.userId, nodes); + await this.permissionChecker.check(workflowId, nodes); } catch (error) { // Create a failed execution with the data for the node, save it and abort execution const runData = generateFailedExecutionFromError(data.executionMode, error, error.node); diff --git a/packages/cli/src/auth/auth.service.ts b/packages/cli/src/auth/auth.service.ts index 9032b446d..ccf562e27 100644 --- a/packages/cli/src/auth/auth.service.ts +++ b/packages/cli/src/auth/auth.service.ts @@ -8,7 +8,7 @@ import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES, Time } from '@/constants'; import type { User } from '@db/entities/User'; import { UserRepository } from '@db/repositories/user.repository'; import { AuthError } from '@/errors/response-errors/auth.error'; -import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { License } from '@/License'; import { Logger } from '@/Logger'; import type { AuthenticatedRequest } from '@/requests'; @@ -92,7 +92,7 @@ export class AuthService { !user.isOwner && !isWithinUsersLimit ) { - throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); + throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); } const token = this.issueJWT(user, browserId); diff --git a/packages/cli/src/commands/import/credentials.ts b/packages/cli/src/commands/import/credentials.ts index 52440e114..03e96aa64 100644 --- a/packages/cli/src/commands/import/credentials.ts +++ b/packages/cli/src/commands/import/credentials.ts @@ -6,7 +6,6 @@ import glob from 'fast-glob'; import type { EntityManager } from '@n8n/typeorm'; import * as Db from '@/Db'; -import type { User } from '@db/entities/User'; import { SharedCredentials } from '@db/entities/SharedCredentials'; import { CredentialsEntity } from '@db/entities/CredentialsEntity'; import { disableAutoGeneratedIds } from '@db/utils/commandHelpers'; @@ -15,6 +14,8 @@ import type { ICredentialsEncrypted } from 'n8n-workflow'; import { ApplicationError, jsonParse } from 'n8n-workflow'; import { UM_FIX_INSTRUCTION } from '@/constants'; import { UserRepository } from '@db/repositories/user.repository'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import type { Project } from '@/databases/entities/Project'; export class ImportCredentialsCommand extends BaseCommand { static description = 'Import credentials'; @@ -23,6 +24,7 @@ export class ImportCredentialsCommand extends BaseCommand { '$ n8n import:credentials --input=file.json', '$ n8n import:credentials --separate --input=backups/latest/', '$ n8n import:credentials --input=file.json --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', + '$ n8n import:credentials --input=file.json --projectId=Ox8O54VQrmBrb4qL', '$ n8n import:credentials --separate --input=backups/latest/ --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', ]; @@ -38,6 +40,9 @@ export class ImportCredentialsCommand extends BaseCommand { userId: Flags.string({ description: 'The ID of the user to assign the imported credentials to', }), + projectId: Flags.string({ + description: 'The ID of the project to assign the imported credential to', + }), }; private transactionManager: EntityManager; @@ -64,21 +69,27 @@ export class ImportCredentialsCommand extends BaseCommand { } } - const user = flags.userId ? await this.getAssignee(flags.userId) : await this.getOwner(); + if (flags.projectId && flags.userId) { + throw new ApplicationError( + 'You cannot use `--userId` and `--projectId` together. Use one or the other.', + ); + } + + const project = await this.getProject(flags.userId, flags.projectId); const credentials = await this.readCredentials(flags.input, flags.separate); await Db.getConnection().transaction(async (transactionManager) => { this.transactionManager = transactionManager; - const result = await this.checkRelations(credentials, flags.userId); + const result = await this.checkRelations(credentials, flags.projectId, flags.userId); if (!result.success) { throw new ApplicationError(result.message); } for (const credential of credentials) { - await this.storeCredential(credential, user); + await this.storeCredential(credential, project); } }); @@ -98,7 +109,7 @@ export class ImportCredentialsCommand extends BaseCommand { ); } - private async storeCredential(credential: Partial, user: User) { + private async storeCredential(credential: Partial, project: Project) { const result = await this.transactionManager.upsert(CredentialsEntity, credential, ['id']); const sharingExists = await this.transactionManager.existsBy(SharedCredentials, { @@ -111,25 +122,34 @@ export class ImportCredentialsCommand extends BaseCommand { SharedCredentials, { credentialsId: result.identifiers[0].id as string, - userId: user.id, role: 'credential:owner', + projectId: project.id, }, - ['credentialsId', 'userId'], + ['credentialsId', 'projectId'], ); } } - private async getOwner() { + private async getOwnerProject() { const owner = await Container.get(UserRepository).findOneBy({ role: 'global:owner' }); if (!owner) { throw new ApplicationError(`Failed to find owner. ${UM_FIX_INSTRUCTION}`); } - return owner; + const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + owner.id, + ); + + return project; } - private async checkRelations(credentials: ICredentialsEncrypted[], userId?: string) { - if (!userId) { + private async checkRelations( + credentials: ICredentialsEncrypted[], + projectId?: string, + userId?: string, + ) { + // The credential is not supposed to be re-owned. + if (!projectId && !userId) { return { success: true as const, message: undefined, @@ -145,15 +165,26 @@ export class ImportCredentialsCommand extends BaseCommand { continue; } - const ownerId = await this.getCredentialOwner(credential.id); - if (!ownerId) { + const { user, project: ownerProject } = await this.getCredentialOwner(credential.id); + + if (!ownerProject) { continue; } - if (ownerId !== userId) { + if (ownerProject.id !== projectId) { + const currentOwner = + ownerProject.type === 'personal' + ? `the user with the ID "${user.id}"` + : `the project with the ID "${ownerProject.id}"`; + const newOwner = userId + ? // The user passed in `--userId`, so let's use the user ID in the error + // message as opposed to the project ID. + `the user with the ID "${userId}"` + : `the project with the ID "${projectId}"`; + return { success: false as const, - message: `The credential with id "${credential.id}" is already owned by the user with the id "${ownerId}". It can't be re-owned by the user with the id "${userId}"`, + message: `The credential with ID "${credential.id}" is already owned by ${currentOwner}. It can't be re-owned by ${newOwner}.`, }; } } @@ -206,26 +237,39 @@ export class ImportCredentialsCommand extends BaseCommand { }); } - private async getAssignee(userId: string) { - const user = await Container.get(UserRepository).findOneBy({ id: userId }); - - if (!user) { - throw new ApplicationError('Failed to find user', { extra: { userId } }); - } - - return user; - } - private async getCredentialOwner(credentialsId: string) { - const sharedCredential = await this.transactionManager.findOneBy(SharedCredentials, { - credentialsId, - role: 'credential:owner', + const sharedCredential = await this.transactionManager.findOne(SharedCredentials, { + where: { credentialsId, role: 'credential:owner' }, + relations: { project: true }, }); - return sharedCredential?.userId; + if (sharedCredential && sharedCredential.project.type === 'personal') { + const user = await Container.get(UserRepository).findOneByOrFail({ + projectRelations: { + role: 'project:personalOwner', + projectId: sharedCredential.projectId, + }, + }); + + return { user, project: sharedCredential.project }; + } + + return {}; } private async credentialExists(credentialId: string) { return await this.transactionManager.existsBy(CredentialsEntity, { id: credentialId }); } + + private async getProject(userId?: string, projectId?: string) { + if (projectId) { + return await Container.get(ProjectRepository).findOneByOrFail({ id: projectId }); + } + + if (userId) { + return await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId); + } + + return await this.getOwnerProject(); + } } diff --git a/packages/cli/src/commands/import/workflow.ts b/packages/cli/src/commands/import/workflow.ts index 40404482f..7a6b7c38f 100644 --- a/packages/cli/src/commands/import/workflow.ts +++ b/packages/cli/src/commands/import/workflow.ts @@ -14,6 +14,7 @@ import type { IWorkflowToImport } from '@/Interfaces'; import { ImportService } from '@/services/import.service'; import { BaseCommand } from '../BaseCommand'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; function assertHasWorkflowsToImport(workflows: unknown): asserts workflows is IWorkflowToImport[] { if (!Array.isArray(workflows)) { @@ -40,6 +41,7 @@ export class ImportWorkflowsCommand extends BaseCommand { '$ n8n import:workflow --input=file.json', '$ n8n import:workflow --separate --input=backups/latest/', '$ n8n import:workflow --input=file.json --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', + '$ n8n import:workflow --input=file.json --projectId=Ox8O54VQrmBrb4qL', '$ n8n import:workflow --separate --input=backups/latest/ --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', ]; @@ -55,6 +57,9 @@ export class ImportWorkflowsCommand extends BaseCommand { userId: Flags.string({ description: 'The ID of the user to assign the imported workflows to', }), + projectId: Flags.string({ + description: 'The ID of the project to assign the imported workflows to', + }), }; async init() { @@ -79,24 +84,32 @@ export class ImportWorkflowsCommand extends BaseCommand { } } - const owner = await this.getOwner(); + if (flags.projectId && flags.userId) { + throw new ApplicationError( + 'You cannot use `--userId` and `--projectId` together. Use one or the other.', + ); + } + + const project = await this.getProject(flags.userId, flags.projectId); const workflows = await this.readWorkflows(flags.input, flags.separate); - const result = await this.checkRelations(workflows, flags.userId); + const result = await this.checkRelations(workflows, flags.projectId, flags.userId); + if (!result.success) { throw new ApplicationError(result.message); } this.logger.info(`Importing ${workflows.length} workflows...`); - await Container.get(ImportService).importWorkflows(workflows, flags.userId ?? owner.id); + await Container.get(ImportService).importWorkflows(workflows, project.id); this.reportSuccess(workflows.length); } - private async checkRelations(workflows: WorkflowEntity[], userId: string | undefined) { - if (!userId) { + private async checkRelations(workflows: WorkflowEntity[], projectId?: string, userId?: string) { + // The credential is not supposed to be re-owned. + if (!userId && !projectId) { return { success: true as const, message: undefined, @@ -108,15 +121,26 @@ export class ImportWorkflowsCommand extends BaseCommand { continue; } - const ownerId = await this.getWorkflowOwner(workflow); - if (!ownerId) { + const { user, project: ownerProject } = await this.getWorkflowOwner(workflow); + + if (!ownerProject) { continue; } - if (ownerId !== userId) { + if (ownerProject.id !== projectId) { + const currentOwner = + ownerProject.type === 'personal' + ? `the user with the ID "${user.id}"` + : `the project with the ID "${ownerProject.id}"`; + const newOwner = userId + ? // The user passed in `--userId`, so let's use the user ID in the error + // message as opposed to the project ID. + `the user with the ID "${userId}"` + : `the project with the ID "${projectId}"`; + return { success: false as const, - message: `The credential with id "${workflow.id}" is already owned by the user with the id "${ownerId}". It can't be re-owned by the user with the id "${userId}"`, + message: `The credential with ID "${workflow.id}" is already owned by ${currentOwner}. It can't be re-owned by ${newOwner}.`, }; } } @@ -136,22 +160,37 @@ export class ImportWorkflowsCommand extends BaseCommand { this.logger.info(`Successfully imported ${total} ${total === 1 ? 'workflow.' : 'workflows.'}`); } - private async getOwner() { + private async getOwnerProject() { const owner = await Container.get(UserRepository).findOneBy({ role: 'global:owner' }); if (!owner) { throw new ApplicationError(`Failed to find owner. ${UM_FIX_INSTRUCTION}`); } - return owner; + const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + owner.id, + ); + + return project; } private async getWorkflowOwner(workflow: WorkflowEntity) { - const sharing = await Container.get(SharedWorkflowRepository).findOneBy({ - workflowId: workflow.id, - role: 'workflow:owner', + const sharing = await Container.get(SharedWorkflowRepository).findOne({ + where: { workflowId: workflow.id, role: 'workflow:owner' }, + relations: { project: true }, }); - return sharing?.userId; + if (sharing && sharing.project.type === 'personal') { + const user = await Container.get(UserRepository).findOneByOrFail({ + projectRelations: { + role: 'project:personalOwner', + projectId: sharing.projectId, + }, + }); + + return { user, project: sharing.project }; + } + + return {}; } private async workflowExists(workflow: WorkflowEntity) { @@ -189,4 +228,16 @@ export class ImportWorkflowsCommand extends BaseCommand { return workflowInstances; } } + + private async getProject(userId?: string, projectId?: string) { + if (projectId) { + return await Container.get(ProjectRepository).findOneByOrFail({ id: projectId }); + } + + if (userId) { + return await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId); + } + + return await this.getOwnerProject(); + } } diff --git a/packages/cli/src/commands/ldap/reset.ts b/packages/cli/src/commands/ldap/reset.ts index 269346523..39dea43ad 100644 --- a/packages/cli/src/commands/ldap/reset.ts +++ b/packages/cli/src/commands/ldap/reset.ts @@ -5,18 +5,115 @@ import { AuthProviderSyncHistoryRepository } from '@db/repositories/authProvider import { SettingsRepository } from '@db/repositories/settings.repository'; import { UserRepository } from '@db/repositories/user.repository'; import { BaseCommand } from '../BaseCommand'; +import { Flags } from '@oclif/core'; +import { ApplicationError } from 'n8n-workflow'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { WorkflowService } from '@/workflows/workflow.service'; +import { In } from '@n8n/typeorm'; +import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; +import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; +import { CredentialsService } from '@/credentials/credentials.service'; +import { UM_FIX_INSTRUCTION } from '@/constants'; + +const wrongFlagsError = + 'You must use exactly one of `--userId`, `--projectId` or `--deleteWorkflowsAndCredentials`.'; export class Reset extends BaseCommand { - static description = '\nResets the database to the default ldap state'; + static description = + '\nResets the database to the default ldap state.\n\nTHIS DELETES ALL LDAP MANAGED USERS.'; + + static examples = [ + '$ n8n ldap:reset --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', + '$ n8n ldap:reset --projectId=Ox8O54VQrmBrb4qL', + '$ n8n ldap:reset --deleteWorkflowsAndCredentials', + ]; + + static flags = { + help: Flags.help({ char: 'h' }), + userId: Flags.string({ + description: + 'The ID of the user to assign the workflows and credentials owned by the deleted LDAP users to', + }), + projectId: Flags.string({ + description: + 'The ID of the project to assign the workflows and credentials owned by the deleted LDAP users to', + }), + deleteWorkflowsAndCredentials: Flags.boolean({ + description: + 'Delete all workflows and credentials owned by the users that were created by the users managed via LDAP.', + }), + }; async run(): Promise { + const { flags } = await this.parse(Reset); + const numberOfOptions = + Number(!!flags.userId) + + Number(!!flags.projectId) + + Number(!!flags.deleteWorkflowsAndCredentials); + + if (numberOfOptions !== 1) { + throw new ApplicationError(wrongFlagsError); + } + + const owner = await this.getOwner(); const ldapIdentities = await Container.get(AuthIdentityRepository).find({ where: { providerType: 'ldap' }, select: ['userId'], }); + const personalProjectIds = await Container.get( + ProjectRelationRepository, + ).getPersonalProjectsForUsers(ldapIdentities.map((i) => i.userId)); + + // Migrate all workflows and credentials to another project. + if (flags.projectId ?? flags.userId) { + if (flags.userId && ldapIdentities.some((i) => i.userId === flags.userId)) { + throw new ApplicationError( + `Can't migrate workflows and credentials to the user with the ID ${flags.userId}. That user was created via LDAP and will be deleted as well.`, + ); + } + + if (flags.projectId && personalProjectIds.includes(flags.projectId)) { + throw new ApplicationError( + `Can't migrate workflows and credentials to the project with the ID ${flags.projectId}. That project is a personal project belonging to a user that was created via LDAP and will be deleted as well.`, + ); + } + + const project = await this.getProject(flags.userId, flags.projectId); + + await Container.get(UserRepository).manager.transaction(async (trx) => { + for (const projectId of personalProjectIds) { + await Container.get(WorkflowService).transferAll(projectId, project.id, trx); + await Container.get(CredentialsService).transferAll(projectId, project.id, trx); + } + }); + } + + const [ownedSharedWorkflows, ownedSharedCredentials] = await Promise.all([ + Container.get(SharedWorkflowRepository).find({ + select: { workflowId: true }, + where: { projectId: In(personalProjectIds), role: 'workflow:owner' }, + }), + Container.get(SharedCredentialsRepository).find({ + relations: { credentials: true }, + where: { projectId: In(personalProjectIds), role: 'credential:owner' }, + }), + ]); + + const ownedCredentials = ownedSharedCredentials.map(({ credentials }) => credentials); + + for (const { workflowId } of ownedSharedWorkflows) { + await Container.get(WorkflowService).delete(owner, workflowId); + } + + for (const credential of ownedCredentials) { + await Container.get(CredentialsService).delete(credential); + } + await Container.get(AuthProviderSyncHistoryRepository).delete({ providerType: 'ldap' }); await Container.get(AuthIdentityRepository).delete({ providerType: 'ldap' }); await Container.get(UserRepository).deleteMany(ldapIdentities.map((i) => i.userId)); + await Container.get(ProjectRepository).delete({ id: In(personalProjectIds) }); await Container.get(SettingsRepository).delete({ key: LDAP_FEATURE_NAME }); await Container.get(SettingsRepository).insert({ key: LDAP_FEATURE_NAME, @@ -27,8 +124,43 @@ export class Reset extends BaseCommand { this.logger.info('Successfully reset the database to default ldap state.'); } + async getProject(userId?: string, projectId?: string) { + if (projectId) { + const project = await Container.get(ProjectRepository).findOneBy({ id: projectId }); + + if (project === null) { + throw new ApplicationError(`Could not find the project with the ID ${projectId}.`); + } + + return project; + } + + if (userId) { + const project = await Container.get(ProjectRepository).getPersonalProjectForUser(userId); + + if (project === null) { + throw new ApplicationError( + `Could not find the user with the ID ${userId} or their personalProject.`, + ); + } + + return project; + } + + throw new ApplicationError(wrongFlagsError); + } + async catch(error: Error): Promise { this.logger.error('Error resetting database. See log messages for details.'); this.logger.error(error.message); } + + private async getOwner() { + const owner = await Container.get(UserRepository).findOneBy({ role: 'global:owner' }); + if (!owner) { + throw new ApplicationError(`Failed to find owner. ${UM_FIX_INSTRUCTION}`); + } + + return owner; + } } diff --git a/packages/cli/src/commands/mfa/disable.ts b/packages/cli/src/commands/mfa/disable.ts index fb39aed79..a839b56e0 100644 --- a/packages/cli/src/commands/mfa/disable.ts +++ b/packages/cli/src/commands/mfa/disable.ts @@ -27,16 +27,27 @@ export class DisableMFACommand extends BaseCommand { return; } - const updateOperationResult = await Container.get(UserRepository).update( - { email: flags.email }, - { mfaSecret: null, mfaRecoveryCodes: [], mfaEnabled: false }, - ); + const user = await Container.get(UserRepository).findOneBy({ email: flags.email }); - if (!updateOperationResult.affected) { + if (!user) { this.reportUserDoesNotExistError(flags.email); return; } + if ( + user.mfaSecret === null && + Array.isArray(user.mfaRecoveryCodes) && + user.mfaRecoveryCodes.length === 0 && + !user.mfaEnabled + ) { + this.reportUserDoesNotExistError(flags.email); + return; + } + + Object.assign(user, { mfaSecret: null, mfaRecoveryCodes: [], mfaEnabled: false }); + + await Container.get(UserRepository).save(user); + this.reportSuccess(flags.email); } diff --git a/packages/cli/src/commands/user-management/reset.ts b/packages/cli/src/commands/user-management/reset.ts index 188183e7d..30f60af0a 100644 --- a/packages/cli/src/commands/user-management/reset.ts +++ b/packages/cli/src/commands/user-management/reset.ts @@ -7,6 +7,7 @@ import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials. import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { UserRepository } from '@db/repositories/user.repository'; import { BaseCommand } from '../BaseCommand'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; const defaultUserProps = { firstName: null, @@ -23,9 +24,12 @@ export class Reset extends BaseCommand { async run(): Promise { const owner = await this.getInstanceOwner(); + const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + owner.id, + ); - await Container.get(SharedWorkflowRepository).makeOwnerOfAllWorkflows(owner); - await Container.get(SharedCredentialsRepository).makeOwnerOfAllCredentials(owner); + await Container.get(SharedWorkflowRepository).makeOwnerOfAllWorkflows(personalProject); + await Container.get(SharedCredentialsRepository).makeOwnerOfAllCredentials(personalProject); await Container.get(UserRepository).deleteAllExcept(owner); await Container.get(UserRepository).save(Object.assign(owner, defaultUserProps)); @@ -38,7 +42,7 @@ export class Reset extends BaseCommand { const newSharedCredentials = danglingCredentials.map((credentials) => Container.get(SharedCredentialsRepository).create({ credentials, - user: owner, + projectId: personalProject.id, role: 'credential:owner', }), ); diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index 965a6ac28..28beddb49 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -17,7 +17,6 @@ import { Queue } from '@/Queue'; import { N8N_VERSION } from '@/constants'; import { ExecutionRepository } from '@db/repositories/execution.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; -import { OwnershipService } from '@/services/ownership.service'; import type { ICredentialsOverwrite } from '@/Interfaces'; import { CredentialsOverwrites } from '@/CredentialsOverwrites'; import { rawBodyReader, bodyParser } from '@/middlewares'; @@ -118,8 +117,6 @@ export class Worker extends BaseCommand { ); await executionRepository.updateStatus(executionId, 'running'); - const workflowOwner = await Container.get(OwnershipService).getWorkflowOwnerCached(workflowId); - let { staticData } = fullExecutionData.workflowData; if (loadStaticData) { const workflowData = await Container.get(WorkflowRepository).findOne({ @@ -160,7 +157,7 @@ export class Worker extends BaseCommand { }); const additionalData = await WorkflowExecuteAdditionalData.getBase( - workflowOwner.id, + undefined, undefined, executionTimeoutTimestamp, ); diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 2253ae832..0dc0ee1fd 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -48,7 +48,8 @@ export const RESPONSE_ERROR_MESSAGES = { USERS_QUOTA_REACHED: 'Maximum number of users reached', OAUTH2_CREDENTIAL_TEST_SUCCEEDED: 'Connection Successful!', OAUTH2_CREDENTIAL_TEST_FAILED: 'This OAuth2 credential was not connected to an account.', -}; + MISSING_SCOPE: 'User is missing a scope required to perform this action', +} as const; export const AUTH_COOKIE_NAME = 'n8n-auth'; @@ -86,6 +87,9 @@ export const LICENSE_FEATURES = { MULTIPLE_MAIN_INSTANCES: 'feat:multipleMainInstances', WORKER_VIEW: 'feat:workerView', ADVANCED_PERMISSIONS: 'feat:advancedPermissions', + PROJECT_ROLE_ADMIN: 'feat:projectRole:admin', + PROJECT_ROLE_EDITOR: 'feat:projectRole:editor', + PROJECT_ROLE_VIEWER: 'feat:projectRole:viewer', } as const; export const LICENSE_QUOTAS = { @@ -93,6 +97,7 @@ export const LICENSE_QUOTAS = { VARIABLES_LIMIT: 'quota:maxVariables', USERS_LIMIT: 'quota:users', WORKFLOW_HISTORY_PRUNE_LIMIT: 'quota:workflowHistoryPrune', + TEAM_PROJECT_LIMIT: 'quota:maxTeamProjects', } as const; export const UNLIMITED_LICENSE_QUOTA = -1; diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 456937bba..97de4c8d8 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -21,7 +21,7 @@ import { MfaService } from '@/Mfa/mfa.service'; import { Logger } from '@/Logger'; import { AuthError } from '@/errors/response-errors/auth.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { ApplicationError } from 'n8n-workflow'; import { UserRepository } from '@/databases/repositories/user.repository'; @@ -130,7 +130,7 @@ export class AuthController { inviterId, inviteeId, }); - throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); + throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); } if (!inviterId || !inviteeId) { diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index 2f431de08..e8fc7c5ca 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -6,10 +6,10 @@ import { UserRepository } from '@db/repositories/user.repository'; import { ActiveWorkflowManager } from '@/ActiveWorkflowManager'; import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; import { License } from '@/License'; -import { LICENSE_FEATURES, inE2ETests } from '@/constants'; +import { LICENSE_FEATURES, LICENSE_QUOTAS, UNLIMITED_LICENSE_QUOTA, inE2ETests } from '@/constants'; import { Patch, Post, RestController } from '@/decorators'; import type { UserSetupPayload } from '@/requests'; -import type { BooleanLicenseFeature, IPushDataType } from '@/Interfaces'; +import type { BooleanLicenseFeature, IPushDataType, NumericLicenseFeature } from '@/Interfaces'; import { MfaService } from '@/Mfa/mfa.service'; import { Push } from '@/push'; import { CacheService } from '@/services/cache/cache.service'; @@ -25,21 +25,23 @@ if (!inE2ETests) { const tablesToTruncate = [ 'auth_identity', 'auth_provider_sync_history', - 'event_destinations', - 'shared_workflow', - 'shared_credentials', - 'webhook_entity', - 'workflows_tags', 'credentials_entity', - 'tag_entity', - 'workflow_statistics', - 'workflow_entity', + 'event_destinations', 'execution_entity', - 'settings', - 'installed_packages', 'installed_nodes', + 'installed_packages', + 'project', + 'project_relation', + 'settings', + 'shared_credentials', + 'shared_workflow', + 'tag_entity', 'user', 'variables', + 'webhook_entity', + 'workflow_entity', + 'workflow_statistics', + 'workflows_tags', ]; type ResetRequest = Request< @@ -81,21 +83,35 @@ export class E2EController { [LICENSE_FEATURES.MULTIPLE_MAIN_INSTANCES]: false, [LICENSE_FEATURES.WORKER_VIEW]: false, [LICENSE_FEATURES.ADVANCED_PERMISSIONS]: false, + [LICENSE_FEATURES.PROJECT_ROLE_ADMIN]: false, + [LICENSE_FEATURES.PROJECT_ROLE_EDITOR]: false, + [LICENSE_FEATURES.PROJECT_ROLE_VIEWER]: false, + }; + + private numericFeatures: Record = { + [LICENSE_QUOTAS.TRIGGER_LIMIT]: -1, + [LICENSE_QUOTAS.VARIABLES_LIMIT]: -1, + [LICENSE_QUOTAS.USERS_LIMIT]: -1, + [LICENSE_QUOTAS.WORKFLOW_HISTORY_PRUNE_LIMIT]: -1, + [LICENSE_QUOTAS.TEAM_PROJECT_LIMIT]: 0, }; constructor( license: License, private readonly settingsRepo: SettingsRepository, - private readonly userRepo: UserRepository, private readonly workflowRunner: ActiveWorkflowManager, private readonly mfaService: MfaService, private readonly cacheService: CacheService, private readonly push: Push, private readonly passwordUtility: PasswordUtility, private readonly eventBus: MessageEventBus, + private readonly userRepository: UserRepository, ) { license.isFeatureEnabled = (feature: BooleanLicenseFeature) => this.enabledFeatures[feature] ?? false; + // eslint-disable-next-line @typescript-eslint/unbound-method + license.getFeatureValue = (feature: NumericLicenseFeature) => + this.numericFeatures[feature] ?? UNLIMITED_LICENSE_QUOTA; } @Post('/reset', { skipAuth: true }) @@ -119,6 +135,12 @@ export class E2EController { this.enabledFeatures[feature] = enabled; } + @Patch('/quota', { skipAuth: true }) + setQuota(req: Request<{}, {}, { feature: NumericLicenseFeature; value: number }>) { + const { value, feature } = req.body; + this.numericFeatures[feature] = value; + } + @Patch('/queue-mode', { skipAuth: true }) async setQueueMode(req: Request<{}, {}, { enabled: boolean }>) { const { enabled } = req.body; @@ -163,34 +185,34 @@ export class E2EController { members: UserSetupPayload[], admin: UserSetupPayload, ) { - const instanceOwner = this.userRepo.create({ - id: uuid(), - ...owner, - password: await this.passwordUtility.hash(owner.password), - role: 'global:owner', - }); - if (owner?.mfaSecret && owner.mfaRecoveryCodes?.length) { const { encryptedRecoveryCodes, encryptedSecret } = this.mfaService.encryptSecretAndRecoveryCodes(owner.mfaSecret, owner.mfaRecoveryCodes); - instanceOwner.mfaSecret = encryptedSecret; - instanceOwner.mfaRecoveryCodes = encryptedRecoveryCodes; + owner.mfaSecret = encryptedSecret; + owner.mfaRecoveryCodes = encryptedRecoveryCodes; } - const adminUser = this.userRepo.create({ - id: uuid(), - ...admin, - password: await this.passwordUtility.hash(admin.password), - role: 'global:admin', - }); + const userCreatePromises = [ + this.userRepository.createUserWithProject({ + id: uuid(), + ...owner, + password: await this.passwordUtility.hash(owner.password), + role: 'global:owner', + }), + ]; - const users = []; - - users.push(instanceOwner, adminUser); + userCreatePromises.push( + this.userRepository.createUserWithProject({ + id: uuid(), + ...admin, + password: await this.passwordUtility.hash(admin.password), + role: 'global:admin', + }), + ); for (const { password, ...payload } of members) { - users.push( - this.userRepo.create({ + userCreatePromises.push( + this.userRepository.createUserWithProject({ id: uuid(), ...payload, password: await this.passwordUtility.hash(password), @@ -199,7 +221,7 @@ export class E2EController { ); } - await this.userRepo.insert(users); + await Promise.all(userCreatePromises); await this.settingsRepo.update( { key: 'userManagement.isInstanceOwnerSetUp' }, diff --git a/packages/cli/src/controllers/invitation.controller.ts b/packages/cli/src/controllers/invitation.controller.ts index e1ca3dcc1..bb5f006a5 100644 --- a/packages/cli/src/controllers/invitation.controller.ts +++ b/packages/cli/src/controllers/invitation.controller.ts @@ -15,7 +15,7 @@ import { PostHogClient } from '@/posthog'; import type { User } from '@/databases/entities/User'; import { UserRepository } from '@db/repositories/user.repository'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { InternalHooks } from '@/InternalHooks'; import { ExternalHooks } from '@/ExternalHooks'; @@ -55,7 +55,7 @@ export class InvitationController { this.logger.debug( 'Request to send email invite(s) to user(s) failed because the user limit quota has been reached', ); - throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); + throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); } if (!config.getEnv('userManagement.isInstanceOwnerSetUp')) { @@ -98,7 +98,7 @@ export class InvitationController { } if (invite.role === 'global:admin' && !this.license.isAdvancedPermissionsLicensed()) { - throw new UnauthorizedError( + throw new ForbiddenError( 'Cannot invite admin user without advanced permissions. Please upgrade to a license that includes this feature.', ); } diff --git a/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts b/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts index 6f2106915..b778216a6 100644 --- a/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts +++ b/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts @@ -47,6 +47,7 @@ export abstract class AbstractOAuthController { const credential = await this.sharedCredentialsRepository.findCredentialForUser( credentialId, req.user, + ['credential:read'], ); if (!credential) { diff --git a/packages/cli/src/controllers/passwordReset.controller.ts b/packages/cli/src/controllers/passwordReset.controller.ts index bf8034a0d..fdf9e4913 100644 --- a/packages/cli/src/controllers/passwordReset.controller.ts +++ b/packages/cli/src/controllers/passwordReset.controller.ts @@ -17,7 +17,7 @@ import { InternalHooks } from '@/InternalHooks'; import { UrlService } from '@/services/url.service'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; import { UserRepository } from '@/databases/repositories/user.repository'; @@ -76,7 +76,7 @@ export class PasswordResetController { this.logger.debug( 'Request to send password reset email failed because the user limit was reached', ); - throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); + throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); } if ( isSamlCurrentAuthenticationMethod() && @@ -88,7 +88,7 @@ export class PasswordResetController { this.logger.debug( 'Request to send password reset email failed because login is handled by SAML', ); - throw new UnauthorizedError( + throw new ForbiddenError( 'Login is handled by SAML. Please contact your Identity Provider to reset your password.', ); } @@ -163,7 +163,7 @@ export class PasswordResetController { 'Request to resolve password token failed because the user limit was reached', { userId: user.id }, ); - throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); + throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); } this.logger.info('Reset-password token resolved successfully', { userId: user.id }); diff --git a/packages/cli/src/controllers/project.controller.ts b/packages/cli/src/controllers/project.controller.ts new file mode 100644 index 000000000..d9fa5d6ff --- /dev/null +++ b/packages/cli/src/controllers/project.controller.ts @@ -0,0 +1,221 @@ +import type { Project } from '@db/entities/Project'; +import { + Get, + Post, + GlobalScope, + RestController, + Licensed, + Patch, + ProjectScope, + Delete, +} from '@/decorators'; +import { ProjectRequest } from '@/requests'; +import { + ProjectService, + TeamProjectOverQuotaError, + UnlicensedProjectRoleError, +} from '@/services/project.service'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { combineScopes } from '@n8n/permissions'; +import type { Scope } from '@n8n/permissions'; +import { RoleService } from '@/services/role.service'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { In, Not } from '@n8n/typeorm'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { InternalHooks } from '@/InternalHooks'; + +@RestController('/projects') +export class ProjectController { + constructor( + private readonly projectsService: ProjectService, + private readonly roleService: RoleService, + private readonly projectRepository: ProjectRepository, + private readonly internalHooks: InternalHooks, + ) {} + + @Get('/') + async getAllProjects(req: ProjectRequest.GetAll): Promise { + return await this.projectsService.getAccessibleProjects(req.user); + } + + @Get('/count') + async getProjectCounts() { + return await this.projectsService.getProjectCounts(); + } + + @Post('/') + @GlobalScope('project:create') + // Using admin as all plans that contain projects should allow admins at the very least + @Licensed('feat:projectRole:admin') + async createProject(req: ProjectRequest.Create): Promise { + try { + const project = await this.projectsService.createTeamProject(req.body.name, req.user); + + void this.internalHooks.onTeamProjectCreated({ + user_id: req.user.id, + role: req.user.role, + }); + + return project; + } catch (e) { + if (e instanceof TeamProjectOverQuotaError) { + throw new BadRequestError(e.message); + } + throw e; + } + } + + @Get('/my-projects') + async getMyProjects( + req: ProjectRequest.GetMyProjects, + ): Promise { + const relations = await this.projectsService.getProjectRelationsForUser(req.user); + const otherTeamProject = req.user.hasGlobalScope('project:read') + ? await this.projectRepository.findBy({ + type: 'team', + id: Not(In(relations.map((pr) => pr.projectId))), + }) + : []; + + const results: ProjectRequest.GetMyProjectsResponse = []; + + for (const pr of relations) { + const result: ProjectRequest.GetMyProjectsResponse[number] = Object.assign( + this.projectRepository.create(pr.project), + { + role: pr.role, + scopes: req.query.includeScopes ? ([] as Scope[]) : undefined, + }, + ); + + if (result.scopes) { + result.scopes.push( + ...combineScopes({ + global: this.roleService.getRoleScopes(req.user.role), + project: this.roleService.getRoleScopes(pr.role), + }), + ); + } + + results.push(result); + } + + for (const project of otherTeamProject) { + const result: ProjectRequest.GetMyProjectsResponse[number] = Object.assign( + this.projectRepository.create(project), + { + // If the user has the global `project:read` scope then they may not + // own this relationship in that case we use the global user role + // instead of the relation role, which is for another user. + role: req.user.role, + scopes: req.query.includeScopes ? [] : undefined, + }, + ); + + if (result.scopes) { + result.scopes.push( + ...combineScopes({ global: this.roleService.getRoleScopes(req.user.role) }), + ); + } + + results.push(result); + } + + // Deduplicate and sort scopes + for (const result of results) { + if (result.scopes) { + result.scopes = [...new Set(result.scopes)].sort(); + } + } + + return results; + } + + @Get('/personal') + async getPersonalProject(req: ProjectRequest.GetPersonalProject) { + const project = await this.projectsService.getPersonalProject(req.user); + if (!project) { + throw new NotFoundError('Could not find a personal project for this user'); + } + const scopes: Scope[] = [ + ...combineScopes({ + global: this.roleService.getRoleScopes(req.user.role), + project: this.roleService.getRoleScopes('project:personalOwner'), + }), + ]; + return { + ...project, + scopes, + }; + } + + @Get('/:projectId') + @ProjectScope('project:read') + async getProject(req: ProjectRequest.Get): Promise { + const [{ id, name, type }, relations] = await Promise.all([ + this.projectsService.getProject(req.params.projectId), + this.projectsService.getProjectRelations(req.params.projectId), + ]); + const myRelation = relations.find((r) => r.userId === req.user.id); + + return { + id, + name, + type, + relations: relations.map((r) => ({ + id: r.user.id, + email: r.user.email, + firstName: r.user.firstName, + lastName: r.user.lastName, + role: r.role, + })), + scopes: [ + ...combineScopes({ + global: this.roleService.getRoleScopes(req.user.role), + ...(myRelation ? { project: this.roleService.getRoleScopes(myRelation.role) } : {}), + }), + ], + }; + } + + @Patch('/:projectId') + @ProjectScope('project:update') + async updateProject(req: ProjectRequest.Update) { + if (req.body.name) { + await this.projectsService.updateProject(req.body.name, req.params.projectId); + } + if (req.body.relations) { + try { + await this.projectsService.syncProjectRelations(req.params.projectId, req.body.relations); + } catch (e) { + if (e instanceof UnlicensedProjectRoleError) { + throw new BadRequestError(e.message); + } + throw e; + } + + void this.internalHooks.onTeamProjectUpdated({ + user_id: req.user.id, + role: req.user.role, + members: req.body.relations.map(({ userId, role }) => ({ user_id: userId, role })), + project_id: req.params.projectId, + }); + } + } + + @Delete('/:projectId') + @ProjectScope('project:delete') + async deleteProject(req: ProjectRequest.Delete) { + await this.projectsService.deleteProject(req.user, req.params.projectId, { + migrateToProject: req.query.transferId, + }); + + void this.internalHooks.onTeamProjectDeleted({ + user_id: req.user.id, + role: req.user.role, + project_id: req.params.projectId, + removal_type: req.query.transferId !== undefined ? 'transfer' : 'delete', + target_project_id: req.query.transferId, + }); + } +} diff --git a/packages/cli/src/controllers/role.controller.ts b/packages/cli/src/controllers/role.controller.ts new file mode 100644 index 000000000..3a9cd3c37 --- /dev/null +++ b/packages/cli/src/controllers/role.controller.ts @@ -0,0 +1,22 @@ +import { Get, RestController } from '@/decorators'; +import { type AllRoleTypes, RoleService } from '@/services/role.service'; + +@RestController('/roles') +export class RoleController { + constructor(private readonly roleService: RoleService) {} + + @Get('/') + async getAllRoles() { + return Object.fromEntries( + Object.entries(this.roleService.getRoles()).map((e) => [ + e[0], + (e[1] as AllRoleTypes[]).map((r) => ({ + name: this.roleService.getRoleName(r), + role: r, + scopes: this.roleService.getRoleScopes(r), + licensed: this.roleService.isRoleLicensed(r), + })), + ]), + ); + } +} diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index 9cc1fd032..391c98c70 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -2,8 +2,6 @@ import { plainToInstance } from 'class-transformer'; import { AuthService } from '@/auth/auth.service'; import { User } from '@db/entities/User'; -import { SharedCredentials } from '@db/entities/SharedCredentials'; -import { SharedWorkflow } from '@db/entities/SharedWorkflow'; import { GlobalScope, Delete, Get, RestController, Patch, Licensed } from '@/decorators'; import { ListQuery, @@ -11,7 +9,6 @@ import { UserRoleChangePayload, UserSettingsUpdatePayload, } from '@/requests'; -import { ActiveWorkflowManager } from '@/ActiveWorkflowManager'; import type { PublicUser, ITelemetryUserDeletionData } from '@/Interfaces'; import { AuthIdentity } from '@db/entities/AuthIdentity'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; @@ -20,12 +17,17 @@ import { UserRepository } from '@db/repositories/user.repository'; import { UserService } from '@/services/user.service'; import { listQueryMiddleware } from '@/middlewares'; import { Logger } from '@/Logger'; -import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ExternalHooks } from '@/ExternalHooks'; import { InternalHooks } from '@/InternalHooks'; import { validateEntity } from '@/GenericHelpers'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { Project } from '@/databases/entities/Project'; +import { WorkflowService } from '@/workflows/workflow.service'; +import { CredentialsService } from '@/credentials/credentials.service'; +import { ProjectService } from '@/services/project.service'; @RestController('/users') export class UsersController { @@ -36,9 +38,12 @@ export class UsersController { private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly userRepository: UserRepository, - private readonly activeWorkflowManager: ActiveWorkflowManager, private readonly authService: AuthService, private readonly userService: UserService, + private readonly projectRepository: ProjectRepository, + private readonly workflowService: WorkflowService, + private readonly credentialsService: CredentialsService, + private readonly projectService: ProjectService, ) {} static ERROR_MESSAGES = { @@ -151,131 +156,92 @@ export class UsersController { const { transferId } = req.query; - if (transferId === idToDelete) { + const userToDelete = await this.userRepository.findOneBy({ id: idToDelete }); + + if (!userToDelete) { + throw new NotFoundError( + 'Request to delete a user failed because the user to delete was not found in DB', + ); + } + + const personalProjectToDelete = await this.projectRepository.getPersonalProjectForUserOrFail( + userToDelete.id, + ); + + if (transferId === personalProjectToDelete.id) { throw new BadRequestError( 'Request to delete a user failed because the user to delete and the transferee are the same user', ); } - const userIds = transferId ? [transferId, idToDelete] : [idToDelete]; - - const users = await this.userRepository.findManyByIds(userIds); - - if (!users.length || (transferId && users.length !== 2)) { - throw new NotFoundError( - 'Request to delete a user failed because the ID of the user to delete and/or the ID of the transferee were not found in DB', - ); - } - - const userToDelete = users.find((user) => user.id === req.params.id) as User; - const telemetryData: ITelemetryUserDeletionData = { user_id: req.user.id, target_user_old_status: userToDelete.isPending ? 'invited' : 'active', target_user_id: idToDelete, + migration_strategy: transferId ? 'transfer_data' : 'delete_data', }; - telemetryData.migration_strategy = transferId ? 'transfer_data' : 'delete_data'; - if (transferId) { - telemetryData.migration_user_id = transferId; - } + const transfereePersonalProject = await this.projectRepository.findOneBy({ id: transferId }); - if (transferId) { - const transferee = users.find((user) => user.id === transferId); - - await this.userService.getManager().transaction(async (transactionManager) => { - // Get all workflow ids belonging to user to delete - const sharedWorkflowIds = await transactionManager - .getRepository(SharedWorkflow) - .find({ - select: ['workflowId'], - where: { userId: userToDelete.id, role: 'workflow:owner' }, - }) - .then((sharedWorkflows) => sharedWorkflows.map(({ workflowId }) => workflowId)); - - // Prevents issues with unique key constraints since user being assigned - // workflows and credentials might be a sharee - await this.sharedWorkflowRepository.deleteByIds( - transactionManager, - sharedWorkflowIds, - transferee, + if (!transfereePersonalProject) { + throw new NotFoundError( + 'Request to delete a user failed because the transferee project was not found in DB', ); + } - // Transfer ownership of owned workflows - await transactionManager.update( - SharedWorkflow, - { user: userToDelete, role: 'workflow:owner' }, - { user: transferee }, - ); - - // Now do the same for creds - - // Get all workflow ids belonging to user to delete - const sharedCredentialIds = await transactionManager - .getRepository(SharedCredentials) - .find({ - select: ['credentialsId'], - where: { userId: userToDelete.id, role: 'credential:owner' }, - }) - .then((sharedCredentials) => sharedCredentials.map(({ credentialsId }) => credentialsId)); - - // Prevents issues with unique key constraints since user being assigned - // workflows and credentials might be a sharee - await this.sharedCredentialsRepository.deleteByIds( - transactionManager, - sharedCredentialIds, - transferee, - ); - - // Transfer ownership of owned credentials - await transactionManager.update( - SharedCredentials, - { user: userToDelete, role: 'credential:owner' }, - { user: transferee }, - ); - - await transactionManager.delete(AuthIdentity, { userId: userToDelete.id }); - - // This will remove all shared workflows and credentials not owned - await transactionManager.delete(User, { id: userToDelete.id }); + const transferee = await this.userRepository.findOneByOrFail({ + projectRelations: { + projectId: transfereePersonalProject.id, + role: 'project:personalOwner', + }, }); - void this.internalHooks.onUserDeletion({ - user: req.user, - telemetryData, - publicApi: false, + telemetryData.migration_user_id = transferee.id; + + await this.userService.getManager().transaction(async (trx) => { + await this.workflowService.transferAll( + personalProjectToDelete.id, + transfereePersonalProject.id, + trx, + ); + await this.credentialsService.transferAll( + personalProjectToDelete.id, + transfereePersonalProject.id, + trx, + ); }); - await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]); - return { success: true }; + + await this.projectService.clearCredentialCanUseExternalSecretsCache( + transfereePersonalProject.id, + ); } const [ownedSharedWorkflows, ownedSharedCredentials] = await Promise.all([ this.sharedWorkflowRepository.find({ - relations: ['workflow'], - where: { userId: userToDelete.id, role: 'workflow:owner' }, + select: { workflowId: true }, + where: { projectId: personalProjectToDelete.id, role: 'workflow:owner' }, }), this.sharedCredentialsRepository.find({ - relations: ['credentials'], - where: { userId: userToDelete.id, role: 'credential:owner' }, + relations: { credentials: true }, + where: { projectId: personalProjectToDelete.id, role: 'credential:owner' }, }), ]); - await this.userService.getManager().transaction(async (transactionManager) => { - const ownedWorkflows = await Promise.all( - ownedSharedWorkflows.map(async ({ workflow }) => { - if (workflow.active) { - // deactivate before deleting - await this.activeWorkflowManager.remove(workflow.id); - } - return workflow; - }), - ); - await transactionManager.remove(ownedWorkflows); - await transactionManager.remove(ownedSharedCredentials.map(({ credentials }) => credentials)); + const ownedCredentials = ownedSharedCredentials.map(({ credentials }) => credentials); - await transactionManager.delete(AuthIdentity, { userId: userToDelete.id }); - await transactionManager.delete(User, { id: userToDelete.id }); + for (const { workflowId } of ownedSharedWorkflows) { + await this.workflowService.delete(userToDelete, workflowId); + } + + for (const credential of ownedCredentials) { + await this.credentialsService.delete(credential); + } + + await this.userService.getManager().transaction(async (trx) => { + await trx.delete(AuthIdentity, { userId: userToDelete.id }); + await trx.delete(Project, { id: personalProjectToDelete.id }); + await trx.delete(User, { id: userToDelete.id }); }); void this.internalHooks.onUserDeletion({ @@ -285,6 +251,7 @@ export class UsersController { }); await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]); + return { success: true }; } @@ -308,11 +275,11 @@ export class UsersController { } if (req.user.role === 'global:admin' && targetUser.role === 'global:owner') { - throw new UnauthorizedError(NO_ADMIN_ON_OWNER); + throw new ForbiddenError(NO_ADMIN_ON_OWNER); } if (req.user.role === 'global:owner' && targetUser.role === 'global:owner') { - throw new UnauthorizedError(NO_OWNER_ON_OWNER); + throw new ForbiddenError(NO_OWNER_ON_OWNER); } await this.userService.update(targetUser.id, { role: payload.newRoleName }); @@ -324,6 +291,13 @@ export class UsersController { public_api: false, }); + const projects = await this.projectService.getUserOwnedOrAdminProjects(targetUser.id); + await Promise.all( + projects.map( + async (p) => await this.projectService.clearCredentialCanUseExternalSecretsCache(p.id), + ), + ); + return { success: true }; } } diff --git a/packages/cli/src/controllers/workflowStatistics.controller.ts b/packages/cli/src/controllers/workflowStatistics.controller.ts index caa9f3cae..0c8678612 100644 --- a/packages/cli/src/controllers/workflowStatistics.controller.ts +++ b/packages/cli/src/controllers/workflowStatistics.controller.ts @@ -29,13 +29,15 @@ export class WorkflowStatisticsController { */ // TODO: move this into a new decorator `@ValidateWorkflowPermission` @Middleware() - async hasWorkflowAccess(req: StatisticsRequest.GetOne, res: Response, next: NextFunction) { + async hasWorkflowAccess(req: StatisticsRequest.GetOne, _res: Response, next: NextFunction) { const { user } = req; const workflowId = req.params.id; - const hasAccess = await this.sharedWorkflowRepository.hasAccess(workflowId, user); + const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [ + 'workflow:read', + ]); - if (hasAccess) { + if (workflow) { next(); } else { this.logger.verbose('User attempted to read a workflow without permissions', { diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index aba4e1543..2542c9d60 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -1,41 +1,53 @@ import { deepCopy } from 'n8n-workflow'; import config from '@/config'; import { CredentialsService } from './credentials.service'; -import { CredentialRequest, ListQuery } from '@/requests'; +import { CredentialRequest } from '@/requests'; import { InternalHooks } from '@/InternalHooks'; import { Logger } from '@/Logger'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NamingService } from '@/services/naming.service'; import { License } from '@/License'; -import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; -import { OwnershipService } from '@/services/ownership.service'; import { EnterpriseCredentialsService } from './credentials.service.ee'; -import { Delete, Get, Licensed, Patch, Post, Put, RestController } from '@/decorators'; +import { + Delete, + Get, + Licensed, + Patch, + Post, + Put, + RestController, + ProjectScope, +} from '@/decorators'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { UserManagementMailer } from '@/UserManagement/email'; import * as Db from '@/Db'; import * as utils from '@/utils'; import { listQueryMiddleware } from '@/middlewares'; +import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository'; +import { In } from '@n8n/typeorm'; +import { SharedCredentials } from '@/databases/entities/SharedCredentials'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; @RestController('/credentials') export class CredentialsController { constructor( private readonly credentialsService: CredentialsService, private readonly enterpriseCredentialsService: EnterpriseCredentialsService, - private readonly credentialsRepository: CredentialsRepository, private readonly namingService: NamingService, private readonly license: License, private readonly logger: Logger, - private readonly ownershipService: OwnershipService, private readonly internalHooks: InternalHooks, private readonly userManagementMailer: UserManagementMailer, + private readonly sharedCredentialsRepository: SharedCredentialsRepository, + private readonly projectRelationRepository: ProjectRelationRepository, ) {} @Get('/', { middlewares: listQueryMiddleware }) - async getMany(req: ListQuery.Request) { + async getMany(req: CredentialRequest.GetMany) { return await this.credentialsService.getMany(req.user, { listQueryOptions: req.listQueryOptions, + includeScopes: req.query.includeScopes, }); } @@ -48,128 +60,73 @@ export class CredentialsController { }; } - @Get('/:id') + @Get('/:credentialId') + @ProjectScope('credential:read') async getOne(req: CredentialRequest.Get) { if (this.license.isSharingEnabled()) { - const { id: credentialId } = req.params; - const includeDecryptedData = req.query.includeData === 'true'; - - let credential = await this.credentialsRepository.findOne({ - where: { id: credentialId }, - relations: ['shared', 'shared.user'], - }); - - if (!credential) { - throw new NotFoundError( - 'Could not load the credential. If you think this is an error, ask the owner to share it with you again', - ); - } - - const userSharing = credential.shared?.find((shared) => shared.user.id === req.user.id); - - if (!userSharing && !req.user.hasGlobalScope('credential:read')) { - throw new UnauthorizedError('Forbidden.'); - } - - credential = this.ownershipService.addOwnedByAndSharedWith(credential); - - // Below, if `userSharing` does not exist, it means this credential is being - // fetched by the instance owner or an admin. In this case, they get the full data - if (!includeDecryptedData || userSharing?.role === 'credential:user') { - const { data: _, ...rest } = credential; - return { ...rest }; - } - - const { data: _, ...rest } = credential; - - const decryptedData = this.credentialsService.redact( - this.credentialsService.decrypt(credential), - credential, + const credentials = await this.enterpriseCredentialsService.getOne( + req.user, + req.params.credentialId, + // TODO: editor-ui is always sending this, maybe we can just rely on the + // the scopes and always decrypt the data if the user has the permissions + // to do so. + req.query.includeData === 'true', ); - return { data: decryptedData, ...rest }; + const scopes = await this.credentialsService.getCredentialScopes( + req.user, + req.params.credentialId, + ); + + return { ...credentials, scopes }; } // non-enterprise - const { id: credentialId } = req.params; - const includeDecryptedData = req.query.includeData === 'true'; - - const sharing = await this.credentialsService.getSharing( + const credentials = await this.credentialsService.getOne( req.user, - credentialId, - { allowGlobalScope: true, globalScope: 'credential:read' }, - ['credentials'], + req.params.credentialId, + req.query.includeData === 'true', ); - if (!sharing) { - throw new NotFoundError(`Credential with ID "${credentialId}" could not be found.`); - } - - const { credentials: credential } = sharing; - - const { data: _, ...rest } = credential; - - if (!includeDecryptedData) { - return { ...rest }; - } - - const decryptedData = this.credentialsService.redact( - this.credentialsService.decrypt(credential), - credential, + const scopes = await this.credentialsService.getCredentialScopes( + req.user, + req.params.credentialId, ); - return { data: decryptedData, ...rest }; + return { ...credentials, scopes }; } + // TODO: Write at least test cases for the failure paths. @Post('/test') async testCredentials(req: CredentialRequest.Test) { - if (this.license.isSharingEnabled()) { - const { credentials } = req.body; - - const credentialId = credentials.id; - const { ownsCredential } = await this.enterpriseCredentialsService.isOwned( - req.user, - credentialId, - ); - - const sharing = await this.enterpriseCredentialsService.getSharing(req.user, credentialId, { - allowGlobalScope: true, - globalScope: 'credential:read', - }); - if (!ownsCredential) { - if (!sharing) { - throw new UnauthorizedError('Forbidden'); - } - - const decryptedData = this.credentialsService.decrypt(sharing.credentials); - Object.assign(credentials, { data: decryptedData }); - } - - const mergedCredentials = deepCopy(credentials); - if (mergedCredentials.data && sharing?.credentials) { - const decryptedData = this.credentialsService.decrypt(sharing.credentials); - mergedCredentials.data = this.credentialsService.unredact( - mergedCredentials.data, - decryptedData, - ); - } - - return await this.credentialsService.test(req.user, mergedCredentials); - } - - // non-enterprise - const { credentials } = req.body; - const sharing = await this.credentialsService.getSharing(req.user, credentials.id, { - allowGlobalScope: true, - globalScope: 'credential:read', - }); + const storedCredential = await this.sharedCredentialsRepository.findCredentialForUser( + credentials.id, + req.user, + ['credential:read'], + ); + + if (!storedCredential) { + throw new ForbiddenError(); + } const mergedCredentials = deepCopy(credentials); - if (mergedCredentials.data && sharing?.credentials) { - const decryptedData = this.credentialsService.decrypt(sharing.credentials); + const decryptedData = this.credentialsService.decrypt(storedCredential); + + // When a sharee opens a credential, the fields and the credential data are missing + // so the payload will be empty + // We need to replace the credential contents with the db version if that's the case + // So the credential can be tested properly + this.credentialsService.replaceCredentialContentsForSharee( + req.user, + storedCredential, + decryptedData, + mergedCredentials, + ); + + if (mergedCredentials.data && storedCredential) { mergedCredentials.data = this.credentialsService.unredact( mergedCredentials.data, decryptedData, @@ -184,7 +141,12 @@ export class CredentialsController { const newCredential = await this.credentialsService.prepareCreateData(req.body); const encryptedData = this.credentialsService.createEncryptedData(null, newCredential); - const credential = await this.credentialsService.save(newCredential, encryptedData, req.user); + const credential = await this.credentialsService.save( + newCredential, + encryptedData, + req.user, + req.body.projectId, + ); void this.internalHooks.onUserCreatedCredentials({ user: req.user, @@ -194,24 +156,23 @@ export class CredentialsController { public_api: false, }); - return credential; + const scopes = await this.credentialsService.getCredentialScopes(req.user, credential.id); + + return { ...credential, scopes }; } - @Patch('/:id') + @Patch('/:credentialId') + @ProjectScope('credential:update') async updateCredentials(req: CredentialRequest.Update) { - const { id: credentialId } = req.params; + const { credentialId } = req.params; - const sharing = await this.credentialsService.getSharing( - req.user, + const credential = await this.sharedCredentialsRepository.findCredentialForUser( credentialId, - { - allowGlobalScope: true, - globalScope: 'credential:update', - }, - ['credentials'], + req.user, + ['credential:update'], ); - if (!sharing) { + if (!credential) { this.logger.info('Attempt to update credential blocked due to lack of permissions', { credentialId, userId: req.user.id, @@ -221,16 +182,6 @@ export class CredentialsController { ); } - if (sharing.role !== 'credential:owner' && !req.user.hasGlobalScope('credential:update')) { - this.logger.info('Attempt to update credential blocked due to lack of permissions', { - credentialId, - userId: req.user.id, - }); - throw new UnauthorizedError('You can only update credentials owned by you'); - } - - const { credentials: credential } = sharing; - const decryptedData = this.credentialsService.decrypt(credential); const preparedCredentialData = await this.credentialsService.prepareUpdateData( req.body, @@ -259,24 +210,23 @@ export class CredentialsController { credential_id: credential.id, }); - return { ...rest }; + const scopes = await this.credentialsService.getCredentialScopes(req.user, credential.id); + + return { ...rest, scopes }; } - @Delete('/:id') + @Delete('/:credentialId') + @ProjectScope('credential:delete') async deleteCredentials(req: CredentialRequest.Delete) { - const { id: credentialId } = req.params; + const { credentialId } = req.params; - const sharing = await this.credentialsService.getSharing( - req.user, + const credential = await this.sharedCredentialsRepository.findCredentialForUser( credentialId, - { - allowGlobalScope: true, - globalScope: 'credential:delete', - }, - ['credentials'], + req.user, + ['credential:delete'], ); - if (!sharing) { + if (!credential) { this.logger.info('Attempt to delete credential blocked due to lack of permissions', { credentialId, userId: req.user.id, @@ -286,16 +236,6 @@ export class CredentialsController { ); } - if (sharing.role !== 'credential:owner' && !req.user.hasGlobalScope('credential:delete')) { - this.logger.info('Attempt to delete credential blocked due to lack of permissions', { - credentialId, - userId: req.user.id, - }); - throw new UnauthorizedError('You can only remove credentials owned by you'); - } - - const { credentials: credential } = sharing; - await this.credentialsService.delete(credential); void this.internalHooks.onUserDeletedCredentials({ @@ -309,9 +249,10 @@ export class CredentialsController { } @Licensed('feat:sharing') - @Put('/:id/share') + @Put('/:credentialId/share') + @ProjectScope('credential:share') async shareCredentials(req: CredentialRequest.Share) { - const { id: credentialId } = req.params; + const { credentialId } = req.params; const { shareWithIds } = req.body; if ( @@ -321,59 +262,45 @@ export class CredentialsController { throw new BadRequestError('Bad request'); } - const isOwnedRes = await this.enterpriseCredentialsService.isOwned(req.user, credentialId); - const { ownsCredential } = isOwnedRes; - let { credential } = isOwnedRes; - if (!ownsCredential || !credential) { - credential = undefined; - // Allow owners/admins to share - if (req.user.hasGlobalScope('credential:share')) { - const sharedRes = await this.enterpriseCredentialsService.getSharing( - req.user, - credentialId, - { - allowGlobalScope: true, - globalScope: 'credential:share', - }, - ); - credential = sharedRes?.credentials; - } - if (!credential) { - throw new UnauthorizedError('Forbidden'); - } - } + const credential = await this.sharedCredentialsRepository.findCredentialForUser( + credentialId, + req.user, + ['credential:share'], + ); - const ownerIds = ( - await this.enterpriseCredentialsService.getSharings( - Db.getConnection().createEntityManager(), - credentialId, - ['shared'], - ) - ) - .filter((e) => e.role === 'credential:owner') - .map((e) => e.userId); + if (!credential) { + throw new ForbiddenError(); + } let amountRemoved: number | null = null; let newShareeIds: string[] = []; + await Db.transaction(async (trx) => { - // remove all sharings that are not supposed to exist anymore - const { affected } = await this.credentialsRepository.pruneSharings(trx, credentialId, [ - ...ownerIds, - ...shareWithIds, - ]); - if (affected) amountRemoved = affected; + const currentPersonalProjectIDs = credential.shared + .filter((sc) => sc.role === 'credential:user') + .map((sc) => sc.projectId); + const newPersonalProjectIds = shareWithIds; - const sharings = await this.enterpriseCredentialsService.getSharings(trx, credentialId); - - // extract the new sharings that need to be added - newShareeIds = utils.rightDiff( - [sharings, (sharing) => sharing.userId], - [shareWithIds, (shareeId) => shareeId], + const toShare = utils.rightDiff( + [currentPersonalProjectIDs, (id) => id], + [newPersonalProjectIds, (id) => id], + ); + const toUnshare = utils.rightDiff( + [newPersonalProjectIds, (id) => id], + [currentPersonalProjectIDs, (id) => id], ); - if (newShareeIds.length) { - await this.enterpriseCredentialsService.share(trx, credential, newShareeIds); + const deleteResult = await trx.delete(SharedCredentials, { + credentialsId: credentialId, + projectId: In(toUnshare), + }); + await this.enterpriseCredentialsService.shareWithProjects(credential, toShare, trx); + + if (deleteResult.affected) { + amountRemoved = deleteResult.affected; } + + newShareeIds = toShare; }); void this.internalHooks.onUserSharedCredentials({ @@ -386,9 +313,14 @@ export class CredentialsController { sharees_removed: amountRemoved, }); + const projectsRelations = await this.projectRelationRepository.findBy({ + projectId: In(newShareeIds), + role: 'project:personalOwner', + }); + await this.userManagementMailer.notifyCredentialsShared({ sharer: req.user, - newShareeIds, + newShareeIds: projectsRelations.map((pr) => pr.userId), credentialsName: credential.name, }); } diff --git a/packages/cli/src/credentials/credentials.service.ee.ts b/packages/cli/src/credentials/credentials.service.ee.ts index 0958a02db..c90a2d0d5 100644 --- a/packages/cli/src/credentials/credentials.service.ee.ts +++ b/packages/cli/src/credentials/credentials.service.ee.ts @@ -1,77 +1,94 @@ -import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm'; -import type { SharedCredentials } from '@db/entities/SharedCredentials'; +import { In, type EntityManager } from '@n8n/typeorm'; import type { User } from '@db/entities/User'; -import { type CredentialsGetSharedOptions } from './credentials.service'; +import { CredentialsService } from './credentials.service'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; -import { UserRepository } from '@/databases/repositories/user.repository'; -import { CredentialsEntity } from '@/databases/entities/CredentialsEntity'; +import type { CredentialsEntity } from '@/databases/entities/CredentialsEntity'; import { Service } from 'typedi'; +import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { OwnershipService } from '@/services/ownership.service'; +import { Project } from '@/databases/entities/Project'; @Service() export class EnterpriseCredentialsService { constructor( - private readonly userRepository: UserRepository, private readonly sharedCredentialsRepository: SharedCredentialsRepository, + private readonly ownershipService: OwnershipService, + private readonly credentialsService: CredentialsService, ) {} - async isOwned(user: User, credentialId: string) { - const sharing = await this.getSharing(user, credentialId, { allowGlobalScope: false }, [ - 'credentials', - ]); - - if (!sharing || sharing.role !== 'credential:owner') return { ownsCredential: false }; - - const { credentials: credential } = sharing; - - return { ownsCredential: true, credential }; - } - - /** - * Retrieve the sharing that matches a user and a credential. - */ - async getSharing( - user: User, - credentialId: string, - options: CredentialsGetSharedOptions, - relations: string[] = ['credentials'], + async shareWithProjects( + credential: CredentialsEntity, + shareWithIds: string[], + entityManager?: EntityManager, ) { - const where: FindOptionsWhere = { credentialsId: credentialId }; + const em = entityManager ?? this.sharedCredentialsRepository.manager; - // Omit user from where if the requesting user has relevant - // global credential permissions. This allows the user to - // access credentials they don't own. - if (!options.allowGlobalScope || !user.hasGlobalScope(options.globalScope)) { - where.userId = user.id; - } - - return await this.sharedCredentialsRepository.findOne({ - where, - relations, - }); - } - - async getSharings(transaction: EntityManager, credentialId: string, relations = ['shared']) { - const credential = await transaction.findOne(CredentialsEntity, { - where: { id: credentialId }, - relations, + const projects = await em.find(Project, { + where: { id: In(shareWithIds), type: 'personal' }, }); - return credential?.shared ?? []; - } - - async share(transaction: EntityManager, credential: CredentialsEntity, shareWithIds: string[]) { - const users = await this.userRepository.getByIds(transaction, shareWithIds); - - const newSharedCredentials = users - .filter((user) => !user.isPending) - .map((user) => + const newSharedCredentials = projects + // We filter by role === 'project:personalOwner' above and there should + // always only be one owner. + .map((project) => this.sharedCredentialsRepository.create({ credentialsId: credential.id, - userId: user.id, role: 'credential:user', + projectId: project.id, }), ); - return await transaction.save(newSharedCredentials); + return await em.save(newSharedCredentials); + } + + async getOne(user: User, credentialId: string, includeDecryptedData: boolean) { + let credential: CredentialsEntity | null = null; + let decryptedData: ICredentialDataDecryptedObject | null = null; + + credential = includeDecryptedData + ? // Try to get the credential with `credential:update` scope, which + // are required for decrypting the data. + await this.sharedCredentialsRepository.findCredentialForUser( + credentialId, + user, + // TODO: replace credential:update with credential:decrypt once it lands + // see: https://n8nio.slack.com/archives/C062YRE7EG4/p1708531433206069?thread_ts=1708525972.054149&cid=C062YRE7EG4 + ['credential:read', 'credential:update'], + ) + : null; + + if (credential) { + // Decrypt the data if we found the credential with the `credential:update` + // scope. + decryptedData = this.credentialsService.redact( + this.credentialsService.decrypt(credential), + credential, + ); + } else { + // Otherwise try to find them with only the `credential:read` scope. In + // that case we return them without the decrypted data. + credential = await this.sharedCredentialsRepository.findCredentialForUser( + credentialId, + user, + ['credential:read'], + ); + } + + if (!credential) { + throw new NotFoundError( + 'Could not load the credential. If you think this is an error, ask the owner to share it with you again', + ); + } + + credential = this.ownershipService.addOwnedByAndSharedWith(credential); + + const { data: _, ...rest } = credential; + + if (decryptedData) { + return { data: decryptedData, ...rest }; + } + + return { ...rest }; } } diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index ac0598aa3..d23dbf0cc 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -5,8 +5,13 @@ import type { ICredentialType, INodeProperties, } from 'n8n-workflow'; -import { CREDENTIAL_EMPTY_VALUE, deepCopy, NodeHelpers } from 'n8n-workflow'; -import type { FindOptionsWhere } from '@n8n/typeorm'; +import { ApplicationError, CREDENTIAL_EMPTY_VALUE, deepCopy, NodeHelpers } from 'n8n-workflow'; +import { + In, + type EntityManager, + type FindOptionsRelations, + type FindOptionsWhere, +} from '@n8n/typeorm'; import type { Scope } from '@n8n/permissions'; import * as Db from '@/Db'; import type { ICredentialsDb } from '@/Interfaces'; @@ -25,6 +30,12 @@ import { CredentialsRepository } from '@db/repositories/credentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { Service } from 'typedi'; import { CredentialsTester } from '@/services/credentials-tester.service'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { ProjectService } from '@/services/project.service'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import type { ProjectRelation } from '@/databases/entities/ProjectRelation'; +import { RoleService } from '@/services/role.service'; export type CredentialsGetSharedOptions = | { allowGlobalScope: true; globalScope: Scope } @@ -40,62 +51,129 @@ export class CredentialsService { private readonly credentialsTester: CredentialsTester, private readonly externalHooks: ExternalHooks, private readonly credentialTypes: CredentialTypes, + private readonly projectRepository: ProjectRepository, + private readonly projectService: ProjectService, + private readonly roleService: RoleService, ) {} - async get(where: FindOptionsWhere, options?: { relations: string[] }) { - return await this.credentialsRepository.findOne({ - relations: options?.relations, - where, - }); - } - async getMany( user: User, - options: { listQueryOptions?: ListQuery.Options; onlyOwn?: boolean } = {}, + options: { + listQueryOptions?: ListQuery.Options; + onlyOwn?: boolean; + includeScopes?: string; + } = {}, ) { const returnAll = user.hasGlobalScope('credential:list') && !options.onlyOwn; const isDefaultSelect = !options.listQueryOptions?.select; - if (returnAll) { - const credentials = await this.credentialsRepository.findMany(options.listQueryOptions); - - return isDefaultSelect - ? credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c)) - : credentials; + let projectRelations: ProjectRelation[] | undefined = undefined; + if (options.includeScopes) { + projectRelations = await this.projectService.getProjectRelationsForUser(user); + if (options.listQueryOptions?.filter?.projectId && user.hasGlobalScope('credential:list')) { + // Only instance owners and admins have the credential:list scope + // Those users should be able to use _all_ credentials within their workflows. + // TODO: Change this so we filter by `workflowId` in this case. Require a slight FE change + const projectRelation = projectRelations.find( + (relation) => relation.projectId === options.listQueryOptions?.filter?.projectId, + ); + if (projectRelation?.role === 'project:personalOwner') { + // Will not affect team projects as these have admins, not owners. + delete options.listQueryOptions?.filter?.projectId; + } + } } - const ids = await this.sharedCredentialsRepository.getAccessibleCredentialIds([user.id]); + if (returnAll) { + let credentials = await this.credentialsRepository.findMany(options.listQueryOptions); - const credentials = await this.credentialsRepository.findMany( + if (isDefaultSelect) { + credentials = credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c)); + } + + if (options.includeScopes) { + credentials = credentials.map((c) => + this.roleService.addScopes(c, user, projectRelations!), + ); + } + + credentials.forEach((c) => { + // @ts-expect-error: This is to emulate the old behaviour of removing the shared + // field as part of `addOwnedByAndSharedWith`. We need this field in `addScopes` + // though. So to avoid leaking the information we just delete it. + delete c.shared; + }); + + return credentials; + } + + // If the workflow is part of a personal project we want to show the credentials the user making the request has access to, not the credentials the user owning the workflow has access to. + if (typeof options.listQueryOptions?.filter?.projectId === 'string') { + const project = await this.projectService.getProject( + options.listQueryOptions.filter.projectId, + ); + if (project?.type === 'personal') { + const currentUsersPersonalProject = await this.projectService.getPersonalProject(user); + options.listQueryOptions.filter.projectId = currentUsersPersonalProject?.id; + } + } + + const ids = await this.sharedCredentialsRepository.getCredentialIdsByUserAndRole([user.id], { + scopes: ['credential:read'], + }); + + let credentials = await this.credentialsRepository.findMany( options.listQueryOptions, ids, // only accessible credentials ); - return isDefaultSelect - ? credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c)) - : credentials; + if (isDefaultSelect) { + credentials = credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c)); + } + + if (options.includeScopes) { + credentials = credentials.map((c) => this.roleService.addScopes(c, user, projectRelations!)); + } + + credentials.forEach((c) => { + // @ts-expect-error: This is to emulate the old behaviour of removing the shared + // field as part of `addOwnedByAndSharedWith`. We need this field in `addScopes` + // though. So to avoid leaking the information we just delete it. + delete c.shared; + }); + + return credentials; } /** * Retrieve the sharing that matches a user and a credential. */ + // TODO: move to SharedCredentialsService async getSharing( user: User, credentialId: string, - options: CredentialsGetSharedOptions, - relations: string[] = ['credentials'], + globalScopes: Scope[], + relations: FindOptionsRelations = { credentials: true }, ): Promise { - const where: FindOptionsWhere = { credentialsId: credentialId }; + let where: FindOptionsWhere = { credentialsId: credentialId }; - // Omit user from where if the requesting user has relevant - // global credential permissions. This allows the user to - // access credentials they don't own. - if (!options.allowGlobalScope || !user.hasGlobalScope(options.globalScope)) { - where.userId = user.id; - where.role = 'credential:owner'; + if (!user.hasGlobalScope(globalScopes, { mode: 'allOf' })) { + where = { + ...where, + role: 'credential:owner', + project: { + projectRelations: { + role: 'project:personalOwner', + userId: user.id, + }, + }, + }; } - return await this.sharedCredentialsRepository.findOne({ where, relations }); + return await this.sharedCredentialsRepository.findOne({ + where, + relations, + }); } async prepareCreateData( @@ -128,7 +206,7 @@ export class CredentialsService { await validateEntity(updateData); // Do not overwrite the oauth data else data like the access or refresh token would get lost - // everytime anybody changes anything on the credentials even if it is just the name. + // every time anybody changes anything on the credentials even if it is just the name. if (decryptedData.oauthTokenData) { // @ts-ignore updateData.data.oauthTokenData = decryptedData.oauthTokenData; @@ -165,7 +243,12 @@ export class CredentialsService { return await this.credentialsRepository.findOneBy({ id: credentialId }); } - async save(credential: CredentialsEntity, encryptedData: ICredentialsDb, user: User) { + async save( + credential: CredentialsEntity, + encryptedData: ICredentialsDb, + user: User, + projectId?: string, + ) { // To avoid side effects const newCredential = new CredentialsEntity(); Object.assign(newCredential, credential, encryptedData); @@ -177,12 +260,31 @@ export class CredentialsService { savedCredential.data = newCredential.data; - const newSharedCredential = new SharedCredentials(); + const project = + projectId === undefined + ? await this.projectRepository.getPersonalProjectForUserOrFail(user.id) + : await this.projectService.getProjectWithScope( + user, + projectId, + ['credential:create'], + transactionManager, + ); - Object.assign(newSharedCredential, { + if (typeof projectId === 'string' && project === null) { + throw new BadRequestError( + "You don't have the permissions to save the workflow in this project.", + ); + } + + // Safe guard in case the personal project does not exist for whatever reason. + if (project === null) { + throw new ApplicationError('No personal project found'); + } + + const newSharedCredential = this.sharedCredentialsRepository.create({ role: 'credential:owner', - user, credentials: savedCredential, + projectId: project.id, }); await transactionManager.save(newSharedCredential); @@ -295,4 +397,134 @@ export class CredentialsService { this.unredactRestoreValues(mergedData, savedData); return mergedData; } + + async getOne(user: User, credentialId: string, includeDecryptedData: boolean) { + let sharing: SharedCredentials | null = null; + let decryptedData: ICredentialDataDecryptedObject | null = null; + + sharing = includeDecryptedData + ? // Try to get the credential with `credential:update` scope, which + // are required for decrypting the data. + await this.getSharing(user, credentialId, [ + 'credential:read', + // TODO: Enable this once the scope exists and has been added to the + // global:owner role. + // 'credential:decrypt', + ]) + : null; + + if (sharing) { + // Decrypt the data if we found the credential with the `credential:update` + // scope. + decryptedData = this.redact(this.decrypt(sharing.credentials), sharing.credentials); + } else { + // Otherwise try to find them with only the `credential:read` scope. In + // that case we return them without the decrypted data. + sharing = await this.getSharing(user, credentialId, ['credential:read']); + } + + if (!sharing) { + throw new NotFoundError(`Credential with ID "${credentialId}" could not be found.`); + } + + const { credentials: credential } = sharing; + + const { data: _, ...rest } = credential; + + if (decryptedData) { + return { data: decryptedData, ...rest }; + } + return { ...rest }; + } + + async getCredentialScopes(user: User, credentialId: string): Promise { + const userProjectRelations = await this.projectService.getProjectRelationsForUser(user); + const shared = await this.sharedCredentialsRepository.find({ + where: { + projectId: In([...new Set(userProjectRelations.map((pr) => pr.projectId))]), + credentialsId: credentialId, + }, + }); + return this.roleService.combineResourceScopes('credential', user, shared, userProjectRelations); + } + + /** + * Transfers all credentials owned by a project to another one. + * This has only been tested for personal projects. It may need to be amended + * for team projects. + **/ + async transferAll(fromProjectId: string, toProjectId: string, trx?: EntityManager) { + trx = trx ?? this.credentialsRepository.manager; + + // Get all shared credentials for both projects. + const allSharedCredentials = await trx.findBy(SharedCredentials, { + projectId: In([fromProjectId, toProjectId]), + }); + + const sharedCredentialsOfFromProject = allSharedCredentials.filter( + (sc) => sc.projectId === fromProjectId, + ); + + // For all credentials that the from-project owns transfer the ownership + // to the to-project. + // This will override whatever relationship the to-project already has to + // the resources at the moment. + const ownedCredentialIds = sharedCredentialsOfFromProject + .filter((sc) => sc.role === 'credential:owner') + .map((sc) => sc.credentialsId); + + await this.sharedCredentialsRepository.makeOwner(ownedCredentialIds, toProjectId, trx); + + // Delete the relationship to the from-project. + await this.sharedCredentialsRepository.deleteByIds(ownedCredentialIds, fromProjectId, trx); + + // Transfer relationships that are not `credential:owner`. + // This will NOT override whatever relationship the to-project already has + // to the resource at the moment. + const sharedCredentialIdsOfTransferee = allSharedCredentials + .filter((sc) => sc.projectId === toProjectId) + .map((sc) => sc.credentialsId); + + // All resources that are shared with the from-project, but not with the + // to-project. + const sharedCredentialsToTransfer = sharedCredentialsOfFromProject.filter( + (sc) => + sc.role !== 'credential:owner' && + !sharedCredentialIdsOfTransferee.includes(sc.credentialsId), + ); + + await trx.insert( + SharedCredentials, + sharedCredentialsToTransfer.map((sc) => ({ + credentialsId: sc.credentialsId, + projectId: toProjectId, + role: sc.role, + })), + ); + } + + replaceCredentialContentsForSharee( + user: User, + credential: CredentialsEntity, + decryptedData: ICredentialDataDecryptedObject, + mergedCredentials: ICredentialsDecrypted, + ) { + credential.shared.forEach((sharedCredentials) => { + if (sharedCredentials.role === 'credential:owner') { + if (sharedCredentials.project.type === 'personal') { + // Find the owner of this personal project + sharedCredentials.project.projectRelations.forEach((projectRelation) => { + if ( + projectRelation.role === 'project:personalOwner' && + projectRelation.user.id !== user.id + ) { + // If we realize that the current user does not own this credential + // We replace the payload with the stored decrypted data + mergedCredentials.data = decryptedData; + } + }); + } + } + }); + } } diff --git a/packages/cli/src/databases/config.ts b/packages/cli/src/databases/config.ts index 7db8e1b95..4e81a7a9e 100644 --- a/packages/cli/src/databases/config.ts +++ b/packages/cli/src/databases/config.ts @@ -11,6 +11,7 @@ import { ApplicationError } from 'n8n-workflow'; import config from '@/config'; import { entities } from './entities'; +import { subscribers } from './subscribers'; import { mysqlMigrations } from './migrations/mysqldb'; import { postgresMigrations } from './migrations/postgresdb'; import { sqliteMigrations } from './migrations/sqlite'; @@ -32,6 +33,7 @@ const getCommonOptions = () => { return { entityPrefix, entities: Object.values(entities), + subscribers: Object.values(subscribers), migrationsTableName: `${entityPrefix}migrations`, migrationsRun: false, synchronize: false, diff --git a/packages/cli/src/databases/dsl/Column.ts b/packages/cli/src/databases/dsl/Column.ts index 359ffd65a..1c01562f4 100644 --- a/packages/cli/src/databases/dsl/Column.ts +++ b/packages/cli/src/databases/dsl/Column.ts @@ -94,9 +94,11 @@ export class Column { options.type = isPostgres ? 'timestamptz' : 'datetime'; } else if (type === 'json' && isSqlite) { options.type = 'text'; - } else if (type === 'uuid' && isMysql) { + } else if (type === 'uuid') { // mysql does not support uuid type - options.type = 'varchar(36)'; + if (isMysql) options.type = 'varchar(36)'; + // we haven't been defining length on "uuid" varchar on sqlite + if (isSqlite) options.type = 'varchar'; } if ((type === 'varchar' || type === 'timestamp') && length !== 'auto') { diff --git a/packages/cli/src/databases/dsl/Table.ts b/packages/cli/src/databases/dsl/Table.ts index 08cea8d29..b2d3fcea3 100644 --- a/packages/cli/src/databases/dsl/Table.ts +++ b/packages/cli/src/databases/dsl/Table.ts @@ -46,7 +46,13 @@ export class CreateTable extends TableOperation { withForeignKey( columnName: string, - ref: { tableName: string; columnName: string; onDelete?: 'CASCADE'; onUpdate?: 'CASCADE' }, + ref: { + tableName: string; + columnName: string; + onDelete?: 'CASCADE'; + onUpdate?: 'CASCADE'; + name?: string; + }, ) { const foreignKey: TableForeignKeyOptions = { columnNames: [columnName], @@ -55,6 +61,7 @@ export class CreateTable extends TableOperation { }; if (ref.onDelete) foreignKey.onDelete = ref.onDelete; if (ref.onUpdate) foreignKey.onUpdate = ref.onUpdate; + if (ref.name) foreignKey.name = ref.name; this.foreignKeys.add(foreignKey); return this; } diff --git a/packages/cli/src/databases/entities/Project.ts b/packages/cli/src/databases/entities/Project.ts new file mode 100644 index 000000000..5156ed35d --- /dev/null +++ b/packages/cli/src/databases/entities/Project.ts @@ -0,0 +1,25 @@ +import { Column, Entity, OneToMany } from '@n8n/typeorm'; +import { WithTimestampsAndStringId } from './AbstractEntity'; +import type { ProjectRelation } from './ProjectRelation'; +import type { SharedCredentials } from './SharedCredentials'; +import type { SharedWorkflow } from './SharedWorkflow'; + +export type ProjectType = 'personal' | 'team'; + +@Entity() +export class Project extends WithTimestampsAndStringId { + @Column({ length: 255, nullable: true }) + name: string; + + @Column({ length: 36 }) + type: ProjectType; + + @OneToMany('ProjectRelation', 'project') + projectRelations: ProjectRelation[]; + + @OneToMany('SharedCredentials', 'project') + sharedCredentials: SharedCredentials[]; + + @OneToMany('SharedWorkflow', 'project') + sharedWorkflows: SharedWorkflow[]; +} diff --git a/packages/cli/src/databases/entities/ProjectRelation.ts b/packages/cli/src/databases/entities/ProjectRelation.ts new file mode 100644 index 000000000..e66a77112 --- /dev/null +++ b/packages/cli/src/databases/entities/ProjectRelation.ts @@ -0,0 +1,25 @@ +import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; +import { User } from './User'; +import { WithTimestamps } from './AbstractEntity'; +import { Project } from './Project'; + +// personalOwner is only used for personal projects +export type ProjectRole = 'project:personalOwner' | 'project:admin' | 'project:editor'; + +@Entity() +export class ProjectRelation extends WithTimestamps { + @Column() + role: ProjectRole; + + @ManyToOne('User', 'projectRelations') + user: User; + + @PrimaryColumn('uuid') + userId: string; + + @ManyToOne('Project', 'projectRelations') + project: Project; + + @PrimaryColumn() + projectId: string; +} diff --git a/packages/cli/src/databases/entities/SharedCredentials.ts b/packages/cli/src/databases/entities/SharedCredentials.ts index e43f3031d..35335ddf0 100644 --- a/packages/cli/src/databases/entities/SharedCredentials.ts +++ b/packages/cli/src/databases/entities/SharedCredentials.ts @@ -1,7 +1,7 @@ import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; import { CredentialsEntity } from './CredentialsEntity'; -import { User } from './User'; import { WithTimestamps } from './AbstractEntity'; +import { Project } from './Project'; export type CredentialSharingRole = 'credential:owner' | 'credential:user'; @@ -10,15 +10,15 @@ export class SharedCredentials extends WithTimestamps { @Column() role: CredentialSharingRole; - @ManyToOne('User', 'sharedCredentials') - user: User; - - @PrimaryColumn() - userId: string; - @ManyToOne('CredentialsEntity', 'shared') credentials: CredentialsEntity; @PrimaryColumn() credentialsId: string; + + @ManyToOne('Project', 'sharedCredentials') + project: Project; + + @PrimaryColumn() + projectId: string; } diff --git a/packages/cli/src/databases/entities/SharedWorkflow.ts b/packages/cli/src/databases/entities/SharedWorkflow.ts index d5681f646..a61fb0025 100644 --- a/packages/cli/src/databases/entities/SharedWorkflow.ts +++ b/packages/cli/src/databases/entities/SharedWorkflow.ts @@ -1,24 +1,24 @@ import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; import { WorkflowEntity } from './WorkflowEntity'; -import { User } from './User'; import { WithTimestamps } from './AbstractEntity'; +import { Project } from './Project'; -export type WorkflowSharingRole = 'workflow:owner' | 'workflow:editor' | 'workflow:user'; +export type WorkflowSharingRole = 'workflow:owner' | 'workflow:editor'; @Entity() export class SharedWorkflow extends WithTimestamps { @Column() role: WorkflowSharingRole; - @ManyToOne('User', 'sharedWorkflows') - user: User; - - @PrimaryColumn() - userId: string; - @ManyToOne('WorkflowEntity', 'shared') workflow: WorkflowEntity; @PrimaryColumn() workflowId: string; + + @ManyToOne('Project', 'sharedWorkflows') + project: Project; + + @PrimaryColumn() + projectId: string; } diff --git a/packages/cli/src/databases/entities/User.ts b/packages/cli/src/databases/entities/User.ts index 9538a3e60..9aeb62d92 100644 --- a/packages/cli/src/databases/entities/User.ts +++ b/packages/cli/src/databases/entities/User.ts @@ -18,16 +18,21 @@ import { objectRetriever, lowerCaser } from '../utils/transformers'; import { WithTimestamps, jsonColumnType } from './AbstractEntity'; import type { IPersonalizationSurveyAnswers } from '@/Interfaces'; import type { AuthIdentity } from './AuthIdentity'; -import { ownerPermissions, memberPermissions, adminPermissions } from '@/permissions/roles'; +import { + GLOBAL_OWNER_SCOPES, + GLOBAL_MEMBER_SCOPES, + GLOBAL_ADMIN_SCOPES, +} from '@/permissions/global-roles'; import { hasScope, type ScopeOptions, type Scope } from '@n8n/permissions'; +import type { ProjectRelation } from './ProjectRelation'; export type GlobalRole = 'global:owner' | 'global:admin' | 'global:member'; export type AssignableRole = Exclude; const STATIC_SCOPE_MAP: Record = { - 'global:owner': ownerPermissions, - 'global:member': memberPermissions, - 'global:admin': adminPermissions, + 'global:owner': GLOBAL_OWNER_SCOPES, + 'global:member': GLOBAL_MEMBER_SCOPES, + 'global:admin': GLOBAL_ADMIN_SCOPES, }; @Entity() @@ -85,6 +90,9 @@ export class User extends WithTimestamps implements IUser { @OneToMany('SharedCredentials', 'user') sharedCredentials: SharedCredentials[]; + @OneToMany('ProjectRelation', 'user') + projectRelations: ProjectRelation[]; + @Column({ type: Boolean, default: false }) disabled: boolean; @@ -138,6 +146,7 @@ export class User extends WithTimestamps implements IUser { { global: this.globalScopes, }, + undefined, scopeOptions, ); } @@ -146,4 +155,14 @@ export class User extends WithTimestamps implements IUser { const { password, apiKey, mfaSecret, mfaRecoveryCodes, ...rest } = this; return rest; } + + createPersonalProjectName() { + if (this.firstName && this.lastName && this.email) { + return `${this.firstName} ${this.lastName} <${this.email}>`; + } else if (this.email) { + return `<${this.email}>`; + } else { + return 'Unnamed Project'; + } + } } diff --git a/packages/cli/src/databases/entities/index.ts b/packages/cli/src/databases/entities/index.ts index db1f5a5ce..71be3c07b 100644 --- a/packages/cli/src/databases/entities/index.ts +++ b/packages/cli/src/databases/entities/index.ts @@ -19,6 +19,8 @@ import { WorkflowStatistics } from './WorkflowStatistics'; import { ExecutionMetadata } from './ExecutionMetadata'; import { ExecutionData } from './ExecutionData'; import { WorkflowHistory } from './WorkflowHistory'; +import { Project } from './Project'; +import { ProjectRelation } from './ProjectRelation'; export const entities = { AuthIdentity, @@ -41,4 +43,6 @@ export const entities = { ExecutionMetadata, ExecutionData, WorkflowHistory, + Project, + ProjectRelation, }; diff --git a/packages/cli/src/databases/migrations/common/1711390882123-MoveSshKeysToDatabase.ts b/packages/cli/src/databases/migrations/common/1711390882123-MoveSshKeysToDatabase.ts index 30d7d87c4..be3311890 100644 --- a/packages/cli/src/databases/migrations/common/1711390882123-MoveSshKeysToDatabase.ts +++ b/packages/cli/src/databases/migrations/common/1711390882123-MoveSshKeysToDatabase.ts @@ -35,6 +35,11 @@ export class MoveSshKeysToDatabase1711390882123 implements ReversibleMigration { return; } + if (!privateKey && !publicKey) { + logger.info(`[${migrationName}] No SSH keys in filesystem, skipping`); + return; + } + const settings = escape.tableName('settings'); const key = escape.columnName('key'); const value = escape.columnName('value'); diff --git a/packages/cli/src/databases/migrations/common/1714133768519-CreateProject.ts b/packages/cli/src/databases/migrations/common/1714133768519-CreateProject.ts new file mode 100644 index 000000000..b28d7a710 --- /dev/null +++ b/packages/cli/src/databases/migrations/common/1714133768519-CreateProject.ts @@ -0,0 +1,328 @@ +import type { MigrationContext, ReversibleMigration } from '@db/types'; +import type { ProjectRole } from '@/databases/entities/ProjectRelation'; +import type { User } from '@/databases/entities/User'; +import { generateNanoId } from '@/databases/utils/generators'; +import { ApplicationError } from 'n8n-workflow'; +import { nanoid } from 'nanoid'; + +const projectAdminRole: ProjectRole = 'project:personalOwner'; + +type RelationTable = 'shared_workflow' | 'shared_credentials'; + +const table = { + sharedCredentials: 'shared_credentials', + sharedCredentialsTemp: 'shared_credentials_2', + sharedWorkflow: 'shared_workflow', + sharedWorkflowTemp: 'shared_workflow_2', + project: 'project', + user: 'user', + projectRelation: 'project_relation', +} as const; + +function escapeNames(escape: MigrationContext['escape']) { + const t = { + project: escape.tableName(table.project), + projectRelation: escape.tableName(table.projectRelation), + sharedCredentials: escape.tableName(table.sharedCredentials), + sharedCredentialsTemp: escape.tableName(table.sharedCredentialsTemp), + sharedWorkflow: escape.tableName(table.sharedWorkflow), + sharedWorkflowTemp: escape.tableName(table.sharedWorkflowTemp), + user: escape.tableName(table.user), + }; + const c = { + createdAt: escape.columnName('createdAt'), + updatedAt: escape.columnName('updatedAt'), + workflowId: escape.columnName('workflowId'), + credentialsId: escape.columnName('credentialsId'), + userId: escape.columnName('userId'), + projectId: escape.columnName('projectId'), + firstName: escape.columnName('firstName'), + lastName: escape.columnName('lastName'), + }; + + return { t, c }; +} + +export class CreateProject1714133768519 implements ReversibleMigration { + async setupTables({ schemaBuilder: { createTable, column } }: MigrationContext) { + await createTable(table.project).withColumns( + column('id').varchar(36).primary.notNull, + column('name').varchar(255).notNull, + column('type').varchar(36).notNull, + ).withTimestamps; + + await createTable(table.projectRelation) + .withColumns( + column('projectId').varchar(36).primary.notNull, + column('userId').uuid.primary.notNull, + column('role').varchar().notNull, + ) + .withIndexOn('projectId') + .withIndexOn('userId') + .withForeignKey('projectId', { + tableName: table.project, + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('userId', { + tableName: 'user', + columnName: 'id', + onDelete: 'CASCADE', + }).withTimestamps; + } + + async alterSharedTable( + relationTableName: RelationTable, + { + escape, + isMysql, + runQuery, + schemaBuilder: { addForeignKey, addColumns, addNotNull, createIndex, column }, + }: MigrationContext, + ) { + const projectIdColumn = column('projectId').varchar(36).default('NULL'); + await addColumns(relationTableName, [projectIdColumn]); + + const relationTable = escape.tableName(relationTableName); + const { t, c } = escapeNames(escape); + + // Populate projectId + const subQuery = ` + SELECT P.id as ${c.projectId}, T.${c.userId} + FROM ${t.projectRelation} T + LEFT JOIN ${t.project} P + ON T.${c.projectId} = P.id AND P.type = 'personal' + LEFT JOIN ${relationTable} S + ON T.${c.userId} = S.${c.userId} + WHERE P.id IS NOT NULL + `; + const swQuery = isMysql + ? `UPDATE ${relationTable}, (${subQuery}) as mapping + SET ${relationTable}.${c.projectId} = mapping.${c.projectId} + WHERE ${relationTable}.${c.userId} = mapping.${c.userId}` + : `UPDATE ${relationTable} + SET ${c.projectId} = mapping.${c.projectId} + FROM (${subQuery}) as mapping + WHERE ${relationTable}.${c.userId} = mapping.${c.userId}`; + + await runQuery(swQuery); + + await addForeignKey(relationTableName, 'projectId', ['project', 'id']); + + await addNotNull(relationTableName, 'projectId'); + + // Index the new projectId column + await createIndex(relationTableName, ['projectId']); + } + + async alterSharedCredentials({ + escape, + runQuery, + schemaBuilder: { column, createTable, dropTable }, + }: MigrationContext) { + await createTable(table.sharedCredentialsTemp) + .withColumns( + column('credentialsId').varchar(36).notNull.primary, + column('projectId').varchar(36).notNull.primary, + column('role').text.notNull, + ) + .withForeignKey('credentialsId', { + tableName: 'credentials_entity', + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('projectId', { + tableName: table.project, + columnName: 'id', + onDelete: 'CASCADE', + }).withTimestamps; + + const { c, t } = escapeNames(escape); + + await runQuery(` + INSERT INTO ${t.sharedCredentialsTemp} (${c.createdAt}, ${c.updatedAt}, ${c.credentialsId}, ${c.projectId}, role) + SELECT ${c.createdAt}, ${c.updatedAt}, ${c.credentialsId}, ${c.projectId}, role FROM ${t.sharedCredentials}; + `); + + await dropTable(table.sharedCredentials); + await runQuery(`ALTER TABLE ${t.sharedCredentialsTemp} RENAME TO ${t.sharedCredentials};`); + } + + async alterSharedWorkflow({ + escape, + runQuery, + schemaBuilder: { column, createTable, dropTable }, + }: MigrationContext) { + await createTable(table.sharedWorkflowTemp) + .withColumns( + column('workflowId').varchar(36).notNull.primary, + column('projectId').varchar(36).notNull.primary, + column('role').text.notNull, + ) + .withForeignKey('workflowId', { + tableName: 'workflow_entity', + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('projectId', { + tableName: table.project, + columnName: 'id', + onDelete: 'CASCADE', + }).withTimestamps; + + const { c, t } = escapeNames(escape); + + await runQuery(` + INSERT INTO ${t.sharedWorkflowTemp} (${c.createdAt}, ${c.updatedAt}, ${c.workflowId}, ${c.projectId}, role) + SELECT ${c.createdAt}, ${c.updatedAt}, ${c.workflowId}, ${c.projectId}, role FROM ${t.sharedWorkflow}; + `); + + await dropTable(table.sharedWorkflow); + await runQuery(`ALTER TABLE ${t.sharedWorkflowTemp} RENAME TO ${t.sharedWorkflow};`); + } + + async createUserPersonalProjects({ runQuery, runInBatches, escape }: MigrationContext) { + const { c, t } = escapeNames(escape); + const getUserQuery = `SELECT id, ${c.firstName}, ${c.lastName}, email FROM ${t.user}`; + await runInBatches>( + getUserQuery, + async (users) => { + await Promise.all( + users.map(async (user) => { + const projectId = generateNanoId(); + const name = this.createPersonalProjectName(user.firstName, user.lastName, user.email); + await runQuery( + `INSERT INTO ${t.project} (id, type, name) VALUES (:projectId, 'personal', :name)`, + { + projectId, + name, + }, + ); + + await runQuery( + `INSERT INTO ${t.projectRelation} (${c.projectId}, ${c.userId}, role) VALUES (:projectId, :userId, :projectRole)`, + { + projectId, + userId: user.id, + projectRole: projectAdminRole, + }, + ); + }), + ); + }, + ); + } + + // Duplicated from packages/cli/src/databases/entities/User.ts + // Reason: + // This migration should work the same even if we refactor the function in + // `User.ts`. + createPersonalProjectName(firstName?: string, lastName?: string, email?: string) { + if (firstName && lastName && email) { + return `${firstName} ${lastName} <${email}>`; + } else if (email) { + return `<${email}>`; + } else { + return 'Unnamed Project'; + } + } + + async up(context: MigrationContext) { + await this.setupTables(context); + await this.createUserPersonalProjects(context); + await this.alterSharedTable(table.sharedCredentials, context); + await this.alterSharedCredentials(context); + await this.alterSharedTable(table.sharedWorkflow, context); + await this.alterSharedWorkflow(context); + } + + async down({ isMysql, logger, escape, runQuery, schemaBuilder: sb }: MigrationContext) { + const { t, c } = escapeNames(escape); + + // 0. check if all projects are personal projects + const [{ count: nonPersonalProjects }] = await runQuery<[{ count: number }]>( + `SELECT COUNT(*) FROM ${t.project} WHERE type <> 'personal';`, + ); + + if (nonPersonalProjects > 0) { + const message = + 'Down migration only possible when there are no projects. Please delete all projects that were created via the UI first.'; + logger.error(message); + throw new ApplicationError(message); + } + + // 1. create temp table for shared workflows + await sb + .createTable(table.sharedWorkflowTemp) + .withColumns( + sb.column('workflowId').varchar(36).notNull.primary, + sb.column('userId').uuid.notNull.primary, + sb.column('role').text.notNull, + ) + .withForeignKey('workflowId', { + tableName: 'workflow_entity', + columnName: 'id', + onDelete: 'CASCADE', + // In MySQL foreignKey names must be unique across all tables and + // TypeORM creates predictable names based on the columnName. + // So the current shared_workflow table's foreignKey for workflowId would + // clash with this one if we don't create a random name. + name: isMysql ? nanoid() : undefined, + }) + .withForeignKey('userId', { + tableName: table.user, + columnName: 'id', + onDelete: 'CASCADE', + }).withTimestamps; + + // 2. migrate data into temp table + await runQuery(` + INSERT INTO ${t.sharedWorkflowTemp} (${c.createdAt}, ${c.updatedAt}, ${c.workflowId}, role, ${c.userId}) + SELECT SW.${c.createdAt}, SW.${c.updatedAt}, SW.${c.workflowId}, SW.role, PR.${c.userId} + FROM ${t.sharedWorkflow} SW + LEFT JOIN project_relation PR on SW.${c.projectId} = PR.${c.projectId} AND PR.role = 'project:personalOwner' + `); + + // 3. drop shared workflow table + await sb.dropTable(table.sharedWorkflow); + + // 4. rename temp table + await runQuery(`ALTER TABLE ${t.sharedWorkflowTemp} RENAME TO ${t.sharedWorkflow};`); + + // 5. same for shared creds + await sb + .createTable(table.sharedCredentialsTemp) + .withColumns( + sb.column('credentialsId').varchar(36).notNull.primary, + sb.column('userId').uuid.notNull.primary, + sb.column('role').text.notNull, + ) + .withForeignKey('credentialsId', { + tableName: 'credentials_entity', + columnName: 'id', + onDelete: 'CASCADE', + // In MySQL foreignKey names must be unique across all tables and + // TypeORM creates predictable names based on the columnName. + // So the current shared_credentials table's foreignKey for credentialsId would + // clash with this one if we don't create a random name. + name: isMysql ? nanoid() : undefined, + }) + .withForeignKey('userId', { + tableName: table.user, + columnName: 'id', + onDelete: 'CASCADE', + }).withTimestamps; + await runQuery(` + INSERT INTO ${t.sharedCredentialsTemp} (${c.createdAt}, ${c.updatedAt}, ${c.credentialsId}, role, ${c.userId}) + SELECT SC.${c.createdAt}, SC.${c.updatedAt}, SC.${c.credentialsId}, SC.role, PR.${c.userId} + FROM ${t.sharedCredentials} SC + LEFT JOIN project_relation PR on SC.${c.projectId} = PR.${c.projectId} AND PR.role = 'project:personalOwner' + `); + await sb.dropTable(table.sharedCredentials); + await runQuery(`ALTER TABLE ${t.sharedCredentialsTemp} RENAME TO ${t.sharedCredentials};`); + + // 6. drop project and project relation table + await sb.dropTable(table.projectRelation); + await sb.dropTable(table.project); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index 7952a923a..daa57b2c5 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -51,6 +51,7 @@ import { ExecutionSoftDelete1693491613982 } from '../common/1693491613982-Execut import { AddWorkflowMetadata1695128658538 } from '../common/1695128658538-AddWorkflowMetadata'; import { ModifyWorkflowHistoryNodesAndConnections1695829275184 } from '../common/1695829275184-ModifyWorkflowHistoryNodesAndConnections'; import { AddGlobalAdminRole1700571993961 } from '../common/1700571993961-AddGlobalAdminRole'; +import { CreateProject1714133768519 } from '../common/1714133768519-CreateProject'; import { DropRoleMapping1705429061930 } from '../common/1705429061930-DropRoleMapping'; import { RemoveFailedExecutionStatus1711018413374 } from '../common/1711018413374-RemoveFailedExecutionStatus'; import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase'; @@ -113,4 +114,5 @@ export const mysqlMigrations: Migration[] = [ RemoveFailedExecutionStatus1711018413374, MoveSshKeysToDatabase1711390882123, RemoveNodesAccess1712044305787, + CreateProject1714133768519, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index cbf63389a..b31e2970d 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -50,6 +50,7 @@ import { AddWorkflowMetadata1695128658538 } from '../common/1695128658538-AddWor import { MigrateToTimestampTz1694091729095 } from './1694091729095-MigrateToTimestampTz'; import { ModifyWorkflowHistoryNodesAndConnections1695829275184 } from '../common/1695829275184-ModifyWorkflowHistoryNodesAndConnections'; import { AddGlobalAdminRole1700571993961 } from '../common/1700571993961-AddGlobalAdminRole'; +import { CreateProject1714133768519 } from '../common/1714133768519-CreateProject'; import { DropRoleMapping1705429061930 } from '../common/1705429061930-DropRoleMapping'; import { RemoveFailedExecutionStatus1711018413374 } from '../common/1711018413374-RemoveFailedExecutionStatus'; import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase'; @@ -111,4 +112,5 @@ export const postgresMigrations: Migration[] = [ RemoveFailedExecutionStatus1711018413374, MoveSshKeysToDatabase1711390882123, RemoveNodesAccess1712044305787, + CreateProject1714133768519, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index f26f07008..834354fd6 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -48,6 +48,7 @@ import { ExecutionSoftDelete1693491613982 } from './1693491613982-ExecutionSoftD import { AddWorkflowMetadata1695128658538 } from '../common/1695128658538-AddWorkflowMetadata'; import { ModifyWorkflowHistoryNodesAndConnections1695829275184 } from '../common/1695829275184-ModifyWorkflowHistoryNodesAndConnections'; import { AddGlobalAdminRole1700571993961 } from '../common/1700571993961-AddGlobalAdminRole'; +import { CreateProject1714133768519 } from '../common/1714133768519-CreateProject'; import { DropRoleMapping1705429061930 } from './1705429061930-DropRoleMapping'; import { RemoveFailedExecutionStatus1711018413374 } from '../common/1711018413374-RemoveFailedExecutionStatus'; import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase'; @@ -107,6 +108,7 @@ const sqliteMigrations: Migration[] = [ RemoveFailedExecutionStatus1711018413374, MoveSshKeysToDatabase1711390882123, RemoveNodesAccess1712044305787, + CreateProject1714133768519, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/databases/repositories/credentials.repository.ts b/packages/cli/src/databases/repositories/credentials.repository.ts index 02ed714ca..5af221c81 100644 --- a/packages/cli/src/databases/repositories/credentials.repository.ts +++ b/packages/cli/src/databases/repositories/credentials.repository.ts @@ -1,8 +1,7 @@ import { Service } from 'typedi'; -import { DataSource, In, Not, Repository, Like } from '@n8n/typeorm'; -import type { FindManyOptions, DeleteResult, EntityManager, FindOptionsWhere } from '@n8n/typeorm'; +import { DataSource, In, Repository, Like } from '@n8n/typeorm'; +import type { FindManyOptions } from '@n8n/typeorm'; import { CredentialsEntity } from '../entities/CredentialsEntity'; -import { SharedCredentials } from '../entities/SharedCredentials'; import type { ListQuery } from '@/requests'; @Service() @@ -11,18 +10,6 @@ export class CredentialsRepository extends Repository { super(CredentialsEntity, dataSource.manager); } - async pruneSharings( - transaction: EntityManager, - credentialId: string, - userIds: string[], - ): Promise { - const conditions: FindOptionsWhere = { - credentialsId: credentialId, - userId: Not(In(userIds)), - }; - return await transaction.delete(SharedCredentials, conditions); - } - async findStartingWith(credentialName: string) { return await this.find({ select: ['name'], @@ -45,7 +32,7 @@ export class CredentialsRepository extends Repository { type Select = Array; - const defaultRelations = ['shared', 'shared.user']; + const defaultRelations = ['shared', 'shared.project']; const defaultSelect: Select = ['id', 'name', 'type', 'createdAt', 'updatedAt']; if (!listQueryOptions) return { select: defaultSelect, relations: defaultRelations }; @@ -60,6 +47,11 @@ export class CredentialsRepository extends Repository { filter.type = Like(`%${filter.type}%`); } + if (typeof filter?.projectId === 'string' && filter.projectId !== '') { + filter.shared = { projectId: filter.projectId }; + delete filter.projectId; + } + if (filter) findManyOptions.where = filter; if (select) findManyOptions.select = select; if (take) findManyOptions.take = take; @@ -81,7 +73,11 @@ export class CredentialsRepository extends Repository { const findManyOptions: FindManyOptions = { where: { id: In(ids) } }; if (withSharings) { - findManyOptions.relations = ['shared', 'shared.user']; + findManyOptions.relations = { + shared: { + project: true, + }, + }; } return await this.find(findManyOptions); diff --git a/packages/cli/src/databases/repositories/project.repository.ts b/packages/cli/src/databases/repositories/project.repository.ts new file mode 100644 index 000000000..faae0bb9c --- /dev/null +++ b/packages/cli/src/databases/repositories/project.repository.ts @@ -0,0 +1,45 @@ +import { Service } from 'typedi'; +import type { EntityManager } from '@n8n/typeorm'; +import { DataSource, Repository } from '@n8n/typeorm'; +import { Project } from '../entities/Project'; + +@Service() +export class ProjectRepository extends Repository { + constructor(dataSource: DataSource) { + super(Project, dataSource.manager); + } + + async getPersonalProjectForUser(userId: string, entityManager?: EntityManager) { + const em = entityManager ?? this.manager; + + return await em.findOne(Project, { + where: { type: 'personal', projectRelations: { userId, role: 'project:personalOwner' } }, + }); + } + + async getPersonalProjectForUserOrFail(userId: string) { + return await this.findOneOrFail({ + where: { type: 'personal', projectRelations: { userId, role: 'project:personalOwner' } }, + }); + } + + async getAccessibleProjects(userId: string) { + return await this.find({ + where: [ + { type: 'personal' }, + { + projectRelations: { + userId, + }, + }, + ], + }); + } + + async getProjectCounts() { + return { + personal: await this.count({ where: { type: 'personal' } }), + team: await this.count({ where: { type: 'team' } }), + }; + } +} diff --git a/packages/cli/src/databases/repositories/projectRelation.repository.ts b/packages/cli/src/databases/repositories/projectRelation.repository.ts new file mode 100644 index 000000000..bddfd6e38 --- /dev/null +++ b/packages/cli/src/databases/repositories/projectRelation.repository.ts @@ -0,0 +1,55 @@ +import { Service } from 'typedi'; +import { DataSource, In, Repository } from '@n8n/typeorm'; +import { ProjectRelation, type ProjectRole } from '../entities/ProjectRelation'; + +@Service() +export class ProjectRelationRepository extends Repository { + constructor(dataSource: DataSource) { + super(ProjectRelation, dataSource.manager); + } + + async getPersonalProjectOwners(projectIds: string[]) { + return await this.find({ + where: { + projectId: In(projectIds), + role: 'project:personalOwner', + }, + relations: { user: true }, + }); + } + + async getPersonalProjectsForUsers(userIds: string[]) { + const projectRelations = await this.find({ + where: { + userId: In(userIds), + role: 'project:personalOwner', + }, + }); + + return projectRelations.map((pr) => pr.projectId); + } + + /** + * Find the role of a user in a project. + */ + async findProjectRole({ userId, projectId }: { userId: string; projectId: string }) { + const relation = await this.findOneBy({ projectId, userId }); + + return relation?.role ?? null; + } + + /** Counts the number of users in each role, e.g. `{ admin: 2, member: 6, owner: 1 }` */ + async countUsersByRole() { + const rows = (await this.createQueryBuilder() + .select(['role', 'COUNT(role) as count']) + .groupBy('role') + .execute()) as Array<{ role: ProjectRole; count: string }>; + return rows.reduce( + (acc, row) => { + acc[row.role] = parseInt(row.count, 10); + return acc; + }, + {} as Record, + ); + } +} diff --git a/packages/cli/src/databases/repositories/sharedCredentials.repository.ts b/packages/cli/src/databases/repositories/sharedCredentials.repository.ts index 4b08c2174..8d2d1fa7a 100644 --- a/packages/cli/src/databases/repositories/sharedCredentials.repository.ts +++ b/packages/cli/src/databases/repositories/sharedCredentials.repository.ts @@ -1,22 +1,53 @@ import { Service } from 'typedi'; -import type { EntityManager } from '@n8n/typeorm'; +import type { EntityManager, FindOptionsRelations, FindOptionsWhere } from '@n8n/typeorm'; import { DataSource, In, Not, Repository } from '@n8n/typeorm'; import { type CredentialSharingRole, SharedCredentials } from '../entities/SharedCredentials'; import type { User } from '../entities/User'; +import { RoleService } from '@/services/role.service'; +import type { Scope } from '@n8n/permissions'; +import type { Project } from '../entities/Project'; +import type { ProjectRole } from '../entities/ProjectRelation'; @Service() export class SharedCredentialsRepository extends Repository { - constructor(dataSource: DataSource) { + constructor( + dataSource: DataSource, + private readonly roleService: RoleService, + ) { super(SharedCredentials, dataSource.manager); } /** Get a credential if it has been shared with a user */ - async findCredentialForUser(credentialsId: string, user: User) { + async findCredentialForUser( + credentialsId: string, + user: User, + scopes: Scope[], + _relations?: FindOptionsRelations, + ) { + let where: FindOptionsWhere = { credentialsId }; + + if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) { + const projectRoles = this.roleService.rolesWithScope('project', scopes); + const credentialRoles = this.roleService.rolesWithScope('credential', scopes); + where = { + ...where, + role: In(credentialRoles), + project: { + projectRelations: { + role: In(projectRoles), + userId: user.id, + }, + }, + }; + } + const sharedCredential = await this.findOne({ - relations: ['credentials'], - where: { - credentialsId, - ...(!user.hasGlobalScope('credential:read') ? { userId: user.id } : {}), + where, + // TODO: write a small relations merger and use that one here + relations: { + credentials: { + shared: { project: { projectRelations: { user: true } } }, + }, }, }); if (!sharedCredential) return null; @@ -25,7 +56,7 @@ export class SharedCredentialsRepository extends Repository { async findByCredentialIds(credentialIds: string[], role: CredentialSharingRole) { return await this.find({ - relations: ['credentials', 'user'], + relations: { credentials: true, project: { projectRelations: { user: true } } }, where: { credentialsId: In(credentialIds), role, @@ -33,37 +64,91 @@ export class SharedCredentialsRepository extends Repository { }); } - async makeOwnerOfAllCredentials(user: User) { - return await this.update({ userId: Not(user.id), role: 'credential:owner' }, { user }); + async makeOwnerOfAllCredentials(project: Project) { + return await this.update( + { + projectId: Not(project.id), + role: 'credential:owner', + }, + { project }, + ); } - /** Get the IDs of all credentials owned by a user */ - async getOwnedCredentialIds(userIds: string[]) { - return await this.getCredentialIdsByUserAndRole(userIds, ['credential:owner']); + async makeOwner(credentialIds: string[], projectId: string, trx?: EntityManager) { + trx = trx ?? this.manager; + return await trx.upsert( + SharedCredentials, + credentialIds.map( + (credentialsId) => + ({ + projectId, + credentialsId, + role: 'credential:owner', + }) as const, + ), + ['projectId', 'credentialsId'], + ); } - /** Get the IDs of all credentials owned by or shared with a user */ - async getAccessibleCredentialIds(userIds: string[]) { - return await this.getCredentialIdsByUserAndRole(userIds, [ - 'credential:owner', - 'credential:user', - ]); - } + async getCredentialIdsByUserAndRole( + userIds: string[], + options: + | { scopes: Scope[] } + | { projectRoles: ProjectRole[]; credentialRoles: CredentialSharingRole[] }, + ) { + const projectRoles = + 'scopes' in options + ? this.roleService.rolesWithScope('project', options.scopes) + : options.projectRoles; + const credentialRoles = + 'scopes' in options + ? this.roleService.rolesWithScope('credential', options.scopes) + : options.credentialRoles; - private async getCredentialIdsByUserAndRole(userIds: string[], roles: CredentialSharingRole[]) { const sharings = await this.find({ where: { - userId: In(userIds), - role: In(roles), + role: In(credentialRoles), + project: { + projectRelations: { + userId: In(userIds), + role: In(projectRoles), + }, + }, }, }); return sharings.map((s) => s.credentialsId); } - async deleteByIds(transaction: EntityManager, sharedCredentialsIds: string[], user?: User) { - return await transaction.delete(SharedCredentials, { - user, + async deleteByIds(sharedCredentialsIds: string[], projectId: string, trx?: EntityManager) { + trx = trx ?? this.manager; + + return await trx.delete(SharedCredentials, { + projectId, credentialsId: In(sharedCredentialsIds), }); } + + async getFilteredAccessibleCredentials( + projectIds: string[], + credentialsIds: string[], + ): Promise { + return ( + await this.find({ + where: { + projectId: In(projectIds), + credentialsId: In(credentialsIds), + }, + select: ['credentialsId'], + }) + ).map((s) => s.credentialsId); + } + + async findCredentialOwningProject(credentialsId: string) { + return ( + await this.findOne({ + where: { credentialsId, role: 'credential:owner' }, + relations: { project: true }, + }) + )?.project; + } } diff --git a/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts b/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts index 3716daa45..f8ff3523b 100644 --- a/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts +++ b/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts @@ -4,33 +4,18 @@ import type { EntityManager, FindManyOptions, FindOptionsWhere } from '@n8n/type import { SharedWorkflow, type WorkflowSharingRole } from '../entities/SharedWorkflow'; import { type User } from '../entities/User'; import type { Scope } from '@n8n/permissions'; -import type { WorkflowEntity } from '../entities/WorkflowEntity'; +import { RoleService } from '@/services/role.service'; +import type { Project } from '../entities/Project'; @Service() export class SharedWorkflowRepository extends Repository { - constructor(dataSource: DataSource) { + constructor( + dataSource: DataSource, + private roleService: RoleService, + ) { super(SharedWorkflow, dataSource.manager); } - async hasAccess(workflowId: string, user: User) { - const where: FindOptionsWhere = { - workflowId, - }; - if (!user.hasGlobalScope('workflow:read')) { - where.userId = user.id; - } - return await this.exist({ where }); - } - - /** Get the IDs of all users this workflow is shared with */ - async getSharedUserIds(workflowId: string) { - const sharedWorkflows = await this.find({ - select: ['userId'], - where: { workflowId }, - }); - return sharedWorkflows.map((sharing) => sharing.userId); - } - async getSharedWorkflowIds(workflowIds: string[]) { const sharedWorkflows = await this.find({ select: ['workflowId'], @@ -43,11 +28,11 @@ export class SharedWorkflowRepository extends Repository { async findByWorkflowIds(workflowIds: string[]) { return await this.find({ - relations: ['user'], where: { role: 'workflow:owner', workflowId: In(workflowIds), }, + relations: { project: { projectRelations: { user: true } } }, }); } @@ -55,90 +40,49 @@ export class SharedWorkflowRepository extends Repository { userId: string, workflowId: string, ): Promise { - return await this.findOne({ - select: ['role'], - where: { workflowId, userId }, - }).then((shared) => shared?.role); - } - - async findSharing( - workflowId: string, - user: User, - scope: Scope, - { roles, extraRelations }: { roles?: WorkflowSharingRole[]; extraRelations?: string[] } = {}, - ) { - const where: FindOptionsWhere = { - workflow: { id: workflowId }, - }; - - if (!user.hasGlobalScope(scope)) { - where.user = { id: user.id }; - } - - if (roles) { - where.role = In(roles); - } - - const relations = ['workflow']; - - if (extraRelations) relations.push(...extraRelations); - - return await this.findOne({ relations, where }); - } - - async makeOwnerOfAllWorkflows(user: User) { - return await this.update({ userId: Not(user.id), role: 'workflow:owner' }, { user }); - } - - async getSharing( - user: User, - workflowId: string, - options: { allowGlobalScope: true; globalScope: Scope } | { allowGlobalScope: false }, - relations: string[] = ['workflow'], - ): Promise { - const where: FindOptionsWhere = { workflowId }; - - // Omit user from where if the requesting user has relevant - // global workflow permissions. This allows the user to - // access workflows they don't own. - if (!options.allowGlobalScope || !user.hasGlobalScope(options.globalScope)) { - where.userId = user.id; - } - - return await this.findOne({ where, relations }); - } - - async getSharedWorkflows( - user: User, - options: { - relations?: string[]; - workflowIds?: string[]; - }, - ): Promise { - return await this.find({ - where: { - ...(!['global:owner', 'global:admin'].includes(user.role) && { userId: user.id }), - ...(options.workflowIds && { workflowId: In(options.workflowIds) }), + const sharing = await this.findOne({ + // NOTE: We have to select everything that is used in the `where` clause. Otherwise typeorm will create an invalid query and we get this error: + // QueryFailedError: SQLITE_ERROR: no such column: distinctAlias.SharedWorkflow_... + select: { + role: true, + workflowId: true, + projectId: true, + }, + where: { + workflowId, + project: { projectRelations: { role: 'project:personalOwner', userId } }, }, - ...(options.relations && { relations: options.relations }), }); + + return sharing?.role; } - async share(transaction: EntityManager, workflow: WorkflowEntity, users: User[]) { - const newSharedWorkflows = users.reduce((acc, user) => { - if (user.isPending) { - return acc; - } - const entity: Partial = { - workflowId: workflow.id, - userId: user.id, - role: 'workflow:editor', - }; - acc.push(this.create(entity)); - return acc; - }, []); + async makeOwnerOfAllWorkflows(project: Project) { + return await this.update( + { + projectId: Not(project.id), + role: 'workflow:owner', + }, + { project }, + ); + } - return await transaction.save(newSharedWorkflows); + async makeOwner(workflowIds: string[], projectId: string, trx?: EntityManager) { + trx = trx ?? this.manager; + + return await trx.upsert( + SharedWorkflow, + workflowIds.map( + (workflowId) => + ({ + workflowId, + projectId, + role: 'workflow:owner', + }) as const, + ), + + ['projectId', 'workflowId'], + ); } async findWithFields( @@ -153,10 +97,107 @@ export class SharedWorkflowRepository extends Repository { }); } - async deleteByIds(transaction: EntityManager, sharedWorkflowIds: string[], user?: User) { - return await transaction.delete(SharedWorkflow, { - user, + async deleteByIds(sharedWorkflowIds: string[], projectId: string, trx?: EntityManager) { + trx = trx ?? this.manager; + + return await trx.delete(SharedWorkflow, { + projectId, workflowId: In(sharedWorkflowIds), }); } + + async findWorkflowForUser( + workflowId: string, + user: User, + scopes: Scope[], + { includeTags = false, em = this.manager } = {}, + ) { + let where: FindOptionsWhere = { workflowId }; + + if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) { + const projectRoles = this.roleService.rolesWithScope('project', scopes); + const workflowRoles = this.roleService.rolesWithScope('workflow', scopes); + + where = { + ...where, + role: In(workflowRoles), + project: { + projectRelations: { + role: In(projectRoles), + userId: user.id, + }, + }, + }; + } + + const sharedWorkflow = await em.findOne(SharedWorkflow, { + where, + relations: { + workflow: { + shared: { project: { projectRelations: { user: true } } }, + tags: includeTags, + }, + }, + }); + + if (!sharedWorkflow) { + return null; + } + + return sharedWorkflow.workflow; + } + + async findAllWorkflowsForUser(user: User, scopes: Scope[]) { + let where: FindOptionsWhere = {}; + + if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) { + const projectRoles = this.roleService.rolesWithScope('project', scopes); + const workflowRoles = this.roleService.rolesWithScope('workflow', scopes); + + where = { + ...where, + role: In(workflowRoles), + project: { + projectRelations: { + role: In(projectRoles), + userId: user.id, + }, + }, + }; + } + + const sharedWorkflows = await this.find({ + where, + relations: { + workflow: { + shared: { project: { projectRelations: { user: true } } }, + }, + }, + }); + + return sharedWorkflows.map((sw) => sw.workflow); + } + + /** + * Find the IDs of all the projects where a workflow is accessible. + */ + async findProjectIds(workflowId: string) { + const rows = await this.find({ where: { workflowId }, select: ['projectId'] }); + + const projectIds = rows.reduce((acc, row) => { + if (row.projectId) acc.push(row.projectId); + return acc; + }, []); + + return [...new Set(projectIds)]; + } + + async getWorkflowOwningProject(workflowId: string) { + return ( + await this.findOne({ + where: { workflowId, role: 'workflow:owner' }, + relations: { project: true }, + }) + )?.project; + } } diff --git a/packages/cli/src/databases/repositories/user.repository.ts b/packages/cli/src/databases/repositories/user.repository.ts index 6b81f8984..4591c2049 100644 --- a/packages/cli/src/databases/repositories/user.repository.ts +++ b/packages/cli/src/databases/repositories/user.repository.ts @@ -1,9 +1,11 @@ import { Service } from 'typedi'; -import type { EntityManager, FindManyOptions } from '@n8n/typeorm'; +import type { DeepPartial, EntityManager, FindManyOptions } from '@n8n/typeorm'; import { DataSource, In, IsNull, Not, Repository } from '@n8n/typeorm'; import type { ListQuery } from '@/requests'; import { type GlobalRole, User } from '../entities/User'; +import { Project } from '../entities/Project'; +import { ProjectRelation } from '../entities/ProjectRelation'; @Service() export class UserRepository extends Repository { constructor(dataSource: DataSource) { @@ -16,6 +18,19 @@ export class UserRepository extends Repository { }); } + /** + * @deprecated Use `UserRepository.save` instead if you can. + * + * We need to use `save` so that that the subscriber in + * packages/cli/src/databases/entities/Project.ts receives the full user. + * With `update` it would only receive the updated fields, e.g. the `id` + * would be missing. test('does not use `Repository.update`, but + * `Repository.save` instead'. + */ + async update(...args: Parameters['update']>) { + return await super.update(...args); + } + async deleteAllExcept(user: User) { await this.delete({ id: Not(user.id) }); } @@ -104,4 +119,34 @@ export class UserRepository extends Repository { where: { id: In(userIds), password: Not(IsNull()) }, }); } + + async createUserWithProject( + user: DeepPartial, + transactionManager?: EntityManager, + ): Promise<{ user: User; project: Project }> { + const createInner = async (entityManager: EntityManager) => { + const newUser = entityManager.create(User, user); + const savedUser = await entityManager.save(newUser); + const savedProject = await entityManager.save( + entityManager.create(Project, { + type: 'personal', + name: savedUser.createPersonalProjectName(), + }), + ); + await entityManager.save( + entityManager.create(ProjectRelation, { + projectId: savedProject.id, + userId: savedUser.id, + role: 'project:personalOwner', + }), + ); + return { user: savedUser, project: savedProject }; + }; + if (transactionManager) { + return await createInner(transactionManager); + } + // TODO: use a transactions + // This is blocked by TypeORM having concurrency issues with transactions + return await createInner(this.manager); + } } diff --git a/packages/cli/src/databases/repositories/workflow.repository.ts b/packages/cli/src/databases/repositories/workflow.repository.ts index 9d0c09ea8..5326df89a 100644 --- a/packages/cli/src/databases/repositories/workflow.repository.ts +++ b/packages/cli/src/databases/repositories/workflow.repository.ts @@ -8,15 +8,12 @@ import { type FindOptionsWhere, type FindOptionsSelect, type FindManyOptions, - type EntityManager, - type DeleteResult, - Not, + type FindOptionsRelations, } from '@n8n/typeorm'; import type { ListQuery } from '@/requests'; import { isStringArray } from '@/utils'; import config from '@/config'; import { WorkflowEntity } from '../entities/WorkflowEntity'; -import { SharedWorkflow } from '../entities/SharedWorkflow'; import { WebhookEntity } from '../entities/WebhookEntity'; @Service() @@ -25,7 +22,10 @@ export class WorkflowRepository extends Repository { super(WorkflowEntity, dataSource.manager); } - async get(where: FindOptionsWhere, options?: { relations: string[] }) { + async get( + where: FindOptionsWhere, + options?: { relations: string[] | FindOptionsRelations }, + ) { return await this.findOne({ where, relations: options?.relations, @@ -35,7 +35,7 @@ export class WorkflowRepository extends Repository { async getAllActive() { return await this.find({ where: { active: true }, - relations: ['shared', 'shared.user'], + relations: { shared: { project: { projectRelations: true } } }, }); } @@ -50,7 +50,7 @@ export class WorkflowRepository extends Repository { async findById(workflowId: string) { return await this.findOne({ where: { id: workflowId }, - relations: ['shared', 'shared.user'], + relations: { shared: { project: { projectRelations: true } } }, }); } @@ -71,29 +71,6 @@ export class WorkflowRepository extends Repository { return totalTriggerCount ?? 0; } - async getSharings( - transaction: EntityManager, - workflowId: string, - relations = ['shared'], - ): Promise { - const workflow = await transaction.findOne(WorkflowEntity, { - where: { id: workflowId }, - relations, - }); - return workflow?.shared ?? []; - } - - async pruneSharings( - transaction: EntityManager, - workflowId: string, - userIds: string[], - ): Promise { - return await transaction.delete(SharedWorkflow, { - workflowId, - userId: Not(In(userIds)), - }); - } - async updateWorkflowTriggerCount(id: string, triggerCount: number): Promise { const qb = this.createQueryBuilder('workflow'); return await qb @@ -114,6 +91,11 @@ export class WorkflowRepository extends Repository { async getMany(sharedWorkflowIds: string[], options?: ListQuery.Options) { if (sharedWorkflowIds.length === 0) return { workflows: [], count: 0 }; + if (typeof options?.filter?.projectId === 'string' && options.filter.projectId !== '') { + options.filter.shared = { projectId: options.filter.projectId }; + delete options.filter.projectId; + } + const where: FindOptionsWhere = { ...options?.filter, id: In(sharedWorkflowIds), @@ -135,7 +117,7 @@ export class WorkflowRepository extends Repository { createdAt: true, updatedAt: true, versionId: true, - shared: { userId: true, role: true }, + shared: { role: true }, }; delete select?.ownedBy; // remove non-entity field, handled after query @@ -152,7 +134,7 @@ export class WorkflowRepository extends Repository { select.tags = { id: true, name: true }; } - if (isOwnedByIncluded) relations.push('shared', 'shared.user'); + if (isOwnedByIncluded) relations.push('shared', 'shared.project'); if (typeof where.name === 'string' && where.name !== '') { where.name = Like(`%${where.name}%`); diff --git a/packages/cli/src/databases/repositories/workflowStatistics.repository.ts b/packages/cli/src/databases/repositories/workflowStatistics.repository.ts index 0faef0184..a514c2d3b 100644 --- a/packages/cli/src/databases/repositories/workflowStatistics.repository.ts +++ b/packages/cli/src/databases/repositories/workflowStatistics.repository.ts @@ -1,10 +1,8 @@ import { Service } from 'typedi'; -import { DataSource, QueryFailedError, Repository } from '@n8n/typeorm'; +import { DataSource, MoreThanOrEqual, QueryFailedError, Repository } from '@n8n/typeorm'; import config from '@/config'; import { StatisticsNames, WorkflowStatistics } from '../entities/WorkflowStatistics'; import type { User } from '@/databases/entities/User'; -import { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; -import { SharedWorkflow } from '@/databases/entities/SharedWorkflow'; type StatisticsInsertResult = 'insert' | 'failed' | 'alreadyExists'; type StatisticsUpsertResult = StatisticsInsertResult | 'update'; @@ -102,18 +100,18 @@ export class WorkflowStatisticsRepository extends Repository } async queryNumWorkflowsUserHasWithFiveOrMoreProdExecs(userId: User['id']): Promise { - return await this.createQueryBuilder('workflow_statistics') - .innerJoin(WorkflowEntity, 'workflow', 'workflow.id = workflow_statistics.workflowId') - .innerJoin( - SharedWorkflow, - 'shared_workflow', - 'shared_workflow.workflowId = workflow_statistics.workflowId', - ) - .where('shared_workflow.userId = :userId', { userId }) - .andWhere('workflow.active = :isActive', { isActive: true }) - .andWhere('workflow_statistics.name = :name', { name: StatisticsNames.productionSuccess }) - .andWhere('workflow_statistics.count >= 5') - .andWhere('role = :roleName', { roleName: 'workflow:owner' }) - .getCount(); + return await this.count({ + where: { + workflow: { + shared: { + role: 'workflow:owner', + project: { projectRelations: { userId, role: 'project:personalOwner' } }, + }, + active: true, + }, + name: StatisticsNames.productionSuccess, + count: MoreThanOrEqual(5), + }, + }); } } diff --git a/packages/cli/src/databases/subscribers/UserSubscriber.ts b/packages/cli/src/databases/subscribers/UserSubscriber.ts new file mode 100644 index 000000000..e5fad5bf5 --- /dev/null +++ b/packages/cli/src/databases/subscribers/UserSubscriber.ts @@ -0,0 +1,73 @@ +import type { EntitySubscriberInterface, UpdateEvent } from '@n8n/typeorm'; +import { EventSubscriber } from '@n8n/typeorm'; +import { User } from '../entities/User'; +import Container from 'typedi'; +import { ProjectRepository } from '../repositories/project.repository'; +import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow'; +import { Logger } from '@/Logger'; +import { UserRepository } from '../repositories/user.repository'; +import { Project } from '../entities/Project'; + +@EventSubscriber() +export class UserSubscriber implements EntitySubscriberInterface { + listenTo() { + return User; + } + + async afterUpdate(event: UpdateEvent): Promise { + if (event.entity) { + const newUserData = event.entity; + + if (event.databaseEntity) { + const fields = event.updatedColumns.map((c) => c.propertyName); + + if ( + fields.includes('firstName') || + fields.includes('lastName') || + fields.includes('email') + ) { + const oldUser = event.databaseEntity; + const name = + newUserData instanceof User + ? newUserData.createPersonalProjectName() + : Container.get(UserRepository).create(newUserData).createPersonalProjectName(); + + const project = await Container.get(ProjectRepository).getPersonalProjectForUser( + oldUser.id, + ); + + if (!project) { + // Since this is benign we're not throwing the exception. We don't + // know if we're running inside a transaction and thus there is a risk + // that this could cause further data inconsistencies. + const message = "Could not update the personal project's name"; + Container.get(Logger).warn(message, event.entity); + const exception = new ApplicationError(message); + ErrorReporterProxy.warn(exception, event.entity); + return; + } + + project.name = name; + + await event.manager.save(Project, project); + } + } else { + // This means the user was updated using `Repository.update`. In this + // case we're missing the user's id and cannot update their project. + // + // When updating the user's firstName, lastName or email we must use + // `Repository.save`, so this is a bug and we should report it to sentry. + // + if (event.entity.firstName || event.entity.lastName || event.entity.email) { + // Since this is benign we're not throwing the exception. We don't + // know if we're running inside a transaction and thus there is a risk + // that this could cause further data inconsistencies. + const message = "Could not update the personal project's name"; + Container.get(Logger).warn(message, event.entity); + const exception = new ApplicationError(message); + ErrorReporterProxy.warn(exception, event.entity); + } + } + } + } +} diff --git a/packages/cli/src/databases/subscribers/index.ts b/packages/cli/src/databases/subscribers/index.ts new file mode 100644 index 000000000..9d9383c4d --- /dev/null +++ b/packages/cli/src/databases/subscribers/index.ts @@ -0,0 +1,5 @@ +import { UserSubscriber } from './UserSubscriber'; + +export const subscribers = { + UserSubscriber, +}; diff --git a/packages/cli/src/decorators/Scoped.ts b/packages/cli/src/decorators/Scoped.ts new file mode 100644 index 000000000..0d4644ae1 --- /dev/null +++ b/packages/cli/src/decorators/Scoped.ts @@ -0,0 +1,60 @@ +import type { Scope } from '@n8n/permissions'; +import type { RouteScopeMetadata } from './types'; +import { CONTROLLER_ROUTE_SCOPES } from './constants'; + +const Scoped = (scope: Scope | Scope[], { globalOnly } = { globalOnly: false }) => { + return (target: Function | object, handlerName?: string) => { + const controllerClass = handlerName ? target.constructor : target; + const scopes = (Reflect.getMetadata(CONTROLLER_ROUTE_SCOPES, controllerClass) ?? + {}) as RouteScopeMetadata; + + const metadata = { + scopes: Array.isArray(scope) ? scope : [scope], + globalOnly, + }; + + scopes[handlerName ?? '*'] = metadata; + Reflect.defineMetadata(CONTROLLER_ROUTE_SCOPES, scopes, controllerClass); + }; +}; + +/** + * Decorator for a controller method to ensure the user has a scope, + * checking only at the global level. + * + * To check only at project level as well, use the `@ProjectScope` decorator. + * + * @example + * ```ts + * @RestController() + * export class UsersController { + * @Delete('/:id') + * @GlobalScope('user:delete') + * async deleteUser(req, res) { ... } + * } + * ``` + */ +export const GlobalScope = (scope: Scope | Scope[]) => { + return Scoped(scope, { globalOnly: true }); +}; + +/** + * Decorator for a controller method to ensure the user has a scope, + * checking first at project level and then at global level. + * + * To check only at global level, use the `@GlobalScope` decorator. + * + * @example + * ```ts + * @RestController() + * export class WorkflowController { + * @Get('/:workflowId') + * @GlobalScope('workflow:read') + * async getWorkflow(req, res) { ... } + * } + * ``` + */ + +export const ProjectScope = (scope: Scope | Scope[]) => { + return Scoped(scope); +}; diff --git a/packages/cli/src/decorators/Scopes.ts b/packages/cli/src/decorators/Scopes.ts deleted file mode 100644 index aa2518017..000000000 --- a/packages/cli/src/decorators/Scopes.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Scope } from '@n8n/permissions'; -import type { ScopeMetadata } from './types'; -import { CONTROLLER_REQUIRED_SCOPES } from './constants'; - -export const GlobalScope = (scope: Scope | Scope[]) => { - // eslint-disable-next-line @typescript-eslint/ban-types - return (target: Function | object, handlerName?: string) => { - const controllerClass = handlerName ? target.constructor : target; - const scopes = (Reflect.getMetadata(CONTROLLER_REQUIRED_SCOPES, controllerClass) ?? - []) as ScopeMetadata; - scopes[handlerName ?? '*'] = Array.isArray(scope) ? scope : [scope]; - Reflect.defineMetadata(CONTROLLER_REQUIRED_SCOPES, scopes, controllerClass); - }; -}; diff --git a/packages/cli/src/decorators/constants.ts b/packages/cli/src/decorators/constants.ts index 1487f91a0..8f3aac403 100644 --- a/packages/cli/src/decorators/constants.ts +++ b/packages/cli/src/decorators/constants.ts @@ -2,4 +2,4 @@ export const CONTROLLER_ROUTES = 'CONTROLLER_ROUTES'; export const CONTROLLER_BASE_PATH = 'CONTROLLER_BASE_PATH'; export const CONTROLLER_MIDDLEWARES = 'CONTROLLER_MIDDLEWARES'; export const CONTROLLER_LICENSE_FEATURES = 'CONTROLLER_LICENSE_FEATURES'; -export const CONTROLLER_REQUIRED_SCOPES = 'CONTROLLER_REQUIRED_SCOPES'; +export const CONTROLLER_ROUTE_SCOPES = 'CONTROLLER_ROUTE_SCOPES'; diff --git a/packages/cli/src/decorators/index.ts b/packages/cli/src/decorators/index.ts index 94c94ef18..576b55cdd 100644 --- a/packages/cli/src/decorators/index.ts +++ b/packages/cli/src/decorators/index.ts @@ -3,4 +3,4 @@ export { Get, Post, Put, Patch, Delete } from './Route'; export { Middleware } from './Middleware'; export { registerController } from './registerController'; export { Licensed } from './Licensed'; -export { GlobalScope } from './Scopes'; +export { GlobalScope, ProjectScope } from './Scoped'; diff --git a/packages/cli/src/decorators/registerController.ts b/packages/cli/src/decorators/registerController.ts index ab4c3c4f4..3cc0d35d9 100644 --- a/packages/cli/src/decorators/registerController.ts +++ b/packages/cli/src/decorators/registerController.ts @@ -2,13 +2,13 @@ import { Container } from 'typedi'; import { Router } from 'express'; import type { Application, Request, Response, RequestHandler } from 'express'; import { rateLimit as expressRateLimit } from 'express-rate-limit'; -import type { Scope } from '@n8n/permissions'; import { ApplicationError } from 'n8n-workflow'; import type { Class } from 'n8n-core'; import { AuthService } from '@/auth/auth.service'; import config from '@/config'; -import { inE2ETests, inTest } from '@/constants'; +import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error'; +import { inE2ETests, inTest, RESPONSE_ERROR_MESSAGES } from '@/constants'; import type { BooleanLicenseFeature } from '@/Interfaces'; import { License } from '@/License'; import type { AuthenticatedRequest } from '@/requests'; @@ -17,7 +17,7 @@ import { CONTROLLER_BASE_PATH, CONTROLLER_LICENSE_FEATURES, CONTROLLER_MIDDLEWARES, - CONTROLLER_REQUIRED_SCOPES, + CONTROLLER_ROUTE_SCOPES, CONTROLLER_ROUTES, } from './constants'; import type { @@ -25,8 +25,9 @@ import type { LicenseMetadata, MiddlewareMetadata, RouteMetadata, - ScopeMetadata, + RouteScopeMetadata, } from './types'; +import { userHasScope } from '@/permissions/checkAccess'; const throttle = expressRateLimit({ windowMs: 5 * 60 * 1000, // 5 minutes @@ -53,18 +54,24 @@ export const createLicenseMiddleware = return next(); }; -export const createGlobalScopeMiddleware = - (scopes: Scope[]): RequestHandler => - async ({ user }: AuthenticatedRequest, res, next) => { - if (scopes.length === 0) { - return next(); - } +export const createScopedMiddleware = + (routeScopeMetadata: RouteScopeMetadata[string]): RequestHandler => + async ( + req: AuthenticatedRequest<{ credentialId?: string; workflowId?: string; projectId?: string }>, + res, + next, + ) => { + if (!req.user) throw new UnauthenticatedError(); - if (!user) return res.status(401).json({ status: 'error', message: 'Unauthorized' }); + const { scopes, globalOnly } = routeScopeMetadata; - const hasScopes = user.hasGlobalScope(scopes); - if (!hasScopes) { - return res.status(403).json({ status: 'error', message: 'Unauthorized' }); + if (scopes.length === 0) return next(); + + if (!(await userHasScope(req.user, scopes, globalOnly, req.params))) { + return res.status(403).json({ + status: 'error', + message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE, + }); } return next(); @@ -84,8 +91,8 @@ export const registerController = (app: Application, controllerClass: Class 0) { @@ -112,7 +119,7 @@ export const registerController = (app: Application, controllerClass: Class { const features = licenseFeatures?.[handlerName] ?? licenseFeatures?.['*']; - const scopes = requiredScopes?.[handlerName] ?? requiredScopes?.['*']; + const scopes = routeScopes?.[handlerName] ?? routeScopes?.['*']; const handler = async (req: Request, res: Response) => await controller[handlerName](req, res); router[method]( @@ -121,7 +128,7 @@ export const registerController = (app: Application, controllerClass: Class; -export type ScopeMetadata = Record; +export type RouteScopeMetadata = { + [handlerName: string]: { + scopes: Scope[]; + globalOnly: boolean; + }; +}; export interface MiddlewareMetadata { handlerName: string; diff --git a/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts index eb8269493..f939ce39b 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts @@ -29,6 +29,7 @@ import { Logger } from '@/Logger'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { WorkflowTagMappingRepository } from '@db/repositories/workflowTagMapping.repository'; +import type { ResourceOwner } from './types/resourceOwner'; @Service() export class SourceControlExportService { @@ -79,7 +80,7 @@ export class SourceControlExportService { private async writeExportableWorkflowsToExportFolder( workflowsToBeExported: WorkflowEntity[], - owners: Record, + owners: Record, ) { await Promise.all( workflowsToBeExported.map(async (e) => { @@ -109,8 +110,37 @@ export class SourceControlExportService { const workflows = await Container.get(WorkflowRepository).findByIds(workflowIds); // determine owner of each workflow to be exported - const owners: Record = {}; - sharedWorkflows.forEach((e) => (owners[e.workflowId] = e.user.email)); + const owners: Record = {}; + sharedWorkflows.forEach((e) => { + const project = e.project; + + if (!project) { + throw new ApplicationError(`Workflow ${e.workflow.display()} has no owner`); + } + + if (project.type === 'personal') { + const ownerRelation = project.projectRelations.find( + (pr) => pr.role === 'project:personalOwner', + ); + if (!ownerRelation) { + throw new ApplicationError(`Workflow ${e.workflow.display()} has no owner`); + } + owners[e.workflowId] = { + type: 'personal', + personalEmail: ownerRelation.user.email, + }; + } else if (project.type === 'team') { + owners[e.workflowId] = { + type: 'team', + teamId: project.id, + teamName: project.name, + }; + } else { + throw new ApplicationError( + `Workflow belongs to unknown project type: ${project.type as string}`, + ); + } + }); // write the workflows to the export folder as json files await this.writeExportableWorkflowsToExportFolder(workflows, owners); @@ -243,12 +273,31 @@ export class SourceControlExportService { const { name, type, data, id } = sharing.credentials; const credentials = new Credentials({ id, name }, type, data); + let owner: ResourceOwner | null = null; + if (sharing.project.type === 'personal') { + const ownerRelation = sharing.project.projectRelations.find( + (pr) => pr.role === 'project:personalOwner', + ); + if (ownerRelation) { + owner = { + type: 'personal', + personalEmail: ownerRelation.user.email, + }; + } + } else if (sharing.project.type === 'team') { + owner = { + type: 'team', + teamId: sharing.project.id, + teamName: sharing.project.name, + }; + } + const stub: ExportableCredential = { id, name, type, data: this.replaceCredentialData(credentials.getData()), - ownedBy: sharing.user.email, + ownedBy: owner, }; const filePath = this.getCredentialsPath(id); diff --git a/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts index 604e733e5..6a497c441 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts @@ -26,13 +26,17 @@ import type { SourceControlledFile } from './types/sourceControlledFile'; import { VariablesService } from '../variables/variables.service.ee'; import { TagRepository } from '@db/repositories/tag.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; -import { UserRepository } from '@db/repositories/user.repository'; import { Logger } from '@/Logger'; import { CredentialsRepository } from '@db/repositories/credentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { WorkflowTagMappingRepository } from '@db/repositories/workflowTagMapping.repository'; import { VariablesRepository } from '@db/repositories/variables.repository'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import type { Project } from '@/databases/entities/Project'; +import type { ResourceOwner } from './types/resourceOwner'; +import { assertNever } from '@/utils'; +import { UserRepository } from '@/databases/repositories/user.repository'; @Service() export class SourceControlImportService { @@ -203,116 +207,94 @@ export class SourceControlImportService { } public async importWorkflowFromWorkFolder(candidates: SourceControlledFile[], userId: string) { - const workflowRunner = this.activeWorkflowManager; + const personalProject = + await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId); + const workflowManager = this.activeWorkflowManager; const candidateIds = candidates.map((c) => c.id); const existingWorkflows = await Container.get(WorkflowRepository).findByIds(candidateIds, { fields: ['id', 'name', 'versionId', 'active'], }); const allSharedWorkflows = await Container.get(SharedWorkflowRepository).findWithFields( candidateIds, - { select: ['workflowId', 'role', 'userId'] }, + { select: ['workflowId', 'role', 'projectId'] }, ); - const cachedOwnerIds = new Map(); - const importWorkflowsResult = await Promise.all( - candidates.map(async (candidate) => { - this.logger.debug(`Parsing workflow file ${candidate.file}`); - const importedWorkflow = jsonParse( - await fsReadFile(candidate.file, { encoding: 'utf8' }), - ); - if (!importedWorkflow?.id) { - return; - } - const existingWorkflow = existingWorkflows.find((e) => e.id === importedWorkflow.id); - importedWorkflow.active = existingWorkflow?.active ?? false; - this.logger.debug(`Updating workflow id ${importedWorkflow.id ?? 'new'}`); - const upsertResult = await Container.get(WorkflowRepository).upsert( - { ...importedWorkflow }, - ['id'], - ); - if (upsertResult?.identifiers?.length !== 1) { - throw new ApplicationError('Failed to upsert workflow', { - extra: { workflowId: importedWorkflow.id ?? 'new' }, - }); - } - // Update workflow owner to the user who exported the workflow, if that user exists - // in the instance, and the workflow doesn't already have an owner - let workflowOwnerId = userId; - if (cachedOwnerIds.has(importedWorkflow.owner)) { - workflowOwnerId = cachedOwnerIds.get(importedWorkflow.owner) ?? userId; - } else { - const foundUser = await Container.get(UserRepository).findOne({ - where: { - email: importedWorkflow.owner, - }, - select: ['id'], - }); - if (foundUser) { - cachedOwnerIds.set(importedWorkflow.owner, foundUser.id); - workflowOwnerId = foundUser.id; - } - } + const importWorkflowsResult = []; - const existingSharedWorkflowOwnerByRoleId = allSharedWorkflows.find( - (e) => e.workflowId === importedWorkflow.id && e.role === 'workflow:owner', - ); - const existingSharedWorkflowOwnerByUserId = allSharedWorkflows.find( - (e) => e.workflowId === importedWorkflow.id && e.role === 'workflow:owner', - ); - if (!existingSharedWorkflowOwnerByUserId && !existingSharedWorkflowOwnerByRoleId) { - // no owner exists yet, so create one - await Container.get(SharedWorkflowRepository).insert({ + // Due to SQLite concurrency issues, we cannot save all workflows at once + // as project creation might cause constraint issues. + // We must iterate over the array and run the whole process workflow by workflow + for (const candidate of candidates) { + this.logger.debug(`Parsing workflow file ${candidate.file}`); + const importedWorkflow = jsonParse( + await fsReadFile(candidate.file, { encoding: 'utf8' }), + ); + if (!importedWorkflow?.id) { + continue; + } + const existingWorkflow = existingWorkflows.find((e) => e.id === importedWorkflow.id); + importedWorkflow.active = existingWorkflow?.active ?? false; + this.logger.debug(`Updating workflow id ${importedWorkflow.id ?? 'new'}`); + const upsertResult = await Container.get(WorkflowRepository).upsert({ ...importedWorkflow }, [ + 'id', + ]); + if (upsertResult?.identifiers?.length !== 1) { + throw new ApplicationError('Failed to upsert workflow', { + extra: { workflowId: importedWorkflow.id ?? 'new' }, + }); + } + + const isOwnedLocally = allSharedWorkflows.some( + (w) => w.workflowId === importedWorkflow.id && w.role === 'workflow:owner', + ); + + if (!isOwnedLocally) { + const remoteOwnerProject: Project | null = importedWorkflow.owner + ? await this.findOrCreateOwnerProject(importedWorkflow.owner) + : null; + + await Container.get(SharedWorkflowRepository).upsert( + { workflowId: importedWorkflow.id, - userId: workflowOwnerId, + projectId: remoteOwnerProject?.id ?? personalProject.id, role: 'workflow:owner', - }); - } else if (existingSharedWorkflowOwnerByRoleId) { - // skip, because the workflow already has a global owner - } else if (existingSharedWorkflowOwnerByUserId && !existingSharedWorkflowOwnerByRoleId) { - // if the workflow has a non-global owner that is referenced by the owner file, - // and no existing global owner, update the owner to the user referenced in the owner file - await Container.get(SharedWorkflowRepository).update( - { - workflowId: importedWorkflow.id, - userId: workflowOwnerId, - }, - { role: 'workflow:owner' }, + }, + ['workflowId', 'projectId'], + ); + } + + if (existingWorkflow?.active) { + try { + // remove active pre-import workflow + this.logger.debug(`Deactivating workflow id ${existingWorkflow.id}`); + await workflowManager.remove(existingWorkflow.id); + // try activating the imported workflow + this.logger.debug(`Reactivating workflow id ${existingWorkflow.id}`); + await workflowManager.add(existingWorkflow.id, 'activate'); + // update the versionId of the workflow to match the imported workflow + } catch (error) { + this.logger.error(`Failed to activate workflow ${existingWorkflow.id}`, error as Error); + } finally { + await Container.get(WorkflowRepository).update( + { id: existingWorkflow.id }, + { versionId: importedWorkflow.versionId }, ); } - if (existingWorkflow?.active) { - try { - // remove active pre-import workflow - this.logger.debug(`Deactivating workflow id ${existingWorkflow.id}`); - await workflowRunner.remove(existingWorkflow.id); - // try activating the imported workflow - this.logger.debug(`Reactivating workflow id ${existingWorkflow.id}`); - await workflowRunner.add(existingWorkflow.id, 'activate'); - // update the versionId of the workflow to match the imported workflow - } catch (error) { - this.logger.error(`Failed to activate workflow ${existingWorkflow.id}`, error as Error); - } finally { - await Container.get(WorkflowRepository).update( - { id: existingWorkflow.id }, - { versionId: importedWorkflow.versionId }, - ); - } - } + } - return { - id: importedWorkflow.id ?? 'unknown', - name: candidate.file, - }; - }), - ); + importWorkflowsResult.push({ + id: importedWorkflow.id ?? 'unknown', + name: candidate.file, + }); + } return importWorkflowsResult.filter((e) => e !== undefined) as Array<{ id: string; name: string; }>; } - public async importCredentialsFromWorkFolder( - candidates: SourceControlledFile[], - importingUserId: string, - ) { + public async importCredentialsFromWorkFolder(candidates: SourceControlledFile[], userId: string) { + const personalProject = + await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId); const candidateIds = candidates.map((c) => c.id); const existingCredentials = await Container.get(CredentialsRepository).find({ where: { @@ -321,7 +303,7 @@ export class SourceControlImportService { select: ['id', 'name', 'type', 'data'], }); const existingSharedCredentials = await Container.get(SharedCredentialsRepository).find({ - select: ['userId', 'credentialsId', 'role'], + select: ['credentialsId', 'role'], where: { credentialsId: In(candidateIds), role: 'credential:owner', @@ -350,27 +332,22 @@ export class SourceControlImportService { await Container.get(CredentialsRepository).upsert(newCredentialObject, ['id']); const isOwnedLocally = existingSharedCredentials.some( - (c) => c.credentialsId === credential.id, + (c) => c.credentialsId === credential.id && c.role === 'credential:owner', ); if (!isOwnedLocally) { - const remoteOwnerId = credential.ownedBy - ? await Container.get(UserRepository) - .findOne({ - where: { email: credential.ownedBy }, - select: { id: true }, - }) - .then((user) => user?.id) + const remoteOwnerProject: Project | null = credential.ownedBy + ? await this.findOrCreateOwnerProject(credential.ownedBy) : null; const newSharedCredential = new SharedCredentials(); newSharedCredential.credentialsId = newCredentialObject.id as string; - newSharedCredential.userId = remoteOwnerId ?? importingUserId; + newSharedCredential.projectId = remoteOwnerProject?.id ?? personalProject.id; newSharedCredential.role = 'credential:owner'; await Container.get(SharedCredentialsRepository).upsert({ ...newSharedCredential }, [ 'credentialsId', - 'userId', + 'projectId', ]); } @@ -469,7 +446,7 @@ export class SourceControlImportService { if (!variable.key) { continue; } - // by default no value is stored remotely, so an empty string is retuned + // by default no value is stored remotely, so an empty string is returned // it must be changed to undefined so as to not overwrite existing values! if (variable.value === '') { variable.value = undefined; @@ -511,4 +488,52 @@ export class SourceControlImportService { return result; } + + private async findOrCreateOwnerProject(owner: ResourceOwner): Promise { + const projectRepository = Container.get(ProjectRepository); + const userRepository = Container.get(UserRepository); + if (typeof owner === 'string' || owner.type === 'personal') { + const email = typeof owner === 'string' ? owner : owner.personalEmail; + const user = await userRepository.findOne({ + where: { email }, + }); + if (!user) { + return null; + } + return await projectRepository.getPersonalProjectForUserOrFail(user.id); + } else if (owner.type === 'team') { + let teamProject = await projectRepository.findOne({ + where: { id: owner.teamId }, + }); + if (!teamProject) { + try { + teamProject = await projectRepository.save( + projectRepository.create({ + id: owner.teamId, + name: owner.teamName, + type: 'team', + }), + ); + } catch (e) { + teamProject = await projectRepository.findOne({ + where: { id: owner.teamId }, + }); + if (!teamProject) { + throw e; + } + } + } + + return teamProject; + } + + assertNever(owner); + + const errorOwner = owner as ResourceOwner; + throw new ApplicationError( + `Unknown resource owner type "${ + typeof errorOwner !== 'string' ? errorOwner.type : 'UNKNOWN' + }" found when importing from source controller`, + ); + } } diff --git a/packages/cli/src/environments/sourceControl/types/exportableCredential.ts b/packages/cli/src/environments/sourceControl/types/exportableCredential.ts index 36197da6c..7ef071117 100644 --- a/packages/cli/src/environments/sourceControl/types/exportableCredential.ts +++ b/packages/cli/src/environments/sourceControl/types/exportableCredential.ts @@ -1,4 +1,5 @@ import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; +import type { ResourceOwner } from './resourceOwner'; export interface ExportableCredential { id: string; @@ -10,5 +11,5 @@ export interface ExportableCredential { * Email of the user who owns this credential at the source instance. * Ownership is mirrored at target instance if user is also present there. */ - ownedBy: string | null; + ownedBy: ResourceOwner | null; } diff --git a/packages/cli/src/environments/sourceControl/types/exportableWorkflow.ts b/packages/cli/src/environments/sourceControl/types/exportableWorkflow.ts index 26b866ddc..a0803bce8 100644 --- a/packages/cli/src/environments/sourceControl/types/exportableWorkflow.ts +++ b/packages/cli/src/environments/sourceControl/types/exportableWorkflow.ts @@ -1,4 +1,5 @@ import type { INode, IConnections, IWorkflowSettings } from 'n8n-workflow'; +import type { ResourceOwner } from './resourceOwner'; export interface ExportableWorkflow { id: string; @@ -8,5 +9,5 @@ export interface ExportableWorkflow { settings?: IWorkflowSettings; triggerCount: number; versionId: string; - owner: string; + owner: ResourceOwner; } diff --git a/packages/cli/src/environments/sourceControl/types/resourceOwner.ts b/packages/cli/src/environments/sourceControl/types/resourceOwner.ts new file mode 100644 index 000000000..292ea9f18 --- /dev/null +++ b/packages/cli/src/environments/sourceControl/types/resourceOwner.ts @@ -0,0 +1,11 @@ +export type ResourceOwner = + | string + | { + type: 'personal'; + personalEmail: string; + } + | { + type: 'team'; + teamId: string; + teamName: string; + }; diff --git a/packages/cli/src/environments/variables/variables.controller.ee.ts b/packages/cli/src/environments/variables/variables.controller.ee.ts index 16a59f26d..d2b7f62b9 100644 --- a/packages/cli/src/environments/variables/variables.controller.ee.ts +++ b/packages/cli/src/environments/variables/variables.controller.ee.ts @@ -1,5 +1,5 @@ import { VariablesRequest } from '@/requests'; -import { Delete, Get, Licensed, Patch, Post, GlobalScope, RestController } from '@/decorators'; +import { Delete, Get, GlobalScope, Licensed, Patch, Post, RestController } from '@/decorators'; import { VariablesService } from './variables.service.ee'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; diff --git a/packages/cli/src/errors/response-errors/forbidden.error.ts b/packages/cli/src/errors/response-errors/forbidden.error.ts new file mode 100644 index 000000000..4856f7cd4 --- /dev/null +++ b/packages/cli/src/errors/response-errors/forbidden.error.ts @@ -0,0 +1,7 @@ +import { ResponseError } from './abstract/response.error'; + +export class ForbiddenError extends ResponseError { + constructor(message = 'Forbidden', hint?: string) { + super(message, 403, 403, hint); + } +} diff --git a/packages/cli/src/errors/response-errors/unauthenticated.error.ts b/packages/cli/src/errors/response-errors/unauthenticated.error.ts new file mode 100644 index 000000000..7f1409da7 --- /dev/null +++ b/packages/cli/src/errors/response-errors/unauthenticated.error.ts @@ -0,0 +1,7 @@ +import { ResponseError } from './abstract/response.error'; + +export class UnauthenticatedError extends ResponseError { + constructor(message = 'Unauthenticated', hint?: string) { + super(message, 401, 401, hint); + } +} diff --git a/packages/cli/src/errors/response-errors/unauthorized.error.ts b/packages/cli/src/errors/response-errors/unauthorized.error.ts deleted file mode 100644 index bc8993c01..000000000 --- a/packages/cli/src/errors/response-errors/unauthorized.error.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ResponseError } from './abstract/response.error'; - -export class UnauthorizedError extends ResponseError { - constructor(message: string, hint: string | undefined = undefined) { - super(message, 403, 403, hint); - } -} diff --git a/packages/cli/src/executions/execution.service.ee.ts b/packages/cli/src/executions/execution.service.ee.ts index 29e2c5b8f..a7e9a5175 100644 --- a/packages/cli/src/executions/execution.service.ee.ts +++ b/packages/cli/src/executions/execution.service.ee.ts @@ -22,23 +22,24 @@ export class EnterpriseExecutionsService { if (!execution) return; - const relations = ['shared', 'shared.user']; - - const workflow = (await this.workflowRepository.get( - { id: execution.workflowId }, - { relations }, - )) as WorkflowWithSharingsAndCredentials; + const workflow = (await this.workflowRepository.get({ + id: execution.workflowId, + })) as WorkflowWithSharingsAndCredentials; if (!workflow) return; - this.enterpriseWorkflowService.addOwnerAndSharings(workflow); - await this.enterpriseWorkflowService.addCredentialsToWorkflow(workflow, req.user); + const workflowWithSharingsMetaData = + this.enterpriseWorkflowService.addOwnerAndSharings(workflow); + await this.enterpriseWorkflowService.addCredentialsToWorkflow( + workflowWithSharingsMetaData, + req.user, + ); execution.workflowData = { ...execution.workflowData, - ownedBy: workflow.ownedBy, - sharedWith: workflow.sharedWith, - usedCredentials: workflow.usedCredentials, + homeProject: workflowWithSharingsMetaData.homeProject, + sharedWithProjects: workflowWithSharingsMetaData.sharedWithProjects, + usedCredentials: workflowWithSharingsMetaData.usedCredentials, } as WorkflowWithSharingsAndCredentials; return execution; diff --git a/packages/cli/src/executions/executions.controller.ts b/packages/cli/src/executions/executions.controller.ts index bffb0c383..9f7fd1b74 100644 --- a/packages/cli/src/executions/executions.controller.ts +++ b/packages/cli/src/executions/executions.controller.ts @@ -7,6 +7,7 @@ import { WorkflowSharingService } from '@/workflows/workflowSharing.service'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { parseRangeQuery } from './parse-range-query.middleware'; import type { User } from '@/databases/entities/User'; +import type { Scope } from '@n8n/permissions'; @RestController('/executions') export class ExecutionsController { @@ -17,15 +18,20 @@ export class ExecutionsController { private readonly license: License, ) {} - private async getAccessibleWorkflowIds(user: User) { - return this.license.isSharingEnabled() - ? await this.workflowSharingService.getSharedWorkflowIds(user) - : await this.workflowSharingService.getSharedWorkflowIds(user, ['workflow:owner']); + private async getAccessibleWorkflowIds(user: User, scope: Scope) { + if (this.license.isSharingEnabled()) { + return await this.workflowSharingService.getSharedWorkflowIds(user, { scopes: [scope] }); + } else { + return await this.workflowSharingService.getSharedWorkflowIds(user, { + workflowRoles: ['workflow:owner'], + projectRoles: ['project:personalOwner'], + }); + } } @Get('/', { middlewares: [parseRangeQuery] }) async getMany(req: ExecutionRequest.GetMany) { - const accessibleWorkflowIds = await this.getAccessibleWorkflowIds(req.user); + const accessibleWorkflowIds = await this.getAccessibleWorkflowIds(req.user, 'workflow:read'); if (accessibleWorkflowIds.length === 0) { return { count: 0, estimated: false, results: [] }; @@ -53,7 +59,7 @@ export class ExecutionsController { @Get('/:id') async getOne(req: ExecutionRequest.GetOne) { - const workflowIds = await this.getAccessibleWorkflowIds(req.user); + const workflowIds = await this.getAccessibleWorkflowIds(req.user, 'workflow:read'); if (workflowIds.length === 0) throw new NotFoundError('Execution not found'); @@ -64,7 +70,7 @@ export class ExecutionsController { @Post('/:id/stop') async stop(req: ExecutionRequest.Stop) { - const workflowIds = await this.getAccessibleWorkflowIds(req.user); + const workflowIds = await this.getAccessibleWorkflowIds(req.user, 'workflow:execute'); if (workflowIds.length === 0) throw new NotFoundError('Execution not found'); @@ -73,7 +79,7 @@ export class ExecutionsController { @Post('/:id/retry') async retry(req: ExecutionRequest.Retry) { - const workflowIds = await this.getAccessibleWorkflowIds(req.user); + const workflowIds = await this.getAccessibleWorkflowIds(req.user, 'workflow:execute'); if (workflowIds.length === 0) throw new NotFoundError('Execution not found'); @@ -82,7 +88,7 @@ export class ExecutionsController { @Post('/delete') async delete(req: ExecutionRequest.Delete) { - const workflowIds = await this.getAccessibleWorkflowIds(req.user); + const workflowIds = await this.getAccessibleWorkflowIds(req.user, 'workflow:execute'); if (workflowIds.length === 0) throw new NotFoundError('Execution not found'); diff --git a/packages/cli/src/jest.d.ts b/packages/cli/src/jest.d.ts index dcb4e4e7b..af1963de3 100644 --- a/packages/cli/src/jest.d.ts +++ b/packages/cli/src/jest.d.ts @@ -1,5 +1,7 @@ namespace jest { interface Matchers { toBeEmptyArray(): T; + toBeEmptySet(): T; + toBeSetContaining(...items: string[]): T; } } diff --git a/packages/cli/src/license/license.controller.ts b/packages/cli/src/license/license.controller.ts index 086ab3d4f..c9b70609d 100644 --- a/packages/cli/src/license/license.controller.ts +++ b/packages/cli/src/license/license.controller.ts @@ -1,4 +1,4 @@ -import { Get, Post, GlobalScope, RestController } from '@/decorators'; +import { Get, Post, RestController, GlobalScope } from '@/decorators'; import { LicenseRequest } from '@/requests'; import { LicenseService } from './license.service'; diff --git a/packages/cli/src/middlewares/listQuery/dtos/credentials.filter.dto.ts b/packages/cli/src/middlewares/listQuery/dtos/credentials.filter.dto.ts index 5b5cdb1a6..191799b15 100644 --- a/packages/cli/src/middlewares/listQuery/dtos/credentials.filter.dto.ts +++ b/packages/cli/src/middlewares/listQuery/dtos/credentials.filter.dto.ts @@ -13,6 +13,11 @@ export class CredentialsFilter extends BaseFilter { @Expose() type?: string; + @IsString() + @IsOptional() + @Expose() + projectId?: string; + static async fromString(rawFilter: string) { return await this.toFilter(rawFilter, CredentialsFilter); } diff --git a/packages/cli/src/middlewares/listQuery/dtos/workflow.filter.dto.ts b/packages/cli/src/middlewares/listQuery/dtos/workflow.filter.dto.ts index cadb945a6..d608589f0 100644 --- a/packages/cli/src/middlewares/listQuery/dtos/workflow.filter.dto.ts +++ b/packages/cli/src/middlewares/listQuery/dtos/workflow.filter.dto.ts @@ -20,6 +20,11 @@ export class WorkflowFilter extends BaseFilter { @Expose() tags?: string[]; + @IsString() + @IsOptional() + @Expose() + projectId?: string; + static async fromString(rawFilter: string) { return await this.toFilter(rawFilter, WorkflowFilter); } diff --git a/packages/cli/src/permissions/checkAccess.ts b/packages/cli/src/permissions/checkAccess.ts new file mode 100644 index 000000000..f0b4166a4 --- /dev/null +++ b/packages/cli/src/permissions/checkAccess.ts @@ -0,0 +1,87 @@ +import { Container } from 'typedi'; +import { In } from '@n8n/typeorm'; + +import { RoleService } from '@/services/role.service'; +import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; +import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; +import { ProjectRepository } from '@db/repositories/project.repository'; +import type { User } from '@/databases/entities/User'; +import type { Scope } from '@n8n/permissions'; +import { ApplicationError } from 'n8n-workflow'; + +export const userHasScope = async ( + user: User, + scopes: Scope[], + globalOnly: boolean, + { + credentialId, + workflowId, + projectId, + }: { credentialId?: string; workflowId?: string; projectId?: string }, +): Promise => { + // Short circuit here since a global role will always have access + if (user.hasGlobalScope(scopes, { mode: 'allOf' })) { + return true; + } else if (globalOnly) { + // The above check already failed so the user doesn't have access + return false; + } + + const roleService = Container.get(RoleService); + const projectRoles = roleService.rolesWithScope('project', scopes); + const userProjectIds = ( + await Container.get(ProjectRepository).find({ + where: { + projectRelations: { + userId: user.id, + role: In(projectRoles), + }, + }, + select: ['id'], + }) + ).map((p) => p.id); + + if (credentialId) { + const exists = await Container.get(SharedCredentialsRepository).find({ + where: { + projectId: In(userProjectIds), + credentialsId: credentialId, + role: In(roleService.rolesWithScope('credential', scopes)), + }, + }); + + if (!exists.length) { + return false; + } + + return true; + } + + if (workflowId) { + const exists = await Container.get(SharedWorkflowRepository).find({ + where: { + projectId: In(userProjectIds), + workflowId, + role: In(roleService.rolesWithScope('workflow', scopes)), + }, + }); + + if (!exists.length) { + return false; + } + + return true; + } + + if (projectId) { + if (!userProjectIds.includes(projectId)) { + return false; + } + + return true; + } + + throw new ApplicationError( + "@ProjectScope decorator was used but does not have a credentialId, workflowId, or projectId in it's URL parameters. This is likely an implementation error. If you're a developer, please check you're URL is correct or that this should be using @GlobalScope.", + ); +}; diff --git a/packages/cli/src/permissions/roles.ts b/packages/cli/src/permissions/global-roles.ts similarity index 86% rename from packages/cli/src/permissions/roles.ts rename to packages/cli/src/permissions/global-roles.ts index 68d61af0b..17303d2af 100644 --- a/packages/cli/src/permissions/roles.ts +++ b/packages/cli/src/permissions/global-roles.ts @@ -1,6 +1,6 @@ import type { Scope } from '@n8n/permissions'; -export const ownerPermissions: Scope[] = [ +export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'auditLogs:manage', 'banner:dismiss', 'credential:create', @@ -41,6 +41,7 @@ export const ownerPermissions: Scope[] = [ 'orchestration:read', 'orchestration:list', 'saml:manage', + 'securityAudit:generate', 'sourceControl:pull', 'sourceControl:push', 'sourceControl:manage', @@ -69,9 +70,16 @@ export const ownerPermissions: Scope[] = [ 'workflow:share', 'workflow:execute', 'workersView:manage', + 'project:list', + 'project:create', + 'project:read', + 'project:update', + 'project:delete', ]; -export const adminPermissions: Scope[] = ownerPermissions.concat(); -export const memberPermissions: Scope[] = [ + +export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat(); + +export const GLOBAL_MEMBER_SCOPES: Scope[] = [ 'eventBusEvent:list', 'eventBusEvent:read', 'eventBusDestination:list', diff --git a/packages/cli/src/permissions/project-roles.ts b/packages/cli/src/permissions/project-roles.ts new file mode 100644 index 000000000..3c649fb5e --- /dev/null +++ b/packages/cli/src/permissions/project-roles.ts @@ -0,0 +1,59 @@ +import type { Scope } from '@n8n/permissions'; + +/** + * Diff between admin in personal project and admin in other projects: + * - You cannot rename your personal project. + * - You cannot invite people to your personal project. + */ + +export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [ + 'workflow:create', + 'workflow:read', + 'workflow:update', + 'workflow:delete', + 'workflow:list', + 'workflow:execute', + 'credential:create', + 'credential:read', + 'credential:update', + 'credential:delete', + 'credential:list', + 'project:list', + 'project:read', + 'project:update', + 'project:delete', +]; + +export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [ + 'workflow:create', + 'workflow:read', + 'workflow:update', + 'workflow:delete', + 'workflow:list', + 'workflow:execute', + 'workflow:share', + 'credential:create', + 'credential:read', + 'credential:update', + 'credential:delete', + 'credential:list', + 'credential:share', + 'project:list', + 'project:read', +]; + +export const PROJECT_EDITOR_SCOPES: Scope[] = [ + 'workflow:create', + 'workflow:read', + 'workflow:update', + 'workflow:delete', + 'workflow:list', + 'workflow:execute', + 'credential:create', + 'credential:read', + 'credential:update', + 'credential:delete', + 'credential:list', + 'project:list', + 'project:read', +]; diff --git a/packages/cli/src/permissions/resource-roles.ts b/packages/cli/src/permissions/resource-roles.ts new file mode 100644 index 000000000..429242a0c --- /dev/null +++ b/packages/cli/src/permissions/resource-roles.ts @@ -0,0 +1,24 @@ +import type { Scope } from '@n8n/permissions'; + +export const CREDENTIALS_SHARING_OWNER_SCOPES: Scope[] = [ + 'credential:read', + 'credential:update', + 'credential:delete', + 'credential:share', +]; + +export const CREDENTIALS_SHARING_USER_SCOPES: Scope[] = ['credential:read']; + +export const WORKFLOW_SHARING_OWNER_SCOPES: Scope[] = [ + 'workflow:read', + 'workflow:update', + 'workflow:delete', + 'workflow:execute', + 'workflow:share', +]; + +export const WORKFLOW_SHARING_EDITOR_SCOPES: Scope[] = [ + 'workflow:read', + 'workflow:update', + 'workflow:execute', +]; diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 36fc8940b..f58e8ad46 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -14,11 +14,15 @@ import { Expose } from 'class-transformer'; import { IsBoolean, IsEmail, IsIn, IsOptional, IsString, Length } from 'class-validator'; import { NoXss } from '@db/utils/customValidators'; import type { PublicUser, SecretsProvider, SecretsProviderState } from '@/Interfaces'; -import { AssignableRole, type User } from '@db/entities/User'; +import { AssignableRole } from '@db/entities/User'; +import type { GlobalRole, User } from '@db/entities/User'; import type { Variables } from '@db/entities/Variables'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; import type { WorkflowHistory } from '@db/entities/WorkflowHistory'; +import type { Project, ProjectType } from '@db/entities/Project'; +import type { ProjectRole } from './databases/entities/ProjectRelation'; +import type { Scope } from '@n8n/permissions'; export class UserUpdatePayload implements Pick { @Expose() @@ -118,7 +122,9 @@ export namespace ListQuery { type SharedField = Partial>; - type OwnedByField = { ownedBy: SlimUser | null }; + type OwnedByField = { ownedBy: SlimUser | null; homeProject: SlimProject | null }; + + type ScopesField = { scopes: Scope[] }; export type Plain = BaseFields; @@ -126,23 +132,38 @@ export namespace ListQuery { export type WithOwnership = BaseFields & OwnedByField; - type SharedWithField = { sharedWith: SlimUser[] }; + type SharedWithField = { sharedWith: SlimUser[]; sharedWithProjects: SlimProject[] }; - export type WithOwnedByAndSharedWith = BaseFields & OwnedByField & SharedWithField; + export type WithOwnedByAndSharedWith = BaseFields & + OwnedByField & + SharedWithField & + SharedField; + + export type WithScopes = BaseFields & ScopesField & SharedField; } export namespace Credentials { - type OwnedByField = { ownedBy: SlimUser | null }; + type OwnedByField = { homeProject: SlimProject | null }; - type SharedWithField = { sharedWith: SlimUser[] }; + type SharedField = Partial>; - export type WithSharing = CredentialsEntity & Partial>; + type SharedWithField = { sharedWithProjects: SlimProject[] }; - export type WithOwnedByAndSharedWith = CredentialsEntity & OwnedByField & SharedWithField; + type ScopesField = { scopes: Scope[] }; + + export type WithSharing = CredentialsEntity & SharedField; + + export type WithOwnedByAndSharedWith = CredentialsEntity & + OwnedByField & + SharedWithField & + SharedField; + + export type WithScopes = CredentialsEntity & ScopesField & SharedField; } } type SlimUser = Pick; +export type SlimProject = Pick; export function hasSharing( workflows: ListQuery.Workflow.Plain[] | ListQuery.Workflow.WithSharing[], @@ -169,27 +190,32 @@ export interface AIGenerateCurlPayload { export declare namespace CredentialRequest { type CredentialProperties = Partial<{ - id: string; // delete if sent + id: string; // deleted if sent name: string; type: string; data: ICredentialDataDecryptedObject; + projectId?: string; }>; type Create = AuthenticatedRequest<{}, {}, CredentialProperties>; - type Get = AuthenticatedRequest<{ id: string }, {}, {}, Record>; + type Get = AuthenticatedRequest<{ credentialId: string }, {}, {}, Record>; + + type GetMany = AuthenticatedRequest<{}, {}, {}, ListQuery.Params & { includeScopes?: string }> & { + listQueryOptions: ListQuery.Options; + }; type Delete = Get; type GetAll = AuthenticatedRequest<{}, {}, {}, { filter: string }>; - type Update = AuthenticatedRequest<{ id: string }, {}, CredentialProperties>; + type Update = AuthenticatedRequest<{ credentialId: string }, {}, CredentialProperties>; type NewName = AuthenticatedRequest<{}, {}, {}, { name?: string }>; type Test = AuthenticatedRequest<{}, {}, INodeCredentialTestRequest>; - type Share = AuthenticatedRequest<{ id: string }, {}, { shareWithIds: string[] }>; + type Share = AuthenticatedRequest<{ credentialId: string }, {}, { shareWithIds: string[] }>; } // ---------------------------------- @@ -526,3 +552,57 @@ export declare namespace ActiveWorkflowRequest { type GetActivationError = AuthenticatedRequest<{ id: string }>; } + +// ---------------------------------- +// /projects +// ---------------------------------- + +export declare namespace ProjectRequest { + type GetAll = AuthenticatedRequest<{}, Project[]>; + + type Create = AuthenticatedRequest< + {}, + Project, + { + name: string; + } + >; + + type GetMyProjects = AuthenticatedRequest< + {}, + Array, + {}, + { + includeScopes?: boolean; + } + >; + type GetMyProjectsResponse = Array< + Project & { role: ProjectRole | GlobalRole; scopes?: Scope[] } + >; + + type GetPersonalProject = AuthenticatedRequest<{}, Project>; + + type ProjectRelationPayload = { userId: string; role: ProjectRole }; + type ProjectRelationResponse = { + id: string; + email: string; + firstName: string; + lastName: string; + role: ProjectRole; + }; + type ProjectWithRelations = { + id: string; + name: string | undefined; + type: ProjectType; + relations: ProjectRelationResponse[]; + scopes: Scope[]; + }; + + type Get = AuthenticatedRequest<{ projectId: string }, {}>; + type Update = AuthenticatedRequest< + { projectId: string }, + {}, + { name?: string; relations?: ProjectRelationPayload[] } + >; + type Delete = AuthenticatedRequest<{ projectId: string }, {}, {}, { transferId?: string }>; +} diff --git a/packages/cli/src/services/activeWorkflows.service.ts b/packages/cli/src/services/activeWorkflows.service.ts index 25de43fd1..ae2c083d7 100644 --- a/packages/cli/src/services/activeWorkflows.service.ts +++ b/packages/cli/src/services/activeWorkflows.service.ts @@ -37,8 +37,10 @@ export class ActiveWorkflowsService { } async getActivationError(workflowId: string, user: User) { - const hasAccess = await this.sharedWorkflowRepository.hasAccess(workflowId, user); - if (!hasAccess) { + const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [ + 'workflow:read', + ]); + if (!workflow) { this.logger.verbose('User attempted to access workflow errors without permissions', { workflowId, userId: user.id, diff --git a/packages/cli/src/services/credentials-tester.service.ts b/packages/cli/src/services/credentials-tester.service.ts index bcb48e495..c9ddc7b15 100644 --- a/packages/cli/src/services/credentials-tester.service.ts +++ b/packages/cli/src/services/credentials-tester.service.ts @@ -194,7 +194,7 @@ export class CredentialsTester { 'internal' as WorkflowExecuteMode, undefined, undefined, - user.hasGlobalScope('externalSecret:use'), + await this.credentialsHelper.credentialCanUseExternalSecrets(credentialsDecrypted), ); } catch (error) { this.logger.debug('Credential test failed', error); diff --git a/packages/cli/src/services/events.service.ts b/packages/cli/src/services/events.service.ts index 10a0e7dc6..8017597e4 100644 --- a/packages/cli/src/services/events.service.ts +++ b/packages/cli/src/services/events.service.ts @@ -49,21 +49,26 @@ export class EventsService extends EventEmitter { const upsertResult = await this.repository.upsertWorkflowStatistics(name, workflowId); if (name === StatisticsNames.productionSuccess && upsertResult === 'insert') { - const owner = await Container.get(OwnershipService).getWorkflowOwnerCached(workflowId); - const metrics = { - user_id: owner.id, - workflow_id: workflowId, - }; + const project = await Container.get(OwnershipService).getWorkflowProjectCached(workflowId); + if (project.type === 'personal') { + const owner = await Container.get(OwnershipService).getProjectOwnerCached(project.id); - if (!owner.settings?.userActivated) { - await Container.get(UserService).updateSettings(owner.id, { - firstSuccessfulWorkflowId: workflowId, - userActivated: true, - }); + const metrics = { + project_id: project.id, + workflow_id: workflowId, + user_id: owner?.id, + }; + + if (owner && !owner.settings?.userActivated) { + await Container.get(UserService).updateSettings(owner.id, { + firstSuccessfulWorkflowId: workflowId, + userActivated: true, + }); + } + + // Send the metrics + this.emit('telemetry.onFirstProductionWorkflowSuccess', metrics); } - - // Send the metrics - this.emit('telemetry.onFirstProductionWorkflowSuccess', metrics); } } catch (error) { this.logger.verbose('Unable to fire first workflow success telemetry event'); @@ -80,10 +85,12 @@ export class EventsService extends EventEmitter { if (insertResult === 'failed' || insertResult === 'alreadyExists') return; // Compile the metrics since this was a new data loaded event - const owner = await this.ownershipService.getWorkflowOwnerCached(workflowId); + const project = await this.ownershipService.getWorkflowProjectCached(workflowId); + const owner = await this.ownershipService.getProjectOwnerCached(project.id); let metrics = { - user_id: owner.id, + user_id: owner?.id, + project_id: project.id, workflow_id: workflowId, node_type: node.type, node_id: node.id, diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 95670b823..65f0330b5 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -185,6 +185,11 @@ export class FrontendService { workflowHistory: false, workerView: false, advancedPermissions: false, + projects: { + team: { + limit: 0, + }, + }, }, mfa: { enabled: false, @@ -318,6 +323,8 @@ export class FrontendService { this.settings.binaryDataMode = config.getEnv('binaryDataManager.mode'); + this.settings.enterprise.projects.team.limit = this.license.getTeamProjectLimit(); + return this.settings; } diff --git a/packages/cli/src/services/import.service.ts b/packages/cli/src/services/import.service.ts index 11b726225..c2226c65b 100644 --- a/packages/cli/src/services/import.service.ts +++ b/packages/cli/src/services/import.service.ts @@ -1,4 +1,4 @@ -import { Service } from 'typedi'; +import Container, { Service } from 'typedi'; import { v4 as uuid } from 'uuid'; import { type INode, type INodeCredentialsDetails } from 'n8n-workflow'; @@ -12,6 +12,7 @@ import { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { WorkflowTagMapping } from '@db/entities/WorkflowTagMapping'; import type { TagEntity } from '@db/entities/TagEntity'; import type { ICredentialsDb } from '@/Interfaces'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; @Service() export class ImportService { @@ -30,7 +31,7 @@ export class ImportService { this.dbTags = await this.tagRepository.find(); } - async importWorkflows(workflows: WorkflowEntity[], userId: string) { + async importWorkflows(workflows: WorkflowEntity[], projectId: string) { await this.initRecords(); for (const workflow of workflows) { @@ -58,12 +59,17 @@ export class ImportService { const upsertResult = await tx.upsert(WorkflowEntity, workflow, ['id']); const workflowId = upsertResult.identifiers.at(0)?.id as string; + const personalProject = await Container.get(ProjectRepository).findOneByOrFail({ + id: projectId, + }); + // Create relationship if the workflow was inserted instead of updated. if (!exists) { - await tx.upsert(SharedWorkflow, { workflowId, userId, role: 'workflow:owner' }, [ - 'workflowId', - 'userId', - ]); + await tx.upsert( + SharedWorkflow, + { workflowId, projectId: personalProject.id, role: 'workflow:owner' }, + ['workflowId', 'projectId'], + ); } if (!workflow.tags?.length) continue; diff --git a/packages/cli/src/services/ownership.service.ts b/packages/cli/src/services/ownership.service.ts index 10c8da633..bda4ddcc4 100644 --- a/packages/cli/src/services/ownership.service.ts +++ b/packages/cli/src/services/ownership.service.ts @@ -1,37 +1,61 @@ import { Service } from 'typedi'; import { CacheService } from '@/services/cache/cache.service'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; -import type { User } from '@db/entities/User'; import { UserRepository } from '@db/repositories/user.repository'; import type { ListQuery } from '@/requests'; +import type { Project } from '@/databases/entities/Project'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import type { User } from '@/databases/entities/User'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; @Service() export class OwnershipService { constructor( private cacheService: CacheService, private userRepository: UserRepository, + private projectRepository: ProjectRepository, + private projectRelationRepository: ProjectRelationRepository, private sharedWorkflowRepository: SharedWorkflowRepository, ) {} /** - * Retrieve the user who owns the workflow. Note that workflow ownership is **immutable**. + * Retrieve the project that owns the workflow. Note that workflow ownership is **immutable**. */ - async getWorkflowOwnerCached(workflowId: string) { - const cachedValue = await this.cacheService.getHashValue( - 'workflow-ownership', + async getWorkflowProjectCached(workflowId: string): Promise { + const cachedValue = await this.cacheService.getHashValue( + 'workflow-project', workflowId, ); - if (cachedValue) return this.userRepository.create(cachedValue); + if (cachedValue) return this.projectRepository.create(cachedValue); const sharedWorkflow = await this.sharedWorkflowRepository.findOneOrFail({ where: { workflowId, role: 'workflow:owner' }, - relations: ['user'], + relations: ['project'], }); - void this.cacheService.setHash('workflow-ownership', { [workflowId]: sharedWorkflow.user }); + void this.cacheService.setHash('workflow-project', { [workflowId]: sharedWorkflow.project }); - return sharedWorkflow.user; + return sharedWorkflow.project; + } + + /** + * Retrieve the user that owns the project, or null if it's not an ownable project. Note that project ownership is **immutable**. + */ + async getProjectOwnerCached(projectId: string): Promise { + const cachedValue = await this.cacheService.getHashValue( + 'project-owner', + projectId, + ); + + if (cachedValue) this.userRepository.create(cachedValue); + if (cachedValue === null) return null; + + const ownerRel = await this.projectRelationRepository.getPersonalProjectOwners([projectId]); + const owner = ownerRel[0]?.user ?? null; + void this.cacheService.setHash('project-owner', { [projectId]: owner }); + + return owner; } addOwnedByAndSharedWith( @@ -43,23 +67,37 @@ export class OwnershipService { addOwnedByAndSharedWith( rawEntity: ListQuery.Workflow.WithSharing | ListQuery.Credentials.WithSharing, ): ListQuery.Workflow.WithOwnedByAndSharedWith | ListQuery.Credentials.WithOwnedByAndSharedWith { - const { shared, ...rest } = rawEntity; - - const entity = rest as + const shared = rawEntity.shared; + const entity = rawEntity as | ListQuery.Workflow.WithOwnedByAndSharedWith | ListQuery.Credentials.WithOwnedByAndSharedWith; - Object.assign(entity, { ownedBy: null, sharedWith: [] }); + Object.assign(entity, { + homeProject: null, + sharedWithProjects: [], + }); - shared?.forEach(({ user, role }) => { - const { id, email, firstName, lastName } = user; + if (shared === undefined) { + return entity; + } + + for (const sharedEntity of shared) { + const { project, role } = sharedEntity; if (role === 'credential:owner' || role === 'workflow:owner') { - entity.ownedBy = { id, email, firstName, lastName }; + entity.homeProject = { + id: project.id, + type: project.type, + name: project.name, + }; } else { - entity.sharedWith.push({ id, email, firstName, lastName }); + entity.sharedWithProjects.push({ + id: project.id, + type: project.type, + name: project.name, + }); } - }); + } return entity; } diff --git a/packages/cli/src/services/project.service.ts b/packages/cli/src/services/project.service.ts new file mode 100644 index 000000000..1d65dd607 --- /dev/null +++ b/packages/cli/src/services/project.service.ts @@ -0,0 +1,343 @@ +import { Project, type ProjectType } from '@/databases/entities/Project'; +import { ProjectRelation } from '@/databases/entities/ProjectRelation'; +import type { ProjectRole } from '@/databases/entities/ProjectRelation'; +import type { User } from '@/databases/entities/User'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; +import type { FindOptionsWhere, EntityManager } from '@n8n/typeorm'; +import Container, { Service } from 'typedi'; +import { type Scope } from '@n8n/permissions'; +import { In, Not } from '@n8n/typeorm'; +import { RoleService } from './role.service'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; +import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { CacheService } from './cache/cache.service'; +import { License } from '@/License'; +import { UNLIMITED_LICENSE_QUOTA } from '@/constants'; + +export class TeamProjectOverQuotaError extends Error { + constructor(limit: number) { + super( + `Attempted to create a new project but quota is already exhausted. You may have a maximum of ${limit} team projects.`, + ); + } +} + +export class UnlicensedProjectRoleError extends Error { + constructor(role: ProjectRole) { + super(`Your instance is not licensed to use role "${role}".`); + } +} + +@Service() +export class ProjectService { + constructor( + private readonly sharedWorkflowRepository: SharedWorkflowRepository, + private readonly projectRepository: ProjectRepository, + private readonly projectRelationRepository: ProjectRelationRepository, + private readonly roleService: RoleService, + private readonly sharedCredentialsRepository: SharedCredentialsRepository, + private readonly cacheService: CacheService, + private readonly license: License, + ) {} + + private get workflowService() { + return import('@/workflows/workflow.service').then(({ WorkflowService }) => + Container.get(WorkflowService), + ); + } + + private get credentialsService() { + return import('@/credentials/credentials.service').then(({ CredentialsService }) => + Container.get(CredentialsService), + ); + } + + async deleteProject( + user: User, + projectId: string, + { migrateToProject }: { migrateToProject?: string } = {}, + ) { + const workflowService = await this.workflowService; + const credentialsService = await this.credentialsService; + + if (projectId === migrateToProject) { + throw new BadRequestError( + 'Request to delete a project failed because the project to delete and the project to migrate to are the same project', + ); + } + + const project = await this.getProjectWithScope(user, projectId, ['project:delete']); + if (!project) { + throw new NotFoundError(`Could not find project with ID: ${projectId}`); + } + + let targetProject: Project | null = null; + if (migrateToProject) { + targetProject = await this.getProjectWithScope(user, migrateToProject, [ + 'credential:create', + 'workflow:create', + ]); + + if (!targetProject) { + throw new NotFoundError( + `Could not find project to migrate to. ID: ${targetProject}. You may lack permissions to create workflow and credentials in the target project.`, + ); + } + } + + // 0. check if this is a team project + if (project.type !== 'team') { + throw new ForbiddenError( + `Can't delete project. Project with ID "${projectId}" is not a team project.`, + ); + } + + // 1. delete or migrate workflows owned by this project + const ownedSharedWorkflows = await this.sharedWorkflowRepository.find({ + where: { projectId: project.id, role: 'workflow:owner' }, + }); + + if (targetProject) { + await this.sharedWorkflowRepository.makeOwner( + ownedSharedWorkflows.map((sw) => sw.workflowId), + targetProject.id, + ); + } else { + for (const sharedWorkflow of ownedSharedWorkflows) { + await workflowService.delete(user, sharedWorkflow.workflowId); + } + } + + // 2. delete credentials owned by this project + const ownedCredentials = await this.sharedCredentialsRepository.find({ + where: { projectId: project.id, role: 'credential:owner' }, + relations: { credentials: true }, + }); + + if (targetProject) { + await this.sharedCredentialsRepository.makeOwner( + ownedCredentials.map((sc) => sc.credentialsId), + targetProject.id, + ); + } else { + for (const sharedCredential of ownedCredentials) { + await credentialsService.delete(sharedCredential.credentials); + } + } + + // 3. delete shared credentials into this project + // Cascading deletes take care of this. + + // 4. delete shared workflows into this project + // Cascading deletes take care of this. + + // 5. delete project + await this.projectRepository.remove(project); + + // 6. delete project relations + // Cascading deletes take care of this. + } + + /** + * Find all the projects where a workflow is accessible, + * along with the roles of a user in those projects. + */ + async findProjectsWorkflowIsIn(workflowId: string) { + return await this.sharedWorkflowRepository.findProjectIds(workflowId); + } + + async getAccessibleProjects(user: User): Promise { + // This user is probably an admin, show them everything + if (user.hasGlobalScope('project:read')) { + return await this.projectRepository.find(); + } + return await this.projectRepository.getAccessibleProjects(user.id); + } + + async getPersonalProjectOwners(projectIds: string[]): Promise { + return await this.projectRelationRepository.getPersonalProjectOwners(projectIds); + } + + async createTeamProject(name: string, adminUser: User, id?: string): Promise { + const limit = this.license.getTeamProjectLimit(); + if ( + limit !== UNLIMITED_LICENSE_QUOTA && + limit <= (await this.projectRepository.count({ where: { type: 'team' } })) + ) { + throw new TeamProjectOverQuotaError(limit); + } + + const project = await this.projectRepository.save( + this.projectRepository.create({ + id, + name, + type: 'team', + }), + ); + + // Link admin + await this.addUser(project.id, adminUser.id, 'project:admin'); + + return project; + } + + async updateProject(name: string, projectId: string): Promise { + const result = await this.projectRepository.update( + { + id: projectId, + type: 'team', + }, + { + name, + }, + ); + + if (!result.affected) { + throw new ForbiddenError('Project not found'); + } + return await this.projectRepository.findOneByOrFail({ id: projectId }); + } + + async getPersonalProject(user: User): Promise { + return await this.projectRepository.getPersonalProjectForUser(user.id); + } + + async getProjectRelationsForUser(user: User): Promise { + return await this.projectRelationRepository.find({ + where: { userId: user.id }, + relations: ['project'], + }); + } + + async syncProjectRelations( + projectId: string, + relations: Array<{ userId: string; role: ProjectRole }>, + ) { + const project = await this.projectRepository.findOneOrFail({ + where: { id: projectId, type: Not('personal') }, + relations: { projectRelations: true }, + }); + + // Check to see if the instance is licensed to use all roles provided + for (const r of relations) { + const existing = project.projectRelations.find((pr) => pr.userId === r.userId); + // We don't throw an error if the user already exists with that role so + // existing projects continue working as is. + if (existing?.role !== r.role && !this.roleService.isRoleLicensed(r.role)) { + throw new UnlicensedProjectRoleError(r.role); + } + } + + await this.projectRelationRepository.manager.transaction(async (em) => { + await this.pruneRelations(em, project); + await this.addManyRelations(em, project, relations); + }); + await this.clearCredentialCanUseExternalSecretsCache(projectId); + } + + async clearCredentialCanUseExternalSecretsCache(projectId: string) { + const shares = await this.sharedCredentialsRepository.find({ + where: { + projectId, + role: 'credential:owner', + }, + select: ['credentialsId'], + }); + if (shares.length) { + await this.cacheService.deleteMany( + shares.map((share) => `credential-can-use-secrets:${share.credentialsId}`), + ); + } + } + + async pruneRelations(em: EntityManager, project: Project) { + await em.delete(ProjectRelation, { projectId: project.id }); + } + + async addManyRelations( + em: EntityManager, + project: Project, + relations: Array<{ userId: string; role: ProjectRole }>, + ) { + await em.insert( + ProjectRelation, + relations.map((v) => + this.projectRelationRepository.create({ + projectId: project.id, + userId: v.userId, + role: v.role, + }), + ), + ); + } + + async getProjectWithScope( + user: User, + projectId: string, + scopes: Scope[], + entityManager?: EntityManager, + ) { + const em = entityManager ?? this.projectRepository.manager; + let where: FindOptionsWhere = { + id: projectId, + }; + + if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) { + const projectRoles = this.roleService.rolesWithScope('project', scopes); + + where = { + ...where, + projectRelations: { + role: In(projectRoles), + userId: user.id, + }, + }; + } + + return await em.findOne(Project, { + where, + }); + } + + async addUser(projectId: string, userId: string, role: ProjectRole) { + return await this.projectRelationRepository.save({ + projectId, + userId, + role, + }); + } + + async getProject(projectId: string): Promise { + return await this.projectRepository.findOneOrFail({ + where: { + id: projectId, + }, + }); + } + + async getProjectRelations(projectId: string): Promise { + return await this.projectRelationRepository.find({ + where: { projectId }, + relations: { user: true }, + }); + } + + async getUserOwnedOrAdminProjects(userId: string): Promise { + return await this.projectRepository.find({ + where: { + projectRelations: { + userId, + role: In(['project:personalOwner', 'project:admin']), + }, + }, + }); + } + + async getProjectCounts(): Promise> { + return await this.projectRepository.getProjectCounts(); + } +} diff --git a/packages/cli/src/services/role.service.ts b/packages/cli/src/services/role.service.ts new file mode 100644 index 000000000..e9fa17eb6 --- /dev/null +++ b/packages/cli/src/services/role.service.ts @@ -0,0 +1,239 @@ +import type { ProjectRelation, ProjectRole } from '@/databases/entities/ProjectRelation'; +import type { + CredentialSharingRole, + SharedCredentials, +} from '@/databases/entities/SharedCredentials'; +import type { SharedWorkflow, WorkflowSharingRole } from '@/databases/entities/SharedWorkflow'; +import type { GlobalRole, User } from '@/databases/entities/User'; +import { + GLOBAL_ADMIN_SCOPES, + GLOBAL_MEMBER_SCOPES, + GLOBAL_OWNER_SCOPES, +} from '@/permissions/global-roles'; +import { + PERSONAL_PROJECT_OWNER_SCOPES, + PROJECT_EDITOR_SCOPES, + REGULAR_PROJECT_ADMIN_SCOPES, +} from '@/permissions/project-roles'; +import { + CREDENTIALS_SHARING_OWNER_SCOPES, + CREDENTIALS_SHARING_USER_SCOPES, + WORKFLOW_SHARING_EDITOR_SCOPES, + WORKFLOW_SHARING_OWNER_SCOPES, +} from '@/permissions/resource-roles'; +import type { ListQuery } from '@/requests'; +import { combineScopes, type Resource, type Scope } from '@n8n/permissions'; +import { Service } from 'typedi'; +import { ApplicationError } from 'n8n-workflow'; +import { License } from '@/License'; + +export type RoleNamespace = 'global' | 'project' | 'credential' | 'workflow'; + +const GLOBAL_SCOPE_MAP: Record = { + 'global:owner': GLOBAL_OWNER_SCOPES, + 'global:admin': GLOBAL_ADMIN_SCOPES, + 'global:member': GLOBAL_MEMBER_SCOPES, +}; + +const PROJECT_SCOPE_MAP: Record = { + 'project:admin': REGULAR_PROJECT_ADMIN_SCOPES, + 'project:personalOwner': PERSONAL_PROJECT_OWNER_SCOPES, + 'project:editor': PROJECT_EDITOR_SCOPES, +}; + +const CREDENTIALS_SHARING_SCOPE_MAP: Record = { + 'credential:owner': CREDENTIALS_SHARING_OWNER_SCOPES, + 'credential:user': CREDENTIALS_SHARING_USER_SCOPES, +}; + +const WORKFLOW_SHARING_SCOPE_MAP: Record = { + 'workflow:owner': WORKFLOW_SHARING_OWNER_SCOPES, + 'workflow:editor': WORKFLOW_SHARING_EDITOR_SCOPES, +}; + +interface AllMaps { + global: Record; + project: Record; + credential: Record; + workflow: Record; +} + +const ALL_MAPS: AllMaps = { + global: GLOBAL_SCOPE_MAP, + project: PROJECT_SCOPE_MAP, + credential: CREDENTIALS_SHARING_SCOPE_MAP, + workflow: WORKFLOW_SHARING_SCOPE_MAP, +} as const; + +const COMBINED_MAP = Object.fromEntries( + Object.values(ALL_MAPS).flatMap((o: Record) => Object.entries(o)), +) as Record; + +export interface RoleMap { + global: GlobalRole[]; + project: ProjectRole[]; + credential: CredentialSharingRole[]; + workflow: WorkflowSharingRole[]; +} +export type AllRoleTypes = GlobalRole | ProjectRole | WorkflowSharingRole | CredentialSharingRole; + +const ROLE_NAMES: Record< + GlobalRole | ProjectRole | WorkflowSharingRole | CredentialSharingRole, + string +> = { + 'global:owner': 'Owner', + 'global:admin': 'Admin', + 'global:member': 'Member', + 'project:personalOwner': 'Project Owner', + 'project:admin': 'Project Admin', + 'project:editor': 'Project Editor', + 'credential:user': 'Credential User', + 'credential:owner': 'Credential Owner', + 'workflow:owner': 'Workflow Owner', + 'workflow:editor': 'Workflow Editor', +}; + +@Service() +export class RoleService { + constructor(private readonly license: License) {} + + rolesWithScope(namespace: 'global', scopes: Scope | Scope[]): GlobalRole[]; + rolesWithScope(namespace: 'project', scopes: Scope | Scope[]): ProjectRole[]; + rolesWithScope(namespace: 'credential', scopes: Scope | Scope[]): CredentialSharingRole[]; + rolesWithScope(namespace: 'workflow', scopes: Scope | Scope[]): WorkflowSharingRole[]; + rolesWithScope(namespace: RoleNamespace, scopes: Scope | Scope[]) { + if (!Array.isArray(scopes)) { + scopes = [scopes]; + } + + return Object.keys(ALL_MAPS[namespace]).filter((k) => { + return scopes.every((s) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + ((ALL_MAPS[namespace] as any)[k] as Scope[]).includes(s), + ); + }); + } + + getRoles(): RoleMap { + return Object.fromEntries( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + Object.entries(ALL_MAPS).map((e) => [e[0], Object.keys(e[1])]), + ) as unknown as RoleMap; + } + + getRoleName(role: AllRoleTypes): string { + return ROLE_NAMES[role]; + } + + getRoleScopes( + role: GlobalRole | ProjectRole | WorkflowSharingRole | CredentialSharingRole, + filters?: Resource[], + ): Scope[] { + let scopes = COMBINED_MAP[role]; + if (filters) { + scopes = scopes.filter((s) => filters.includes(s.split(':')[0] as Resource)); + } + return scopes; + } + + /** + * Find all distinct scopes in a set of project roles. + */ + getScopesBy(projectRoles: Set) { + return [...projectRoles].reduce>((acc, projectRole) => { + for (const scope of PROJECT_SCOPE_MAP[projectRole] ?? []) { + acc.add(scope); + } + + return acc; + }, new Set()); + } + + addScopes( + rawWorkflow: ListQuery.Workflow.WithSharing | ListQuery.Workflow.WithOwnedByAndSharedWith, + user: User, + userProjectRelations: ProjectRelation[], + ): ListQuery.Workflow.WithScopes; + addScopes( + rawCredential: + | ListQuery.Credentials.WithSharing + | ListQuery.Credentials.WithOwnedByAndSharedWith, + user: User, + userProjectRelations: ProjectRelation[], + ): ListQuery.Credentials.WithScopes; + addScopes( + rawEntity: + | ListQuery.Workflow.WithSharing + | ListQuery.Credentials.WithOwnedByAndSharedWith + | ListQuery.Credentials.WithSharing + | ListQuery.Workflow.WithOwnedByAndSharedWith, + user: User, + userProjectRelations: ProjectRelation[], + ): ListQuery.Workflow.WithScopes | ListQuery.Credentials.WithScopes { + const shared = rawEntity.shared; + const entity = rawEntity as ListQuery.Workflow.WithScopes | ListQuery.Credentials.WithScopes; + + Object.assign(entity, { + scopes: [], + }); + + if (shared === undefined) { + return entity; + } + + if (!('active' in entity) && !('type' in entity)) { + throw new ApplicationError('Cannot detect if entity is a workflow or credential.'); + } + + entity.scopes = this.combineResourceScopes( + 'active' in entity ? 'workflow' : 'credential', + user, + shared, + userProjectRelations, + ); + + return entity; + } + + combineResourceScopes( + type: 'workflow' | 'credential', + user: User, + shared: SharedCredentials[] | SharedWorkflow[], + userProjectRelations: ProjectRelation[], + ): Scope[] { + const globalScopes = this.getRoleScopes(user.role, [type]); + const scopesSet: Set = new Set(globalScopes); + for (const sharedEntity of shared) { + const pr = userProjectRelations.find( + (p) => p.projectId === (sharedEntity.projectId ?? sharedEntity.project.id), + ); + let projectScopes: Scope[] = []; + if (pr) { + projectScopes = this.getRoleScopes(pr.role); + } + const resourceMask = this.getRoleScopes(sharedEntity.role); + const mergedScopes = combineScopes( + { + global: globalScopes, + project: projectScopes, + }, + { sharing: resourceMask }, + ); + mergedScopes.forEach((s) => scopesSet.add(s)); + } + return [...scopesSet].sort(); + } + + isRoleLicensed(role: AllRoleTypes) { + switch (role) { + case 'project:admin': + return this.license.isProjectRoleAdminLicensed(); + case 'project:editor': + return this.license.isProjectRoleEditorLicensed(); + case 'global:admin': + return this.license.isAdvancedPermissionsLicensed(); + default: + return true; + } + } +} diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index 0ce290da8..e65e5a07c 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -2,7 +2,7 @@ import { Container, Service } from 'typedi'; import type { IUserSettings } from 'n8n-workflow'; import { ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; -import { type AssignableRole, User } from '@db/entities/User'; +import type { User, AssignableRole } from '@db/entities/User'; import { UserRepository } from '@db/repositories/user.repository'; import type { PublicUser } from '@/Interfaces'; import type { PostHogClient } from '@/posthog'; @@ -23,7 +23,13 @@ export class UserService { ) {} async update(userId: string, data: Partial) { - return await this.userRepository.update(userId, data); + const user = await this.userRepository.findOneBy({ id: userId }); + + if (user) { + await this.userRepository.save({ ...user, ...data }, { transaction: true }); + } + + return; } getManager() { @@ -31,9 +37,15 @@ export class UserService { } async updateSettings(userId: string, newSettings: Partial) { - const { settings } = await this.userRepository.findOneOrFail({ where: { id: userId } }); + const user = await this.userRepository.findOneOrFail({ where: { id: userId } }); - return await this.userRepository.update(userId, { settings: { ...settings, ...newSettings } }); + if (user.settings) { + Object.assign(user.settings, newSettings); + } else { + user.settings = newSettings; + } + + await this.userRepository.save(user); } async toPublic( @@ -192,8 +204,10 @@ export class UserService { async (transactionManager) => await Promise.all( toCreateUsers.map(async ({ email, role }) => { - const newUser = transactionManager.create(User, { email, role }); - const savedUser = await transactionManager.save(newUser); + const { user: savedUser } = await this.userRepository.createUserWithProject( + { email, role }, + transactionManager, + ); createdUsers.set(email, savedUser.id); return savedUser; }), diff --git a/packages/cli/src/services/userOnboarding.service.ts b/packages/cli/src/services/userOnboarding.service.ts index 3f61a4aac..7f3f3b8ce 100644 --- a/packages/cli/src/services/userOnboarding.service.ts +++ b/packages/cli/src/services/userOnboarding.service.ts @@ -25,7 +25,12 @@ export class UserOnboardingService { const ownedWorkflowsIds = await this.sharedWorkflowRepository .find({ where: { - userId: user.id, + project: { + projectRelations: { + role: 'project:personalOwner', + userId: user.id, + }, + }, role: 'workflow:owner', }, select: ['workflowId'], diff --git a/packages/cli/src/sso/saml/saml.service.ee.ts b/packages/cli/src/sso/saml/saml.service.ee.ts index 92318cb14..1103bf73d 100644 --- a/packages/cli/src/sso/saml/saml.service.ee.ts +++ b/packages/cli/src/sso/saml/saml.service.ee.ts @@ -349,7 +349,8 @@ export class SamlService { } catch (error) { // throw error; throw new AuthError( - `SAML Authentication failed. Could not parse SAML response. ${(error as Error).message}`, + // INFO: The error can be a string. Samlify rejects promises with strings. + `SAML Authentication failed. Could not parse SAML response. ${error instanceof Error ? error.message : error}`, ); } const { attributes, missingAttributes } = getMappedSamlAttributesFromFlowResult( diff --git a/packages/cli/src/sso/saml/samlHelpers.ts b/packages/cli/src/sso/saml/samlHelpers.ts index e87d73ba7..0334e01b4 100644 --- a/packages/cli/src/sso/saml/samlHelpers.ts +++ b/packages/cli/src/sso/saml/samlHelpers.ts @@ -1,7 +1,7 @@ import { Container } from 'typedi'; import config from '@/config'; import { AuthIdentity } from '@db/entities/AuthIdentity'; -import { User } from '@db/entities/User'; +import type { User } from '@db/entities/User'; import { License } from '@/License'; import { PasswordUtility } from '@/services/password.utility'; import type { SamlPreferences } from './types/samlPreferences'; @@ -97,26 +97,29 @@ export function generatePassword(): string { } export async function createUserFromSamlAttributes(attributes: SamlUserAttributes): Promise { - const user = new User(); - const authIdentity = new AuthIdentity(); - const lowerCasedEmail = attributes.email?.toLowerCase() ?? ''; - user.email = lowerCasedEmail; - user.firstName = attributes.firstName; - user.lastName = attributes.lastName; - user.role = 'global:member'; - // generates a password that is not used or known to the user - user.password = await Container.get(PasswordUtility).hash(generatePassword()); - authIdentity.providerId = attributes.userPrincipalName; - authIdentity.providerType = 'saml'; - authIdentity.user = user; - const resultAuthIdentity = await Container.get(AuthIdentityRepository).save(authIdentity, { - transaction: false, + return await Container.get(UserRepository).manager.transaction(async (trx) => { + const { user } = await Container.get(UserRepository).createUserWithProject( + { + email: attributes.email.toLowerCase(), + firstName: attributes.firstName, + lastName: attributes.lastName, + role: 'global:member', + // generates a password that is not used or known to the user + password: await Container.get(PasswordUtility).hash(generatePassword()), + }, + trx, + ); + + await trx.save( + trx.create(AuthIdentity, { + providerId: attributes.userPrincipalName, + providerType: 'saml', + userId: user.id, + }), + ); + + return user; }); - if (!resultAuthIdentity) throw new AuthError('Could not create AuthIdentity'); - user.authIdentities = [authIdentity]; - const resultUser = await Container.get(UserRepository).save(user, { transaction: false }); - if (!resultUser) throw new AuthError('Could not create User'); - return resultUser; } export async function updateUserFromSamlAttributes( diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index 697cd1f03..7e56859ca 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -13,6 +13,8 @@ import { N8N_VERSION } from '@/constants'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { SourceControlPreferencesService } from '../environments/sourceControl/sourceControlPreferences.service.ee'; import { UserRepository } from '@db/repositories/user.repository'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; type ExecutionTrackDataKey = 'manual_error' | 'manual_success' | 'prod_error' | 'prod_success'; @@ -126,6 +128,8 @@ export class Telemetry { source_control_set_up: Container.get(SourceControlPreferencesService).isSourceControlSetup(), branchName: sourceControlPreferences.branchName, read_only_instance: sourceControlPreferences.branchReadOnly, + team_projects: (await Container.get(ProjectRepository).getProjectCounts()).team, + project_role_count: await Container.get(ProjectRelationRepository).countUsersByRole(), }; allPromises.push(this.track('pulse', pulsePacket)); return await Promise.all(allPromises); diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 6926c825b..028b80b55 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -85,3 +85,10 @@ export function rightDiff( return acc; }, []); } + +/** + * Asserts that the passed in type is never. + * Can be used to make sure the type is exhausted + * in switch statements or if/else chains. + */ +export const assertNever = (value: never) => {}; diff --git a/packages/cli/src/workflows/workflow.request.ts b/packages/cli/src/workflows/workflow.request.ts index 77d653a2a..017e90606 100644 --- a/packages/cli/src/workflows/workflow.request.ts +++ b/packages/cli/src/workflows/workflow.request.ts @@ -1,5 +1,5 @@ import type { IWorkflowDb } from '@/Interfaces'; -import type { AuthenticatedRequest } from '@/requests'; +import type { AuthenticatedRequest, ListQuery } from '@/requests'; import type { INode, IConnections, @@ -11,7 +11,7 @@ import type { export declare namespace WorkflowRequest { type CreateUpdatePayload = Partial<{ - id: string; // delete if sent + id: string; // deleted if sent name: string; nodes: INode[]; connections: IConnections; @@ -20,6 +20,7 @@ export declare namespace WorkflowRequest { tags: string[]; hash: string; meta: Record; + projectId: string; }>; type ManualRunPayload = { @@ -32,12 +33,16 @@ export declare namespace WorkflowRequest { type Create = AuthenticatedRequest<{}, {}, CreateUpdatePayload>; - type Get = AuthenticatedRequest<{ id: string }>; + type Get = AuthenticatedRequest<{ workflowId: string }>; + + type GetMany = AuthenticatedRequest<{}, {}, {}, ListQuery.Params & { includeScopes?: string }> & { + listQueryOptions: ListQuery.Options; + }; type Delete = Get; type Update = AuthenticatedRequest< - { id: string }, + { workflowId: string }, {}, CreateUpdatePayload, { forceSave?: string } @@ -45,7 +50,7 @@ export declare namespace WorkflowRequest { type NewName = AuthenticatedRequest<{}, {}, {}, { name?: string }>; - type ManualRun = AuthenticatedRequest<{}, {}, ManualRunPayload>; + type ManualRun = AuthenticatedRequest<{ workflowId: string }, {}, ManualRunPayload>; type Share = AuthenticatedRequest<{ workflowId: string }, {}, { shareWithIds: string[] }>; diff --git a/packages/cli/src/workflows/workflow.service.ee.ts b/packages/cli/src/workflows/workflow.service.ee.ts index a95536d80..250b6e601 100644 --- a/packages/cli/src/workflows/workflow.service.ee.ts +++ b/packages/cli/src/workflows/workflow.service.ee.ts @@ -15,7 +15,11 @@ import { Logger } from '@/Logger'; import type { CredentialUsedByWorkflow, WorkflowWithSharingsAndCredentials, + WorkflowWithSharingsMetaDataAndCredentials, } from './workflows.types'; +import { OwnershipService } from '@/services/ownership.service'; +import { In, type EntityManager } from '@n8n/typeorm'; +import { Project } from '@/databases/entities/Project'; @Service() export class EnterpriseWorkflowService { @@ -25,49 +29,48 @@ export class EnterpriseWorkflowService { private readonly workflowRepository: WorkflowRepository, private readonly credentialsRepository: CredentialsRepository, private readonly credentialsService: CredentialsService, + private readonly ownershipService: OwnershipService, ) {} - async isOwned( - user: User, - workflowId: string, - ): Promise<{ ownsWorkflow: boolean; workflow?: WorkflowEntity }> { - const sharing = await this.sharedWorkflowRepository.getSharing( - user, - workflowId, - { allowGlobalScope: false }, - ['workflow'], - ); + async shareWithProjects( + workflow: WorkflowEntity, + shareWithIds: string[], + entityManager: EntityManager, + ) { + const em = entityManager ?? this.sharedWorkflowRepository.manager; - if (!sharing || sharing.role !== 'workflow:owner') return { ownsWorkflow: false }; - - const { workflow } = sharing; - - return { ownsWorkflow: true, workflow }; - } - - addOwnerAndSharings(workflow: WorkflowWithSharingsAndCredentials): void { - workflow.ownedBy = null; - workflow.sharedWith = []; - if (!workflow.usedCredentials) { - workflow.usedCredentials = []; - } - - workflow.shared?.forEach(({ user, role }) => { - const { id, email, firstName, lastName } = user; - - if (role === 'workflow:owner') { - workflow.ownedBy = { id, email, firstName, lastName }; - return; - } - - workflow.sharedWith?.push({ id, email, firstName, lastName }); + const projects = await em.find(Project, { + where: { id: In(shareWithIds), type: 'personal' }, }); - delete workflow.shared; + const newSharedWorkflows = projects + // We filter by role === 'project:personalOwner' above and there should + // always only be one owner. + .map((project) => + this.sharedWorkflowRepository.create({ + workflowId: workflow.id, + role: 'workflow:editor', + projectId: project.id, + }), + ); + + return await em.save(newSharedWorkflows); + } + + addOwnerAndSharings( + workflow: WorkflowWithSharingsAndCredentials, + ): WorkflowWithSharingsMetaDataAndCredentials { + const workflowWithMetaData = this.ownershipService.addOwnedByAndSharedWith(workflow); + + return { + ...workflow, + ...workflowWithMetaData, + usedCredentials: workflow.usedCredentials ?? [], + }; } async addCredentialsToWorkflow( - workflow: WorkflowWithSharingsAndCredentials, + workflow: WorkflowWithSharingsMetaDataAndCredentials, currentUser: User, ): Promise { workflow.usedCredentials = []; @@ -100,14 +103,7 @@ export class EnterpriseWorkflowService { sharedWith: [], ownedBy: null, }; - credential.shared?.forEach(({ user, role }) => { - const { id, email, firstName, lastName } = user; - if (role === 'credential:owner') { - workflowCredential.ownedBy = { id, email, firstName, lastName }; - } else { - workflowCredential.sharedWith?.push({ id, email, firstName, lastName }); - } - }); + credential = this.ownershipService.addOwnedByAndSharedWith(credential); workflow.usedCredentials?.push(workflowCredential); }); } diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index a752e3e5c..d03fd6564 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -8,8 +8,6 @@ import { BinaryDataService } from 'n8n-core'; import config from '@/config'; import type { User } from '@db/entities/User'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; -import type { WorkflowSharingRole } from '@db/entities/SharedWorkflow'; -import { ExecutionRepository } from '@db/repositories/execution.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { WorkflowTagMappingRepository } from '@db/repositories/workflowTagMapping.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; @@ -26,12 +24,19 @@ import { Logger } from '@/Logger'; import { OrchestrationService } from '@/services/orchestration.service'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { RoleService } from '@/services/role.service'; +import { WorkflowSharingService } from './workflowSharing.service'; +import { ProjectService } from '@/services/project.service'; +import { ExecutionRepository } from '@/databases/repositories/execution.repository'; +import type { Scope } from '@n8n/permissions'; +import type { EntityManager } from '@n8n/typeorm'; +import { In } from '@n8n/typeorm'; +import { SharedWorkflow } from '@/databases/entities/SharedWorkflow'; @Service() export class WorkflowService { constructor( private readonly logger: Logger, - private readonly executionRepository: ExecutionRepository, private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly workflowRepository: WorkflowRepository, private readonly workflowTagMappingRepository: WorkflowTagMappingRepository, @@ -42,36 +47,52 @@ export class WorkflowService { private readonly orchestrationService: OrchestrationService, private readonly externalHooks: ExternalHooks, private readonly activeWorkflowManager: ActiveWorkflowManager, + private readonly roleService: RoleService, + private readonly workflowSharingService: WorkflowSharingService, + private readonly projectService: ProjectService, + private readonly executionRepository: ExecutionRepository, ) {} - async getMany(sharedWorkflowIds: string[], options?: ListQuery.Options) { - const { workflows, count } = await this.workflowRepository.getMany(sharedWorkflowIds, options); + async getMany(user: User, options?: ListQuery.Options, includeScopes?: boolean) { + const sharedWorkflowIds = await this.workflowSharingService.getSharedWorkflowIds(user, { + scopes: ['workflow:read'], + }); - return hasSharing(workflows) - ? { - workflows: workflows.map((w) => this.ownershipService.addOwnedByAndSharedWith(w)), - count, - } - : { workflows, count }; + // eslint-disable-next-line prefer-const + let { workflows, count } = await this.workflowRepository.getMany(sharedWorkflowIds, options); + + if (hasSharing(workflows)) { + workflows = workflows.map((w) => this.ownershipService.addOwnedByAndSharedWith(w)); + } + + if (includeScopes) { + const projectRelations = await this.projectService.getProjectRelationsForUser(user); + workflows = workflows.map((w) => this.roleService.addScopes(w, user, projectRelations)); + } + + workflows.forEach((w) => { + // @ts-expect-error: This is to emulate the old behaviour of removing the shared + // field as part of `addOwnedByAndSharedWith`. We need this field in `addScopes` + // though. So to avoid leaking the information we just delete it. + delete w.shared; + }); + + return { workflows, count }; } // eslint-disable-next-line complexity async update( user: User, - workflow: WorkflowEntity, + workflowUpdateData: WorkflowEntity, workflowId: string, tagIds?: string[], forceSave?: boolean, - roles?: WorkflowSharingRole[], ): Promise { - const shared = await this.sharedWorkflowRepository.findSharing( - workflowId, - user, + const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [ 'workflow:update', - { roles }, - ); + ]); - if (!shared) { + if (!workflow) { this.logger.verbose('User attempted to update a workflow without permissions', { workflowId, userId: user.id, @@ -83,8 +104,8 @@ export class WorkflowService { if ( !forceSave && - workflow.versionId !== '' && - workflow.versionId !== shared.workflow.versionId + workflowUpdateData.versionId !== '' && + workflowUpdateData.versionId !== workflow.versionId ) { throw new BadRequestError( 'Your most recent changes may be lost, because someone else just updated this workflow. Open this workflow in a new tab to see those new updates.', @@ -92,25 +113,25 @@ export class WorkflowService { ); } - if (Object.keys(omit(workflow, ['id', 'versionId', 'active'])).length > 0) { + if (Object.keys(omit(workflowUpdateData, ['id', 'versionId', 'active'])).length > 0) { // Update the workflow's version when changing properties such as // `name`, `pinData`, `nodes`, `connections`, `settings` or `tags` - workflow.versionId = uuid(); + workflowUpdateData.versionId = uuid(); this.logger.verbose( `Updating versionId for workflow ${workflowId} for user ${user.id} after saving`, { - previousVersionId: shared.workflow.versionId, - newVersionId: workflow.versionId, + previousVersionId: workflow.versionId, + newVersionId: workflowUpdateData.versionId, }, ); } // check credentials for old format - await WorkflowHelpers.replaceInvalidCredentials(workflow); + await WorkflowHelpers.replaceInvalidCredentials(workflowUpdateData); - WorkflowHelpers.addNodeIds(workflow); + WorkflowHelpers.addNodeIds(workflowUpdateData); - await this.externalHooks.run('workflow.update', [workflow]); + await this.externalHooks.run('workflow.update', [workflowUpdateData]); /** * If the workflow being updated is stored as `active`, remove it from @@ -119,11 +140,11 @@ export class WorkflowService { * If a trigger or poller in the workflow was updated, the new value * will take effect only on removing and re-adding. */ - if (shared.workflow.active) { + if (workflow.active) { await this.activeWorkflowManager.remove(workflowId); } - const workflowSettings = workflow.settings ?? {}; + const workflowSettings = workflowUpdateData.settings ?? {}; const keysAllowingDefault = [ 'timezone', @@ -144,14 +165,14 @@ export class WorkflowService { delete workflowSettings.executionTimeout; } - if (workflow.name) { - workflow.updatedAt = new Date(); // required due to atomic update - await validateEntity(workflow); + if (workflowUpdateData.name) { + workflowUpdateData.updatedAt = new Date(); // required due to atomic update + await validateEntity(workflowUpdateData); } await this.workflowRepository.update( workflowId, - pick(workflow, [ + pick(workflowUpdateData, [ 'name', 'active', 'nodes', @@ -168,8 +189,8 @@ export class WorkflowService { await this.workflowTagMappingRepository.overwriteTaggings(workflowId, tagIds); } - if (workflow.versionId !== shared.workflow.versionId) { - await this.workflowHistoryService.saveVersion(user, workflow, workflowId); + if (workflowUpdateData.versionId !== workflow.versionId) { + await this.workflowHistoryService.saveVersion(user, workflowUpdateData, workflowId); } const relations = config.getEnv('workflowTagsDisabled') ? [] : ['tags']; @@ -200,16 +221,13 @@ export class WorkflowService { // When the workflow is supposed to be active add it again try { await this.externalHooks.run('workflow.activate', [updatedWorkflow]); - await this.activeWorkflowManager.add( - workflowId, - shared.workflow.active ? 'update' : 'activate', - ); + await this.activeWorkflowManager.add(workflowId, workflow.active ? 'update' : 'activate'); } catch (error) { // If workflow could not be activated set it again to inactive // and revert the versionId change so UI remains consistent await this.workflowRepository.update(workflowId, { active: false, - versionId: shared.workflow.versionId, + versionId: workflow.versionId, }); // Also set it in the returned data @@ -232,18 +250,15 @@ export class WorkflowService { async delete(user: User, workflowId: string): Promise { await this.externalHooks.run('workflow.delete', [workflowId]); - const sharedWorkflow = await this.sharedWorkflowRepository.findSharing( - workflowId, - user, + const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [ 'workflow:delete', - { roles: ['workflow:owner'] }, - ); + ]); - if (!sharedWorkflow) { + if (!workflow) { return; } - if (sharedWorkflow.workflow.active) { + if (workflow.active) { // deactivate before deleting await this.activeWorkflowManager.remove(workflowId); } @@ -261,6 +276,71 @@ export class WorkflowService { void Container.get(InternalHooks).onWorkflowDeleted(user, workflowId, false); await this.externalHooks.run('workflow.afterDelete', [workflowId]); - return sharedWorkflow.workflow; + return workflow; + } + + async getWorkflowScopes(user: User, workflowId: string): Promise { + const userProjectRelations = await this.projectService.getProjectRelationsForUser(user); + const shared = await this.sharedWorkflowRepository.find({ + where: { + projectId: In([...new Set(userProjectRelations.map((pr) => pr.projectId))]), + workflowId, + }, + }); + return this.roleService.combineResourceScopes('workflow', user, shared, userProjectRelations); + } + + /** + * Transfers all workflows owned by a project to another one. + * This has only been tested for personal projects. It may need to be amended + * for team projects. + **/ + async transferAll(fromProjectId: string, toProjectId: string, trx?: EntityManager) { + trx = trx ?? this.workflowRepository.manager; + + // Get all shared workflows for both projects. + const allSharedWorkflows = await trx.findBy(SharedWorkflow, { + projectId: In([fromProjectId, toProjectId]), + }); + const sharedWorkflowsOfFromProject = allSharedWorkflows.filter( + (sw) => sw.projectId === fromProjectId, + ); + + // For all workflows that the from-project owns transfer the ownership to + // the to-project. + // This will override whatever relationship the to-project already has to + // the resources at the moment. + + const ownedWorkflowIds = sharedWorkflowsOfFromProject + .filter((sw) => sw.role === 'workflow:owner') + .map((sw) => sw.workflowId); + + await this.sharedWorkflowRepository.makeOwner(ownedWorkflowIds, toProjectId, trx); + + // Delete the relationship to the from-project. + await this.sharedWorkflowRepository.deleteByIds(ownedWorkflowIds, fromProjectId, trx); + + // Transfer relationships that are not `workflow:owner`. + // This will NOT override whatever relationship the from-project already + // has to the resource at the moment. + const sharedWorkflowIdsOfTransferee = allSharedWorkflows + .filter((sw) => sw.projectId === toProjectId) + .map((sw) => sw.workflowId); + + // All resources that are shared with the from-project, but not with the + // to-project. + const sharedWorkflowsToTransfer = sharedWorkflowsOfFromProject.filter( + (sw) => + sw.role !== 'workflow:owner' && !sharedWorkflowIdsOfTransferee.includes(sw.workflowId), + ); + + await trx.insert( + SharedWorkflow, + sharedWorkflowsToTransfer.map((sw) => ({ + workflowId: sw.workflowId, + projectId: toProjectId, + role: sw.role, + })), + ); } } diff --git a/packages/cli/src/workflows/workflowExecution.service.ts b/packages/cli/src/workflows/workflowExecution.service.ts index c77b10244..e34c80e94 100644 --- a/packages/cli/src/workflows/workflowExecution.service.ts +++ b/packages/cli/src/workflows/workflowExecution.service.ts @@ -34,6 +34,7 @@ import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData' import { TestWebhooks } from '@/TestWebhooks'; import { Logger } from '@/Logger'; import { PermissionChecker } from '@/UserManagement/PermissionChecker'; +import type { Project } from '@/databases/entities/Project'; @Service() export class WorkflowExecutionService { @@ -161,7 +162,7 @@ export class WorkflowExecutionService { async executeErrorWorkflow( workflowId: string, workflowErrorData: IWorkflowErrorData, - runningUser: User, + runningProject: Project, ): Promise { // Wrap everything in try/catch to make sure that no errors bubble up and all get caught here try { @@ -284,7 +285,7 @@ export class WorkflowExecutionService { executionMode, executionData: runExecutionData, workflowData, - userId: runningUser.id, + projectId: runningProject.id, }; await this.workflowRunner.run(runData); diff --git a/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts b/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts index 4bdc337bc..b92fc440c 100644 --- a/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts +++ b/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts @@ -1,4 +1,3 @@ -import type { SharedWorkflow } from '@db/entities/SharedWorkflow'; import type { User } from '@db/entities/User'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { WorkflowHistory } from '@db/entities/WorkflowHistory'; @@ -18,28 +17,23 @@ export class WorkflowHistoryService { private readonly sharedWorkflowRepository: SharedWorkflowRepository, ) {} - private async getSharedWorkflow(user: User, workflowId: string): Promise { - return await this.sharedWorkflowRepository.findOne({ - where: { - ...(!user.hasGlobalScope('workflow:read') && { userId: user.id }), - workflowId, - }, - }); - } - async getList( user: User, workflowId: string, take: number, skip: number, ): Promise>> { - const sharedWorkflow = await this.getSharedWorkflow(user, workflowId); - if (!sharedWorkflow) { + const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [ + 'workflow:read', + ]); + + if (!workflow) { throw new SharedWorkflowNotFoundError(''); } + return await this.workflowHistoryRepository.find({ where: { - workflowId: sharedWorkflow.workflowId, + workflowId: workflow.id, }, take, skip, @@ -49,13 +43,17 @@ export class WorkflowHistoryService { } async getVersion(user: User, workflowId: string, versionId: string): Promise { - const sharedWorkflow = await this.getSharedWorkflow(user, workflowId); - if (!sharedWorkflow) { + const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [ + 'workflow:read', + ]); + + if (!workflow) { throw new SharedWorkflowNotFoundError(''); } + const hist = await this.workflowHistoryRepository.findOne({ where: { - workflowId: sharedWorkflow.workflowId, + workflowId: workflow.id, versionId, }, }); diff --git a/packages/cli/src/workflows/workflowSharing.service.ts b/packages/cli/src/workflows/workflowSharing.service.ts index 93df8e0ac..8036831ed 100644 --- a/packages/cli/src/workflows/workflowSharing.service.ts +++ b/packages/cli/src/workflows/workflowSharing.service.ts @@ -1,30 +1,61 @@ import { Service } from 'typedi'; -import { In, type FindOptionsWhere } from '@n8n/typeorm'; +import { In } from '@n8n/typeorm'; -import type { SharedWorkflow, WorkflowSharingRole } from '@db/entities/SharedWorkflow'; import type { User } from '@db/entities/User'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; +import { RoleService } from '@/services/role.service'; +import type { Scope } from '@n8n/permissions'; +import type { ProjectRole } from '@/databases/entities/ProjectRelation'; +import type { WorkflowSharingRole } from '@/databases/entities/SharedWorkflow'; @Service() export class WorkflowSharingService { - constructor(private readonly sharedWorkflowRepository: SharedWorkflowRepository) {} + constructor( + private readonly sharedWorkflowRepository: SharedWorkflowRepository, + private readonly roleService: RoleService, + ) {} /** - * Get the IDs of the workflows that have been shared with the user. - * Returns all IDs if user has the 'workflow:read' scope. + * Get the IDs of the workflows that have been shared with the user based on + * scope or roles. + * If `scopes` is passed the roles are inferred. Alternatively `projectRoles` + * and `workflowRoles` can be passed specifically. + * + * Returns all IDs if user has the 'workflow:read' global scope. */ - async getSharedWorkflowIds(user: User, roles?: WorkflowSharingRole[]): Promise { - const where: FindOptionsWhere = {}; - if (!user.hasGlobalScope('workflow:read')) { - where.userId = user.id; - } - if (roles?.length) { - where.role = In(roles); + async getSharedWorkflowIds( + user: User, + options: + | { scopes: Scope[] } + | { projectRoles: ProjectRole[]; workflowRoles: WorkflowSharingRole[] }, + ): Promise { + if (user.hasGlobalScope('workflow:read')) { + const sharedWorkflows = await this.sharedWorkflowRepository.find({ select: ['workflowId'] }); + return sharedWorkflows.map(({ workflowId }) => workflowId); } + + const projectRoles = + 'scopes' in options + ? this.roleService.rolesWithScope('project', options.scopes) + : options.projectRoles; + const workflowRoles = + 'scopes' in options + ? this.roleService.rolesWithScope('workflow', options.scopes) + : options.workflowRoles; + const sharedWorkflows = await this.sharedWorkflowRepository.find({ - where, + where: { + role: In(workflowRoles), + project: { + projectRelations: { + userId: user.id, + role: In(projectRoles), + }, + }, + }, select: ['workflowId'], }); + return sharedWorkflows.map(({ workflowId }) => workflowId); } } diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 3a775dcee..497ff6edc 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -7,16 +7,14 @@ import * as ResponseHelper from '@/ResponseHelper'; import * as WorkflowHelpers from '@/WorkflowHelpers'; import type { IWorkflowResponse } from '@/Interfaces'; import config from '@/config'; -import { Delete, Get, Patch, Post, Put, RestController } from '@/decorators'; -import { SharedWorkflow, type WorkflowSharingRole } from '@db/entities/SharedWorkflow'; +import { Delete, Get, Patch, Post, ProjectScope, Put, RestController } from '@/decorators'; +import { SharedWorkflow } from '@db/entities/SharedWorkflow'; import { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { TagRepository } from '@db/repositories/tag.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; -import { UserRepository } from '@db/repositories/user.repository'; import { validateEntity } from '@/GenericHelpers'; import { ExternalHooks } from '@/ExternalHooks'; -import { ListQuery } from '@/requests'; import { WorkflowService } from './workflow.service'; import { License } from '@/License'; import { InternalHooks } from '@/InternalHooks'; @@ -28,15 +26,20 @@ import { Logger } from '@/Logger'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NamingService } from '@/services/naming.service'; import { UserOnboardingService } from '@/services/userOnboarding.service'; import { CredentialsService } from '../credentials/credentials.service'; import { WorkflowRequest } from './workflow.request'; import { EnterpriseWorkflowService } from './workflow.service.ee'; import { WorkflowExecutionService } from './workflowExecution.service'; -import { WorkflowSharingService } from './workflowSharing.service'; import { UserManagementMailer } from '@/UserManagement/email'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { ProjectService } from '@/services/project.service'; +import { ApplicationError } from 'n8n-workflow'; +import { In, type FindOptionsRelations } from '@n8n/typeorm'; +import type { Project } from '@/databases/entities/Project'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; @RestController('/workflows') export class WorkflowsController { @@ -53,17 +56,21 @@ export class WorkflowsController { private readonly workflowRepository: WorkflowRepository, private readonly workflowService: WorkflowService, private readonly workflowExecutionService: WorkflowExecutionService, - private readonly workflowSharingService: WorkflowSharingService, private readonly sharedWorkflowRepository: SharedWorkflowRepository, - private readonly userRepository: UserRepository, private readonly license: License, private readonly mailer: UserManagementMailer, private readonly credentialsService: CredentialsService, + private readonly projectRepository: ProjectRepository, + private readonly projectService: ProjectService, + private readonly projectRelationRepository: ProjectRelationRepository, ) {} @Post('/') async create(req: WorkflowRequest.Create) { delete req.body.id; // delete if sent + // @ts-expect-error: We shouldn't accept this because it can + // mess with relations of other workflows + delete req.body.shared; const newWorkflow = new WorkflowEntity(); @@ -87,7 +94,7 @@ export class WorkflowsController { if (this.license.isSharingEnabled()) { // This is a new workflow, so we simply check if the user has access to - // all used workflows + // all used credentials const allCredentials = await this.credentialsService.getMany(req.user); @@ -103,20 +110,46 @@ export class WorkflowsController { } } - let savedWorkflow: undefined | WorkflowEntity; + let project: Project | null; + const savedWorkflow = await Db.transaction(async (transactionManager) => { + const workflow = await transactionManager.save(newWorkflow); - await Db.transaction(async (transactionManager) => { - savedWorkflow = await transactionManager.save(newWorkflow); + const { projectId } = req.body; + project = + projectId === undefined + ? await this.projectRepository.getPersonalProjectForUser(req.user.id, transactionManager) + : await this.projectService.getProjectWithScope( + req.user, + projectId, + ['workflow:create'], + transactionManager, + ); - const newSharedWorkflow = new SharedWorkflow(); + if (typeof projectId === 'string' && project === null) { + throw new BadRequestError( + "You don't have the permissions to save the workflow in this project.", + ); + } - Object.assign(newSharedWorkflow, { + // Safe guard in case the personal project does not exist for whatever reason. + if (project === null) { + throw new ApplicationError('No personal project found'); + } + + const newSharedWorkflow = this.sharedWorkflowRepository.create({ role: 'workflow:owner', - user: req.user, - workflow: savedWorkflow, + projectId: project.id, + workflow, }); await transactionManager.save(newSharedWorkflow); + + return await this.sharedWorkflowRepository.findWorkflowForUser( + workflow.id, + req.user, + ['workflow:read'], + { em: transactionManager, includeTags: true }, + ); }); if (!savedWorkflow) { @@ -132,26 +165,28 @@ export class WorkflowsController { }); } - await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]); - void this.internalHooks.onWorkflowCreated(req.user, newWorkflow, false); + const savedWorkflowWithMetaData = + this.enterpriseWorkflowService.addOwnerAndSharings(savedWorkflow); - return savedWorkflow; + // @ts-expect-error: This is added as part of addOwnerAndSharings but + // shouldn't be returned to the frontend + delete savedWorkflowWithMetaData.shared; + + await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]); + void this.internalHooks.onWorkflowCreated(req.user, newWorkflow, project!, false); + + const scopes = await this.workflowService.getWorkflowScopes(req.user, savedWorkflow.id); + + return { ...savedWorkflowWithMetaData, scopes }; } @Get('/', { middlewares: listQueryMiddleware }) - async getAll(req: ListQuery.Request, res: express.Response) { + async getAll(req: WorkflowRequest.GetMany, res: express.Response) { try { - const roles: WorkflowSharingRole[] = this.license.isSharingEnabled() - ? [] - : ['workflow:owner']; - const sharedWorkflowIds = await this.workflowSharingService.getSharedWorkflowIds( - req.user, - roles, - ); - const { workflows: data, count } = await this.workflowService.getMany( - sharedWorkflowIds, + req.user, req.listQueryOptions, + !!req.query.includeScopes, ); res.json({ count, data }); @@ -210,48 +245,60 @@ export class WorkflowsController { return workflowData; } - @Get('/:id') + @Get('/:workflowId') + @ProjectScope('workflow:read') async getWorkflow(req: WorkflowRequest.Get) { - const { id: workflowId } = req.params; + const { workflowId } = req.params; if (this.license.isSharingEnabled()) { - const relations = ['shared', 'shared.user']; + const relations: FindOptionsRelations = { + shared: { + project: { + projectRelations: true, + }, + }, + }; + if (!config.getEnv('workflowTagsDisabled')) { - relations.push('tags'); + relations.tags = true; } - const workflow = await this.workflowRepository.get({ id: workflowId }, { relations }); + const workflow = await this.sharedWorkflowRepository.findWorkflowForUser( + workflowId, + req.user, + ['workflow:read'], + { includeTags: !config.getEnv('workflowTagsDisabled') }, + ); if (!workflow) { throw new NotFoundError(`Workflow with ID "${workflowId}" does not exist`); } - const userSharing = workflow.shared?.find((shared) => shared.user.id === req.user.id); - if (!userSharing && !req.user.hasGlobalScope('workflow:read')) { - throw new UnauthorizedError( - 'You do not have permission to access this workflow. Ask the owner to share it with you', - ); - } - const enterpriseWorkflowService = this.enterpriseWorkflowService; - enterpriseWorkflowService.addOwnerAndSharings(workflow); - await enterpriseWorkflowService.addCredentialsToWorkflow(workflow, req.user); - return workflow; + const workflowWithMetaData = enterpriseWorkflowService.addOwnerAndSharings(workflow); + + await enterpriseWorkflowService.addCredentialsToWorkflow(workflowWithMetaData, req.user); + + // @ts-expect-error: This is added as part of addOwnerAndSharings but + // shouldn't be returned to the frontend + delete workflowWithMetaData.shared; + + const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId); + + return { ...workflowWithMetaData, scopes }; } // sharing disabled - const extraRelations = config.getEnv('workflowTagsDisabled') ? [] : ['workflow.tags']; - - const shared = await this.sharedWorkflowRepository.findSharing( + const workflow = await this.sharedWorkflowRepository.findWorkflowForUser( workflowId, req.user, - 'workflow:read', - { extraRelations }, + ['workflow:read'], + { includeTags: !config.getEnv('workflowTagsDisabled') }, ); - if (!shared) { + if (!workflow) { this.logger.verbose('User attempted to access a workflow without permissions', { workflowId, userId: req.user.id, @@ -261,12 +308,15 @@ export class WorkflowsController { ); } - return shared.workflow; + const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId); + + return { ...workflow, scopes }; } - @Patch('/:id') + @Patch('/:workflowId') + @ProjectScope('workflow:update') async update(req: WorkflowRequest.Update) { - const { id: workflowId } = req.params; + const { workflowId } = req.params; const forceSave = req.query.forceSave === 'true'; let updateData = new WorkflowEntity(); @@ -288,15 +338,17 @@ export class WorkflowsController { workflowId, tags, isSharingEnabled ? forceSave : true, - isSharingEnabled ? undefined : ['workflow:owner'], ); - return updatedWorkflow; + const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId); + + return { ...updatedWorkflow, scopes }; } - @Delete('/:id') + @Delete('/:workflowId') + @ProjectScope('workflow:delete') async delete(req: WorkflowRequest.Delete) { - const { id: workflowId } = req.params; + const { workflowId } = req.params; const workflow = await this.workflowService.delete(req.user, workflowId); if (!workflow) { @@ -312,19 +364,30 @@ export class WorkflowsController { return true; } - @Post('/run') + @Post('/:workflowId/run') + @ProjectScope('workflow:execute') async runManually(req: WorkflowRequest.ManualRun) { + if (!req.body.workflowData.id) { + throw new ApplicationError('You cannot execute a workflow without an ID', { + level: 'warning', + }); + } + + if (req.params.workflowId !== req.body.workflowData.id) { + throw new ApplicationError('Workflow ID in body does not match workflow ID in URL', { + level: 'warning', + }); + } + if (this.license.isSharingEnabled()) { const workflow = this.workflowRepository.create(req.body.workflowData); - if (req.body.workflowData.id !== undefined) { - const safeWorkflow = await this.enterpriseWorkflowService.preventTampering( - workflow, - workflow.id, - req.user, - ); - req.body.workflowData.nodes = safeWorkflow.nodes; - } + const safeWorkflow = await this.enterpriseWorkflowService.preventTampering( + workflow, + workflow.id, + req.user, + ); + req.body.workflowData.nodes = safeWorkflow.nodes; } return await this.workflowExecutionService.executeManually( @@ -335,6 +398,7 @@ export class WorkflowsController { } @Put('/:workflowId/share') + @ProjectScope('workflow:share') async share(req: WorkflowRequest.Share) { if (!this.license.isSharingEnabled()) throw new NotFoundError('Route not found'); @@ -348,59 +412,51 @@ export class WorkflowsController { throw new BadRequestError('Bad request'); } - const isOwnedRes = await this.enterpriseWorkflowService.isOwned(req.user, workflowId); - const { ownsWorkflow } = isOwnedRes; - let { workflow } = isOwnedRes; + const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, req.user, [ + 'workflow:share', + ]); - if (!ownsWorkflow || !workflow) { - workflow = undefined; - // Allow owners/admins to share - if (req.user.hasGlobalScope('workflow:share')) { - const sharedRes = await this.sharedWorkflowRepository.getSharing(req.user, workflowId, { - allowGlobalScope: true, - globalScope: 'workflow:share', - }); - workflow = sharedRes?.workflow; - } - if (!workflow) { - throw new UnauthorizedError('Forbidden'); - } + if (!workflow) { + throw new ForbiddenError(); } - const ownerIds = ( - await this.workflowRepository.getSharings( - Db.getConnection().createEntityManager(), - workflowId, - ['shared'], - ) - ) - .filter((e) => e.role === 'workflow:owner') - .map((e) => e.userId); - let newShareeIds: string[] = []; await Db.transaction(async (trx) => { - // remove all sharings that are not supposed to exist anymore - await this.workflowRepository.pruneSharings(trx, workflowId, [...ownerIds, ...shareWithIds]); + const currentPersonalProjectIDs = workflow.shared + .filter((sw) => sw.role === 'workflow:editor') + .map((sw) => sw.projectId); + const newPersonalProjectIDs = shareWithIds; - const sharings = await this.workflowRepository.getSharings(trx, workflowId); - - // extract the new sharings that need to be added - newShareeIds = utils.rightDiff( - [sharings, (sharing) => sharing.userId], - [shareWithIds, (shareeId) => shareeId], + const toShare = utils.rightDiff( + [currentPersonalProjectIDs, (id) => id], + [newPersonalProjectIDs, (id) => id], ); - if (newShareeIds.length) { - const users = await this.userRepository.getByIds(trx, newShareeIds); - await this.sharedWorkflowRepository.share(trx, workflow, users); - } + const toUnshare = utils.rightDiff( + [newPersonalProjectIDs, (id) => id], + [currentPersonalProjectIDs, (id) => id], + ); + + await trx.delete(SharedWorkflow, { + workflowId, + projectId: In(toUnshare), + }); + + await this.enterpriseWorkflowService.shareWithProjects(workflow, toShare, trx); + + newShareeIds = toShare; }); void this.internalHooks.onWorkflowSharingUpdate(workflowId, req.user.id, shareWithIds); + const projectsRelations = await this.projectRelationRepository.findBy({ + projectId: In(newShareeIds), + role: 'project:personalOwner', + }); + await this.mailer.notifyWorkflowShared({ sharer: req.user, - newShareeIds, + newShareeIds: projectsRelations.map((pr) => pr.userId), workflow, }); } diff --git a/packages/cli/src/workflows/workflows.types.ts b/packages/cli/src/workflows/workflows.types.ts index ef30bb18c..cc9d0ef40 100644 --- a/packages/cli/src/workflows/workflows.types.ts +++ b/packages/cli/src/workflows/workflows.types.ts @@ -1,14 +1,21 @@ import type { IUser } from 'n8n-workflow'; import type { SharedWorkflow } from '@db/entities/SharedWorkflow'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; +import type { SlimProject } from '@/requests'; export interface WorkflowWithSharingsAndCredentials extends Omit { - ownedBy?: IUser | null; - sharedWith?: IUser[]; + homeProject?: SlimProject; + sharedWithProjects?: SlimProject[]; usedCredentials?: CredentialUsedByWorkflow[]; shared?: SharedWorkflow[]; } +export interface WorkflowWithSharingsMetaDataAndCredentials extends Omit { + homeProject?: SlimProject | null; + sharedWithProjects: SlimProject[]; + usedCredentials?: CredentialUsedByWorkflow[]; +} + export interface CredentialUsedByWorkflow { id: string; name: string; diff --git a/packages/cli/test/extend-expect.ts b/packages/cli/test/extend-expect.ts index 328daf2b0..5aba8574d 100644 --- a/packages/cli/test/extend-expect.ts +++ b/packages/cli/test/extend-expect.ts @@ -9,4 +9,26 @@ expect.extend({ : () => `Expected ${actual} not to be an empty array`, }; }, + + toBeEmptySet(this: jest.MatcherContext, actual: unknown) { + const pass = actual instanceof Set && actual.size === 0; + + return { + pass, + message: pass + ? () => `Expected ${[...actual]} to be an empty set` + : () => `Expected ${actual} not to be an empty set`, + }; + }, + + toBeSetContaining(this: jest.MatcherContext, actual: unknown, ...expectedElements: string[]) { + const pass = actual instanceof Set && expectedElements.every((e) => actual.has(e)); + + return { + pass, + message: pass + ? () => `Expected ${[...actual]} to be a set containing ${expectedElements}` + : () => `Expected ${actual} not to be a set containing ${expectedElements}`, + }; + }, }); diff --git a/packages/cli/test/integration/CredentialsHelper.test.ts b/packages/cli/test/integration/CredentialsHelper.test.ts new file mode 100644 index 000000000..88738c3c2 --- /dev/null +++ b/packages/cli/test/integration/CredentialsHelper.test.ts @@ -0,0 +1,152 @@ +import Container from 'typedi'; +import * as testDb from '../integration/shared/testDb'; + +import { CredentialsHelper } from '@/CredentialsHelper'; +import { createOwner, createAdmin, createMember } from './shared/db/users'; +import type { User } from '@/databases/entities/User'; +import { saveCredential } from './shared/db/credentials'; +import { randomCredentialPayload } from './shared/random'; +import { createTeamProject, linkUserToProject } from './shared/db/projects'; + +let credentialHelper: CredentialsHelper; +let owner: User; +let admin: User; +let member: User; + +beforeAll(async () => { + await testDb.init(); + + credentialHelper = Container.get(CredentialsHelper); + owner = await createOwner(); + admin = await createAdmin(); + member = await createMember(); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +describe('CredentialsHelper', () => { + describe('credentialOwnedBySuperUsers', () => { + test.each([ + { + testName: 'owners are super users', + user: () => owner, + credentialRole: 'credential:owner', + expectedResult: true, + } as const, + { + testName: 'admins are super users', + user: () => admin, + credentialRole: 'credential:owner', + expectedResult: true, + } as const, + { + testName: 'owners need to own the credential', + user: () => owner, + credentialRole: 'credential:user', + expectedResult: false, + } as const, + { + testName: 'admins need to own the credential', + user: () => admin, + credentialRole: 'credential:user', + expectedResult: false, + } as const, + { + testName: 'members are no super users', + user: () => member, + credentialRole: 'credential:owner', + expectedResult: false, + } as const, + ])('$testName', async ({ user, credentialRole, expectedResult }) => { + const credential = await saveCredential(randomCredentialPayload(), { + user: user(), + role: credentialRole, + }); + + const result = await credentialHelper.credentialCanUseExternalSecrets(credential); + + expect(result).toBe(expectedResult); + }); + + test('credential in team project with instance owner as an admin can use external secrets', async () => { + const teamProject = await createTeamProject(); + const [credential] = await Promise.all([ + await saveCredential(randomCredentialPayload(), { + project: teamProject, + role: 'credential:owner', + }), + await linkUserToProject(owner, teamProject, 'project:admin'), + await linkUserToProject(member, teamProject, 'project:admin'), + ]); + + const result = await credentialHelper.credentialCanUseExternalSecrets(credential); + + expect(result).toBe(true); + }); + + test('credential in team project with instance admin as an admin can use external secrets', async () => { + const teamProject = await createTeamProject(); + const [credential] = await Promise.all([ + await saveCredential(randomCredentialPayload(), { + project: teamProject, + role: 'credential:owner', + }), + await linkUserToProject(admin, teamProject, 'project:admin'), + await linkUserToProject(member, teamProject, 'project:admin'), + ]); + + const result = await credentialHelper.credentialCanUseExternalSecrets(credential); + + expect(result).toBe(true); + }); + + test('credential in team project with instance owner as an editor cannot use external secrets', async () => { + const teamProject = await createTeamProject(); + const [credential] = await Promise.all([ + await saveCredential(randomCredentialPayload(), { + project: teamProject, + role: 'credential:owner', + }), + await linkUserToProject(owner, teamProject, 'project:editor'), + await linkUserToProject(member, teamProject, 'project:admin'), + ]); + + const result = await credentialHelper.credentialCanUseExternalSecrets(credential); + + expect(result).toBe(false); + }); + + test('credential in team project with instance admin as an editor cannot use external secrets', async () => { + const teamProject = await createTeamProject(); + const [credential] = await Promise.all([ + await saveCredential(randomCredentialPayload(), { + project: teamProject, + role: 'credential:owner', + }), + await linkUserToProject(admin, teamProject, 'project:editor'), + await linkUserToProject(member, teamProject, 'project:admin'), + ]); + + const result = await credentialHelper.credentialCanUseExternalSecrets(credential); + + expect(result).toBe(false); + }); + + test('credential in team project with no instance admin or owner as part of the project cannot use external secrets', async () => { + const teamProject = await createTeamProject(); + const [credential] = await Promise.all([ + await saveCredential(randomCredentialPayload(), { + project: teamProject, + role: 'credential:owner', + }), + await linkUserToProject(member, teamProject, 'project:admin'), + ]); + + const result = await credentialHelper.credentialCanUseExternalSecrets(credential); + + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/cli/test/integration/PermissionChecker.test.ts b/packages/cli/test/integration/PermissionChecker.test.ts index f80fbc02d..6c176cb5d 100644 --- a/packages/cli/test/integration/PermissionChecker.test.ts +++ b/packages/cli/test/integration/PermissionChecker.test.ts @@ -4,10 +4,9 @@ import type { INode, WorkflowSettings } from 'n8n-workflow'; import { SubworkflowOperationError, Workflow } from 'n8n-workflow'; import config from '@/config'; -import { User } from '@db/entities/User'; +import type { User } from '@db/entities/User'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; -import { UserRepository } from '@/databases/repositories/user.repository'; import { generateNanoId } from '@/databases/utils/generators'; import { License } from '@/License'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; @@ -28,7 +27,10 @@ import { mockNodeTypesData } from '../unit/Helpers'; import { affixRoleToSaveCredential } from '../integration/shared/db/credentials'; import { createOwner, createUser } from '../integration/shared/db/users'; import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository'; +import { getPersonalProject } from './shared/db/projects'; import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; +import { Project } from '@/databases/entities/Project'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; export const toTargetCallErrorMsg = (subworkflowId: string) => `Target workflow ID ${subworkflowId} may not be called`; @@ -71,9 +73,11 @@ export function createSubworkflow({ }); } +const ownershipService = mockInstance(OwnershipService); + const createWorkflow = async (nodes: INode[], workflowOwner?: User): Promise => { const workflowDetails = { - id: uuid(), + id: randomPositiveDigit().toString(), name: 'test', active: false, connections: {}, @@ -82,11 +86,13 @@ const createWorkflow = async (nodes: INode[], workflowOwner?: User): Promise { await testDb.init(); @@ -116,16 +122,12 @@ beforeAll(async () => { permissionChecker = Container.get(PermissionChecker); [owner, member] = await Promise.all([createOwner(), createUser()]); - - license = new LicenseMocker(); - license.mock(Container.get(License)); - license.setDefaults({ - features: ['feat:sharing'], - }); -}); - -beforeEach(() => { - license.reset(); + ownerPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + owner.id, + ); + memberPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + member.id, + ); }); describe('check()', () => { @@ -150,46 +152,19 @@ describe('check()', () => { ]; const workflow = await createWorkflow(nodes, member); + ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(memberPersonalProject); - await expect( - permissionChecker.check(workflow.id, member.id, workflow.nodes), - ).resolves.not.toThrow(); + await expect(permissionChecker.check(workflow.id, nodes)).resolves.not.toThrow(); }); - test('should allow if requesting user is instance owner', async () => { - const owner = await createOwner(); - const nodes: INode[] = [ - { - id: uuid(), - name: 'Action Network', - type: 'n8n-nodes-base.actionNetwork', - parameters: {}, - typeVersion: 1, - position: [0, 0], - credentials: { - actionNetworkApi: { - id: randomPositiveDigit().toString(), - name: 'Action Network Account', - }, - }, - }, - ]; - - const workflow = await createWorkflow(nodes); - - await expect( - permissionChecker.check(workflow.id, owner.id, workflow.nodes), - ).resolves.not.toThrow(); - }); - - test('should allow if workflow creds are valid subset (shared credential)', async () => { + test('should allow if workflow creds are valid subset', async () => { const ownerCred = await saveCredential(randomCred(), { user: owner }); const memberCred = await saveCredential(randomCred(), { user: member }); await Container.get(SharedCredentialsRepository).save( Container.get(SharedCredentialsRepository).create({ + projectId: (await getPersonalProject(member)).id, credentialsId: ownerCred.id, - userId: member.id, role: 'credential:user', }), ); @@ -225,119 +200,18 @@ describe('check()', () => { }, ]; - const workflow = await createWorkflow(nodes, member); + const workflowEntity = await createWorkflow(nodes, member); - await expect( - permissionChecker.check(workflow.id, member.id, workflow.nodes), - ).resolves.not.toThrow(); - }); + ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(memberPersonalProject); - test('should allow if workflow creds are valid subset (shared workflow)', async () => { - const ownerCred = await saveCredential(randomCred(), { user: owner }); - const memberCred = await saveCredential(randomCred(), { user: member }); - - const nodes: INode[] = [ - { - id: uuid(), - name: 'Action Network', - type: 'n8n-nodes-base.actionNetwork', - parameters: {}, - typeVersion: 1, - position: [0, 0], - credentials: { - actionNetworkApi: { - id: ownerCred.id, - name: ownerCred.name, - }, - }, - }, - { - id: uuid(), - name: 'Action Network 2', - type: 'n8n-nodes-base.actionNetwork', - parameters: {}, - typeVersion: 1, - position: [0, 0], - credentials: { - actionNetworkApi: { - id: memberCred.id, - name: memberCred.name, - }, - }, - }, - ]; - - const workflow = await createWorkflow(nodes, member); - await Container.get(SharedWorkflowRepository).save( - Container.get(SharedWorkflowRepository).create({ - workflowId: workflow.id, - userId: owner.id, - role: 'workflow:editor', - }), - ); - - await expect( - permissionChecker.check(workflow.id, member.id, workflow.nodes), - ).resolves.not.toThrow(); - }); - - test('should deny if workflow creds are valid subset but sharing is disabled', async () => { - const [owner, member] = await Promise.all([createOwner(), createUser()]); - - const ownerCred = await saveCredential(randomCred(), { user: owner }); - const memberCred = await saveCredential(randomCred(), { user: member }); - - await Container.get(SharedCredentialsRepository).save( - Container.get(SharedCredentialsRepository).create({ - credentialsId: ownerCred.id, - userId: member.id, - role: 'credential:user', - }), - ); - - const nodes: INode[] = [ - { - id: uuid(), - name: 'Action Network', - type: 'n8n-nodes-base.actionNetwork', - parameters: {}, - typeVersion: 1, - position: [0, 0], - credentials: { - actionNetworkApi: { - id: ownerCred.id, - name: ownerCred.name, - }, - }, - }, - { - id: uuid(), - name: 'Action Network 2', - type: 'n8n-nodes-base.actionNetwork', - parameters: {}, - typeVersion: 1, - position: [0, 0], - credentials: { - actionNetworkApi: { - id: memberCred.id, - name: memberCred.name, - }, - }, - }, - ]; - - const workflow = await createWorkflow(nodes, member); - - license.disable('feat:sharing'); - await expect(permissionChecker.check(workflow.id, member.id, nodes)).rejects.toThrow(); + await expect(permissionChecker.check(workflowEntity.id, nodes)).resolves.not.toThrow(); }); test('should deny if workflow creds are not valid subset', async () => { - const member = await createUser(); - const memberCred = await saveCredential(randomCred(), { user: member }); + const ownerCred = await saveCredential(randomCred(), { user: owner }); - const nodes: INode[] = [ + const nodes = [ { id: uuid(), name: 'Action Network', @@ -361,21 +235,73 @@ describe('check()', () => { position: [0, 0] as [number, number], credentials: { actionNetworkApi: { - id: 'non-existing-credential-id', - name: 'Non-existing credential name', + id: ownerCred.id, + name: ownerCred.name, }, }, }, ]; - const workflow = await createWorkflow(nodes, member); + const workflowEntity = await createWorkflow(nodes, member); - await expect(permissionChecker.check(workflow.id, member.id, workflow.nodes)).rejects.toThrow(); + await expect( + permissionChecker.check(workflowEntity.id, workflowEntity.nodes), + ).rejects.toThrow(); + }); + + test('should allow all credentials if current user is instance owner', async () => { + const memberCred = await saveCredential(randomCred(), { user: member }); + const ownerCred = await saveCredential(randomCred(), { user: owner }); + + const nodes = [ + { + id: uuid(), + name: 'Action Network', + type: 'n8n-nodes-base.actionNetwork', + parameters: {}, + typeVersion: 1, + position: [0, 0] as [number, number], + credentials: { + actionNetworkApi: { + id: memberCred.id, + name: memberCred.name, + }, + }, + }, + { + id: uuid(), + name: 'Action Network 2', + type: 'n8n-nodes-base.actionNetwork', + parameters: {}, + typeVersion: 1, + position: [0, 0] as [number, number], + credentials: { + actionNetworkApi: { + id: ownerCred.id, + name: ownerCred.name, + }, + }, + }, + ]; + + const workflowEntity = await createWorkflow(nodes, owner); + ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(ownerPersonalProject); + ownershipService.getProjectOwnerCached.mockResolvedValueOnce(owner); + + await expect( + permissionChecker.check(workflowEntity.id, workflowEntity.nodes), + ).resolves.not.toThrow(); }); }); describe('checkSubworkflowExecutePolicy()', () => { - const ownershipService = mockInstance(OwnershipService); + let license: LicenseMocker; + + beforeAll(() => { + license = new LicenseMocker(); + license.mock(Container.get(License)); + license.enable('feat:sharing'); + }); describe('no caller policy', () => { test('should fall back to N8N_WORKFLOW_CALLER_POLICY_DEFAULT_OPTION', async () => { @@ -384,7 +310,7 @@ describe('checkSubworkflowExecutePolicy()', () => { const parentWorkflow = createParentWorkflow(); const subworkflow = createSubworkflow(); // no caller policy - ownershipService.getWorkflowOwnerCached.mockResolvedValue(new User()); + ownershipService.getWorkflowProjectCached.mockResolvedValue(memberPersonalProject); const check = permissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id); @@ -401,11 +327,11 @@ describe('checkSubworkflowExecutePolicy()', () => { const parentWorkflow = createParentWorkflow(); const subworkflow = createSubworkflow({ policy: 'any' }); // should be overridden - const firstUser = Container.get(UserRepository).create({ id: uuid() }); - const secondUser = Container.get(UserRepository).create({ id: uuid() }); + const firstProject = Container.get(ProjectRepository).create({ id: uuid() }); + const secondProject = Container.get(ProjectRepository).create({ id: uuid() }); - ownershipService.getWorkflowOwnerCached.mockResolvedValueOnce(firstUser); // parent workflow - ownershipService.getWorkflowOwnerCached.mockResolvedValueOnce(secondUser); // subworkflow + ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(firstProject); // parent workflow + ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(secondProject); // subworkflow const check = permissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id); @@ -416,7 +342,7 @@ describe('checkSubworkflowExecutePolicy()', () => { } catch (error) { if (error instanceof SubworkflowOperationError) { expect(error.description).toBe( - `${firstUser.firstName} (${firstUser.email}) can make this change. You may need to tell them the ID of this workflow, which is ${subworkflow.id}`, + `An admin for the ${firstProject.name} project can make this change. You may need to tell them the ID of the sub-workflow, which is ${subworkflow.id}`, ); } } @@ -457,7 +383,7 @@ describe('checkSubworkflowExecutePolicy()', () => { test('should not throw', async () => { const parentWorkflow = createParentWorkflow(); const subworkflow = createSubworkflow({ policy: 'any' }); - ownershipService.getWorkflowOwnerCached.mockResolvedValue(new User()); + ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(new Project()); const check = permissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id); @@ -467,11 +393,11 @@ describe('checkSubworkflowExecutePolicy()', () => { describe('workflows-from-same-owner caller policy', () => { test('should deny if the two workflows are owned by different users', async () => { - const parentWorkflowOwner = Container.get(UserRepository).create({ id: uuid() }); - const subworkflowOwner = Container.get(UserRepository).create({ id: uuid() }); + const parentWorkflowProject = Container.get(ProjectRepository).create({ id: uuid() }); + const subworkflowOwner = Container.get(ProjectRepository).create({ id: uuid() }); - ownershipService.getWorkflowOwnerCached.mockResolvedValueOnce(parentWorkflowOwner); // parent workflow - ownershipService.getWorkflowOwnerCached.mockResolvedValueOnce(subworkflowOwner); // subworkflow + ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(parentWorkflowProject); // parent workflow + ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(subworkflowOwner); // subworkflow const subworkflow = createSubworkflow({ policy: 'workflowsFromSameOwner' }); @@ -483,10 +409,10 @@ describe('checkSubworkflowExecutePolicy()', () => { test('should allow if both workflows are owned by the same user', async () => { const parentWorkflow = createParentWorkflow(); - const bothWorkflowsOwner = Container.get(UserRepository).create({ id: uuid() }); + const bothWorkflowsProject = Container.get(ProjectRepository).create({ id: uuid() }); - ownershipService.getWorkflowOwnerCached.mockResolvedValueOnce(bothWorkflowsOwner); // parent workflow - ownershipService.getWorkflowOwnerCached.mockResolvedValueOnce(bothWorkflowsOwner); // subworkflow + ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(bothWorkflowsProject); // parent workflow + ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(bothWorkflowsProject); // subworkflow const subworkflow = createSubworkflow({ policy: 'workflowsFromSameOwner' }); diff --git a/packages/cli/test/integration/auth.api.test.ts b/packages/cli/test/integration/auth.api.test.ts index 9435fa7a7..356a97b66 100644 --- a/packages/cli/test/integration/auth.api.test.ts +++ b/packages/cli/test/integration/auth.api.test.ts @@ -368,7 +368,8 @@ describe('GET /resolve-signup-token', () => { .query({ inviteeId }); // cause inconsistent DB state - await Container.get(UserRepository).update(owner.id, { email: '' }); + owner.email = ''; + await Container.get(UserRepository).save(owner); const fifth = await authOwnerAgent .get('/resolve-signup-token') .query({ inviterId: owner.id }) diff --git a/packages/cli/test/integration/commands/credentials.cmd.test.ts b/packages/cli/test/integration/commands/credentials.cmd.test.ts index 730a0cd5d..6c25fa615 100644 --- a/packages/cli/test/integration/commands/credentials.cmd.test.ts +++ b/packages/cli/test/integration/commands/credentials.cmd.test.ts @@ -8,6 +8,8 @@ import { mockInstance } from '../../shared/mocking'; import * as testDb from '../shared/testDb'; import { getAllCredentials, getAllSharedCredentials } from '../shared/db/credentials'; import { createMember, createOwner } from '../shared/db/users'; +import { getPersonalProject } from '../shared/db/projects'; +import { nanoid } from 'nanoid'; const oclifConfig = new Config({ root: __dirname }); @@ -36,6 +38,7 @@ test('import:credentials should import a credential', async () => { // ARRANGE // const owner = await createOwner(); + const ownerProject = await getPersonalProject(owner); // // ACT @@ -54,7 +57,11 @@ test('import:credentials should import a credential', async () => { expect(after).toMatchObject({ credentials: [expect.objectContaining({ id: '123', name: 'cred-aws-test' })], sharings: [ - expect.objectContaining({ credentialsId: '123', userId: owner.id, role: 'credential:owner' }), + expect.objectContaining({ + credentialsId: '123', + projectId: ownerProject.id, + role: 'credential:owner', + }), ], }); }); @@ -64,6 +71,7 @@ test('import:credentials should import a credential from separated files', async // ARRANGE // const owner = await createOwner(); + const ownerProject = await getPersonalProject(owner); // // ACT @@ -92,7 +100,7 @@ test('import:credentials should import a credential from separated files', async sharings: [ expect.objectContaining({ credentialsId: '123', - userId: owner.id, + projectId: ownerProject.id, role: 'credential:owner', }), ], @@ -104,6 +112,7 @@ test('`import:credentials --userId ...` should fail if the credential exists alr // ARRANGE // const owner = await createOwner(); + const ownerProject = await getPersonalProject(owner); const member = await createMember(); // import credential the first time, assigning it to the owner @@ -122,7 +131,7 @@ test('`import:credentials --userId ...` should fail if the credential exists alr sharings: [ expect.objectContaining({ credentialsId: '123', - userId: owner.id, + projectId: ownerProject.id, role: 'credential:owner', }), ], @@ -140,7 +149,7 @@ test('`import:credentials --userId ...` should fail if the credential exists alr `--userId=${member.id}`, ]), ).rejects.toThrowError( - `The credential with id "123" is already owned by the user with the id "${owner.id}". It can't be re-owned by the user with the id "${member.id}"`, + `The credential with ID "123" is already owned by the user with the ID "${owner.id}". It can't be re-owned by the user with the ID "${member.id}"`, ); // @@ -162,19 +171,20 @@ test('`import:credentials --userId ...` should fail if the credential exists alr sharings: [ expect.objectContaining({ credentialsId: '123', - userId: owner.id, + projectId: ownerProject.id, role: 'credential:owner', }), ], }); }); -test("only update credential, don't create or update owner if `--userId` is not passed", async () => { +test("only update credential, don't create or update owner if neither `--userId` nor `--projectId` is passed", async () => { // // ARRANGE // await createOwner(); const member = await createMember(); + const memberProject = await getPersonalProject(member); // import credential the first time, assigning it to a member await importCredential([ @@ -192,7 +202,7 @@ test("only update credential, don't create or update owner if `--userId` is not sharings: [ expect.objectContaining({ credentialsId: '123', - userId: member.id, + projectId: memberProject.id, role: 'credential:owner', }), ], @@ -225,9 +235,93 @@ test("only update credential, don't create or update owner if `--userId` is not sharings: [ expect.objectContaining({ credentialsId: '123', - userId: member.id, + projectId: memberProject.id, role: 'credential:owner', }), ], }); }); + +test('`import:credential --projectId ...` should fail if the credential already exists and is owned by another project', async () => { + // + // ARRANGE + // + const owner = await createOwner(); + const ownerProject = await getPersonalProject(owner); + const member = await createMember(); + const memberProject = await getPersonalProject(member); + + // import credential the first time, assigning it to the owner + await importCredential([ + '--input=./test/integration/commands/importCredentials/credentials.json', + `--userId=${owner.id}`, + ]); + + // making sure the import worked + const before = { + credentials: await getAllCredentials(), + sharings: await getAllSharedCredentials(), + }; + expect(before).toMatchObject({ + credentials: [expect.objectContaining({ id: '123', name: 'cred-aws-test' })], + sharings: [ + expect.objectContaining({ + credentialsId: '123', + projectId: ownerProject.id, + role: 'credential:owner', + }), + ], + }); + + // + // ACT + // + + // Import again while updating the name we try to assign the + // credential to another user. + await expect( + importCredential([ + '--input=./test/integration/commands/importCredentials/credentials-updated.json', + `--projectId=${memberProject.id}`, + ]), + ).rejects.toThrowError( + `The credential with ID "123" is already owned by the user with the ID "${owner.id}". It can't be re-owned by the project with the ID "${memberProject.id}".`, + ); + + // + // ASSERT + // + const after = { + credentials: await getAllCredentials(), + sharings: await getAllSharedCredentials(), + }; + + expect(after).toMatchObject({ + credentials: [ + expect.objectContaining({ + id: '123', + // only the name was updated + name: 'cred-aws-test', + }), + ], + sharings: [ + expect.objectContaining({ + credentialsId: '123', + projectId: ownerProject.id, + role: 'credential:owner', + }), + ], + }); +}); + +test('`import:credential --projectId ... --userId ...` fails explaining that only one of the options can be used at a time', async () => { + await expect( + importCredential([ + '--input=./test/integration/commands/importCredentials/credentials-updated.json', + `--projectId=${nanoid()}`, + `--userId=${nanoid()}`, + ]), + ).rejects.toThrowError( + 'You cannot use `--userId` and `--projectId` together. Use one or the other.', + ); +}); diff --git a/packages/cli/test/integration/commands/import.cmd.test.ts b/packages/cli/test/integration/commands/import.cmd.test.ts index e65325c47..362801e8c 100644 --- a/packages/cli/test/integration/commands/import.cmd.test.ts +++ b/packages/cli/test/integration/commands/import.cmd.test.ts @@ -8,6 +8,8 @@ import { mockInstance } from '../../shared/mocking'; import * as testDb from '../shared/testDb'; import { getAllSharedWorkflows, getAllWorkflows } from '../shared/db/workflows'; import { createMember, createOwner } from '../shared/db/users'; +import { getPersonalProject } from '../shared/db/projects'; +import { nanoid } from 'nanoid'; const oclifConfig = new Config({ root: __dirname }); @@ -36,6 +38,7 @@ test('import:workflow should import active workflow and deactivate it', async () // ARRANGE // const owner = await createOwner(); + const ownerProject = await getPersonalProject(owner); // // ACT @@ -58,8 +61,16 @@ test('import:workflow should import active workflow and deactivate it', async () expect.objectContaining({ name: 'inactive-workflow', active: false }), ], sharings: [ - expect.objectContaining({ workflowId: '998', userId: owner.id, role: 'workflow:owner' }), - expect.objectContaining({ workflowId: '999', userId: owner.id, role: 'workflow:owner' }), + expect.objectContaining({ + workflowId: '998', + projectId: ownerProject.id, + role: 'workflow:owner', + }), + expect.objectContaining({ + workflowId: '999', + projectId: ownerProject.id, + role: 'workflow:owner', + }), ], }); }); @@ -69,6 +80,7 @@ test('import:workflow should import active workflow from combined file and deact // ARRANGE // const owner = await createOwner(); + const ownerProject = await getPersonalProject(owner); // // ACT @@ -90,8 +102,16 @@ test('import:workflow should import active workflow from combined file and deact expect.objectContaining({ name: 'inactive-workflow', active: false }), ], sharings: [ - expect.objectContaining({ workflowId: '998', userId: owner.id, role: 'workflow:owner' }), - expect.objectContaining({ workflowId: '999', userId: owner.id, role: 'workflow:owner' }), + expect.objectContaining({ + workflowId: '998', + projectId: ownerProject.id, + role: 'workflow:owner', + }), + expect.objectContaining({ + workflowId: '999', + projectId: ownerProject.id, + role: 'workflow:owner', + }), ], }); }); @@ -101,6 +121,7 @@ test('`import:workflow --userId ...` should fail if the workflow exists already // ARRANGE // const owner = await createOwner(); + const ownerProject = await getPersonalProject(owner); const member = await createMember(); // Import workflow the first time, assigning it to a member. @@ -119,7 +140,7 @@ test('`import:workflow --userId ...` should fail if the workflow exists already sharings: [ expect.objectContaining({ workflowId: '998', - userId: owner.id, + projectId: ownerProject.id, role: 'workflow:owner', }), ], @@ -136,7 +157,7 @@ test('`import:workflow --userId ...` should fail if the workflow exists already `--userId=${member.id}`, ]), ).rejects.toThrowError( - `The credential with id "998" is already owned by the user with the id "${owner.id}". It can't be re-owned by the user with the id "${member.id}"`, + `The credential with ID "998" is already owned by the user with the ID "${owner.id}". It can't be re-owned by the user with the ID "${member.id}"`, ); // @@ -152,7 +173,7 @@ test('`import:workflow --userId ...` should fail if the workflow exists already sharings: [ expect.objectContaining({ workflowId: '998', - userId: owner.id, + projectId: ownerProject.id, role: 'workflow:owner', }), ], @@ -165,6 +186,7 @@ test("only update the workflow, don't create or update the owner if `--userId` i // await createOwner(); const member = await createMember(); + const memberProject = await getPersonalProject(member); // Import workflow the first time, assigning it to a member. await importWorkflow([ @@ -182,7 +204,7 @@ test("only update the workflow, don't create or update the owner if `--userId` i sharings: [ expect.objectContaining({ workflowId: '998', - userId: member.id, + projectId: memberProject.id, role: 'workflow:owner', }), ], @@ -209,9 +231,86 @@ test("only update the workflow, don't create or update the owner if `--userId` i sharings: [ expect.objectContaining({ workflowId: '998', - userId: member.id, + projectId: memberProject.id, role: 'workflow:owner', }), ], }); }); + +test('`import:workflow --projectId ...` should fail if the credential already exists and is owned by another project', async () => { + // + // ARRANGE + // + const owner = await createOwner(); + const ownerProject = await getPersonalProject(owner); + const member = await createMember(); + const memberProject = await getPersonalProject(member); + + // Import workflow the first time, assigning it to a member. + await importWorkflow([ + '--input=./test/integration/commands/importWorkflows/combined-with-update/original.json', + `--userId=${owner.id}`, + ]); + + const before = { + workflows: await getAllWorkflows(), + sharings: await getAllSharedWorkflows(), + }; + // Make sure the workflow and sharing have been created. + expect(before).toMatchObject({ + workflows: [expect.objectContaining({ id: '998', name: 'active-workflow' })], + sharings: [ + expect.objectContaining({ + workflowId: '998', + projectId: ownerProject.id, + role: 'workflow:owner', + }), + ], + }); + + // + // ACT + // + // Import the same workflow again, with another name but the same ID, and try + // to assign it to the member. + await expect( + importWorkflow([ + '--input=./test/integration/commands/importWorkflows/combined-with-update/updated.json', + `--projectId=${memberProject.id}`, + ]), + ).rejects.toThrowError( + `The credential with ID "998" is already owned by the user with the ID "${owner.id}". It can't be re-owned by the project with the ID "${memberProject.id}"`, + ); + + // + // ASSERT + // + const after = { + workflows: await getAllWorkflows(), + sharings: await getAllSharedWorkflows(), + }; + // Make sure there is no new sharing and that the name DID NOT change. + expect(after).toMatchObject({ + workflows: [expect.objectContaining({ id: '998', name: 'active-workflow' })], + sharings: [ + expect.objectContaining({ + workflowId: '998', + projectId: ownerProject.id, + role: 'workflow:owner', + }), + ], + }); +}); + +test('`import:workflow --projectId ... --userId ...` fails explaining that only one of the options can be used at a time', async () => { + await expect( + importWorkflow([ + '--input=./test/integration/commands/importWorkflows/combined-with-update/updated.json', + `--userId=${nanoid()}`, + `--projectId=${nanoid()}`, + ]), + ).rejects.toThrowError( + 'You cannot use `--userId` and `--projectId` together. Use one or the other.', + ); +}); diff --git a/packages/cli/test/integration/commands/ldap/reset.test.ts b/packages/cli/test/integration/commands/ldap/reset.test.ts new file mode 100644 index 000000000..8727b13e4 --- /dev/null +++ b/packages/cli/test/integration/commands/ldap/reset.test.ts @@ -0,0 +1,381 @@ +import { Reset } from '@/commands/ldap/reset'; +import { Config } from '@oclif/core'; + +import * as testDb from '../../shared/testDb'; +import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; +import { mockInstance } from '../../../shared/mocking'; +import { InternalHooks } from '@/InternalHooks'; +import { createLdapUser, createMember, getUserById } from '../../shared/db/users'; +import { createWorkflow } from '../../shared/db/workflows'; +import { randomCredentialPayload } from '../../shared/random'; +import { saveCredential } from '../../shared/db/credentials'; +import Container from 'typedi'; +import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; +import { EntityNotFoundError } from '@n8n/typeorm'; +import { Push } from '@/push'; +import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; +import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository'; +import { createTeamProject, findProject, getPersonalProject } from '../../shared/db/projects'; +import { WaitTracker } from '@/WaitTracker'; +import { getLdapSynchronizations, saveLdapSynchronization } from '@/Ldap/helpers'; +import { createLdapConfig } from '../../shared/ldap'; +import { LdapService } from '@/Ldap/ldap.service'; +import { v4 as uuid } from 'uuid'; + +const oclifConfig = new Config({ root: __dirname }); + +async function resetLDAP(argv: string[]) { + const cmd = new Reset(argv, oclifConfig); + try { + await cmd.init(); + } catch (error) { + console.error(error); + throw error; + } + await cmd.run(); +} + +beforeAll(async () => { + mockInstance(Push); + mockInstance(InternalHooks); + mockInstance(LoadNodesAndCredentials); + // This needs to be mocked, otherwise the time setInterval would prevent jest + // from exiting properly. + mockInstance(WaitTracker); + await testDb.init(); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +test('fails if neither `--userId` nor `--projectId` nor `--deleteWorkflowsAndCredentials` is passed', async () => { + await expect(resetLDAP([])).rejects.toThrowError( + 'You must use exactly one of `--userId`, `--projectId` or `--deleteWorkflowsAndCredentials`.', + ); +}); + +test.each([ + [`--userId=${uuid()}`, `--projectId=${uuid()}`, '--deleteWorkflowsAndCredentials'], + + [`--userId=${uuid()}`, `--projectId=${uuid()}`], + [`--userId=${uuid()}`, '--deleteWorkflowsAndCredentials'], + + ['--deleteWorkflowsAndCredentials', `--projectId=${uuid()}`], +])( + 'fails if more than one of `--userId`, `--projectId`, `--deleteWorkflowsAndCredentials` are passed', + async (...argv) => { + await expect(resetLDAP(argv)).rejects.toThrowError( + 'You must use exactly one of `--userId`, `--projectId` or `--deleteWorkflowsAndCredentials`.', + ); + }, +); + +describe('--deleteWorkflowsAndCredentials', () => { + test('deletes personal projects, workflows and credentials owned by LDAP managed users', async () => { + // + // ARRANGE + // + const member = await createLdapUser({ role: 'global:member' }, uuid()); + const memberProject = await getPersonalProject(member); + const workflow = await createWorkflow({}, member); + const credential = await saveCredential(randomCredentialPayload(), { + user: member, + role: 'credential:owner', + }); + + const normalMember = await createMember(); + const workflow2 = await createWorkflow({}, normalMember); + const credential2 = await saveCredential(randomCredentialPayload(), { + user: normalMember, + role: 'credential:owner', + }); + + // + // ACT + // + await resetLDAP(['--deleteWorkflowsAndCredentials']); + + // + // ASSERT + // + // LDAP user is deleted + await expect(getUserById(member.id)).rejects.toThrowError(EntityNotFoundError); + await expect(findProject(memberProject.id)).rejects.toThrowError(EntityNotFoundError); + await expect( + Container.get(WorkflowRepository).findOneBy({ id: workflow.id }), + ).resolves.toBeNull(); + await expect( + Container.get(CredentialsRepository).findOneBy({ id: credential.id }), + ).resolves.toBeNull(); + + // Non LDAP user is not deleted + await expect(getUserById(normalMember.id)).resolves.not.toThrowError(); + await expect( + Container.get(WorkflowRepository).findOneBy({ id: workflow2.id }), + ).resolves.not.toBeNull(); + await expect( + Container.get(CredentialsRepository).findOneBy({ id: credential2.id }), + ).resolves.not.toBeNull(); + }); + + test('deletes the LDAP sync history', async () => { + // + // ARRANGE + // + await saveLdapSynchronization({ + created: 1, + disabled: 1, + scanned: 1, + updated: 1, + endedAt: new Date(), + startedAt: new Date(), + error: '', + runMode: 'dry', + status: 'success', + }); + + // + // ACT + // + await resetLDAP(['--deleteWorkflowsAndCredentials']); + + // + // ASSERT + // + await expect(getLdapSynchronizations(0, 10)).resolves.toHaveLength(0); + }); + + test('resets LDAP settings', async () => { + // + // ARRANGE + // + await createLdapConfig(); + await expect(Container.get(LdapService).loadConfig()).resolves.toMatchObject({ + loginEnabled: true, + }); + + // + // ACT + // + await resetLDAP(['--deleteWorkflowsAndCredentials']); + + // + // ASSERT + // + await expect(Container.get(LdapService).loadConfig()).resolves.toMatchObject({ + loginEnabled: false, + }); + }); +}); + +describe('--userId', () => { + test('fails if the user does not exist', async () => { + const userId = uuid(); + await expect(resetLDAP([`--userId=${userId}`])).rejects.toThrowError( + `Could not find the user with the ID ${userId} or their personalProject.`, + ); + }); + + test('fails if the user to migrate to is also an LDAP user', async () => { + // + // ARRANGE + // + const member = await createLdapUser({ role: 'global:member' }, uuid()); + + await expect(resetLDAP([`--userId=${member.id}`])).rejects.toThrowError( + `Can't migrate workflows and credentials to the user with the ID ${member.id}. That user was created via LDAP and will be deleted as well.`, + ); + }); + + test("transfers all workflows and credentials to the user's personal project", async () => { + // + // ARRANGE + // + const member = await createLdapUser({ role: 'global:member' }, uuid()); + const memberProject = await getPersonalProject(member); + const workflow = await createWorkflow({}, member); + const credential = await saveCredential(randomCredentialPayload(), { + user: member, + role: 'credential:owner', + }); + + const normalMember = await createMember(); + const normalMemberProject = await getPersonalProject(normalMember); + const workflow2 = await createWorkflow({}, normalMember); + const credential2 = await saveCredential(randomCredentialPayload(), { + user: normalMember, + role: 'credential:owner', + }); + + // + // ACT + // + await resetLDAP([`--userId=${normalMember.id}`]); + + // + // ASSERT + // + // LDAP user is deleted + await expect(getUserById(member.id)).rejects.toThrowError(EntityNotFoundError); + await expect(findProject(memberProject.id)).rejects.toThrowError(EntityNotFoundError); + + // Their workflow and credential have been migrated to the normal user. + await expect( + Container.get(SharedWorkflowRepository).findOneBy({ + workflowId: workflow.id, + projectId: normalMemberProject.id, + }), + ).resolves.not.toBeNull(); + await expect( + Container.get(SharedCredentialsRepository).findOneBy({ + credentialsId: credential.id, + projectId: normalMemberProject.id, + }), + ).resolves.not.toBeNull(); + + // Non LDAP user is not deleted + await expect(getUserById(normalMember.id)).resolves.not.toThrowError(); + await expect( + Container.get(WorkflowRepository).findOneBy({ id: workflow2.id }), + ).resolves.not.toBeNull(); + await expect( + Container.get(CredentialsRepository).findOneBy({ id: credential2.id }), + ).resolves.not.toBeNull(); + }); +}); + +describe('--projectId', () => { + test('fails if the project does not exist', async () => { + const projectId = uuid(); + await expect(resetLDAP([`--projectId=${projectId}`])).rejects.toThrowError( + `Could not find the project with the ID ${projectId}.`, + ); + }); + + test('fails if the user to migrate to is also an LDAP user', async () => { + // + // ARRANGE + // + const member = await createLdapUser({ role: 'global:member' }, uuid()); + const memberProject = await getPersonalProject(member); + + await expect(resetLDAP([`--projectId=${memberProject.id}`])).rejects.toThrowError( + `Can't migrate workflows and credentials to the project with the ID ${memberProject.id}. That project is a personal project belonging to a user that was created via LDAP and will be deleted as well.`, + ); + }); + + test('transfers all workflows and credentials to a personal project', async () => { + // + // ARRANGE + // + const member = await createLdapUser({ role: 'global:member' }, uuid()); + const memberProject = await getPersonalProject(member); + const workflow = await createWorkflow({}, member); + const credential = await saveCredential(randomCredentialPayload(), { + user: member, + role: 'credential:owner', + }); + + const normalMember = await createMember(); + const normalMemberProject = await getPersonalProject(normalMember); + const workflow2 = await createWorkflow({}, normalMember); + const credential2 = await saveCredential(randomCredentialPayload(), { + user: normalMember, + role: 'credential:owner', + }); + + // + // ACT + // + await resetLDAP([`--projectId=${normalMemberProject.id}`]); + + // + // ASSERT + // + // LDAP user is deleted + await expect(getUserById(member.id)).rejects.toThrowError(EntityNotFoundError); + await expect(findProject(memberProject.id)).rejects.toThrowError(EntityNotFoundError); + + // Their workflow and credential have been migrated to the normal user. + await expect( + Container.get(SharedWorkflowRepository).findOneBy({ + workflowId: workflow.id, + projectId: normalMemberProject.id, + }), + ).resolves.not.toBeNull(); + await expect( + Container.get(SharedCredentialsRepository).findOneBy({ + credentialsId: credential.id, + projectId: normalMemberProject.id, + }), + ).resolves.not.toBeNull(); + + // Non LDAP user is not deleted + await expect(getUserById(normalMember.id)).resolves.not.toThrowError(); + await expect( + Container.get(WorkflowRepository).findOneBy({ id: workflow2.id }), + ).resolves.not.toBeNull(); + await expect( + Container.get(CredentialsRepository).findOneBy({ id: credential2.id }), + ).resolves.not.toBeNull(); + }); + + test('transfers all workflows and credentials to a team project', async () => { + // + // ARRANGE + // + const member = await createLdapUser({ role: 'global:member' }, uuid()); + const memberProject = await getPersonalProject(member); + const workflow = await createWorkflow({}, member); + const credential = await saveCredential(randomCredentialPayload(), { + user: member, + role: 'credential:owner', + }); + + const normalMember = await createMember(); + const workflow2 = await createWorkflow({}, normalMember); + const credential2 = await saveCredential(randomCredentialPayload(), { + user: normalMember, + role: 'credential:owner', + }); + + const teamProject = await createTeamProject(); + + // + // ACT + // + await resetLDAP([`--projectId=${teamProject.id}`]); + + // + // ASSERT + // + // LDAP user is deleted + await expect(getUserById(member.id)).rejects.toThrowError(EntityNotFoundError); + await expect(findProject(memberProject.id)).rejects.toThrowError(EntityNotFoundError); + + // Their workflow and credential have been migrated to the team project. + await expect( + Container.get(SharedWorkflowRepository).findOneBy({ + workflowId: workflow.id, + projectId: teamProject.id, + }), + ).resolves.not.toBeNull(); + await expect( + Container.get(SharedCredentialsRepository).findOneBy({ + credentialsId: credential.id, + projectId: teamProject.id, + }), + ).resolves.not.toBeNull(); + + // Non LDAP user is not deleted + await expect(getUserById(normalMember.id)).resolves.not.toThrowError(); + await expect( + Container.get(WorkflowRepository).findOneBy({ id: workflow2.id }), + ).resolves.not.toBeNull(); + await expect( + Container.get(CredentialsRepository).findOneBy({ id: credential2.id }), + ).resolves.not.toBeNull(); + }); +}); diff --git a/packages/cli/test/integration/commands/reset.cmd.test.ts b/packages/cli/test/integration/commands/reset.cmd.test.ts index fd32fee1f..04e92dbdf 100644 --- a/packages/cli/test/integration/commands/reset.cmd.test.ts +++ b/packages/cli/test/integration/commands/reset.cmd.test.ts @@ -7,7 +7,16 @@ import { UserRepository } from '@db/repositories/user.repository'; import { mockInstance } from '../../shared/mocking'; import * as testDb from '../shared/testDb'; -import { createUser } from '../shared/db/users'; +import { createMember, createUser } from '../shared/db/users'; +import { createWorkflow } from '../shared/db/workflows'; +import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; +import { getPersonalProject } from '../shared/db/projects'; +import { encryptCredentialData, saveCredential } from '../shared/db/credentials'; +import { randomCredentialPayload } from '../shared/random'; +import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository'; +import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; +import { CredentialsEntity } from '@/databases/entities/CredentialsEntity'; +import { SettingsRepository } from '@/databases/repositories/settings.repository'; beforeAll(async () => { mockInstance(InternalHooks); @@ -25,20 +34,75 @@ afterAll(async () => { }); // eslint-disable-next-line n8n-local-rules/no-skipped-tests -test.skip('user-management:reset should reset DB to default user state', async () => { - await createUser({ role: 'global:owner' }); +test('user-management:reset should reset DB to default user state', async () => { + // + // ARRANGE + // + const owner = await createUser({ role: 'global:owner' }); + const ownerProject = await getPersonalProject(owner); + // should be deleted + const member = await createMember(); + + // should be re-owned + const workflow = await createWorkflow({}, member); + const credential = await saveCredential(randomCredentialPayload(), { + user: member, + role: 'credential:owner', + }); + + // dangling credentials should also be re-owned + const danglingCredential = await Container.get(CredentialsRepository).save( + await encryptCredentialData(Object.assign(new CredentialsEntity(), randomCredentialPayload())), + ); + + // mark instance as set up + await Container.get(SettingsRepository).update( + { key: 'userManagement.isInstanceOwnerSetUp' }, + { value: 'true' }, + ); + + // + // ACT + // await Reset.run(); - const user = await Container.get(UserRepository).findOneBy({ role: 'global:owner' }); + // + // ASSERT + // - if (!user) { - fail('No owner found after DB reset to default user state'); - } + // check if the owner account was reset: + await expect( + Container.get(UserRepository).findOneBy({ role: 'global:owner' }), + ).resolves.toMatchObject({ + email: null, + firstName: null, + lastName: null, + password: null, + personalizationAnswers: null, + }); - expect(user.email).toBeNull(); - expect(user.firstName).toBeNull(); - expect(user.lastName).toBeNull(); - expect(user.password).toBeNull(); - expect(user.personalizationAnswers).toBeNull(); + // all members were deleted: + const members = await Container.get(UserRepository).findOneBy({ role: 'global:member' }); + expect(members).toBeNull(); + + // all workflows are owned by the owner: + await expect( + Container.get(SharedWorkflowRepository).findBy({ workflowId: workflow.id }), + ).resolves.toMatchObject([{ projectId: ownerProject.id, role: 'workflow:owner' }]); + + // all credentials are owned by the owner + await expect( + Container.get(SharedCredentialsRepository).findBy({ credentialsId: credential.id }), + ).resolves.toMatchObject([{ projectId: ownerProject.id, role: 'credential:owner' }]); + + // all dangling credentials are owned by the owner + await expect( + Container.get(SharedCredentialsRepository).findBy({ credentialsId: danglingCredential.id }), + ).resolves.toMatchObject([{ projectId: ownerProject.id, role: 'credential:owner' }]); + + // the instance is marked as not set up: + await expect( + Container.get(SettingsRepository).findBy({ key: 'userManagement.isInstanceOwnerSetUp' }), + ).resolves.toMatchObject([{ value: 'false' }]); }); diff --git a/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts b/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts index b6371fdf9..390ab8919 100644 --- a/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts +++ b/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts @@ -26,6 +26,8 @@ import { import type { User } from '@/databases/entities/User'; import type { UserInvitationResult } from '../../shared/utils/users'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; describe('InvitationController', () => { const mailer = mockInstance(UserManagementMailer); @@ -36,9 +38,13 @@ describe('InvitationController', () => { let instanceOwner: User; let userRepository: UserRepository; + let projectRepository: ProjectRepository; + let projectRelationRepository: ProjectRelationRepository; beforeAll(async () => { userRepository = Container.get(UserRepository); + projectRepository = Container.get(ProjectRepository); + projectRelationRepository = Container.get(ProjectRelationRepository); instanceOwner = await createOwner(); }); @@ -271,6 +277,39 @@ describe('InvitationController', () => { assertStoredUserProps(storedUser); }); + test('should create personal project for shell account', async () => { + mailer.invite.mockResolvedValue({ emailSent: false }); + + const response: InvitationResponse = await testServer + .authAgentFor(instanceOwner) + .post('/invitations') + .send([{ email: randomEmail() }]) + .expect(200); + + const [result] = response.body.data; + + const storedUser = await userRepository.findOneByOrFail({ + id: result.user.id, + }); + + assertStoredUserProps(storedUser); + + const projectRelation = await projectRelationRepository.findOneOrFail({ + where: { + userId: storedUser.id, + role: 'project:personalOwner', + project: { + type: 'personal', + }, + }, + relations: { project: true }, + }); + + expect(projectRelation).not.toBeUndefined(); + expect(projectRelation.project.name).toBe(storedUser.createPersonalProjectName()); + expect(projectRelation.project.type).toBe('personal'); + }); + test('should create admin shell when advanced permissions is licensed', async () => { testServer.license.enable('feat:advancedPermissions'); diff --git a/packages/cli/test/integration/credentials.test.ts b/packages/cli/test/integration/credentials.test.ts index a25f0108c..c33c83351 100644 --- a/packages/cli/test/integration/credentials.test.ts +++ b/packages/cli/test/integration/credentials.test.ts @@ -1,6 +1,7 @@ import { Container } from 'typedi'; import type { SuperAgentTest } from 'supertest'; +import type { Scope } from '@n8n/permissions'; import config from '@/config'; import type { ListQuery } from '@/requests'; import type { User } from '@db/entities/User'; @@ -12,24 +13,42 @@ import { randomCredentialPayload, randomName, randomString } from './shared/rand import * as testDb from './shared/testDb'; import type { SaveCredentialFunction } from './shared/types'; import * as utils from './shared/utils/'; -import { affixRoleToSaveCredential, shareCredentialWithUsers } from './shared/db/credentials'; +import { + affixRoleToSaveCredential, + shareCredentialWithProjects, + shareCredentialWithUsers, +} from './shared/db/credentials'; import { createManyUsers, createUser } from './shared/db/users'; import { Credentials } from 'n8n-core'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import type { Project } from '@/databases/entities/Project'; +import { ProjectService } from '@/services/project.service'; +import { createTeamProject, linkUserToProject } from './shared/db/projects'; // mock that credentialsSharing is not enabled jest.spyOn(License.prototype, 'isSharingEnabled').mockReturnValue(false); const testServer = utils.setupTestServer({ endpointGroups: ['credentials'] }); let owner: User; +let ownerPersonalProject: Project; let member: User; +let memberPersonalProject: Project; let secondMember: User; let authOwnerAgent: SuperAgentTest; let authMemberAgent: SuperAgentTest; let saveCredential: SaveCredentialFunction; +let projectRepository: ProjectRepository; +let sharedCredentialsRepository: SharedCredentialsRepository; +let projectService: ProjectService; beforeAll(async () => { + projectRepository = Container.get(ProjectRepository); + sharedCredentialsRepository = Container.get(SharedCredentialsRepository); + projectService = Container.get(ProjectService); owner = await createUser({ role: 'global:owner' }); + ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); member = await createUser({ role: 'global:member' }); + memberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(member.id); secondMember = await createUser({ role: 'global:member' }); saveCredential = affixRoleToSaveCredential('credential:owner'); @@ -86,22 +105,125 @@ describe('GET /credentials', () => { expect(member1Credential.data).toBeUndefined(); expect(member1Credential.id).toBe(savedCredential1.id); }); + + test('should return scopes when ?includeScopes=true', async () => { + const [member1, member2] = await createManyUsers(2, { + role: 'global:member', + }); + + const teamProject = await createTeamProject(undefined, member1); + await linkUserToProject(member2, teamProject, 'project:editor'); + + const [savedCredential1, savedCredential2] = await Promise.all([ + saveCredential(randomCredentialPayload(), { project: teamProject }), + saveCredential(randomCredentialPayload(), { user: member2 }), + ]); + + await shareCredentialWithProjects(savedCredential2, [teamProject]); + + { + const response = await testServer + .authAgentFor(member1) + .get('/credentials?includeScopes=true'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + + const creds = response.body.data as Array; + const cred1 = creds.find((c) => c.id === savedCredential1.id)!; + const cred2 = creds.find((c) => c.id === savedCredential2.id)!; + + // Team cred + expect(cred1.id).toBe(savedCredential1.id); + expect(cred1.scopes).toEqual( + ['credential:read', 'credential:update', 'credential:delete'].sort(), + ); + + // Shared cred + expect(cred2.id).toBe(savedCredential2.id); + expect(cred2.scopes).toEqual(['credential:read']); + } + + { + const response = await testServer + .authAgentFor(member2) + .get('/credentials?includeScopes=true'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + + const creds = response.body.data as Array; + const cred1 = creds.find((c) => c.id === savedCredential1.id)!; + const cred2 = creds.find((c) => c.id === savedCredential2.id)!; + + // Team cred + expect(cred1.id).toBe(savedCredential1.id); + expect(cred1.scopes).toEqual(['credential:delete', 'credential:read', 'credential:update']); + + // Shared cred + expect(cred2.id).toBe(savedCredential2.id); + expect(cred2.scopes).toEqual( + ['credential:read', 'credential:update', 'credential:delete', 'credential:share'].sort(), + ); + } + + { + const response = await testServer.authAgentFor(owner).get('/credentials?includeScopes=true'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + + const creds = response.body.data as Array; + const cred1 = creds.find((c) => c.id === savedCredential1.id)!; + const cred2 = creds.find((c) => c.id === savedCredential2.id)!; + + // Team cred + expect(cred1.id).toBe(savedCredential1.id); + expect(cred1.scopes).toEqual( + [ + 'credential:create', + 'credential:read', + 'credential:update', + 'credential:delete', + 'credential:list', + 'credential:share', + ].sort(), + ); + + // Shared cred + expect(cred2.id).toBe(savedCredential2.id); + expect(cred2.scopes).toEqual( + [ + 'credential:create', + 'credential:read', + 'credential:update', + 'credential:delete', + 'credential:list', + 'credential:share', + ].sort(), + ); + } + }); }); describe('POST /credentials', () => { test('should create cred', async () => { const payload = randomCredentialPayload(); - const response = await authOwnerAgent.post('/credentials').send(payload); + const response = await authMemberAgent.post('/credentials').send(payload); expect(response.statusCode).toBe(200); - const { id, name, type, data: encryptedData } = response.body.data; + const { id, name, type, data: encryptedData, scopes } = response.body.data; expect(name).toBe(payload.name); expect(type).toBe(payload.type); expect(encryptedData).not.toBe(payload.data); + expect(scopes).toEqual( + ['credential:read', 'credential:update', 'credential:delete', 'credential:share'].sort(), + ); + const credential = await Container.get(CredentialsRepository).findOneByOrFail({ id }); expect(credential.name).toBe(payload.name); @@ -109,11 +231,11 @@ describe('POST /credentials', () => { expect(credential.data).not.toBe(payload.data); const sharedCredential = await Container.get(SharedCredentialsRepository).findOneOrFail({ - relations: ['user', 'credentials'], + relations: { project: true, credentials: true }, where: { credentialsId: credential.id }, }); - expect(sharedCredential.user.id).toBe(owner.id); + expect(sharedCredential.project.id).toBe(memberPersonalProject.id); expect(sharedCredential.credentials.name).toBe(payload.name); }); @@ -137,6 +259,96 @@ describe('POST /credentials', () => { expect(secondResponse.body.data.id).not.toBe(8); }); + + test('creates credential in personal project by default', async () => { + // + // ACT + // + const response = await authOwnerAgent.post('/credentials').send(randomCredentialPayload()); + + // + // ASSERT + // + await sharedCredentialsRepository.findOneByOrFail({ + projectId: ownerPersonalProject.id, + credentialsId: response.body.data.id, + }); + }); + + test('creates credential in a specific project if the projectId is passed', async () => { + // + // ARRANGE + // + const project = await createTeamProject('Team Project', owner); + + // + // ACT + // + const response = await authOwnerAgent + .post('/credentials') + .send({ ...randomCredentialPayload(), projectId: project.id }); + + // + // ASSERT + // + await sharedCredentialsRepository.findOneByOrFail({ + projectId: project.id, + credentialsId: response.body.data.id, + }); + }); + + test('does not create the credential in a specific project if the user is not part of the project', async () => { + // + // ARRANGE + // + const project = await projectRepository.save( + projectRepository.create({ + name: 'Team Project', + type: 'team', + }), + ); + + // + // ACT + // + await authMemberAgent + .post('/credentials') + .send({ ...randomCredentialPayload(), projectId: project.id }) + // + // ASSERT + // + .expect(400, { + code: 400, + message: "You don't have the permissions to save the workflow in this project.", + }); + }); + + test('does not create the credential in a specific project if the user does not have the right role to do so', async () => { + // + // ARRANGE + // + const project = await projectRepository.save( + projectRepository.create({ + name: 'Team Project', + type: 'team', + }), + ); + await projectService.addUser(project.id, member.id, 'project:viewer'); + + // + // ACT + // + await authMemberAgent + .post('/credentials') + .send({ ...randomCredentialPayload(), projectId: project.id }) + // + // ASSERT + // + .expect(400, { + code: 400, + message: "You don't have the permissions to save the workflow in this project.", + }); + }); }); describe('DELETE /credentials/:id', () => { @@ -202,7 +414,7 @@ describe('DELETE /credentials/:id', () => { const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(403); const shellCredential = await Container.get(CredentialsRepository).findOneBy({ id: savedCredential.id, @@ -222,7 +434,7 @@ describe('DELETE /credentials/:id', () => { const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(403); const shellCredential = await Container.get(CredentialsRepository).findOneBy({ id: savedCredential.id, @@ -253,11 +465,22 @@ describe('PATCH /credentials/:id', () => { expect(response.statusCode).toBe(200); - const { id, name, type, data: encryptedData } = response.body.data; + const { id, name, type, data: encryptedData, scopes } = response.body.data; expect(name).toBe(patchPayload.name); expect(type).toBe(patchPayload.type); + expect(scopes).toEqual( + [ + 'credential:create', + 'credential:read', + 'credential:update', + 'credential:delete', + 'credential:list', + 'credential:share', + ].sort(), + ); + expect(encryptedData).not.toBe(patchPayload.data); const credential = await Container.get(CredentialsRepository).findOneByOrFail({ id }); @@ -345,7 +568,7 @@ describe('PATCH /credentials/:id', () => { .patch(`/credentials/${savedCredential.id}`) .send(patchPayload); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(403); const shellCredential = await Container.get(CredentialsRepository).findOneByOrFail({ id: savedCredential.id, @@ -363,7 +586,7 @@ describe('PATCH /credentials/:id', () => { .patch(`/credentials/${savedCredential.id}`) .send(patchPayload); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(403); const shellCredential = await Container.get(CredentialsRepository).findOneByOrFail({ id: savedCredential.id, @@ -402,11 +625,19 @@ describe('PATCH /credentials/:id', () => { } }); - test('should fail if cred not found', async () => { + test('should fail with a 404 if the credential does not exist and the actor has the global credential:update scope', async () => { const response = await authOwnerAgent.patch('/credentials/123').send(randomCredentialPayload()); expect(response.statusCode).toBe(404); }); + + test('should fail with a 403 if the credential does not exist and the actor does not have the global credential:update scope', async () => { + const response = await authMemberAgent + .patch('/credentials/123') + .send(randomCredentialPayload()); + + expect(response.statusCode).toBe(403); + }); }); describe('GET /credentials/new', () => { @@ -511,7 +742,7 @@ describe('GET /credentials/:id', () => { const response = await authMemberAgent.get(`/credentials/${savedCredential.id}`); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(403); expect(response.body.data).toBeUndefined(); // owner's cred not returned }); @@ -525,22 +756,21 @@ describe('GET /credentials/:id', () => { }); function validateMainCredentialData(credential: ListQuery.Credentials.WithOwnedByAndSharedWith) { - const { name, type, sharedWith, ownedBy } = credential; + const { name, type, sharedWithProjects, homeProject } = credential; expect(typeof name).toBe('string'); expect(typeof type).toBe('string'); - if (sharedWith) { - expect(Array.isArray(sharedWith)).toBe(true); + if (sharedWithProjects) { + expect(Array.isArray(sharedWithProjects)).toBe(true); } - if (ownedBy) { - const { id, email, firstName, lastName } = ownedBy; + if (homeProject) { + const { id, type, name } = homeProject; expect(typeof id).toBe('string'); - expect(typeof email).toBe('string'); - expect(typeof firstName).toBe('string'); - expect(typeof lastName).toBe('string'); + expect(typeof name).toBe('string'); + expect(type).toBe('personal'); } } diff --git a/packages/cli/test/integration/credentials.controller.test.ts b/packages/cli/test/integration/credentials/credentials.controller.test.ts similarity index 61% rename from packages/cli/test/integration/credentials.controller.test.ts rename to packages/cli/test/integration/credentials/credentials.controller.test.ts index df88c2b12..7d0f9debe 100644 --- a/packages/cli/test/integration/credentials.controller.test.ts +++ b/packages/cli/test/integration/credentials/credentials.controller.test.ts @@ -1,10 +1,14 @@ import type { ListQuery } from '@/requests'; import type { User } from '@db/entities/User'; -import * as testDb from './shared/testDb'; -import { setupTestServer } from './shared/utils/'; -import { randomCredentialPayload as payload } from './shared/random'; -import { saveCredential } from './shared/db/credentials'; -import { createMember, createOwner } from './shared/db/users'; +import * as testDb from '../shared/testDb'; +import { setupTestServer } from '../shared/utils'; +import { randomCredentialPayload as payload } from '../shared/random'; +import { saveCredential, shareCredentialWithUsers } from '../shared/db/credentials'; +import { createMember, createOwner } from '../shared/db/users'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import Container from 'typedi'; +import type { Project } from '@/databases/entities/Project'; +import { createTeamProject, linkUserToProject } from '../shared/db/projects'; const { any } = expect; @@ -13,11 +17,19 @@ const testServer = setupTestServer({ endpointGroups: ['credentials'] }); let owner: User; let member: User; +let ownerPersonalProject: Project; +let memberPersonalProject: Project; beforeEach(async () => { await testDb.truncate(['SharedCredentials', 'Credentials']); owner = await createOwner(); member = await createMember(); + ownerPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + owner.id, + ); + memberPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + member.id, + ); }); type GetAllResponse = { body: { data: ListQuery.Credentials.WithOwnedByAndSharedWith[] } }; @@ -171,6 +183,113 @@ describe('GET /credentials', () => { expect(_response.body.data).toHaveLength(0); }); + + test('should filter credentials by projectId', async () => { + const credential = await saveCredential(payload(), { user: owner, role: 'credential:owner' }); + + const response1: GetAllResponse = await testServer + .authAgentFor(owner) + .get('/credentials') + .query(`filter={ "projectId": "${ownerPersonalProject.id}" }`) + .expect(200); + + expect(response1.body.data).toHaveLength(1); + expect(response1.body.data[0].id).toBe(credential.id); + + const response2 = await testServer + .authAgentFor(owner) + .get('/credentials') + .query('filter={ "projectId": "Non-Existing Project ID" }') + .expect(200); + + expect(response2.body.data).toHaveLength(0); + }); + + test('should return all credentials in a team project that member is part of', async () => { + const teamProjectWithMember = await createTeamProject('Team Project With member', owner); + void (await linkUserToProject(member, teamProjectWithMember, 'project:editor')); + await saveCredential(payload(), { + project: teamProjectWithMember, + role: 'credential:owner', + }); + await saveCredential(payload(), { + project: teamProjectWithMember, + role: 'credential:owner', + }); + const response: GetAllResponse = await testServer + .authAgentFor(member) + .get('/credentials') + .query(`filter={ "projectId": "${teamProjectWithMember.id}" }`) + .expect(200); + + expect(response.body.data).toHaveLength(2); + }); + + test('should return no credentials in a team project that member not is part of', async () => { + const teamProjectWithoutMember = await createTeamProject( + 'Team Project Without member', + owner, + ); + + await saveCredential(payload(), { + project: teamProjectWithoutMember, + role: 'credential:owner', + }); + + const response = await testServer + .authAgentFor(member) + .get('/credentials') + .query(`filter={ "projectId": "${teamProjectWithoutMember.id}" }`) + .expect(200); + + expect(response.body.data).toHaveLength(0); + }); + + test('should return only owned and explicitly shared credentials when filtering by any personal project id', async () => { + // Create credential owned by `owner` and share it to `member` + const ownerCredential = await saveCredential(payload(), { + user: owner, + role: 'credential:owner', + }); + await shareCredentialWithUsers(ownerCredential, [member]); + // Create credential owned by `member` + const memberCredential = await saveCredential(payload(), { + user: member, + role: 'credential:owner', + }); + + // Simulate editing a workflow owned by `owner` so request credentials to their personal project + const response: GetAllResponse = await testServer + .authAgentFor(member) + .get('/credentials') + .query(`filter={ "projectId": "${ownerPersonalProject.id}" }`) + .expect(200); + + expect(response.body.data).toHaveLength(2); + expect(response.body.data.map((credential) => credential.id)).toContain(ownerCredential.id); + expect(response.body.data.map((credential) => credential.id)).toContain(memberCredential.id); + }); + + test('should return all credentials to instance owners when working on their own personal project', async () => { + const ownerCredential = await saveCredential(payload(), { + user: owner, + role: 'credential:owner', + }); + const memberCredential = await saveCredential(payload(), { + user: member, + role: 'credential:owner', + }); + + const response: GetAllResponse = await testServer + .authAgentFor(owner) + .get('/credentials') + .query(`filter={ "projectId": "${ownerPersonalProject.id}" }&includeScopes=true`) + .expect(200); + + expect(response.body.data).toHaveLength(2); + expect(response.body.data.map((credential) => credential.id)).toContain(ownerCredential.id); + expect(response.body.data.map((credential) => credential.id)).toContain(memberCredential.id); + }); }); describe('select', () => { @@ -264,20 +383,19 @@ describe('GET /credentials', () => { }); function validateCredential(credential: ListQuery.Credentials.WithOwnedByAndSharedWith) { - const { name, type, sharedWith, ownedBy } = credential; + const { name, type, sharedWithProjects, homeProject } = credential; expect(typeof name).toBe('string'); expect(typeof type).toBe('string'); expect('data' in credential).toBe(false); - if (sharedWith) expect(Array.isArray(sharedWith)).toBe(true); + if (sharedWithProjects) expect(Array.isArray(sharedWithProjects)).toBe(true); - if (ownedBy) { - const { id, email, firstName, lastName } = ownedBy; + if (homeProject) { + const { id, name, type } = homeProject; expect(typeof id).toBe('string'); - expect(typeof email).toBe('string'); - expect(typeof firstName).toBe('string'); - expect(typeof lastName).toBe('string'); + expect(typeof name).toBe('string'); + expect(type).toBe('personal'); } } diff --git a/packages/cli/test/integration/credentials.ee.test.ts b/packages/cli/test/integration/credentials/credentials.ee.test.ts similarity index 57% rename from packages/cli/test/integration/credentials.ee.test.ts rename to packages/cli/test/integration/credentials/credentials.ee.test.ts index 279f33c01..b00b0091f 100644 --- a/packages/cli/test/integration/credentials.ee.test.ts +++ b/packages/cli/test/integration/credentials/credentials.ee.test.ts @@ -1,40 +1,66 @@ import { Container } from 'typedi'; import type { SuperAgentTest } from 'supertest'; import { In } from '@n8n/typeorm'; -import type { IUser } from 'n8n-workflow'; import type { ListQuery } from '@/requests'; import type { User } from '@db/entities/User'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; -import { randomCredentialPayload } from './shared/random'; -import * as testDb from './shared/testDb'; -import type { SaveCredentialFunction } from './shared/types'; -import * as utils from './shared/utils/'; -import { affixRoleToSaveCredential, shareCredentialWithUsers } from './shared/db/credentials'; -import { createManyUsers, createUser, createUserShell } from './shared/db/users'; +import { randomCredentialPayload } from '../shared/random'; +import * as testDb from '../shared/testDb'; +import type { SaveCredentialFunction } from '../shared/types'; +import * as utils from '../shared/utils'; +import { + affixRoleToSaveCredential, + shareCredentialWithProjects, + shareCredentialWithUsers, +} from '../shared/db/credentials'; +import { createManyUsers, createUser, createUserShell } from '../shared/db/users'; import { UserManagementMailer } from '@/UserManagement/email'; -import { mockInstance } from '../shared/mocking'; +import { mockInstance } from '../../shared/mocking'; import config from '@/config'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import type { Project } from '@/databases/entities/Project'; +import { ProjectService } from '@/services/project.service'; const testServer = utils.setupTestServer({ endpointGroups: ['credentials'], enabledFeatures: ['feat:sharing'], + quotas: { + 'quota:maxTeamProjects': -1, + }, }); let owner: User; +let ownerPersonalProject: Project; let member: User; +let memberPersonalProject: Project; let anotherMember: User; +let anotherMemberPersonalProject: Project; let authOwnerAgent: SuperAgentTest; let authAnotherMemberAgent: SuperAgentTest; let saveCredential: SaveCredentialFunction; const mailer = mockInstance(UserManagementMailer); -beforeAll(async () => { +let projectService: ProjectService; +let projectRepository: ProjectRepository; + +beforeEach(async () => { + await testDb.truncate(['SharedCredentials', 'Credentials', 'Project', 'ProjectRelation']); + projectRepository = Container.get(ProjectRepository); + projectService = Container.get(ProjectService); + owner = await createUser({ role: 'global:owner' }); + ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); + member = await createUser({ role: 'global:member' }); + memberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(member.id); + anotherMember = await createUser({ role: 'global:member' }); + anotherMemberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + anotherMember.id, + ); authOwnerAgent = testServer.authAgentFor(owner); authAnotherMemberAgent = testServer.authAgentFor(anotherMember); @@ -42,10 +68,6 @@ beforeAll(async () => { saveCredential = affixRoleToSaveCredential('credential:owner'); }); -beforeEach(async () => { - await testDb.truncate(['SharedCredentials', 'Credentials']); -}); - afterEach(() => { jest.clearAllMocks(); }); @@ -58,23 +80,35 @@ describe('GET /credentials', () => { const [member1, member2, member3] = await createManyUsers(3, { role: 'global:member', }); + const member1PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + member1.id, + ); + const member2PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + member2.id, + ); + const member3PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + member3.id, + ); const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); await saveCredential(randomCredentialPayload(), { user: member1 }); - const sharedWith = [member1, member2, member3]; - await shareCredentialWithUsers(savedCredential, sharedWith); + const sharedWith = [member1PersonalProject, member2PersonalProject, member3PersonalProject]; + await shareCredentialWithProjects(savedCredential, sharedWith); const response = await authOwnerAgent.get('/credentials'); expect(response.statusCode).toBe(200); expect(response.body.data).toHaveLength(2); // owner retrieved owner cred and member cred - const ownerCredential = response.body.data.find( - (e: ListQuery.Credentials.WithOwnedByAndSharedWith) => e.ownedBy?.id === owner.id, - ); - const memberCredential = response.body.data.find( - (e: ListQuery.Credentials.WithOwnedByAndSharedWith) => e.ownedBy?.id === member1.id, + const ownerCredential: ListQuery.Credentials.WithOwnedByAndSharedWith = response.body.data.find( + (e: ListQuery.Credentials.WithOwnedByAndSharedWith) => + e.homeProject?.id === ownerPersonalProject.id, ); + const memberCredential: ListQuery.Credentials.WithOwnedByAndSharedWith = + response.body.data.find( + (e: ListQuery.Credentials.WithOwnedByAndSharedWith) => + e.homeProject?.id === member1PersonalProject.id, + ); validateMainCredentialData(ownerCredential); expect(ownerCredential.data).toBeUndefined(); @@ -82,46 +116,48 @@ describe('GET /credentials', () => { validateMainCredentialData(memberCredential); expect(memberCredential.data).toBeUndefined(); - expect(ownerCredential.ownedBy).toMatchObject({ - id: owner.id, - email: owner.email, - firstName: owner.firstName, - lastName: owner.lastName, + expect(ownerCredential.homeProject).toMatchObject({ + id: ownerPersonalProject.id, + type: 'personal', + name: owner.createPersonalProjectName(), }); - expect(Array.isArray(ownerCredential.sharedWith)).toBe(true); - expect(ownerCredential.sharedWith).toHaveLength(3); + expect(Array.isArray(ownerCredential.sharedWithProjects)).toBe(true); + expect(ownerCredential.sharedWithProjects).toHaveLength(3); // Fix order issue (MySQL might return items in any order) - const ownerCredentialsSharedWithOrdered = [...ownerCredential.sharedWith!].sort( - (a: IUser, b: IUser) => (a.email < b.email ? -1 : 1), + const ownerCredentialsSharedWithOrdered = [...ownerCredential.sharedWithProjects].sort( + (a, b) => (a.id < b.id ? -1 : 1), ); - const orderedSharedWith = [...sharedWith].sort((a, b) => (a.email < b.email ? -1 : 1)); + const orderedSharedWith = [...sharedWith].sort((a, b) => (a.id < b.id ? -1 : 1)); - ownerCredentialsSharedWithOrdered.forEach((sharee: IUser, idx: number) => { + ownerCredentialsSharedWithOrdered.forEach((sharee, idx) => { expect(sharee).toMatchObject({ id: orderedSharedWith[idx].id, - email: orderedSharedWith[idx].email, - firstName: orderedSharedWith[idx].firstName, - lastName: orderedSharedWith[idx].lastName, + type: orderedSharedWith[idx].type, }); }); - expect(memberCredential.ownedBy).toMatchObject({ - id: member1.id, - email: member1.email, - firstName: member1.firstName, - lastName: member1.lastName, + expect(memberCredential.homeProject).toMatchObject({ + id: member1PersonalProject.id, + type: member1PersonalProject.type, + name: member1.createPersonalProjectName(), }); - expect(Array.isArray(memberCredential.sharedWith)).toBe(true); - expect(memberCredential.sharedWith).toHaveLength(0); + expect(Array.isArray(memberCredential.sharedWithProjects)).toBe(true); + expect(memberCredential.sharedWithProjects).toHaveLength(0); }); test('should return only relevant creds for member', async () => { const [member1, member2] = await createManyUsers(2, { role: 'global:member', }); + const member1PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + member1.id, + ); + const member2PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + member2.id, + ); await saveCredential(randomCredentialPayload(), { user: member2 }); const savedMemberCredential = await saveCredential(randomCredentialPayload(), { @@ -135,30 +171,50 @@ describe('GET /credentials', () => { expect(response.statusCode).toBe(200); expect(response.body.data).toHaveLength(1); // member retrieved only member cred - const [member1Credential] = response.body.data; + const [member1Credential]: [ListQuery.Credentials.WithOwnedByAndSharedWith] = + response.body.data; validateMainCredentialData(member1Credential); expect(member1Credential.data).toBeUndefined(); - expect(member1Credential.ownedBy).toMatchObject({ - id: member1.id, - email: member1.email, - firstName: member1.firstName, - lastName: member1.lastName, + expect(member1Credential.homeProject).toMatchObject({ + id: member1PersonalProject.id, + name: member1.createPersonalProjectName(), + type: member1PersonalProject.type, }); - expect(Array.isArray(member1Credential.sharedWith)).toBe(true); - expect(member1Credential.sharedWith).toHaveLength(1); - - const [sharee] = member1Credential.sharedWith; - - expect(sharee).toMatchObject({ - id: member2.id, - email: member2.email, - firstName: member2.firstName, - lastName: member2.lastName, + expect(member1Credential.sharedWithProjects).toHaveLength(1); + expect(member1Credential.sharedWithProjects[0]).toMatchObject({ + id: member2PersonalProject.id, + name: member2.createPersonalProjectName(), + type: member2PersonalProject.type, }); }); + + test('should show credentials that the user has access to through a team project they are part of', async () => { + // + // ARRANGE + // + const project1 = await projectService.createTeamProject('Team Project', member); + await projectService.addUser(project1.id, anotherMember.id, 'project:editor'); + // anotherMember should see this one + const credential1 = await saveCredential(randomCredentialPayload(), { project: project1 }); + + const project2 = await projectService.createTeamProject('Team Project', member); + // anotherMember should NOT see this one + await saveCredential(randomCredentialPayload(), { project: project2 }); + + // + // ACT + // + const response = await testServer.authAgentFor(anotherMember).get('/credentials'); + + // + // ASSERT + // + expect(response.body.data).toHaveLength(1); + expect(response.body.data[0].id).toBe(credential1.id); + }); }); // ---------------------------------------- @@ -172,16 +228,16 @@ describe('GET /credentials/:id', () => { expect(firstResponse.statusCode).toBe(200); - const { data: firstCredential } = firstResponse.body; + const firstCredential: ListQuery.Credentials.WithOwnedByAndSharedWith = firstResponse.body.data; validateMainCredentialData(firstCredential); expect(firstCredential.data).toBeUndefined(); - expect(firstCredential.ownedBy).toMatchObject({ - id: owner.id, - email: owner.email, - firstName: owner.firstName, - lastName: owner.lastName, + + expect(firstCredential.homeProject).toMatchObject({ + id: ownerPersonalProject.id, + name: owner.createPersonalProjectName(), + type: ownerPersonalProject.type, }); - expect(firstCredential.sharedWith).toHaveLength(0); + expect(firstCredential.sharedWithProjects).toHaveLength(0); const secondResponse = await authOwnerAgent .get(`/credentials/${savedCredential.id}`) @@ -198,77 +254,103 @@ describe('GET /credentials/:id', () => { const [member1, member2] = await createManyUsers(2, { role: 'global:member', }); + const member1PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + member1.id, + ); + const member2PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + member2.id, + ); const savedCredential = await saveCredential(randomCredentialPayload(), { user: member1 }); await shareCredentialWithUsers(savedCredential, [member2]); - const response1 = await authOwnerAgent.get(`/credentials/${savedCredential.id}`); + const response1 = await authOwnerAgent.get(`/credentials/${savedCredential.id}`).expect(200); - expect(response1.statusCode).toBe(200); + const credential: ListQuery.Credentials.WithOwnedByAndSharedWith = response1.body.data; - validateMainCredentialData(response1.body.data); - expect(response1.body.data.data).toBeUndefined(); - expect(response1.body.data.ownedBy).toMatchObject({ - id: member1.id, - email: member1.email, - firstName: member1.firstName, - lastName: member1.lastName, - }); - expect(response1.body.data.sharedWith).toHaveLength(1); - expect(response1.body.data.sharedWith[0]).toMatchObject({ - id: member2.id, - email: member2.email, - firstName: member2.firstName, - lastName: member2.lastName, + validateMainCredentialData(credential); + expect(credential.data).toBeUndefined(); + expect(credential).toMatchObject({ + homeProject: { + id: member1PersonalProject.id, + name: member1.createPersonalProjectName(), + type: member1PersonalProject.type, + }, + sharedWithProjects: [ + { + id: member2PersonalProject.id, + name: member2.createPersonalProjectName(), + type: member2PersonalProject.type, + }, + ], }); const response2 = await authOwnerAgent .get(`/credentials/${savedCredential.id}`) - .query({ includeData: true }); + .query({ includeData: true }) + .expect(200); - expect(response2.statusCode).toBe(200); + const credential2: ListQuery.Credentials.WithOwnedByAndSharedWith = response2.body.data; - validateMainCredentialData(response2.body.data); - expect(response2.body.data.data).toBeDefined(); // Instance owners should be capable of editing all credentials - expect(response2.body.data.sharedWith).toHaveLength(1); + validateMainCredentialData(credential); + expect(credential2.data).toBeDefined(); // Instance owners should be capable of editing all credentials + expect(credential2.sharedWithProjects).toHaveLength(1); }); test('should retrieve owned cred for member', async () => { const [member1, member2, member3] = await createManyUsers(3, { role: 'global:member', }); + const member1PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + member1.id, + ); + const member2PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + member2.id, + ); + const member3PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + member3.id, + ); const authMemberAgent = testServer.authAgentFor(member1); const savedCredential = await saveCredential(randomCredentialPayload(), { user: member1 }); await shareCredentialWithUsers(savedCredential, [member2, member3]); - const firstResponse = await authMemberAgent.get(`/credentials/${savedCredential.id}`); + const firstResponse = await authMemberAgent + .get(`/credentials/${savedCredential.id}`) + .expect(200); - expect(firstResponse.statusCode).toBe(200); - - const { data: firstCredential } = firstResponse.body; + const firstCredential: ListQuery.Credentials.WithOwnedByAndSharedWith = firstResponse.body.data; validateMainCredentialData(firstCredential); expect(firstCredential.data).toBeUndefined(); - expect(firstCredential.ownedBy).toMatchObject({ - id: member1.id, - email: member1.email, - firstName: member1.firstName, - lastName: member1.lastName, - }); - expect(firstCredential.sharedWith).toHaveLength(2); - firstCredential.sharedWith.forEach((sharee: IUser, idx: number) => { - expect([member2.id, member3.id]).toContain(sharee.id); + expect(firstCredential).toMatchObject({ + homeProject: { + id: member1PersonalProject.id, + name: member1.createPersonalProjectName(), + type: 'personal', + }, + sharedWithProjects: expect.arrayContaining([ + { + id: member2PersonalProject.id, + name: member2.createPersonalProjectName(), + type: member2PersonalProject.type, + }, + { + id: member3PersonalProject.id, + name: member3.createPersonalProjectName(), + type: member3PersonalProject.type, + }, + ]), }); const secondResponse = await authMemberAgent .get(`/credentials/${savedCredential.id}`) - .query({ includeData: true }); + .query({ includeData: true }) + .expect(200); - expect(secondResponse.statusCode).toBe(200); - - const { data: secondCredential } = secondResponse.body; + const secondCredential: ListQuery.Credentials.WithOwnedByAndSharedWith = + secondResponse.body.data; validateMainCredentialData(secondCredential); expect(secondCredential.data).toBeDefined(); - expect(firstCredential.sharedWith).toHaveLength(2); + expect(secondCredential.sharedWithProjects).toHaveLength(2); }); test('should not retrieve non-owned cred for member', async () => { @@ -305,13 +387,20 @@ describe('PUT /credentials/:id/share', () => { const [member1, member2, member3, member4, member5] = await createManyUsers(5, { role: 'global:member', }); - const shareWithIds = [member1.id, member2.id, member3.id]; + // TODO: write helper for getting multiple personal projects by user id + const shareWithProjectIds = ( + await Promise.all([ + projectRepository.getPersonalProjectForUserOrFail(member1.id), + projectRepository.getPersonalProjectForUserOrFail(member2.id), + projectRepository.getPersonalProjectForUserOrFail(member3.id), + ]) + ).map((project) => project.id); await shareCredentialWithUsers(savedCredential, [member4, member5]); const response = await authOwnerAgent .put(`/credentials/${savedCredential.id}/share`) - .send({ shareWithIds }); + .send({ shareWithIds: shareWithProjectIds }); expect(response.statusCode).toBe(200); expect(response.body.data).toBeUndefined(); @@ -321,40 +410,54 @@ describe('PUT /credentials/:id/share', () => { }); // check that sharings have been removed/added correctly - expect(sharedCredentials.length).toBe(shareWithIds.length + 1); // +1 for the owner + expect(sharedCredentials.length).toBe(shareWithProjectIds.length + 1); // +1 for the owner sharedCredentials.forEach((sharedCredential) => { - if (sharedCredential.userId === owner.id) { + if (sharedCredential.projectId === ownerPersonalProject.id) { expect(sharedCredential.role).toBe('credential:owner'); return; } - expect(shareWithIds).toContain(sharedCredential.userId); + expect(shareWithProjectIds).toContain(sharedCredential.projectId); expect(sharedCredential.role).toBe('credential:user'); }); expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(1); + expect(mailer.notifyCredentialsShared).toHaveBeenCalledWith( + expect.objectContaining({ + newShareeIds: expect.arrayContaining([member1.id, member2.id, member3.id]), + sharer: expect.objectContaining({ id: owner.id }), + credentialsName: savedCredential.name, + }), + ); }); test('should share the credential with the provided userIds', async () => { const [member1, member2, member3] = await createManyUsers(3, { role: 'global:member', }); - const memberIds = [member1.id, member2.id, member3.id]; + const projectIds = ( + await Promise.all([ + projectRepository.getPersonalProjectForUserOrFail(member1.id), + projectRepository.getPersonalProjectForUserOrFail(member2.id), + projectRepository.getPersonalProjectForUserOrFail(member3.id), + ]) + ).map((project) => project.id); + // const memberIds = [member1.id, member2.id, member3.id]; const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); const response = await authOwnerAgent .put(`/credentials/${savedCredential.id}/share`) - .send({ shareWithIds: memberIds }); + .send({ shareWithIds: projectIds }); expect(response.statusCode).toBe(200); expect(response.body.data).toBeUndefined(); // check that sharings got correctly set in DB const sharedCredentials = await Container.get(SharedCredentialsRepository).find({ - where: { credentialsId: savedCredential.id, userId: In([...memberIds]) }, + where: { credentialsId: savedCredential.id, projectId: In(projectIds) }, }); - expect(sharedCredentials.length).toBe(memberIds.length); + expect(sharedCredentials.length).toBe(projectIds.length); sharedCredentials.forEach((sharedCredential) => { expect(sharedCredential.role).toBe('credential:user'); @@ -362,7 +465,7 @@ describe('PUT /credentials/:id/share', () => { // check that owner still exists const ownerSharedCredential = await Container.get(SharedCredentialsRepository).findOneOrFail({ - where: { credentialsId: savedCredential.id, userId: owner.id }, + where: { credentialsId: savedCredential.id, projectId: ownerPersonalProject.id }, }); expect(ownerSharedCredential.role).toBe('credential:owner'); @@ -372,7 +475,7 @@ describe('PUT /credentials/:id/share', () => { test('should respond 403 for non-existing credentials', async () => { const response = await authOwnerAgent .put('/credentials/1234567/share') - .send({ shareWithIds: [member.id] }); + .send({ shareWithIds: [memberPersonalProject.id] }); expect(response.statusCode).toBe(403); expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(0); @@ -385,7 +488,7 @@ describe('PUT /credentials/:id/share', () => { const response = await authAnotherMemberAgent .put(`/credentials/${savedCredential.id}/share`) - .send({ shareWithIds: [owner.id] }); + .send({ shareWithIds: [ownerPersonalProject.id] }); expect(response.statusCode).toBe(403); const sharedCredentials = await Container.get(SharedCredentialsRepository).find({ @@ -400,7 +503,7 @@ describe('PUT /credentials/:id/share', () => { const response = await authAnotherMemberAgent .put(`/credentials/${savedCredential.id}/share`) - .send({ shareWithIds: [anotherMember.id] }); + .send({ shareWithIds: [anotherMemberPersonalProject.id] }); expect(response.statusCode).toBe(403); @@ -414,10 +517,13 @@ describe('PUT /credentials/:id/share', () => { test('should respond 403 for non-owned credentials for non-shared members sharing', async () => { const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); const tempUser = await createUser({ role: 'global:member' }); + const tempUserPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + tempUser.id, + ); const response = await authAnotherMemberAgent .put(`/credentials/${savedCredential.id}/share`) - .send({ shareWithIds: [tempUser.id] }); + .send({ shareWithIds: [tempUserPersonalProject.id] }); expect(response.statusCode).toBe(403); @@ -433,9 +539,9 @@ describe('PUT /credentials/:id/share', () => { const response = await authOwnerAgent .put(`/credentials/${savedCredential.id}/share`) - .send({ shareWithIds: [anotherMember.id] }); + .send({ shareWithIds: [anotherMemberPersonalProject.id] }) + .expect(200); - expect(response.statusCode).toBe(200); const sharedCredentials = await Container.get(SharedCredentialsRepository).find({ where: { credentialsId: savedCredential.id }, }); @@ -443,22 +549,29 @@ describe('PUT /credentials/:id/share', () => { expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(1); }); - test('should ignore pending sharee', async () => { + test('should not ignore pending sharee', async () => { const memberShell = await createUserShell('global:member'); + const memberShellPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + memberShell.id, + ); const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); - const response = await authOwnerAgent + await authOwnerAgent .put(`/credentials/${savedCredential.id}/share`) - .send({ shareWithIds: [memberShell.id] }); - - expect(response.statusCode).toBe(200); + .send({ shareWithIds: [memberShellPersonalProject.id] }) + .expect(200); const sharedCredentials = await Container.get(SharedCredentialsRepository).find({ where: { credentialsId: savedCredential.id }, }); - expect(sharedCredentials).toHaveLength(1); - expect(sharedCredentials[0].userId).toBe(owner.id); + expect(sharedCredentials).toHaveLength(2); + expect( + sharedCredentials.find((c) => c.projectId === ownerPersonalProject.id), + ).not.toBeUndefined(); + expect( + sharedCredentials.find((c) => c.projectId === memberShellPersonalProject.id), + ).not.toBeUndefined(); }); test('should ignore non-existing sharee', async () => { @@ -475,7 +588,7 @@ describe('PUT /credentials/:id/share', () => { }); expect(sharedCredentials).toHaveLength(1); - expect(sharedCredentials[0].userId).toBe(owner.id); + expect(sharedCredentials[0].projectId).toBe(ownerPersonalProject.id); expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(1); }); @@ -511,7 +624,7 @@ describe('PUT /credentials/:id/share', () => { }); expect(sharedCredentials).toHaveLength(1); - expect(sharedCredentials[0].userId).toBe(owner.id); + expect(sharedCredentials[0].projectId).toBe(ownerPersonalProject.id); expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(1); }); @@ -539,6 +652,6 @@ describe('PUT /credentials/:id/share', () => { function validateMainCredentialData(credential: ListQuery.Credentials.WithOwnedByAndSharedWith) { expect(typeof credential.name).toBe('string'); expect(typeof credential.type).toBe('string'); - expect(credential.ownedBy).toBeDefined(); - expect(Array.isArray(credential.sharedWith)).toBe(true); + expect(credential.homeProject).toBeDefined(); + expect(Array.isArray(credential.sharedWithProjects)).toBe(true); } diff --git a/packages/cli/test/integration/credentials/credentials.service.test.ts b/packages/cli/test/integration/credentials/credentials.service.test.ts new file mode 100644 index 000000000..95aea5cbf --- /dev/null +++ b/packages/cli/test/integration/credentials/credentials.service.test.ts @@ -0,0 +1,55 @@ +import type { User } from '@/databases/entities/User'; +import type { CredentialsEntity } from '@/databases/entities/CredentialsEntity'; +import { saveCredential, shareCredentialWithUsers } from '../shared/db/credentials'; +import { createMember } from '../shared/db/users'; +import { randomCredentialPayload } from '../shared/random'; +import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository'; +import Container from 'typedi'; +import { CredentialsService } from '@/credentials/credentials.service'; +import * as testDb from '../shared/testDb'; + +const credentialPayload = randomCredentialPayload(); +let memberWhoOwnsCredential: User; +let memberWhoDoesNotOwnCredential: User; +let credential: CredentialsEntity; + +beforeAll(async () => { + await testDb.init(); + + memberWhoOwnsCredential = await createMember(); + memberWhoDoesNotOwnCredential = await createMember(); + credential = await saveCredential(credentialPayload, { + user: memberWhoOwnsCredential, + role: 'credential:owner', + }); + + await shareCredentialWithUsers(credential, [memberWhoDoesNotOwnCredential]); +}); + +describe('credentials service', () => { + describe('replaceCredentialContentsForSharee', () => { + it('should replace the contents of the credential for sharee', async () => { + const storedCredential = await Container.get( + SharedCredentialsRepository, + ).findCredentialForUser(credential.id, memberWhoDoesNotOwnCredential, ['credential:read']); + + const decryptedData = Container.get(CredentialsService).decrypt(storedCredential!); + + const mergedCredentials = { + id: credential.id, + name: credential.name, + type: credential.type, + data: { accessToken: '' }, + }; + + Container.get(CredentialsService).replaceCredentialContentsForSharee( + memberWhoDoesNotOwnCredential, + storedCredential!, + decryptedData, + mergedCredentials, + ); + + expect(mergedCredentials.data).toEqual({ accessToken: credentialPayload.data.accessToken }); + }); + }); +}); diff --git a/packages/cli/test/integration/database/repositories/project.repository.test.ts b/packages/cli/test/integration/database/repositories/project.repository.test.ts new file mode 100644 index 000000000..449277adb --- /dev/null +++ b/packages/cli/test/integration/database/repositories/project.repository.test.ts @@ -0,0 +1,155 @@ +import Container from 'typedi'; +import { createMember, createOwner } from '../../shared/db/users'; +import * as testDb from '../../shared/testDb'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { EntityNotFoundError } from '@n8n/typeorm'; +import { createTeamProject } from '../../shared/db/projects'; +import { AuthIdentity } from '@/databases/entities/AuthIdentity'; +import { UserRepository } from '@/databases/repositories/user.repository'; + +describe('ProjectRepository', () => { + beforeAll(async () => { + await testDb.init(); + }); + + beforeEach(async () => { + await testDb.truncate(['User', 'Workflow', 'Project']); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + describe('getPersonalProjectForUser', () => { + it('returns the personal project', async () => { + // + // ARRANGE + // + const owner = await createOwner(); + const ownerPersonalProject = await Container.get(ProjectRepository).findOneByOrFail({ + projectRelations: { userId: owner.id }, + }); + + // + // ACT + // + const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUser( + owner.id, + ); + + // + // ASSERT + // + if (!personalProject) { + fail('Expected personalProject to be defined.'); + } + expect(personalProject).toBeDefined(); + expect(personalProject.id).toBe(ownerPersonalProject.id); + }); + + it('does not return non personal projects', async () => { + // + // ARRANGE + // + const owner = await createOwner(); + await Container.get(ProjectRepository).delete({}); + await createTeamProject(undefined, owner); + + // + // ACT + // + const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUser( + owner.id, + ); + + // + // ASSERT + // + expect(personalProject).toBeNull(); + }); + }); + + describe('getPersonalProjectForUserOrFail', () => { + it('returns the personal project', async () => { + // + // ARRANGE + // + const owner = await createOwner(); + const ownerPersonalProject = await Container.get(ProjectRepository).findOneByOrFail({ + projectRelations: { userId: owner.id }, + }); + + // + // ACT + // + const personalProject = await Container.get( + ProjectRepository, + ).getPersonalProjectForUserOrFail(owner.id); + + // + // ASSERT + // + if (!personalProject) { + fail('Expected personalProject to be defined.'); + } + expect(personalProject).toBeDefined(); + expect(personalProject.id).toBe(ownerPersonalProject.id); + }); + + it('does not return non personal projects', async () => { + // + // ARRANGE + // + const owner = await createOwner(); + await Container.get(ProjectRepository).delete({}); + await createTeamProject(undefined, owner); + + // + // ACT + // + const promise = Container.get(ProjectRepository).getPersonalProjectForUserOrFail(owner.id); + + // + // ASSERT + // + await expect(promise).rejects.toThrowError(EntityNotFoundError); + }); + }); + + describe('update personal project name', () => { + // TypeORM enters an infinite loop if you create entities with circular + // references and pass this to the `Repository.create` function. + // + // This actually happened in combination with SAML. + // `samlHelpers.updateUserFromSamlAttributes` and + // `samlHelpers.createUserFromSamlAttributes` would create a User and an + // AuthIdentity and assign them to one another. Then it would call + // `UserRepository.save(user)`. This would then call the UserSubscriber in + // `database/entities/Project.ts` which would pass the circular User into + // `UserRepository.create` and cause the infinite loop. + // + // This test simulates that behavior and makes sure the UserSubscriber + // checks if the entity is already a user and does not pass it into + // `UserRepository.create` in that case. + test('do not pass a User instance with circular references into `UserRepository.create`', async () => { + // + // ARRANGE + // + const user = await createMember(); + + const authIdentity = new AuthIdentity(); + authIdentity.providerId = user.email; + authIdentity.providerType = 'saml'; + authIdentity.user = user; + + user.firstName = `updated ${user.firstName}`; + user.authIdentities = []; + user.authIdentities.push(authIdentity); + + // + // ACT & ASSERT + // + await expect(Container.get(UserRepository).save(user)).resolves.not.toThrow(); + }); + }); +}); diff --git a/packages/cli/test/integration/environments/SourceControl.test.ts b/packages/cli/test/integration/environments/SourceControl.test.ts index f1da8d067..e7f5b349f 100644 --- a/packages/cli/test/integration/environments/SourceControl.test.ts +++ b/packages/cli/test/integration/environments/SourceControl.test.ts @@ -9,10 +9,15 @@ import type { SourceControlledFile } from '@/environments/sourceControl/types/so import * as utils from '../shared/utils/'; import { createUser } from '../shared/db/users'; +import { mockInstance } from '../../shared/mocking'; +import { WaitTracker } from '@/WaitTracker'; let authOwnerAgent: SuperAgentTest; let owner: User; +// This is necessary for the tests to shutdown cleanly. +mockInstance(WaitTracker); + const testServer = utils.setupTestServer({ endpointGroups: ['sourceControl', 'license', 'auth'], enabledFeatures: ['feat:sourceControl', 'feat:sharing'], diff --git a/packages/cli/test/integration/environments/source-control-import.service.test.ts b/packages/cli/test/integration/environments/source-control-import.service.test.ts index fd3327ced..4665178a3 100644 --- a/packages/cli/test/integration/environments/source-control-import.service.test.ts +++ b/packages/cli/test/integration/environments/source-control-import.service.test.ts @@ -13,6 +13,11 @@ import { SharedCredentialsRepository } from '@/databases/repositories/sharedCred import { mockInstance } from '../../shared/mocking'; import type { SourceControlledFile } from '@/environments/sourceControl/types/sourceControlledFile'; import type { ExportableCredential } from '@/environments/sourceControl/types/exportableCredential'; +import { createTeamProject, getPersonalProject } from '../shared/db/projects'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { saveCredential } from '../shared/db/credentials'; +import { randomCredentialPayload } from '../shared/random'; +import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; describe('SourceControlImportService', () => { let service: SourceControlImportService; @@ -66,9 +71,11 @@ describe('SourceControlImportService', () => { importingUser.id, ); + const personalProject = await getPersonalProject(member); + const sharing = await Container.get(SharedCredentialsRepository).findOneBy({ credentialsId: CREDENTIAL_ID, - userId: member.id, + projectId: personalProject.id, role: 'credential:owner', }); @@ -101,9 +108,11 @@ describe('SourceControlImportService', () => { importingUser.id, ); + const personalProject = await getPersonalProject(importingUser); + const sharing = await Container.get(SharedCredentialsRepository).findOneBy({ credentialsId: CREDENTIAL_ID, - userId: importingUser.id, + projectId: personalProject.id, role: 'credential:owner', }); @@ -136,9 +145,11 @@ describe('SourceControlImportService', () => { importingUser.id, ); + const personalProject = await getPersonalProject(importingUser); + const sharing = await Container.get(SharedCredentialsRepository).findOneBy({ credentialsId: CREDENTIAL_ID, - userId: importingUser.id, + projectId: personalProject.id, role: 'credential:owner', }); @@ -146,4 +157,199 @@ describe('SourceControlImportService', () => { }); }); }); + + describe('if owner specified by `ownedBy` does not exist at target instance', () => { + it('should assign the credential ownership to the importing user if it was owned by a personal project in the source instance', async () => { + const importingUser = await getGlobalOwner(); + + fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); + + const CREDENTIAL_ID = nanoid(); + + const stub: ExportableCredential = { + id: CREDENTIAL_ID, + name: 'My Credential', + type: 'someCredentialType', + data: {}, + ownedBy: { + type: 'personal', + personalEmail: 'test@example.com', + }, // user at source instance owns credential + }; + + jest.spyOn(utils, 'jsonParse').mockReturnValue(stub); + + cipher.encrypt.mockReturnValue('some-encrypted-data'); + + await service.importCredentialsFromWorkFolder( + [mock({ id: CREDENTIAL_ID })], + importingUser.id, + ); + + const personalProject = await getPersonalProject(importingUser); + + const sharing = await Container.get(SharedCredentialsRepository).findOneBy({ + credentialsId: CREDENTIAL_ID, + projectId: personalProject.id, + role: 'credential:owner', + }); + + expect(sharing).toBeTruthy(); // original user missing, so importing user owns credential + }); + + it('should create a new team project if the credential was owned by a team project in the source instance', async () => { + const importingUser = await getGlobalOwner(); + + fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); + + const CREDENTIAL_ID = nanoid(); + + const stub: ExportableCredential = { + id: CREDENTIAL_ID, + name: 'My Credential', + type: 'someCredentialType', + data: {}, + ownedBy: { + type: 'team', + teamId: '1234-asdf', + teamName: 'Marketing', + }, // user at source instance owns credential + }; + + jest.spyOn(utils, 'jsonParse').mockReturnValue(stub); + + cipher.encrypt.mockReturnValue('some-encrypted-data'); + + { + const project = await Container.get(ProjectRepository).findOne({ + where: [ + { + id: '1234-asdf', + }, + { name: 'Marketing' }, + ], + }); + + expect(project?.id).not.toBe('1234-asdf'); + expect(project?.name).not.toBe('Marketing'); + } + + await service.importCredentialsFromWorkFolder( + [mock({ id: CREDENTIAL_ID })], + importingUser.id, + ); + + const sharing = await Container.get(SharedCredentialsRepository).findOne({ + where: { + credentialsId: CREDENTIAL_ID, + role: 'credential:owner', + }, + relations: { project: true }, + }); + + expect(sharing?.project.id).toBe('1234-asdf'); + expect(sharing?.project.name).toBe('Marketing'); + expect(sharing?.project.type).toBe('team'); + + expect(sharing).toBeTruthy(); // original user missing, so importing user owns credential + }); + }); + + describe('if owner specified by `ownedBy` does exist at target instance', () => { + it('should use the existing team project if credential owning project is found', async () => { + const importingUser = await getGlobalOwner(); + + fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); + + const CREDENTIAL_ID = nanoid(); + + const project = await createTeamProject('Sales'); + + const stub: ExportableCredential = { + id: CREDENTIAL_ID, + name: 'My Credential', + type: 'someCredentialType', + data: {}, + ownedBy: { + type: 'team', + teamId: project.id, + teamName: 'Sales', + }, + }; + + jest.spyOn(utils, 'jsonParse').mockReturnValue(stub); + + cipher.encrypt.mockReturnValue('some-encrypted-data'); + + await service.importCredentialsFromWorkFolder( + [mock({ id: CREDENTIAL_ID })], + importingUser.id, + ); + + const sharing = await Container.get(SharedCredentialsRepository).findOneBy({ + credentialsId: CREDENTIAL_ID, + projectId: project.id, + role: 'credential:owner', + }); + + expect(sharing).toBeTruthy(); + }); + + it('should not change the owner if the credential is owned by somebody else on the target instance', async () => { + cipher.encrypt.mockReturnValue('some-encrypted-data'); + + const importingUser = await getGlobalOwner(); + + fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); + + const targetProject = await createTeamProject('Marketing'); + const credential = await saveCredential(randomCredentialPayload(), { + project: targetProject, + role: 'credential:owner', + }); + + const sourceProjectId = nanoid(); + + const stub: ExportableCredential = { + id: credential.id, + name: 'My Credential', + type: 'someCredentialType', + data: {}, + ownedBy: { + type: 'team', + teamId: sourceProjectId, + teamName: 'Sales', + }, + }; + + jest.spyOn(utils, 'jsonParse').mockReturnValue(stub); + + await service.importCredentialsFromWorkFolder( + [mock({ id: credential.id })], + importingUser.id, + ); + + await expect( + Container.get(SharedCredentialsRepository).findBy({ + credentialsId: credential.id, + }), + ).resolves.toMatchObject([ + { + projectId: targetProject.id, + role: 'credential:owner', + }, + ]); + await expect( + Container.get(CredentialsRepository).findBy({ + id: credential.id, + }), + ).resolves.toMatchObject([ + { + name: stub.name, + type: stub.type, + data: 'some-encrypted-data', + }, + ]); + }); + }); }); diff --git a/packages/cli/test/integration/executions.controller.test.ts b/packages/cli/test/integration/executions.controller.test.ts index 02f8c3f25..866a680cc 100644 --- a/packages/cli/test/integration/executions.controller.test.ts +++ b/packages/cli/test/integration/executions.controller.test.ts @@ -1,20 +1,20 @@ import type { User } from '@db/entities/User'; -import { EnterpriseExecutionsService } from '@/executions/execution.service.ee'; -import { WaitTracker } from '@/WaitTracker'; import { createSuccessfulExecution, getAllExecutions } from './shared/db/executions'; -import { createOwner } from './shared/db/users'; -import { createWorkflow } from './shared/db/workflows'; +import { createMember, createOwner } from './shared/db/users'; +import { createWorkflow, shareWorkflowWithUsers } from './shared/db/workflows'; import * as testDb from './shared/testDb'; import { setupTestServer } from './shared/utils'; import { mockInstance } from '../shared/mocking'; +import { WaitTracker } from '@/WaitTracker'; -mockInstance(EnterpriseExecutionsService); -mockInstance(WaitTracker); - -let testServer = setupTestServer({ endpointGroups: ['executions'] }); +const testServer = setupTestServer({ endpointGroups: ['executions'] }); let owner: User; +let member: User; + +// This is necessary for the tests to shutdown cleanly. +mockInstance(WaitTracker); const saveExecution = async ({ belongingTo }: { belongingTo: User }) => { const workflow = await createWorkflow({}, belongingTo); @@ -23,7 +23,44 @@ const saveExecution = async ({ belongingTo }: { belongingTo: User }) => { beforeEach(async () => { await testDb.truncate(['Execution', 'Workflow', 'SharedWorkflow']); + testServer.license.reset(); owner = await createOwner(); + member = await createMember(); +}); + +describe('GET /executions', () => { + test('only returns executions of shared workflows if sharing is enabled', async () => { + const workflow = await createWorkflow({}, owner); + await shareWorkflowWithUsers(workflow, [member]); + await createSuccessfulExecution(workflow); + + const response1 = await testServer.authAgentFor(member).get('/executions').expect(200); + expect(response1.body.data.count).toBe(0); + + testServer.license.enable('feat:sharing'); + + const response2 = await testServer.authAgentFor(member).get('/executions').expect(200); + expect(response2.body.data.count).toBe(1); + }); +}); + +describe('GET /executions/:id', () => { + test('only returns executions of shared workflows if sharing is enabled', async () => { + const workflow = await createWorkflow({}, owner); + await shareWorkflowWithUsers(workflow, [member]); + const execution = await createSuccessfulExecution(workflow); + + await testServer.authAgentFor(member).get(`/executions/${execution.id}`).expect(404); + + testServer.license.enable('feat:sharing'); + + const response = await testServer + .authAgentFor(member) + .get(`/executions/${execution.id}`) + .expect(200); + + expect(response.body.data.id).toBe(execution.id); + }); }); describe('POST /executions/delete', () => { diff --git a/packages/cli/test/integration/import.service.test.ts b/packages/cli/test/integration/import.service.test.ts index 2cfdbe408..99252bdab 100644 --- a/packages/cli/test/integration/import.service.test.ts +++ b/packages/cli/test/integration/import.service.test.ts @@ -21,16 +21,20 @@ import { } from './shared/db/workflows'; import type { User } from '@db/entities/User'; +import type { Project } from '@/databases/entities/Project'; +import { getPersonalProject } from './shared/db/projects'; describe('ImportService', () => { let importService: ImportService; let tagRepository: TagRepository; let owner: User; + let ownerPersonalProject: Project; beforeAll(async () => { await testDb.init(); owner = await createOwner(); + ownerPersonalProject = await getPersonalProject(owner); tagRepository = Container.get(TagRepository); @@ -52,7 +56,7 @@ describe('ImportService', () => { test('should import credless and tagless workflow', async () => { const workflowToImport = await createWorkflow(); - await importService.importWorkflows([workflowToImport], owner.id); + await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); const dbWorkflow = await getWorkflowById(workflowToImport.id); @@ -64,27 +68,32 @@ describe('ImportService', () => { test('should make user owner of imported workflow', async () => { const workflowToImport = newWorkflow(); - await importService.importWorkflows([workflowToImport], owner.id); + await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); const dbSharing = await Container.get(SharedWorkflowRepository).findOneOrFail({ - where: { workflowId: workflowToImport.id, userId: owner.id, role: 'workflow:owner' }, + where: { + workflowId: workflowToImport.id, + projectId: ownerPersonalProject.id, + role: 'workflow:owner', + }, }); - expect(dbSharing.userId).toBe(owner.id); + expect(dbSharing.projectId).toBe(ownerPersonalProject.id); }); test('should not change the owner if it already exists', async () => { const member = await createMember(); + const memberPersonalProject = await getPersonalProject(member); const workflowToImport = await createWorkflow(undefined, owner); - await importService.importWorkflows([workflowToImport], member.id); + await importService.importWorkflows([workflowToImport], memberPersonalProject.id); const sharings = await getAllSharedWorkflows(); expect(sharings).toMatchObject([ expect.objectContaining({ workflowId: workflowToImport.id, - userId: owner.id, + projectId: ownerPersonalProject.id, role: 'workflow:owner', }), ]); @@ -93,7 +102,7 @@ describe('ImportService', () => { test('should deactivate imported workflow if active', async () => { const workflowToImport = await createWorkflow({ active: true }); - await importService.importWorkflows([workflowToImport], owner.id); + await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); const dbWorkflow = await getWorkflowById(workflowToImport.id); @@ -121,7 +130,7 @@ describe('ImportService', () => { const workflowToImport = await createWorkflow({ nodes }); - await importService.importWorkflows([workflowToImport], owner.id); + await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); const dbWorkflow = await getWorkflowById(workflowToImport.id); @@ -141,7 +150,7 @@ describe('ImportService', () => { const workflowToImport = await createWorkflow({ tags: [tag] }); - await importService.importWorkflows([workflowToImport], owner.id); + await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); const dbWorkflow = await Container.get(WorkflowRepository).findOneOrFail({ where: { id: workflowToImport.id }, @@ -162,7 +171,7 @@ describe('ImportService', () => { const workflowToImport = await createWorkflow({ tags: [tag] }); - await importService.importWorkflows([workflowToImport], owner.id); + await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); const dbWorkflow = await Container.get(WorkflowRepository).findOneOrFail({ where: { id: workflowToImport.id }, @@ -181,7 +190,7 @@ describe('ImportService', () => { const workflowToImport = await createWorkflow({ tags: [tag] }); - await importService.importWorkflows([workflowToImport], owner.id); + await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); const dbWorkflow = await Container.get(WorkflowRepository).findOneOrFail({ where: { id: workflowToImport.id }, diff --git a/packages/cli/test/integration/ldap/ldap.api.test.ts b/packages/cli/test/integration/ldap/ldap.api.test.ts index 17cb3e7b5..0ab2f8491 100644 --- a/packages/cli/test/integration/ldap/ldap.api.test.ts +++ b/packages/cli/test/integration/ldap/ldap.api.test.ts @@ -2,15 +2,13 @@ import Container from 'typedi'; import type { SuperAgentTest } from 'supertest'; import type { Entry as LdapUser } from 'ldapts'; import { Not } from '@n8n/typeorm'; -import { jsonParse } from 'n8n-workflow'; import { Cipher } from 'n8n-core'; import config from '@/config'; import type { User } from '@db/entities/User'; -import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/Ldap/constants'; +import { LDAP_DEFAULT_CONFIGURATION } from '@/Ldap/constants'; import { LdapService } from '@/Ldap/ldap.service'; import { saveLdapSynchronization } from '@/Ldap/helpers'; -import type { LdapConfig } from '@/Ldap/types'; import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from '@/sso/ssoHelpers'; import { randomEmail, randomName, uniqueId } from './../shared/random'; @@ -19,28 +17,15 @@ import * as utils from '../shared/utils/'; import { createLdapUser, createUser, getAllUsers, getLdapIdentities } from '../shared/db/users'; import { UserRepository } from '@db/repositories/user.repository'; -import { SettingsRepository } from '@db/repositories/settings.repository'; import { AuthProviderSyncHistoryRepository } from '@db/repositories/authProviderSyncHistory.repository'; +import { getPersonalProject } from '../shared/db/projects'; +import { createLdapConfig, defaultLdapConfig } from '../shared/ldap'; jest.mock('@/telemetry'); let owner: User; let authOwnerAgent: SuperAgentTest; -const defaultLdapConfig = { - ...LDAP_DEFAULT_CONFIGURATION, - loginEnabled: true, - loginLabel: '', - ldapIdAttribute: 'uid', - firstNameAttribute: 'givenName', - lastNameAttribute: 'sn', - emailAttribute: 'mail', - loginIdAttribute: 'mail', - baseDn: 'baseDn', - bindingAdminDn: 'adminDn', - bindingAdminPassword: 'adminPassword', -}; - const testServer = utils.setupTestServer({ endpointGroups: ['auth', 'ldap'], enabledFeatures: ['feat:ldap'], @@ -74,18 +59,6 @@ beforeEach(async () => { await setCurrentAuthenticationMethod('email'); }); -const createLdapConfig = async (attributes: Partial = {}): Promise => { - const { value: ldapConfig } = await Container.get(SettingsRepository).save({ - key: LDAP_FEATURE_NAME, - value: JSON.stringify({ - ...defaultLdapConfig, - ...attributes, - }), - loadOnStartup: true, - }); - return await jsonParse(ldapConfig); -}; - test('Member role should not be able to access ldap routes', async () => { const member = await createUser({ role: 'global:member' }); const authAgent = testServer.authAgentFor(member); @@ -366,6 +339,8 @@ describe('POST /ldap/sync', () => { expect(memberUser.email).toBe(ldapUser.mail); expect(memberUser.lastName).toBe(ldapUser.sn); expect(memberUser.firstName).toBe(ldapUser.givenName); + const memberProject = getPersonalProject(memberUser); + expect(memberProject).toBeDefined(); const authIdentities = await getLdapIdentities(); expect(authIdentities.length).toBe(1); @@ -509,6 +484,8 @@ describe('POST /login', () => { expect(localLdapUsers[0].firstName).toBe(ldapUser.givenName); expect(localLdapIdentities[0].providerId).toBe(ldapUser.uid); expect(localLdapUsers[0].disabled).toBe(false); + + await expect(getPersonalProject(localLdapUsers[0])).resolves.toBeDefined(); }; test('should allow new LDAP user to login and synchronize data', async () => { diff --git a/packages/cli/test/integration/license.api.test.ts b/packages/cli/test/integration/license.api.test.ts index 3d1fc4cda..370d36f43 100644 --- a/packages/cli/test/integration/license.api.test.ts +++ b/packages/cli/test/integration/license.api.test.ts @@ -6,6 +6,7 @@ import { License } from '@/License'; import * as testDb from './shared/testDb'; import * as utils from './shared/utils/'; import { createUserShell } from './shared/db/users'; +import { RESPONSE_ERROR_MESSAGES } from '@/constants'; const MOCK_SERVER_URL = 'https://server.com/v1'; const MOCK_RENEW_OFFSET = 259200; @@ -57,7 +58,7 @@ describe('POST /license/activate', () => { await authMemberAgent .post('/license/activate') .send({ activationKey: 'abcde' }) - .expect(403, UNAUTHORIZED_RESPONSE); + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); test('errors out properly', async () => { @@ -79,7 +80,9 @@ describe('POST /license/renew', () => { }); test('does not work for regular users', async () => { - await authMemberAgent.post('/license/renew').expect(403, UNAUTHORIZED_RESPONSE); + await authMemberAgent + .post('/license/renew') + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); test('errors out properly', async () => { diff --git a/packages/cli/test/integration/me.api.test.ts b/packages/cli/test/integration/me.api.test.ts index 2f4be391c..1a7eb1b4f 100644 --- a/packages/cli/test/integration/me.api.test.ts +++ b/packages/cli/test/integration/me.api.test.ts @@ -15,6 +15,7 @@ import * as utils from './shared/utils/'; import { addApiKey, createUser, createUserShell } from './shared/db/users'; import Container from 'typedi'; import { UserRepository } from '@db/repositories/user.repository'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; const testServer = utils.setupTestServer({ endpointGroups: ['me'] }); @@ -65,6 +66,12 @@ describe('Owner shell', () => { expect(storedOwnerShell.email).toBe(validPayload.email.toLowerCase()); expect(storedOwnerShell.firstName).toBe(validPayload.firstName); expect(storedOwnerShell.lastName).toBe(validPayload.lastName); + + const storedPersonalProject = await Container.get( + ProjectRepository, + ).getPersonalProjectForUserOrFail(storedOwnerShell.id); + + expect(storedPersonalProject.name).toBe(storedOwnerShell.createPersonalProjectName()); } }); @@ -77,6 +84,12 @@ describe('Owner shell', () => { expect(storedOwnerShell.email).toBeNull(); expect(storedOwnerShell.firstName).toBeNull(); expect(storedOwnerShell.lastName).toBeNull(); + + const storedPersonalProject = await Container.get( + ProjectRepository, + ).getPersonalProjectForUserOrFail(storedOwnerShell.id); + + expect(storedPersonalProject.name).toBe(storedOwnerShell.createPersonalProjectName()); } }); @@ -176,9 +189,7 @@ describe('Member', () => { test('PATCH /me should succeed with valid inputs', async () => { for (const validPayload of VALID_PATCH_ME_PAYLOADS) { - const response = await authMemberAgent.patch('/me').send(validPayload); - - expect(response.statusCode).toBe(200); + const response = await authMemberAgent.patch('/me').send(validPayload).expect(200); const { id, @@ -207,6 +218,11 @@ describe('Member', () => { expect(storedMember.email).toBe(validPayload.email.toLowerCase()); expect(storedMember.firstName).toBe(validPayload.firstName); expect(storedMember.lastName).toBe(validPayload.lastName); + + const storedPersonalProject = + await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(id); + + expect(storedPersonalProject.name).toBe(storedMember.createPersonalProjectName()); } }); @@ -219,6 +235,12 @@ describe('Member', () => { expect(storedMember.email).toBe(member.email); expect(storedMember.firstName).toBe(member.firstName); expect(storedMember.lastName).toBe(member.lastName); + + const storedPersonalProject = await Container.get( + ProjectRepository, + ).getPersonalProjectForUserOrFail(storedMember.id); + + expect(storedPersonalProject.name).toBe(storedMember.createPersonalProjectName()); } }); @@ -336,6 +358,12 @@ describe('Owner', () => { expect(storedOwner.email).toBe(validPayload.email.toLowerCase()); expect(storedOwner.firstName).toBe(validPayload.firstName); expect(storedOwner.lastName).toBe(validPayload.lastName); + + const storedPersonalProject = await Container.get( + ProjectRepository, + ).getPersonalProjectForUserOrFail(storedOwner.id); + + expect(storedPersonalProject.name).toBe(storedOwner.createPersonalProjectName()); } }); }); @@ -357,11 +385,11 @@ const VALID_PATCH_ME_PAYLOADS = [ firstName: randomName(), lastName: randomName(), }, - { - email: randomEmail().toUpperCase(), - firstName: randomName(), - lastName: randomName(), - }, + // { + // email: randomEmail().toUpperCase(), + // firstName: randomName(), + // lastName: randomName(), + // }, ]; const INVALID_PATCH_ME_PAYLOADS = [ diff --git a/packages/cli/test/integration/project.api.test.ts b/packages/cli/test/integration/project.api.test.ts new file mode 100644 index 000000000..a2371014d --- /dev/null +++ b/packages/cli/test/integration/project.api.test.ts @@ -0,0 +1,1179 @@ +import * as testDb from './shared/testDb'; +import * as utils from './shared/utils/'; +import { createMember, createOwner, createUser } from './shared/db/users'; +import { + createTeamProject, + linkUserToProject, + getPersonalProject, + findProject, + getProjectRelations, +} from './shared/db/projects'; +import Container from 'typedi'; +import type { Project } from '@/databases/entities/Project'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; +import type { ProjectRole } from '@/databases/entities/ProjectRelation'; +import { EntityNotFoundError } from '@n8n/typeorm'; +import { createWorkflow, shareWorkflowWithProjects } from './shared/db/workflows'; +import { + getCredentialById, + saveCredential, + shareCredentialWithProjects, +} from './shared/db/credentials'; +import { randomCredentialPayload } from './shared/random'; +import { getWorkflowById } from '@/PublicApi/v1/handlers/workflows/workflows.service'; +import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; +import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository'; +import type { GlobalRole } from '@/databases/entities/User'; +import type { Scope } from '@n8n/permissions'; +import { CacheService } from '@/services/cache/cache.service'; +import { mockInstance } from '../shared/mocking'; +import { ActiveWorkflowManager } from '@/ActiveWorkflowManager'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; + +const testServer = utils.setupTestServer({ + endpointGroups: ['project'], + enabledFeatures: [ + 'feat:advancedPermissions', + 'feat:projectRole:admin', + 'feat:projectRole:editor', + 'feat:projectRole:viewer', + ], + quotas: { + 'quota:maxTeamProjects': -1, + }, +}); + +// The `ActiveWorkflowRunner` keeps the event loop alive, which in turn leads to jest not shutting down cleanly. +// We don't need it for the tests here, so we can mock it and make the tests exit cleanly. +mockInstance(ActiveWorkflowManager); + +beforeEach(async () => { + await testDb.truncate(['User', 'Project']); +}); + +describe('GET /projects/', () => { + test('member should get all personal projects and team projects they are apart of', async () => { + const [testUser1, testUser2, testUser3] = await Promise.all([ + createUser(), + createUser(), + createUser(), + ]); + const [teamProject1, teamProject2] = await Promise.all([ + createTeamProject(undefined, testUser1), + createTeamProject(), + ]); + + const [personalProject1, personalProject2, personalProject3] = await Promise.all([ + getPersonalProject(testUser1), + getPersonalProject(testUser2), + getPersonalProject(testUser3), + ]); + + const memberAgent = testServer.authAgentFor(testUser1); + + const resp = await memberAgent.get('/projects/'); + expect(resp.status).toBe(200); + const respProjects = resp.body.data as Project[]; + expect(respProjects.length).toBe(4); + + expect( + [personalProject1, personalProject2, personalProject3].every((v, i) => { + const p = respProjects.find((p) => p.id === v.id); + if (!p) { + return false; + } + const u = [testUser1, testUser2, testUser3][i]; + return p.name === u.createPersonalProjectName(); + }), + ).toBe(true); + expect(respProjects.find((p) => p.id === teamProject1.id)).not.toBeUndefined(); + expect(respProjects.find((p) => p.id === teamProject2.id)).toBeUndefined(); + }); + + test('owner should get all projects', async () => { + const [ownerUser, testUser1, testUser2, testUser3] = await Promise.all([ + createOwner(), + createUser(), + createUser(), + createUser(), + ]); + const [teamProject1, teamProject2] = await Promise.all([ + createTeamProject(undefined, testUser1), + createTeamProject(), + ]); + + const [ownerProject, personalProject1, personalProject2, personalProject3] = await Promise.all([ + getPersonalProject(ownerUser), + getPersonalProject(testUser1), + getPersonalProject(testUser2), + getPersonalProject(testUser3), + ]); + + const memberAgent = testServer.authAgentFor(ownerUser); + + const resp = await memberAgent.get('/projects/'); + expect(resp.status).toBe(200); + const respProjects = resp.body.data as Project[]; + expect(respProjects.length).toBe(6); + + expect( + [ownerProject, personalProject1, personalProject2, personalProject3].every((v, i) => { + const p = respProjects.find((p) => p.id === v.id); + if (!p) { + return false; + } + const u = [ownerUser, testUser1, testUser2, testUser3][i]; + return p.name === u.createPersonalProjectName(); + }), + ).toBe(true); + expect(respProjects.find((p) => p.id === teamProject1.id)).not.toBeUndefined(); + expect(respProjects.find((p) => p.id === teamProject2.id)).not.toBeUndefined(); + }); +}); + +describe('GET /projects/count', () => { + test('should return correct number of projects', async () => { + const [firstUser] = await Promise.all([ + createUser(), + createUser(), + createUser(), + createUser(), + createTeamProject(), + createTeamProject(), + createTeamProject(), + ]); + + const resp = await testServer.authAgentFor(firstUser).get('/projects/count'); + + expect(resp.body.data.personal).toBe(4); + expect(resp.body.data.team).toBe(3); + }); +}); + +describe('GET /projects/my-projects', () => { + test('member should get all projects they are apart of', async () => { + // + // ARRANGE + // + const [testUser1, testUser2, testUser3] = await Promise.all([ + createUser(), + createUser(), + createUser(), + ]); + const [teamProject1, teamProject2] = await Promise.all([ + createTeamProject(undefined, testUser1), + createTeamProject(undefined, testUser2), + ]); + + const [personalProject1, personalProject2, personalProject3] = await Promise.all([ + getPersonalProject(testUser1), + getPersonalProject(testUser2), + getPersonalProject(testUser3), + ]); + + // + // ACT + // + const resp = await testServer + .authAgentFor(testUser1) + .get('/projects/my-projects') + .query({ includeScopes: true }) + .expect(200); + const respProjects: Array = + resp.body.data; + + // + // ASSERT + // + expect(respProjects.length).toBe(2); + + const projectsExpected = [ + [ + personalProject1, + { + role: 'project:personalOwner', + scopes: ['project:list', 'project:read', 'credential:create'], + }, + ], + [ + teamProject1, + { + role: 'project:admin', + scopes: [ + 'project:list', + 'project:read', + 'project:update', + 'project:delete', + 'credential:create', + ], + }, + ], + ] as const; + + for (const [project, expected] of projectsExpected) { + const p = respProjects.find((p) => p.id === project.id)!; + + expect(p.role).toBe(expected.role); + expect(expected.scopes.every((s) => p.scopes?.includes(s as Scope))).toBe(true); + } + + expect(respProjects).not.toContainEqual(expect.objectContaining({ id: personalProject2.id })); + expect(respProjects).not.toContainEqual(expect.objectContaining({ id: personalProject3.id })); + expect(respProjects).not.toContainEqual(expect.objectContaining({ id: teamProject2.id })); + }); + + test('owner should get all projects they are apart of', async () => { + // + // ARRANGE + // + const [ownerUser, testUser1, testUser2, testUser3] = await Promise.all([ + createOwner(), + createUser(), + createUser(), + createUser(), + ]); + const [teamProject1, teamProject2, teamProject3, teamProject4] = await Promise.all([ + // owner has no relation ship + createTeamProject(undefined, testUser1), + // owner is admin + createTeamProject(undefined, ownerUser), + // owner is viewer + createTeamProject(undefined, testUser2), + // this project has no relationship at all + createTeamProject(), + ]); + + await linkUserToProject(ownerUser, teamProject3, 'project:editor'); + + const [ownerProject, personalProject1, personalProject2, personalProject3] = await Promise.all([ + getPersonalProject(ownerUser), + getPersonalProject(testUser1), + getPersonalProject(testUser2), + getPersonalProject(testUser3), + ]); + + // + // ACT + // + const resp = await testServer + .authAgentFor(ownerUser) + .get('/projects/my-projects') + .query({ includeScopes: true }) + .expect(200); + const respProjects: Array = + resp.body.data; + + // + // ASSERT + // + expect(respProjects.length).toBe(5); + + const projectsExpected = [ + [ + ownerProject, + { + role: 'project:personalOwner', + scopes: [ + 'project:list', + 'project:create', + 'project:read', + 'project:update', + 'project:delete', + 'credential:create', + ], + }, + ], + [ + teamProject1, + { + role: 'global:owner', + scopes: [ + 'project:list', + 'project:create', + 'project:read', + 'project:update', + 'project:delete', + 'credential:create', + ], + }, + ], + [ + teamProject2, + { + role: 'project:admin', + scopes: [ + 'project:list', + 'project:create', + 'project:read', + 'project:update', + 'project:delete', + 'credential:create', + ], + }, + ], + [ + teamProject3, + { + role: 'project:editor', + scopes: [ + 'project:list', + 'project:create', + 'project:read', + 'project:update', + 'project:delete', + 'credential:create', + ], + }, + ], + [ + teamProject4, + { + role: 'global:owner', + scopes: [ + 'project:list', + 'project:create', + 'project:read', + 'project:update', + 'project:delete', + 'credential:create', + ], + }, + ], + ] as const; + + for (const [project, expected] of projectsExpected) { + const p = respProjects.find((p) => p.id === project.id)!; + + expect(p.role).toBe(expected.role); + expect(expected.scopes.every((s) => p.scopes?.includes(s as Scope))).toBe(true); + } + + expect(respProjects).not.toContainEqual(expect.objectContaining({ id: personalProject1.id })); + expect(respProjects).not.toContainEqual(expect.objectContaining({ id: personalProject2.id })); + expect(respProjects).not.toContainEqual(expect.objectContaining({ id: personalProject3.id })); + }); +}); + +describe('GET /projects/personal', () => { + test("should return the user's personal project", async () => { + const user = await createUser(); + const project = await getPersonalProject(user); + + const memberAgent = testServer.authAgentFor(user); + + const resp = await memberAgent.get('/projects/personal'); + expect(resp.status).toBe(200); + const respProject = resp.body.data as Project & { scopes: Scope[] }; + expect(respProject.id).toEqual(project.id); + expect(respProject.scopes).not.toBeUndefined(); + }); + + test("should return 404 if user doesn't have a personal project", async () => { + const user = await createUser(); + const project = await getPersonalProject(user); + await testDb.truncate(['Project']); + + const memberAgent = testServer.authAgentFor(user); + + const resp = await memberAgent.get('/projects/personal'); + expect(resp.status).toBe(404); + const respProject = resp.body?.data as Project; + expect(respProject?.id).not.toEqual(project.id); + }); +}); + +describe('POST /projects/', () => { + test('should create a team project', async () => { + const ownerUser = await createOwner(); + const ownerAgent = testServer.authAgentFor(ownerUser); + + const resp = await ownerAgent.post('/projects/').send({ name: 'Test Team Project' }); + expect(resp.status).toBe(200); + const respProject = resp.body.data as Project; + expect(respProject.name).toEqual('Test Team Project'); + expect(async () => { + await findProject(respProject.id); + }).not.toThrow(); + }); + + test('should allow to create a team projects if below the quota', async () => { + testServer.license.setQuota('quota:maxTeamProjects', 1); + const ownerUser = await createOwner(); + const ownerAgent = testServer.authAgentFor(ownerUser); + + await ownerAgent.post('/projects/').send({ name: 'Test Team Project' }).expect(200); + expect(await Container.get(ProjectRepository).count({ where: { type: 'team' } })).toBe(1); + }); + + test('should fail to create a team project if at quota', async () => { + testServer.license.setQuota('quota:maxTeamProjects', 1); + await Promise.all([createTeamProject()]); + const ownerUser = await createOwner(); + const ownerAgent = testServer.authAgentFor(ownerUser); + + await ownerAgent.post('/projects/').send({ name: 'Test Team Project' }).expect(400, { + code: 400, + message: + 'Attempted to create a new project but quota is already exhausted. You may have a maximum of 1 team projects.', + }); + + expect(await Container.get(ProjectRepository).count({ where: { type: 'team' } })).toBe(1); + }); + + test('should fail to create a team project if above the quota', async () => { + testServer.license.setQuota('quota:maxTeamProjects', 1); + await Promise.all([createTeamProject(), createTeamProject()]); + const ownerUser = await createOwner(); + const ownerAgent = testServer.authAgentFor(ownerUser); + + await ownerAgent.post('/projects/').send({ name: 'Test Team Project' }).expect(400, { + code: 400, + message: + 'Attempted to create a new project but quota is already exhausted. You may have a maximum of 1 team projects.', + }); + + expect(await Container.get(ProjectRepository).count({ where: { type: 'team' } })).toBe(2); + }); +}); + +describe('PATCH /projects/:projectId', () => { + test('should update a team project name', async () => { + const ownerUser = await createOwner(); + const ownerAgent = testServer.authAgentFor(ownerUser); + + const teamProject = await createTeamProject(); + + const resp = await ownerAgent.patch(`/projects/${teamProject.id}`).send({ name: 'New Name' }); + expect(resp.status).toBe(200); + + const updatedProject = await findProject(teamProject.id); + expect(updatedProject.name).toEqual('New Name'); + }); + + test('should not allow viewers to edit team project name', async () => { + const testUser = await createUser(); + const teamProject = await createTeamProject(); + await linkUserToProject(testUser, teamProject, 'project:viewer'); + + const memberAgent = testServer.authAgentFor(testUser); + + const resp = await memberAgent.patch(`/projects/${teamProject.id}`).send({ name: 'New Name' }); + expect(resp.status).toBe(403); + + const updatedProject = await findProject(teamProject.id); + expect(updatedProject.name).not.toEqual('New Name'); + }); + + test('should not allow owners to edit personal project name', async () => { + const user = await createUser(); + const personalProject = await getPersonalProject(user); + + const ownerUser = await createOwner(); + const ownerAgent = testServer.authAgentFor(ownerUser); + + const resp = await ownerAgent + .patch(`/projects/${personalProject.id}`) + .send({ name: 'New Name' }); + expect(resp.status).toBe(403); + + const updatedProject = await findProject(personalProject.id); + expect(updatedProject.name).not.toEqual('New Name'); + }); +}); + +describe('PATCH /projects/:projectId', () => { + test('should add or remove users from a project', async () => { + const [ownerUser, testUser1, testUser2, testUser3] = await Promise.all([ + createOwner(), + createUser(), + createUser(), + createUser(), + ]); + const [teamProject1, teamProject2] = await Promise.all([ + createTeamProject(undefined, testUser1), + createTeamProject(undefined, testUser2), + ]); + const [credential1, credential2] = await Promise.all([ + saveCredential(randomCredentialPayload(), { + role: 'credential:owner', + project: teamProject1, + }), + saveCredential(randomCredentialPayload(), { + role: 'credential:owner', + project: teamProject2, + }), + saveCredential(randomCredentialPayload(), { + role: 'credential:owner', + project: teamProject2, + }), + ]); + await shareCredentialWithProjects(credential2, [teamProject1]); + + await linkUserToProject(ownerUser, teamProject2, 'project:editor'); + await linkUserToProject(testUser2, teamProject2, 'project:editor'); + + const memberAgent = testServer.authAgentFor(testUser1); + + const deleteSpy = jest.spyOn(Container.get(CacheService), 'deleteMany'); + const resp = await memberAgent.patch(`/projects/${teamProject1.id}`).send({ + name: teamProject1.name, + relations: [ + { userId: testUser1.id, role: 'project:admin' }, + { userId: testUser3.id, role: 'project:editor' }, + { userId: ownerUser.id, role: 'project:viewer' }, + ] as Array<{ + userId: string; + role: ProjectRole; + }>, + }); + expect(resp.status).toBe(200); + + expect(deleteSpy).toBeCalledWith([`credential-can-use-secrets:${credential1.id}`]); + deleteSpy.mockClear(); + + const [tp1Relations, tp2Relations] = await Promise.all([ + getProjectRelations({ projectId: teamProject1.id }), + getProjectRelations({ projectId: teamProject2.id }), + ]); + + expect(tp1Relations.length).toBe(3); + expect(tp2Relations.length).toBe(2); + + expect(tp1Relations.find((p) => p.userId === testUser1.id)).not.toBeUndefined(); + expect(tp1Relations.find((p) => p.userId === testUser2.id)).toBeUndefined(); + expect(tp1Relations.find((p) => p.userId === testUser1.id)?.role).toBe('project:admin'); + expect(tp1Relations.find((p) => p.userId === testUser3.id)?.role).toBe('project:editor'); + expect(tp1Relations.find((p) => p.userId === ownerUser.id)?.role).toBe('project:viewer'); + + // Check we haven't modified the other team project + expect(tp2Relations.find((p) => p.userId === testUser2.id)).not.toBeUndefined(); + expect(tp2Relations.find((p) => p.userId === testUser1.id)).toBeUndefined(); + expect(tp2Relations.find((p) => p.userId === testUser2.id)?.role).toBe('project:editor'); + expect(tp2Relations.find((p) => p.userId === ownerUser.id)?.role).toBe('project:editor'); + }); + + test('should not add or remove users from a project if lacking permissions', async () => { + const [ownerUser, testUser1, testUser2, testUser3] = await Promise.all([ + createOwner(), + createUser(), + createUser(), + createUser(), + ]); + const [teamProject1, teamProject2] = await Promise.all([ + createTeamProject(undefined, testUser2), + createTeamProject(), + ]); + + await linkUserToProject(testUser1, teamProject1, 'project:viewer'); + await linkUserToProject(ownerUser, teamProject2, 'project:editor'); + await linkUserToProject(testUser2, teamProject2, 'project:editor'); + + const memberAgent = testServer.authAgentFor(testUser1); + + const resp = await memberAgent.patch(`/projects/${teamProject1.id}`).send({ + name: teamProject1.name, + relations: [ + { userId: testUser1.id, role: 'project:admin' }, + { userId: testUser3.id, role: 'project:editor' }, + { userId: ownerUser.id, role: 'project:viewer' }, + ] as Array<{ + userId: string; + role: ProjectRole; + }>, + }); + expect(resp.status).toBe(403); + + const [tp1Relations, tp2Relations] = await Promise.all([ + getProjectRelations({ projectId: teamProject1.id }), + getProjectRelations({ projectId: teamProject2.id }), + ]); + + expect(tp1Relations.length).toBe(2); + expect(tp2Relations.length).toBe(2); + + expect(tp1Relations.find((p) => p.userId === testUser1.id)).not.toBeUndefined(); + expect(tp1Relations.find((p) => p.userId === testUser2.id)).not.toBeUndefined(); + expect(tp1Relations.find((p) => p.userId === testUser1.id)?.role).toBe('project:viewer'); + expect(tp1Relations.find((p) => p.userId === testUser2.id)?.role).toBe('project:admin'); + expect(tp1Relations.find((p) => p.userId === testUser3.id)).toBeUndefined(); + + // Check we haven't modified the other team project + expect(tp2Relations.find((p) => p.userId === testUser2.id)).not.toBeUndefined(); + expect(tp2Relations.find((p) => p.userId === testUser1.id)).toBeUndefined(); + expect(tp2Relations.find((p) => p.userId === testUser2.id)?.role).toBe('project:editor'); + expect(tp2Relations.find((p) => p.userId === ownerUser.id)?.role).toBe('project:editor'); + }); + + test('should not add from a project adding user with an unlicensed role', async () => { + testServer.license.disable('feat:projectRole:editor'); + const [testUser1, testUser2, testUser3] = await Promise.all([ + createUser(), + createUser(), + createUser(), + ]); + const teamProject = await createTeamProject(undefined, testUser2); + + await linkUserToProject(testUser1, teamProject, 'project:admin'); + + const memberAgent = testServer.authAgentFor(testUser2); + + const resp = await memberAgent.patch(`/projects/${teamProject.id}`).send({ + name: teamProject.name, + relations: [ + { userId: testUser2.id, role: 'project:admin' }, + { userId: testUser1.id, role: 'project:editor' }, + ] as Array<{ + userId: string; + role: ProjectRole; + }>, + }); + expect(resp.status).toBe(400); + + const tpRelations = await getProjectRelations({ projectId: teamProject.id }); + expect(tpRelations.length).toBe(2); + + expect(tpRelations.find((p) => p.userId === testUser1.id)).not.toBeUndefined(); + expect(tpRelations.find((p) => p.userId === testUser2.id)).not.toBeUndefined(); + expect(tpRelations.find((p) => p.userId === testUser1.id)?.role).toBe('project:admin'); + expect(tpRelations.find((p) => p.userId === testUser2.id)?.role).toBe('project:admin'); + expect(tpRelations.find((p) => p.userId === testUser3.id)).toBeUndefined(); + }); + + test("should not edit a relation of a project when changing a user's role to an unlicensed role", async () => { + testServer.license.disable('feat:projectRole:editor'); + const [testUser1, testUser2, testUser3] = await Promise.all([ + createUser(), + createUser(), + createUser(), + ]); + const teamProject = await createTeamProject(undefined, testUser2); + + await linkUserToProject(testUser1, teamProject, 'project:admin'); + await linkUserToProject(testUser3, teamProject, 'project:admin'); + + const memberAgent = testServer.authAgentFor(testUser2); + + const resp = await memberAgent.patch(`/projects/${teamProject.id}`).send({ + name: teamProject.name, + relations: [ + { userId: testUser2.id, role: 'project:admin' }, + { userId: testUser1.id, role: 'project:editor' }, + { userId: testUser3.id, role: 'project:editor' }, + ] as Array<{ + userId: string; + role: ProjectRole; + }>, + }); + expect(resp.status).toBe(400); + + const tpRelations = await getProjectRelations({ projectId: teamProject.id }); + expect(tpRelations.length).toBe(3); + + expect(tpRelations.find((p) => p.userId === testUser1.id)).not.toBeUndefined(); + expect(tpRelations.find((p) => p.userId === testUser2.id)).not.toBeUndefined(); + expect(tpRelations.find((p) => p.userId === testUser1.id)?.role).toBe('project:admin'); + expect(tpRelations.find((p) => p.userId === testUser2.id)?.role).toBe('project:admin'); + expect(tpRelations.find((p) => p.userId === testUser3.id)?.role).toBe('project:admin'); + }); + + test("should edit a relation of a project when changing a user's role to an licensed role but unlicensed roles are present", async () => { + testServer.license.disable('feat:projectRole:viewer'); + const [testUser1, testUser2, testUser3] = await Promise.all([ + createUser(), + createUser(), + createUser(), + ]); + const teamProject = await createTeamProject(undefined, testUser2); + + await linkUserToProject(testUser1, teamProject, 'project:viewer'); + await linkUserToProject(testUser3, teamProject, 'project:editor'); + + const memberAgent = testServer.authAgentFor(testUser2); + + const resp = await memberAgent.patch(`/projects/${teamProject.id}`).send({ + name: teamProject.name, + relations: [ + { userId: testUser1.id, role: 'project:viewer' }, + { userId: testUser2.id, role: 'project:admin' }, + { userId: testUser3.id, role: 'project:admin' }, + ] as Array<{ + userId: string; + role: ProjectRole; + }>, + }); + expect(resp.status).toBe(200); + + const tpRelations = await getProjectRelations({ projectId: teamProject.id }); + expect(tpRelations.length).toBe(3); + + expect(tpRelations.find((p) => p.userId === testUser1.id)).not.toBeUndefined(); + expect(tpRelations.find((p) => p.userId === testUser2.id)).not.toBeUndefined(); + expect(tpRelations.find((p) => p.userId === testUser3.id)).not.toBeUndefined(); + expect(tpRelations.find((p) => p.userId === testUser1.id)?.role).toBe('project:viewer'); + expect(tpRelations.find((p) => p.userId === testUser2.id)?.role).toBe('project:admin'); + expect(tpRelations.find((p) => p.userId === testUser3.id)?.role).toBe('project:admin'); + }); + + test('should not add or remove users from a personal project', async () => { + const [testUser1, testUser2] = await Promise.all([createUser(), createUser()]); + + const personalProject = await getPersonalProject(testUser1); + + const memberAgent = testServer.authAgentFor(testUser1); + + const resp = await memberAgent.patch(`/projects/${personalProject.id}`).send({ + relations: [ + { userId: testUser1.id, role: 'project:personalOwner' }, + { userId: testUser2.id, role: 'project:admin' }, + ] as Array<{ + userId: string; + role: ProjectRole; + }>, + }); + expect(resp.status).toBe(403); + + const p1Relations = await getProjectRelations({ projectId: personalProject.id }); + expect(p1Relations.length).toBe(1); + }); +}); + +describe('GET /project/:projectId', () => { + test('should get project details and relations', async () => { + const [ownerUser, testUser1, testUser2, _testUser3] = await Promise.all([ + createOwner(), + createUser(), + createUser(), + createUser(), + ]); + const [teamProject1, teamProject2] = await Promise.all([ + createTeamProject(undefined, testUser2), + createTeamProject(), + ]); + + await linkUserToProject(testUser1, teamProject1, 'project:editor'); + await linkUserToProject(ownerUser, teamProject2, 'project:editor'); + await linkUserToProject(testUser2, teamProject2, 'project:editor'); + + const memberAgent = testServer.authAgentFor(testUser1); + + const resp = await memberAgent.get(`/projects/${teamProject1.id}`); + expect(resp.status).toBe(200); + + expect(resp.body.data.id).toBe(teamProject1.id); + expect(resp.body.data.name).toBe(teamProject1.name); + + expect(resp.body.data.relations.length).toBe(2); + expect(resp.body.data.relations).toContainEqual({ + id: testUser1.id, + email: testUser1.email, + firstName: testUser1.firstName, + lastName: testUser1.lastName, + role: 'project:editor', + }); + expect(resp.body.data.relations).toContainEqual({ + id: testUser2.id, + email: testUser2.email, + firstName: testUser2.firstName, + lastName: testUser2.lastName, + role: 'project:admin', + }); + }); +}); + +describe('DELETE /project/:projectId', () => { + test('allows the project:owner to delete a project', async () => { + const member = await createMember(); + const project = await createTeamProject(undefined, member); + + await testServer.authAgentFor(member).delete(`/projects/${project.id}`).expect(200); + + const projectInDB = findProject(project.id); + + await expect(projectInDB).rejects.toThrowError(EntityNotFoundError); + }); + + test('allows the instance owner to delete a team project their are not related to', async () => { + const owner = await createOwner(); + + const member = await createMember(); + const project = await createTeamProject(undefined, member); + + await testServer.authAgentFor(owner).delete(`/projects/${project.id}`).expect(200); + + await expect(findProject(project.id)).rejects.toThrowError(EntityNotFoundError); + }); + + test('does not allow instance members to delete their personal project', async () => { + const member = await createMember(); + const project = await getPersonalProject(member); + + await testServer.authAgentFor(member).delete(`/projects/${project.id}`).expect(403); + + const projectInDB = await findProject(project.id); + + expect(projectInDB).toHaveProperty('id', project.id); + }); + + test('does not allow instance owners to delete their personal projects', async () => { + const owner = await createOwner(); + const project = await getPersonalProject(owner); + + await testServer.authAgentFor(owner).delete(`/projects/${project.id}`).expect(403); + + const projectInDB = await findProject(project.id); + + expect(projectInDB).toHaveProperty('id', project.id); + }); + + test.each(['project:editor', 'project:viewer'] as ProjectRole[])( + 'does not allow users with the role %s to delete a project', + async (role) => { + const member = await createMember(); + const project = await createTeamProject(); + + await linkUserToProject(member, project, role); + + await testServer.authAgentFor(member).delete(`/projects/${project.id}`).expect(403); + + const projectInDB = await findProject(project.id); + + expect(projectInDB).toHaveProperty('id', project.id); + }, + ); + + test('deletes all workflows and credentials it owns as well as the sharings into other projects', async () => { + // + // ARRANGE + // + const member = await createMember(); + + const otherProject = await createTeamProject(undefined, member); + const sharedWorkflow1 = await createWorkflow({}, otherProject); + const sharedWorkflow2 = await createWorkflow({}, otherProject); + const sharedCredential = await saveCredential(randomCredentialPayload(), { + project: otherProject, + role: 'credential:owner', + }); + + const projectToBeDeleted = await createTeamProject(undefined, member); + const ownedWorkflow = await createWorkflow({}, projectToBeDeleted); + const ownedCredential = await saveCredential(randomCredentialPayload(), { + project: projectToBeDeleted, + role: 'credential:owner', + }); + + await shareCredentialWithProjects(sharedCredential, [otherProject]); + await shareWorkflowWithProjects(sharedWorkflow1, [ + { project: otherProject, role: 'workflow:editor' }, + ]); + await shareWorkflowWithProjects(sharedWorkflow2, [ + { project: otherProject, role: 'workflow:user' }, + ]); + + // + // ACT + // + await testServer.authAgentFor(member).delete(`/projects/${projectToBeDeleted.id}`).expect(200); + + // + // ASSERT + // + + // Make sure the project and owned workflow and credential where deleted. + await expect(getWorkflowById(ownedWorkflow.id)).resolves.toBeNull(); + await expect(getCredentialById(ownedCredential.id)).resolves.toBeNull(); + await expect(findProject(projectToBeDeleted.id)).rejects.toThrowError(EntityNotFoundError); + + // Make sure the shared workflow and credential were not deleted + await expect(getWorkflowById(sharedWorkflow1.id)).resolves.not.toBeNull(); + await expect(getCredentialById(sharedCredential.id)).resolves.not.toBeNull(); + + // Make sure the sharings for them have been deleted + await expect( + Container.get(SharedWorkflowRepository).findOneByOrFail({ + projectId: projectToBeDeleted.id, + workflowId: sharedWorkflow1.id, + }), + ).rejects.toThrowError(EntityNotFoundError); + await expect( + Container.get(SharedCredentialsRepository).findOneByOrFail({ + projectId: projectToBeDeleted.id, + credentialsId: sharedCredential.id, + }), + ).rejects.toThrowError(EntityNotFoundError); + }); + + test('unshares all workflows and credentials that were shared with the project', async () => { + // + // ARRANGE + // + const member = await createMember(); + + const projectToBeDeleted = await createTeamProject(undefined, member); + const ownedWorkflow1 = await createWorkflow({}, projectToBeDeleted); + const ownedWorkflow2 = await createWorkflow({}, projectToBeDeleted); + const ownedCredential = await saveCredential(randomCredentialPayload(), { + project: projectToBeDeleted, + role: 'credential:owner', + }); + + const otherProject = await createTeamProject(undefined, member); + + await shareCredentialWithProjects(ownedCredential, [otherProject]); + await shareWorkflowWithProjects(ownedWorkflow1, [ + { project: otherProject, role: 'workflow:editor' }, + ]); + await shareWorkflowWithProjects(ownedWorkflow2, [ + { project: otherProject, role: 'workflow:user' }, + ]); + + // + // ACT + // + await testServer.authAgentFor(member).delete(`/projects/${projectToBeDeleted.id}`).expect(200); + + // + // ASSERT + // + + // Make sure the project and owned workflow and credential where deleted. + await expect(getWorkflowById(ownedWorkflow1.id)).resolves.toBeNull(); + await expect(getWorkflowById(ownedWorkflow2.id)).resolves.toBeNull(); + await expect(getCredentialById(ownedCredential.id)).resolves.toBeNull(); + await expect(findProject(projectToBeDeleted.id)).rejects.toThrowError(EntityNotFoundError); + + // Make sure the sharings for them into the other project have been deleted + await expect( + Container.get(SharedWorkflowRepository).findOneByOrFail({ + projectId: projectToBeDeleted.id, + workflowId: ownedWorkflow1.id, + }), + ).rejects.toThrowError(EntityNotFoundError); + await expect( + Container.get(SharedWorkflowRepository).findOneByOrFail({ + projectId: projectToBeDeleted.id, + workflowId: ownedWorkflow2.id, + }), + ).rejects.toThrowError(EntityNotFoundError); + await expect( + Container.get(SharedCredentialsRepository).findOneByOrFail({ + projectId: projectToBeDeleted.id, + credentialsId: ownedCredential.id, + }), + ).rejects.toThrowError(EntityNotFoundError); + }); + + test('deletes the project relations', async () => { + // + // ARRANGE + // + const member = await createMember(); + const editor = await createMember(); + const viewer = await createMember(); + + const project = await createTeamProject(undefined, member); + await linkUserToProject(editor, project, 'project:editor'); + await linkUserToProject(viewer, project, 'project:viewer'); + + // + // ACT + // + await testServer.authAgentFor(member).delete(`/projects/${project.id}`).expect(200); + + // + // ASSERT + // + await expect( + Container.get(ProjectRelationRepository).findOneByOrFail({ + projectId: project.id, + userId: member.id, + }), + ).rejects.toThrowError(EntityNotFoundError); + await expect( + Container.get(ProjectRelationRepository).findOneByOrFail({ + projectId: project.id, + userId: editor.id, + }), + ).rejects.toThrowError(EntityNotFoundError); + await expect( + Container.get(ProjectRelationRepository).findOneByOrFail({ + projectId: project.id, + userId: viewer.id, + }), + ).rejects.toThrowError(EntityNotFoundError); + }); + + // Tests related to migrating workflows and credentials to new project: + + test('should fail if the project to delete does not exist', async () => { + const member = await createMember(); + + await testServer.authAgentFor(member).delete('/projects/1234').expect(403); + }); + + test('should fail to delete if project to migrate to and the project to delete are the same', async () => { + const member = await createMember(); + const project = await createTeamProject(undefined, member); + + await testServer + .authAgentFor(member) + .delete(`/projects/${project.id}`) + .query({ transferId: project.id }) + .expect(400); + }); + + test('does not migrate credentials and projects if the user does not have the permissions to create workflows or credentials in the target project', async () => { + // + // ARRANGE + // + const member = await createMember(); + + const projectToBeDeleted = await createTeamProject(undefined, member); + const targetProject = await createTeamProject(); + await linkUserToProject(member, targetProject, 'project:viewer'); + + // + // ACT + // + await testServer + .authAgentFor(member) + .delete(`/projects/${projectToBeDeleted.id}`) + .query({ transferId: targetProject.id }) + // + // ASSERT + // + .expect(404); + }); + + test('migrates workflows and credentials to another project if `migrateToProject` is passed', async () => { + // + // ARRANGE + // + const member = await createMember(); + + const projectToBeDeleted = await createTeamProject(undefined, member); + const targetProject = await createTeamProject(undefined, member); + const otherProject = await createTeamProject(undefined, member); + + // these should be re-owned to the targetProject + const ownedCredential = await saveCredential(randomCredentialPayload(), { + project: projectToBeDeleted, + role: 'credential:owner', + }); + const ownedWorkflow = await createWorkflow({}, projectToBeDeleted); + + // these should stay intact + await shareCredentialWithProjects(ownedCredential, [otherProject]); + await shareWorkflowWithProjects(ownedWorkflow, [ + { project: otherProject, role: 'workflow:editor' }, + ]); + + // + // ACT + // + await testServer + .authAgentFor(member) + .delete(`/projects/${projectToBeDeleted.id}`) + .query({ transferId: targetProject.id }) + .expect(200); + + // + // ASSERT + // + + // projectToBeDeleted is deleted + await expect(findProject(projectToBeDeleted.id)).rejects.toThrowError(EntityNotFoundError); + + // ownedWorkflow has not been deleted + await expect(getWorkflowById(ownedWorkflow.id)).resolves.toBeDefined(); + + // ownedCredential has not been deleted + await expect(getCredentialById(ownedCredential.id)).resolves.toBeDefined(); + + // there is a sharing for ownedWorkflow and targetProject + await expect( + Container.get(SharedCredentialsRepository).findOneByOrFail({ + credentialsId: ownedCredential.id, + projectId: targetProject.id, + role: 'credential:owner', + }), + ).resolves.toBeDefined(); + + // there is a sharing for ownedCredential and targetProject + await expect( + Container.get(SharedWorkflowRepository).findOneByOrFail({ + workflowId: ownedWorkflow.id, + projectId: targetProject.id, + role: 'workflow:owner', + }), + ).resolves.toBeDefined(); + + // there is a sharing for ownedWorkflow and otherProject + await expect( + Container.get(SharedWorkflowRepository).findOneByOrFail({ + workflowId: ownedWorkflow.id, + projectId: otherProject.id, + role: 'workflow:editor', + }), + ).resolves.toBeDefined(); + + // there is a sharing for ownedCredential and otherProject + await expect( + Container.get(SharedCredentialsRepository).findOneByOrFail({ + credentialsId: ownedCredential.id, + projectId: otherProject.id, + role: 'credential:user', + }), + ).resolves.toBeDefined(); + }); + + // This test is testing behavior that is explicitly not enabled right now, + // but we want this to work if we in the future allow sharing of credentials + // and/or workflows between team projects. + test('should upgrade a projects role if the workflow/credential is already shared with it', async () => { + // + // ARRANGE + // + const member = await createMember(); + const project = await createTeamProject(undefined, member); + const credential = await saveCredential(randomCredentialPayload(), { + project, + role: 'credential:owner', + }); + const workflow = await createWorkflow({}, project); + const projectToMigrateTo = await createTeamProject(undefined, member); + + await shareWorkflowWithProjects(workflow, [ + { project: projectToMigrateTo, role: 'workflow:editor' }, + ]); + await shareCredentialWithProjects(credential, [projectToMigrateTo]); + + // + // ACT + // + await testServer + .authAgentFor(member) + .delete(`/projects/${project.id}`) + .query({ transferId: projectToMigrateTo.id }) + .expect(200); + + // + // ASSERT + // + + await expect( + Container.get(SharedCredentialsRepository).findOneByOrFail({ + credentialsId: credential.id, + projectId: projectToMigrateTo.id, + role: 'credential:owner', + }), + ).resolves.toBeDefined(); + await expect( + Container.get(SharedWorkflowRepository).findOneByOrFail({ + workflowId: workflow.id, + projectId: projectToMigrateTo.id, + role: 'workflow:owner', + }), + ).resolves.toBeDefined(); + }); +}); diff --git a/packages/cli/test/integration/project.service.integration.test.ts b/packages/cli/test/integration/project.service.integration.test.ts new file mode 100644 index 000000000..77d388c16 --- /dev/null +++ b/packages/cli/test/integration/project.service.integration.test.ts @@ -0,0 +1,116 @@ +import Container from 'typedi'; +import { ProjectService } from '@/services/project.service'; +import * as testDb from './shared/testDb'; +import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; +import { createUser } from './shared/db/users'; +import { createWorkflow } from './shared/db/workflows'; +import { linkUserToProject, createTeamProject } from './shared/db/projects'; + +describe('ProjectService', () => { + let projectService: ProjectService; + + let sharedWorkflowRepository: SharedWorkflowRepository; + + beforeAll(async () => { + await testDb.init(); + + projectService = Container.get(ProjectService); + + sharedWorkflowRepository = Container.get(SharedWorkflowRepository); + }); + + afterEach(async () => { + await testDb.truncate(['User', 'Project', 'ProjectRelation', 'Workflow', 'SharedWorkflow']); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + describe('findRolesInProjects', () => { + describe('when user has roles in projects where workflow is accessible', () => { + it('should return roles and project IDs', async () => { + const user = await createUser(); + const secondUser = await createUser(); // @TODO: Needed only to satisfy index in legacy column + + const firstProject = await createTeamProject('Project 1'); + const secondProject = await createTeamProject('Project 2'); + + await linkUserToProject(user, firstProject, 'project:admin'); + await linkUserToProject(user, secondProject, 'project:viewer'); + + const workflow = await createWorkflow(); + + await sharedWorkflowRepository.insert({ + userId: user.id, // @TODO: Legacy column + projectId: firstProject.id, + workflowId: workflow.id, + role: 'workflow:owner', + }); + + await sharedWorkflowRepository.insert({ + userId: secondUser.id, // @TODO: Legacy column + projectId: secondProject.id, + workflowId: workflow.id, + role: 'workflow:user', + }); + + const projectIds = await projectService.findProjectsWorkflowIsIn(workflow.id); + + expect(projectIds).toEqual(expect.arrayContaining([firstProject.id, secondProject.id])); + }); + }); + + describe('when user has no roles in projects where workflow is accessible', () => { + it('should return project IDs but no roles', async () => { + const user = await createUser(); + const secondUser = await createUser(); // @TODO: Needed only to satisfy index in legacy column + + const firstProject = await createTeamProject('Project 1'); + const secondProject = await createTeamProject('Project 2'); + + // workflow shared with projects, but user not added to any project + + const workflow = await createWorkflow(); + + await sharedWorkflowRepository.insert({ + userId: user.id, // @TODO: Legacy column + projectId: firstProject.id, + workflowId: workflow.id, + role: 'workflow:owner', + }); + + await sharedWorkflowRepository.insert({ + userId: secondUser.id, // @TODO: Legacy column + projectId: secondProject.id, + workflowId: workflow.id, + role: 'workflow:user', + }); + + const projectIds = await projectService.findProjectsWorkflowIsIn(workflow.id); + + expect(projectIds).toEqual(expect.arrayContaining([firstProject.id, secondProject.id])); + }); + }); + + describe('when user has roles in projects where workflow is inaccessible', () => { + it('should return project IDs but no roles', async () => { + const user = await createUser(); + + const firstProject = await createTeamProject('Project 1'); + const secondProject = await createTeamProject('Project 2'); + + await linkUserToProject(user, firstProject, 'project:admin'); + await linkUserToProject(user, secondProject, 'project:viewer'); + + const workflow = await createWorkflow(); + + // user added to projects, but workflow not shared with projects + + const projectIds = await projectService.findProjectsWorkflowIsIn(workflow.id); + + expect(projectIds).toHaveLength(0); + }); + }); + }); +}); diff --git a/packages/cli/test/integration/publicApi/credentials.test.ts b/packages/cli/test/integration/publicApi/credentials.test.ts index d02883463..378b3725c 100644 --- a/packages/cli/test/integration/publicApi/credentials.test.ts +++ b/packages/cli/test/integration/publicApi/credentials.test.ts @@ -63,8 +63,16 @@ describe('POST /credentials', () => { expect(credential.data).not.toBe(payload.data); const sharedCredential = await Container.get(SharedCredentialsRepository).findOneOrFail({ - relations: ['user', 'credentials'], - where: { credentialsId: credential.id, userId: owner.id }, + relations: { credentials: true }, + where: { + credentialsId: credential.id, + project: { + type: 'personal', + projectRelations: { + userId: owner.id, + }, + }, + }, }); expect(sharedCredential.role).toEqual('credential:owner'); @@ -203,7 +211,7 @@ describe('DELETE /credentials/:id', () => { const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(403); const shellCredential = await Container.get(CredentialsRepository).findOneBy({ id: savedCredential.id, diff --git a/packages/cli/test/integration/publicApi/executions.test.ts b/packages/cli/test/integration/publicApi/executions.test.ts index 3cc6bd430..f80438f8a 100644 --- a/packages/cli/test/integration/publicApi/executions.test.ts +++ b/packages/cli/test/integration/publicApi/executions.test.ts @@ -132,6 +132,7 @@ describe('GET /executions/:id', () => { }); test('member should be able to fetch executions of workflows shared with him', async () => { + testServer.license.enable('feat:sharing'); const workflow = await createWorkflow({}, user1); const execution = await createSuccessfulExecution(workflow); @@ -434,6 +435,7 @@ describe('GET /executions', () => { }); test('member should also see executions of workflows shared with him', async () => { + testServer.license.enable('feat:sharing'); const [firstWorkflowForUser1, secondWorkflowForUser1] = await createManyWorkflows(2, {}, user1); await createManyExecutions(2, firstWorkflowForUser1, createSuccessfulExecution); await createManyExecutions(2, secondWorkflowForUser1, createSuccessfulExecution); diff --git a/packages/cli/test/integration/publicApi/workflows.test.ts b/packages/cli/test/integration/publicApi/workflows.test.ts index 9ef71e8af..21863b552 100644 --- a/packages/cli/test/integration/publicApi/workflows.test.ts +++ b/packages/cli/test/integration/publicApi/workflows.test.ts @@ -17,9 +17,13 @@ import { createUser } from '../shared/db/users'; import { createWorkflow, createWorkflowWithTrigger } from '../shared/db/workflows'; import { createTag } from '../shared/db/tags'; import { mockInstance } from '../../shared/mocking'; +import type { Project } from '@/databases/entities/Project'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; let owner: User; +let ownerPersonalProject: Project; let member: User; +let memberPersonalProject: Project; let authOwnerAgent: SuperAgentTest; let authMemberAgent: SuperAgentTest; let activeWorkflowManager: ActiveWorkflowManager; @@ -34,11 +38,17 @@ beforeAll(async () => { role: 'global:owner', apiKey: randomApiKey(), }); + ownerPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + owner.id, + ); member = await createUser({ role: 'global:member', apiKey: randomApiKey(), }); + memberPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + member.id, + ); await utils.initNodeTypes(); @@ -254,10 +264,7 @@ describe('GET /workflows', () => { test('should return all owned workflows filtered by name', async () => { const workflowName = 'Workflow 1'; - const [workflow] = await Promise.all([ - createWorkflow({ name: workflowName }, member), - createWorkflow({}, member), - ]); + await Promise.all([createWorkflow({ name: workflowName }, member), createWorkflow({}, member)]); const response = await authMemberAgent.get(`/workflows?name=${workflowName}`); @@ -274,7 +281,7 @@ describe('GET /workflows', () => { name, createdAt, updatedAt, - tags: wfTags, + tags, } = response.body.data[0]; expect(id).toBeDefined(); @@ -286,6 +293,7 @@ describe('GET /workflows', () => { expect(settings).toBeDefined(); expect(createdAt).toBeDefined(); expect(updatedAt).toBeDefined(); + expect(tags).toEqual([]); }); test('should return all workflows for owner', async () => { @@ -508,7 +516,7 @@ describe('POST /workflows/:id/activate', () => { // check whether the workflow is on the database const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: member.id, + projectId: memberPersonalProject.id, workflowId: workflow.id, }, relations: ['workflow'], @@ -523,9 +531,7 @@ describe('POST /workflows/:id/activate', () => { test('should set non-owned workflow as active when owner', async () => { const workflow = await createWorkflowWithTrigger({}, member); - const response = await authMemberAgent.post(`/workflows/${workflow.id}/activate`); - - expect(response.statusCode).toBe(200); + const response = await authMemberAgent.post(`/workflows/${workflow.id}/activate`).expect(200); const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = response.body; @@ -543,7 +549,7 @@ describe('POST /workflows/:id/activate', () => { // check whether the workflow is on the database const sharedOwnerWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: owner.id, + projectId: ownerPersonalProject.id, workflowId: workflow.id, }, }); @@ -552,7 +558,7 @@ describe('POST /workflows/:id/activate', () => { const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: member.id, + projectId: memberPersonalProject.id, workflowId: workflow.id, }, relations: ['workflow'], @@ -606,7 +612,7 @@ describe('POST /workflows/:id/deactivate', () => { // get the workflow after it was deactivated const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: member.id, + projectId: memberPersonalProject.id, workflowId: workflow.id, }, relations: ['workflow'], @@ -643,7 +649,7 @@ describe('POST /workflows/:id/deactivate', () => { // check whether the workflow is deactivated in the database const sharedOwnerWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: owner.id, + projectId: ownerPersonalProject.id, workflowId: workflow.id, }, }); @@ -652,7 +658,7 @@ describe('POST /workflows/:id/deactivate', () => { const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: member.id, + projectId: memberPersonalProject.id, workflowId: workflow.id, }, relations: ['workflow'], @@ -720,7 +726,7 @@ describe('POST /workflows', () => { // check if created workflow in DB const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: member.id, + projectId: memberPersonalProject.id, workflowId: response.body.id, }, relations: ['workflow'], @@ -959,7 +965,7 @@ describe('PUT /workflows/:id', () => { // check updated workflow in DB const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: member.id, + projectId: memberPersonalProject.id, workflowId: response.body.id, }, relations: ['workflow'], @@ -1128,7 +1134,7 @@ describe('PUT /workflows/:id', () => { // check updated workflow in DB const sharedOwnerWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: owner.id, + projectId: ownerPersonalProject.id, workflowId: response.body.id, }, }); @@ -1137,7 +1143,7 @@ describe('PUT /workflows/:id', () => { const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: member.id, + projectId: memberPersonalProject.id, workflowId: response.body.id, }, relations: ['workflow'], @@ -1269,7 +1275,7 @@ describe('PUT /workflows/:id/tags', () => { // Check the association in DB const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: member.id, + projectId: memberPersonalProject.id, workflowId: workflow.id, }, relations: ['workflow.tags'], @@ -1304,7 +1310,7 @@ describe('PUT /workflows/:id/tags', () => { // Check the association in DB const oldSharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: member.id, + projectId: memberPersonalProject.id, workflowId: workflow.id, }, relations: ['workflow.tags'], @@ -1357,7 +1363,7 @@ describe('PUT /workflows/:id/tags', () => { // Check the association in DB const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: member.id, + projectId: memberPersonalProject.id, workflowId: workflow.id, }, relations: ['workflow.tags'], @@ -1391,7 +1397,7 @@ describe('PUT /workflows/:id/tags', () => { // Check the association in DB const oldSharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: member.id, + projectId: memberPersonalProject.id, workflowId: workflow.id, }, relations: ['workflow.tags'], @@ -1431,7 +1437,7 @@ describe('PUT /workflows/:id/tags', () => { // Check the association in DB const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ where: { - userId: member.id, + projectId: memberPersonalProject.id, workflowId: workflow.id, }, relations: ['workflow.tags'], diff --git a/packages/cli/test/integration/role.api.test.ts b/packages/cli/test/integration/role.api.test.ts new file mode 100644 index 000000000..d5afc38f0 --- /dev/null +++ b/packages/cli/test/integration/role.api.test.ts @@ -0,0 +1,165 @@ +import type { SuperAgentTest } from 'supertest'; +import * as utils from './shared/utils/'; +import { createMember } from './shared/db/users'; +import type { GlobalRole } from '@/databases/entities/User'; +import type { ProjectRole } from '@/databases/entities/ProjectRelation'; +import type { CredentialSharingRole } from '@/databases/entities/SharedCredentials'; +import type { WorkflowSharingRole } from '@/databases/entities/SharedWorkflow'; +import { RoleService } from '@/services/role.service'; +import Container from 'typedi'; +import type { Scope } from '@n8n/permissions'; + +const testServer = utils.setupTestServer({ + endpointGroups: ['role'], +}); + +let memberAgent: SuperAgentTest; + +const expectedCategories = ['global', 'project', 'credential', 'workflow'] as const; +let expectedGlobalRoles: Array<{ + name: string; + role: GlobalRole; + scopes: Scope[]; + licensed: boolean; +}>; +let expectedProjectRoles: Array<{ + name: string; + role: ProjectRole; + scopes: Scope[]; + licensed: boolean; +}>; +let expectedCredentialRoles: Array<{ + name: string; + role: CredentialSharingRole; + scopes: Scope[]; + licensed: boolean; +}>; +let expectedWorkflowRoles: Array<{ + name: string; + role: WorkflowSharingRole; + scopes: Scope[]; + licensed: boolean; +}>; + +beforeAll(async () => { + memberAgent = testServer.authAgentFor(await createMember()); + + expectedGlobalRoles = [ + { + name: 'Owner', + role: 'global:owner', + scopes: Container.get(RoleService).getRoleScopes('global:owner'), + licensed: true, + }, + { + name: 'Admin', + role: 'global:admin', + scopes: Container.get(RoleService).getRoleScopes('global:admin'), + licensed: false, + }, + { + name: 'Member', + role: 'global:member', + scopes: Container.get(RoleService).getRoleScopes('global:member'), + licensed: true, + }, + ]; + expectedProjectRoles = [ + { + name: 'Project Owner', + role: 'project:personalOwner', + scopes: Container.get(RoleService).getRoleScopes('project:personalOwner'), + licensed: true, + }, + { + name: 'Project Admin', + role: 'project:admin', + scopes: Container.get(RoleService).getRoleScopes('project:admin'), + licensed: false, + }, + { + name: 'Project Editor', + role: 'project:editor', + scopes: Container.get(RoleService).getRoleScopes('project:editor'), + licensed: false, + }, + ]; + expectedCredentialRoles = [ + { + name: 'Credential Owner', + role: 'credential:owner', + scopes: Container.get(RoleService).getRoleScopes('credential:owner'), + licensed: true, + }, + { + name: 'Credential User', + role: 'credential:user', + scopes: Container.get(RoleService).getRoleScopes('credential:user'), + licensed: true, + }, + ]; + expectedWorkflowRoles = [ + { + name: 'Workflow Owner', + role: 'workflow:owner', + scopes: Container.get(RoleService).getRoleScopes('workflow:owner'), + licensed: true, + }, + { + name: 'Workflow Editor', + role: 'workflow:editor', + scopes: Container.get(RoleService).getRoleScopes('workflow:editor'), + licensed: true, + }, + ]; +}); + +describe('GET /roles/', () => { + test('should return all role categories', async () => { + const resp = await memberAgent.get('/roles/'); + + expect(resp.status).toBe(200); + + const data: Record = resp.body.data; + + const categories = [...Object.keys(data)]; + expect(categories.length).toBe(expectedCategories.length); + expect(expectedCategories.every((c) => categories.includes(c))).toBe(true); + }); + + test('should return fixed global roles', async () => { + const resp = await memberAgent.get('/roles/'); + + expect(resp.status).toBe(200); + for (const role of expectedGlobalRoles) { + expect(resp.body.data.global).toContainEqual(role); + } + }); + + test('should return fixed project roles', async () => { + const resp = await memberAgent.get('/roles/'); + + expect(resp.status).toBe(200); + for (const role of expectedProjectRoles) { + expect(resp.body.data.project).toContainEqual(role); + } + }); + + test('should return fixed credential sharing roles', async () => { + const resp = await memberAgent.get('/roles/'); + + expect(resp.status).toBe(200); + for (const role of expectedCredentialRoles) { + expect(resp.body.data.credential).toContainEqual(role); + } + }); + + test('should return fixed workflow sharing roles', async () => { + const resp = await memberAgent.get('/roles/'); + + expect(resp.status).toBe(200); + for (const role of expectedWorkflowRoles) { + expect(resp.body.data.workflow).toContainEqual(role); + } + }); +}); diff --git a/packages/cli/test/integration/saml/samlHelpers.test.ts b/packages/cli/test/integration/saml/samlHelpers.test.ts new file mode 100644 index 000000000..7941efada --- /dev/null +++ b/packages/cli/test/integration/saml/samlHelpers.test.ts @@ -0,0 +1,44 @@ +import * as helpers from '@/sso/saml/samlHelpers'; +import type { SamlUserAttributes } from '@/sso/saml/types/samlUserAttributes'; +import { getPersonalProject } from '../../integration/shared/db/projects'; + +import * as testDb from '../shared/testDb'; + +beforeAll(async () => { + await testDb.init(); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +describe('sso/saml/samlHelpers', () => { + describe('createUserFromSamlAttributes', () => { + test('Creates personal project for user', async () => { + // + // ARRANGE + // + const samlUserAttributes: SamlUserAttributes = { + firstName: 'Nathan', + lastName: 'Nathaniel', + email: 'n@8.n', + userPrincipalName: 'Huh?', + }; + + // + // ACT + // + const user = await helpers.createUserFromSamlAttributes(samlUserAttributes); + + // + // ASSERT + // + expect(user).toMatchObject({ + firstName: samlUserAttributes.firstName, + lastName: samlUserAttributes.lastName, + email: samlUserAttributes.email, + }); + await expect(getPersonalProject(user)).resolves.not.toBeNull(); + }); + }); +}); diff --git a/packages/cli/test/integration/services/project.service.test.ts b/packages/cli/test/integration/services/project.service.test.ts new file mode 100644 index 000000000..54cdad2b3 --- /dev/null +++ b/packages/cli/test/integration/services/project.service.test.ts @@ -0,0 +1,202 @@ +import { ProjectService } from '@/services/project.service'; +import * as testDb from '../shared/testDb'; +import Container from 'typedi'; +import { createMember } from '../shared/db/users'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; +import type { ProjectRole } from '@/databases/entities/ProjectRelation'; +import type { Scope } from '@n8n/permissions'; + +let projectRepository: ProjectRepository; +let projectService: ProjectService; +let projectRelationRepository: ProjectRelationRepository; + +beforeAll(async () => { + await testDb.init(); + + projectRepository = Container.get(ProjectRepository); + projectService = Container.get(ProjectService); + projectRelationRepository = Container.get(ProjectRelationRepository); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +afterEach(async () => { + await testDb.truncate(['User']); +}); + +describe('ProjectService', () => { + describe('addUser', () => { + it.each([ + 'project:viewer', + 'project:admin', + 'project:editor', + 'project:personalOwner', + ] as ProjectRole[])( + 'creates a relation between the user and the project using the role %s', + async (role) => { + // + // ARRANGE + // + const member = await createMember(); + const project = await projectRepository.save( + projectRepository.create({ + name: 'Team Project', + type: 'team', + }), + ); + + // + // ACT + // + await projectService.addUser(project.id, member.id, role); + + // + // ASSERT + // + await projectRelationRepository.findOneOrFail({ + where: { userId: member.id, projectId: project.id, role }, + }); + }, + ); + + it('changes the role the user has in the project if the user is already part of the project, instead of creating a new relationship', async () => { + // + // ARRANGE + // + const member = await createMember(); + const project = await projectRepository.save( + projectRepository.create({ + name: 'Team Project', + type: 'team', + }), + ); + await projectService.addUser(project.id, member.id, 'project:viewer'); + + await projectRelationRepository.findOneOrFail({ + where: { userId: member.id, projectId: project.id, role: 'project:viewer' }, + }); + + // + // ACT + // + await projectService.addUser(project.id, member.id, 'project:admin'); + + // + // ASSERT + // + const relationships = await projectRelationRepository.find({ + where: { userId: member.id, projectId: project.id }, + }); + + expect(relationships).toHaveLength(1); + expect(relationships[0]).toHaveProperty('role', 'project:admin'); + }); + }); + + describe('getProjectWithScope', () => { + it.each([ + { role: 'project:admin', scope: 'workflow:list' }, + { role: 'project:admin', scope: 'workflow:create' }, + ] as Array<{ + role: ProjectRole; + scope: Scope; + }>)( + 'should return the project if the user has the role $role and wants the scope $scope', + async ({ role, scope }) => { + // + // ARRANGE + // + const projectOwner = await createMember(); + const project = await projectRepository.save( + projectRepository.create({ + name: 'Team Project', + type: 'team', + }), + ); + await projectService.addUser(project.id, projectOwner.id, role); + + // + // ACT + // + const projectFromService = await projectService.getProjectWithScope( + projectOwner, + project.id, + [scope], + ); + + // + // ASSERT + // + if (projectFromService === null) { + fail('Expected projectFromService not to be null'); + } + expect(project.id).toBe(projectFromService.id); + }, + ); + + it.each([ + { role: 'project:viewer', scope: 'workflow:create' }, + { role: 'project:viewer', scope: 'credential:create' }, + ] as Array<{ + role: ProjectRole; + scope: Scope; + }>)( + 'should return the project if the user has the role $role and wants the scope $scope', + async ({ role, scope }) => { + // + // ARRANGE + // + const projectViewer = await createMember(); + const project = await projectRepository.save( + projectRepository.create({ + name: 'Team Project', + type: 'team', + }), + ); + await projectService.addUser(project.id, projectViewer.id, role); + + // + // ACT + // + const projectFromService = await projectService.getProjectWithScope( + projectViewer, + project.id, + [scope], + ); + + // + // ASSERT + // + expect(projectFromService).toBeNull(); + }, + ); + + it('should not return the project if the user is not part of it', async () => { + // + // ARRANGE + // + const member = await createMember(); + const project = await projectRepository.save( + projectRepository.create({ + name: 'Team Project', + type: 'team', + }), + ); + + // + // ACT + // + const projectFromService = await projectService.getProjectWithScope(member, project.id, [ + 'workflow:list', + ]); + + // + // ASSERT + // + expect(projectFromService).toBeNull(); + }); + }); +}); diff --git a/packages/cli/test/integration/shared/db/credentials.ts b/packages/cli/test/integration/shared/db/credentials.ts index 85b46d26a..9464f06bf 100644 --- a/packages/cli/test/integration/shared/db/credentials.ts +++ b/packages/cli/test/integration/shared/db/credentials.ts @@ -6,15 +6,19 @@ import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials. import type { CredentialSharingRole } from '@db/entities/SharedCredentials'; import type { ICredentialsDb } from '@/Interfaces'; import type { CredentialPayload } from '../types'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import type { Project } from '@/databases/entities/Project'; -async function encryptCredentialData(credential: CredentialsEntity) { +export async function encryptCredentialData( + credential: CredentialsEntity, +): Promise { const { createCredentialsFromCredentialsEntity } = await import('@/CredentialsHelper'); const coreCredential = createCredentialsFromCredentialsEntity(credential, true); // @ts-ignore coreCredential.setData(credential.data); - return coreCredential.getDataToSave() as ICredentialsDb; + return Object.assign(credential, coreCredential.getDataToSave()); } const emptyAttributes = { @@ -46,43 +50,89 @@ export async function createCredentials(attributes: Partial = */ export async function saveCredential( credentialPayload: CredentialPayload, - { user, role }: { user: User; role: CredentialSharingRole }, + options: + | { user: User; role: CredentialSharingRole } + | { + project: Project; + role: CredentialSharingRole; + }, ) { + const role = options.role; const newCredential = new CredentialsEntity(); Object.assign(newCredential, credentialPayload); - const encryptedData = await encryptCredentialData(newCredential); - - Object.assign(newCredential, encryptedData); + await encryptCredentialData(newCredential); const savedCredential = await Container.get(CredentialsRepository).save(newCredential); savedCredential.data = newCredential.data; - await Container.get(SharedCredentialsRepository).save({ - user, - credentials: savedCredential, - role, - }); + if ('user' in options) { + const user = options.user; + const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + user.id, + ); + + await Container.get(SharedCredentialsRepository).save({ + user, + credentials: savedCredential, + role, + project: personalProject, + }); + } else { + const project = options.project; + + await Container.get(SharedCredentialsRepository).save({ + credentials: savedCredential, + role, + project, + }); + } return savedCredential; } export async function shareCredentialWithUsers(credential: CredentialsEntity, users: User[]) { - const newSharedCredentials = users.map((user) => - Container.get(SharedCredentialsRepository).create({ - userId: user.id, - credentialsId: credential.id, - role: 'credential:user', + const newSharedCredentials = await Promise.all( + users.map(async (user) => { + const personalProject = await Container.get( + ProjectRepository, + ).getPersonalProjectForUserOrFail(user.id); + + return Container.get(SharedCredentialsRepository).create({ + credentialsId: credential.id, + role: 'credential:user', + projectId: personalProject.id, + }); }), ); + + return await Container.get(SharedCredentialsRepository).save(newSharedCredentials); +} + +export async function shareCredentialWithProjects( + credential: CredentialsEntity, + projects: Project[], +) { + const newSharedCredentials = await Promise.all( + projects.map(async (project) => { + return Container.get(SharedCredentialsRepository).create({ + credentialsId: credential.id, + role: 'credential:user', + projectId: project.id, + }); + }), + ); + return await Container.get(SharedCredentialsRepository).save(newSharedCredentials); } export function affixRoleToSaveCredential(role: CredentialSharingRole) { - return async (credentialPayload: CredentialPayload, { user }: { user: User }) => - await saveCredential(credentialPayload, { user, role }); + return async ( + credentialPayload: CredentialPayload, + options: { user: User } | { project: Project }, + ) => await saveCredential(credentialPayload, { ...options, role }); } export async function getAllCredentials() { diff --git a/packages/cli/test/integration/shared/db/projects.ts b/packages/cli/test/integration/shared/db/projects.ts new file mode 100644 index 000000000..60548575b --- /dev/null +++ b/packages/cli/test/integration/shared/db/projects.ts @@ -0,0 +1,63 @@ +import Container from 'typedi'; + +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { randomName } from '../random'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; +import type { User } from '@/databases/entities/User'; +import type { Project } from '@/databases/entities/Project'; +import type { ProjectRelation, ProjectRole } from '@/databases/entities/ProjectRelation'; + +export const createTeamProject = async (name?: string, adminUser?: User) => { + const projectRepository = Container.get(ProjectRepository); + const project = await projectRepository.save( + projectRepository.create({ + name: name ?? randomName(), + type: 'team', + }), + ); + + if (adminUser) { + await linkUserToProject(adminUser, project, 'project:admin'); + } + + return project; +}; + +export const linkUserToProject = async (user: User, project: Project, role: ProjectRole) => { + const projectRelationRepository = Container.get(ProjectRelationRepository); + await projectRelationRepository.save( + projectRelationRepository.create({ + projectId: project.id, + userId: user.id, + role, + }), + ); +}; + +export const getPersonalProject = async (user: User): Promise => { + return await Container.get(ProjectRepository).findOneOrFail({ + where: { + projectRelations: { + userId: user.id, + role: 'project:personalOwner', + }, + type: 'personal', + }, + }); +}; + +export const findProject = async (id: string): Promise => { + return await Container.get(ProjectRepository).findOneOrFail({ + where: { id }, + }); +}; + +export const getProjectRelations = async ({ + projectId, + userId, + role, +}: Partial): Promise => { + return await Container.get(ProjectRelationRepository).find({ + where: { projectId, userId, role }, + }); +}; diff --git a/packages/cli/test/integration/shared/db/users.ts b/packages/cli/test/integration/shared/db/users.ts index 4f4ed8af1..81ca3b199 100644 --- a/packages/cli/test/integration/shared/db/users.ts +++ b/packages/cli/test/integration/shared/db/users.ts @@ -1,7 +1,7 @@ import Container from 'typedi'; import { hash } from 'bcryptjs'; import { AuthIdentity } from '@db/entities/AuthIdentity'; -import type { GlobalRole, User } from '@db/entities/User'; +import { type GlobalRole, type User } from '@db/entities/User'; import { AuthIdentityRepository } from '@db/repositories/authIdentity.repository'; import { UserRepository } from '@db/repositories/user.repository'; import { TOTPService } from '@/Mfa/totp.service'; @@ -27,9 +27,10 @@ export async function newUser(attributes: Partial = {}): Promise { /** Store a user object in the DB */ export async function createUser(attributes: Partial = {}): Promise { - const user = await newUser(attributes); + const userInstance = await newUser(attributes); + const { user } = await Container.get(UserRepository).createUserWithProject(userInstance); user.computeIsOwner(); - return await Container.get(UserRepository).save(user); + return user; } export async function createLdapUser(attributes: Partial, ldapId: string): Promise { @@ -90,7 +91,8 @@ export async function createUserShell(role: GlobalRole): Promise { shell.email = randomEmail(); } - return await Container.get(UserRepository).save(shell); + const { user } = await Container.get(UserRepository).createUserWithProject(shell); + return user; } /** @@ -100,12 +102,15 @@ export async function createManyUsers( amount: number, attributes: Partial = {}, ): Promise { - const users = await Promise.all( + const result = await Promise.all( Array(amount) .fill(0) - .map(async () => await newUser(attributes)), + .map(async () => { + const userInstance = await newUser(attributes); + return await Container.get(UserRepository).createUserWithProject(userInstance); + }), ); - return await Container.get(UserRepository).save(users); + return result.map((result) => result.user); } export async function addApiKey(user: User): Promise { @@ -127,7 +132,7 @@ export const getUserById = async (id: string) => export const getLdapIdentities = async () => await Container.get(AuthIdentityRepository).find({ where: { providerType: 'ldap' }, - relations: ['user'], + relations: { user: true }, }); export async function getGlobalOwner() { diff --git a/packages/cli/test/integration/shared/db/workflows.ts b/packages/cli/test/integration/shared/db/workflows.ts index 18a97a693..f81ac044c 100644 --- a/packages/cli/test/integration/shared/db/workflows.ts +++ b/packages/cli/test/integration/shared/db/workflows.ts @@ -2,11 +2,13 @@ import Container from 'typedi'; import type { DeepPartial } from '@n8n/typeorm'; import { v4 as uuid } from 'uuid'; -import type { User } from '@db/entities/User'; +import { User } from '@db/entities/User'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; -import type { SharedWorkflow } from '@db/entities/SharedWorkflow'; +import type { SharedWorkflow, WorkflowSharingRole } from '@db/entities/SharedWorkflow'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { Project } from '@/databases/entities/Project'; export async function createManyWorkflows( amount: number, @@ -48,28 +50,71 @@ export function newWorkflow(attributes: Partial = {}): WorkflowE * @param attributes workflow attributes * @param user user to assign the workflow to */ -export async function createWorkflow(attributes: Partial = {}, user?: User) { +export async function createWorkflow( + attributes: Partial = {}, + userOrProject?: User | Project, +) { const workflow = await Container.get(WorkflowRepository).save(newWorkflow(attributes)); - if (user) { - await Container.get(SharedWorkflowRepository).save({ - user, - workflow, - role: 'workflow:owner', - }); + if (userOrProject instanceof User) { + const user = userOrProject; + const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(user.id); + await Container.get(SharedWorkflowRepository).save( + Container.get(SharedWorkflowRepository).create({ + project, + workflow, + role: 'workflow:owner', + }), + ); } + + if (userOrProject instanceof Project) { + const project = userOrProject; + await Container.get(SharedWorkflowRepository).save( + Container.get(SharedWorkflowRepository).create({ + project, + workflow, + role: 'workflow:owner', + }), + ); + } + return workflow; } export async function shareWorkflowWithUsers(workflow: WorkflowEntity, users: User[]) { - const sharedWorkflows: Array> = users.map((user) => ({ - userId: user.id, - workflowId: workflow.id, - role: 'workflow:editor', - })); + const sharedWorkflows: Array> = await Promise.all( + users.map(async (user) => { + const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + user.id, + ); + return { + projectId: project.id, + workflowId: workflow.id, + role: 'workflow:editor', + }; + }), + ); return await Container.get(SharedWorkflowRepository).save(sharedWorkflows); } +export async function shareWorkflowWithProjects( + workflow: WorkflowEntity, + projectsWithRole: Array<{ project: Project; role?: WorkflowSharingRole }>, +) { + const newSharedWorkflow = await Promise.all( + projectsWithRole.map(async ({ project, role }) => { + return Container.get(SharedWorkflowRepository).create({ + workflowId: workflow.id, + role: role ?? 'workflow:editor', + projectId: project.id, + }); + }), + ); + + return await Container.get(SharedWorkflowRepository).save(newSharedWorkflow); +} + export async function getWorkflowSharing(workflow: WorkflowEntity) { return await Container.get(SharedWorkflowRepository).findBy({ workflowId: workflow.id, diff --git a/packages/cli/test/integration/shared/ldap.ts b/packages/cli/test/integration/shared/ldap.ts new file mode 100644 index 000000000..1223bd0f0 --- /dev/null +++ b/packages/cli/test/integration/shared/ldap.ts @@ -0,0 +1,33 @@ +import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/Ldap/constants'; +import type { LdapConfig } from '@/Ldap/types'; +import { SettingsRepository } from '@/databases/repositories/settings.repository'; +import { jsonParse } from 'n8n-workflow'; +import Container from 'typedi'; + +export const defaultLdapConfig = { + ...LDAP_DEFAULT_CONFIGURATION, + loginEnabled: true, + loginLabel: '', + ldapIdAttribute: 'uid', + firstNameAttribute: 'givenName', + lastNameAttribute: 'sn', + emailAttribute: 'mail', + loginIdAttribute: 'mail', + baseDn: 'baseDn', + bindingAdminDn: 'adminDn', + bindingAdminPassword: 'adminPassword', +}; + +export const createLdapConfig = async ( + attributes: Partial = {}, +): Promise => { + const { value: ldapConfig } = await Container.get(SettingsRepository).save({ + key: LDAP_FEATURE_NAME, + value: JSON.stringify({ + ...defaultLdapConfig, + ...attributes, + }), + loadOnStartup: true, + }); + return await jsonParse(ldapConfig); +}; diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts index c5b25f109..514b04a6b 100644 --- a/packages/cli/test/integration/shared/testDb.ts +++ b/packages/cli/test/integration/shared/testDb.ts @@ -51,12 +51,16 @@ const repositories = [ 'AuthProviderSyncHistory', 'Credentials', 'EventDestinations', + 'Execution', 'ExecutionData', 'ExecutionMetadata', - 'Execution', 'InstalledNodes', 'InstalledPackages', + 'Project', + 'ProjectRelation', 'Role', + 'Project', + 'ProjectRelation', 'Settings', 'SharedCredentials', 'SharedWorkflow', diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 8355d6f39..5efa857ff 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -7,6 +7,7 @@ import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; import type { User } from '@db/entities/User'; import type { BooleanLicenseFeature, ICredentialsDb, NumericLicenseFeature } from '@/Interfaces'; import type { LicenseMocker } from './license'; +import type { Project } from '@/databases/entities/Project'; type EndpointGroup = | 'me' @@ -32,7 +33,9 @@ type EndpointGroup = | 'workflowHistory' | 'binaryData' | 'invitations' - | 'debug'; + | 'debug' + | 'project' + | 'role'; export interface SetupProps { endpointGroups?: EndpointGroup[]; @@ -57,5 +60,5 @@ export type CredentialPayload = { export type SaveCredentialFunction = ( credentialPayload: CredentialPayload, - { user }: { user: User }, + options: { user: User } | { project: Project }, ) => Promise; diff --git a/packages/cli/test/integration/shared/utils/testServer.ts b/packages/cli/test/integration/shared/utils/testServer.ts index d6b9aa40e..49a6cbaa2 100644 --- a/packages/cli/test/integration/shared/utils/testServer.ts +++ b/packages/cli/test/integration/shared/utils/testServer.ts @@ -257,6 +257,16 @@ export const setupTestServer = ({ const { DebugController } = await import('@/controllers/debug.controller'); registerController(app, DebugController); break; + + case 'project': + const { ProjectController } = await import('@/controllers/project.controller'); + registerController(app, ProjectController); + break; + + case 'role': + const { RoleController } = await import('@/controllers/role.controller'); + registerController(app, RoleController); + break; } } } diff --git a/packages/cli/test/integration/user.repository.test.ts b/packages/cli/test/integration/user.repository.test.ts index 6929326b9..d333454aa 100644 --- a/packages/cli/test/integration/user.repository.test.ts +++ b/packages/cli/test/integration/user.repository.test.ts @@ -2,6 +2,8 @@ import Container from 'typedi'; import { UserRepository } from '@db/repositories/user.repository'; import { createAdmin, createMember, createOwner } from './shared/db/users'; import * as testDb from './shared/testDb'; +import { randomEmail } from './shared/random'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; describe('UserRepository', () => { let userRepository: UserRepository; @@ -38,4 +40,25 @@ describe('UserRepository', () => { }); }); }); + + describe('createUserWithProject()', () => { + test('should create personal project for a user', async () => { + const { user, project } = await userRepository.createUserWithProject({ + email: randomEmail(), + role: 'global:member', + }); + + const projectRelation = await Container.get(ProjectRelationRepository).findOneOrFail({ + where: { + userId: user.id, + project: { + type: 'personal', + }, + }, + relations: ['project'], + }); + + expect(projectRelation.project.id).toBe(project.id); + }); + }); }); diff --git a/packages/cli/test/integration/users.api.test.ts b/packages/cli/test/integration/users.api.test.ts index 5c9bdc260..b58f88795 100644 --- a/packages/cli/test/integration/users.api.test.ts +++ b/packages/cli/test/integration/users.api.test.ts @@ -8,15 +8,25 @@ import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials. import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { ExecutionService } from '@/executions/execution.service'; -import { getCredentialById, saveCredential } from './shared/db/credentials'; +import { + getCredentialById, + saveCredential, + shareCredentialWithUsers, +} from './shared/db/credentials'; import { createAdmin, createMember, createOwner, getUserById } from './shared/db/users'; -import { createWorkflow, getWorkflowById } from './shared/db/workflows'; +import { createWorkflow, getWorkflowById, shareWorkflowWithUsers } from './shared/db/workflows'; import { SUCCESS_RESPONSE_BODY } from './shared/constants'; import { validateUser } from './shared/utils/users'; -import { randomName } from './shared/random'; +import { randomCredentialPayload } from './shared/random'; import * as utils from './shared/utils/'; import * as testDb from './shared/testDb'; import { mockInstance } from '../shared/mocking'; +import { RESPONSE_ERROR_MESSAGES } from '@/constants'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { createTeamProject, getPersonalProject, linkUserToProject } from './shared/db/projects'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; +import { CacheService } from '@/services/cache/cache.service'; +import { v4 as uuid } from 'uuid'; mockInstance(ExecutionService); @@ -25,6 +35,12 @@ const testServer = utils.setupTestServer({ enabledFeatures: ['feat:advancedPermissions'], }); +let projectRepository: ProjectRepository; + +beforeAll(() => { + projectRepository = Container.get(ProjectRepository); +}); + describe('GET /users', () => { let owner: User; let member: User; @@ -229,110 +245,338 @@ describe('GET /users', () => { describe('DELETE /users/:id', () => { let owner: User; - let member: User; let ownerAgent: SuperAgentTest; beforeAll(async () => { await testDb.truncate(['User']); owner = await createOwner(); - member = await createMember(); ownerAgent = testServer.authAgentFor(owner); }); test('should delete user and their resources', async () => { - const savedWorkflow = await createWorkflow({ name: randomName() }, member); + // + // ARRANGE + // + // @TODO: Include active workflow and check whether webhook has been removed - const savedCredential = await saveCredential( - { name: randomName(), type: '', data: {} }, - { user: member, role: 'credential:owner' }, - ); + const member = await createMember(); + const memberPersonalProject = await getPersonalProject(member); - const response = await ownerAgent.delete(`/users/${member.id}`); + // stays untouched + const teamProject = await createTeamProject(); + // will be deleted + await linkUserToProject(member, teamProject, 'project:admin'); - expect(response.statusCode).toBe(200); - expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); + const [savedWorkflow, savedCredential, teamWorkflow, teamCredential] = await Promise.all([ + // personal resource -> deleted + createWorkflow({}, member), + saveCredential(randomCredentialPayload(), { + user: member, + role: 'credential:owner', + }), + // resources in a team project -> untouched + createWorkflow({}, teamProject), + saveCredential(randomCredentialPayload(), { + project: teamProject, + role: 'credential:owner', + }), + ]); + + // + // ACT + // + await ownerAgent.delete(`/users/${member.id}`).expect(200, SUCCESS_RESPONSE_BODY); + + // + // ASSERT + // + const userRepository = Container.get(UserRepository); + const projectRepository = Container.get(ProjectRepository); + const projectRelationRepository = Container.get(ProjectRelationRepository); + const sharedWorkflowRepository = Container.get(SharedWorkflowRepository); + const sharedCredentialsRepository = Container.get(SharedCredentialsRepository); + + await Promise.all([ + // user, their personal project and their relationship to the team project is gone + expect(userRepository.findOneBy({ id: member.id })).resolves.toBeNull(), + expect(projectRepository.findOneBy({ id: memberPersonalProject.id })).resolves.toBeNull(), + expect( + projectRelationRepository.findOneBy({ userId: member.id, projectId: teamProject.id }), + ).resolves.toBeNull(), + + // their personal workflows and and credentials are gone + expect( + sharedWorkflowRepository.findOneBy({ + workflowId: savedWorkflow.id, + projectId: memberPersonalProject.id, + }), + ).resolves.toBeNull(), + expect( + sharedCredentialsRepository.findOneBy({ + credentialsId: savedCredential.id, + projectId: memberPersonalProject.id, + }), + ).resolves.toBeNull(), + + // team workflows and credentials are untouched + expect( + sharedWorkflowRepository.findOneBy({ + workflowId: teamWorkflow.id, + projectId: teamProject.id, + role: 'workflow:owner', + }), + ).resolves.not.toBeNull(), + expect( + sharedCredentialsRepository.findOneBy({ + credentialsId: teamCredential.id, + projectId: teamProject.id, + role: 'credential:owner', + }), + ).resolves.not.toBeNull(), + ]); const user = await Container.get(UserRepository).findOneBy({ id: member.id }); - const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ - relations: ['user'], - where: { userId: member.id, role: 'workflow:owner' }, + where: { projectId: memberPersonalProject.id, role: 'workflow:owner' }, }); - const sharedCredential = await Container.get(SharedCredentialsRepository).findOne({ - relations: ['user'], - where: { userId: member.id, role: 'credential:owner' }, + where: { projectId: memberPersonalProject.id, role: 'credential:owner' }, }); - const workflow = await getWorkflowById(savedWorkflow.id); - const credential = await getCredentialById(savedCredential.id); - // @TODO: Include active workflow and check whether webhook has been removed - expect(user).toBeNull(); expect(sharedWorkflow).toBeNull(); expect(sharedCredential).toBeNull(); expect(workflow).toBeNull(); expect(credential).toBeNull(); - - // restore - - member = await createMember(); }); - test('should delete user and transfer their resources', async () => { - const [savedWorkflow, savedCredential] = await Promise.all([ - await createWorkflow({ name: randomName() }, member), - await saveCredential( - { name: randomName(), type: '', data: {} }, - { - user: member, + test('should delete user and team relations and transfer their personal resources', async () => { + // + // ARRANGE + // + const [member, transferee, otherMember] = await Promise.all([ + createMember(), + createMember(), + createMember(), + ]); + + // stays untouched + const teamProject = await createTeamProject(); + await Promise.all([ + // will be deleted + linkUserToProject(member, teamProject, 'project:admin'), + + // stays untouched + linkUserToProject(transferee, teamProject, 'project:editor'), + ]); + + const [ + ownedWorkflow, + ownedCredential, + teamWorkflow, + teamCredential, + sharedByOtherMemberWorkflow, + sharedByOtherMemberCredential, + sharedByTransfereeWorkflow, + sharedByTransfereeCredential, + ] = await Promise.all([ + // personal resource + // -> transferred to transferee's personal project + createWorkflow({}, member), + saveCredential(randomCredentialPayload(), { + user: member, + role: 'credential:owner', + }), + + // resources in a team project + // -> untouched + createWorkflow({}, teamProject), + saveCredential(randomCredentialPayload(), { + project: teamProject, + role: 'credential:owner', + }), + + // credential and workflow that are shared with the user to delete + // -> transferred to transferee's personal project + createWorkflow({}, otherMember), + saveCredential(randomCredentialPayload(), { + user: otherMember, + role: 'credential:owner', + }), + + // credential and workflow that are shared with the user to delete but owned by the transferee + // -> not transferred but deleted + createWorkflow({}, transferee), + saveCredential(randomCredentialPayload(), { + user: transferee, + role: 'credential:owner', + }), + ]); + + await Promise.all([ + shareWorkflowWithUsers(sharedByOtherMemberWorkflow, [member]), + shareCredentialWithUsers(sharedByOtherMemberCredential, [member]), + + shareWorkflowWithUsers(sharedByTransfereeWorkflow, [member]), + shareCredentialWithUsers(sharedByTransfereeCredential, [member]), + ]); + + const [memberPersonalProject, transfereePersonalProject] = await Promise.all([ + getPersonalProject(member), + getPersonalProject(transferee), + ]); + + const deleteSpy = jest.spyOn(Container.get(CacheService), 'deleteMany'); + + // + // ACT + // + await ownerAgent + .delete(`/users/${member.id}`) + .query({ transferId: transfereePersonalProject.id }) + .expect(200); + + // + // ASSERT + // + + expect(deleteSpy).toBeCalledWith( + expect.arrayContaining([ + `credential-can-use-secrets:${sharedByTransfereeCredential.id}`, + `credential-can-use-secrets:${ownedCredential.id}`, + ]), + ); + deleteSpy.mockClear(); + + const userRepository = Container.get(UserRepository); + const projectRepository = Container.get(ProjectRepository); + const projectRelationRepository = Container.get(ProjectRelationRepository); + const sharedWorkflowRepository = Container.get(SharedWorkflowRepository); + const sharedCredentialsRepository = Container.get(SharedCredentialsRepository); + + await Promise.all([ + // user, their personal project and their relationship to the team project is gone + expect(userRepository.findOneBy({ id: member.id })).resolves.toBeNull(), + expect(projectRepository.findOneBy({ id: memberPersonalProject.id })).resolves.toBeNull(), + expect( + projectRelationRepository.findOneBy({ + projectId: teamProject.id, + userId: member.id, + }), + ).resolves.toBeNull(), + + // their owned workflow and credential are transferred to the transferee + expect( + sharedWorkflowRepository.findOneBy({ + workflowId: ownedWorkflow.id, + projectId: transfereePersonalProject.id, + role: 'workflow:owner', + }), + ).resolves.not.toBeNull, + expect( + sharedCredentialsRepository.findOneBy({ + credentialsId: ownedCredential.id, + projectId: transfereePersonalProject.id, role: 'credential:owner', - }, + }), + ).resolves.not.toBeNull(), + + // the credential and workflow shared with them by another member is now shared with the transferee + expect( + sharedWorkflowRepository.findOneBy({ + workflowId: sharedByOtherMemberWorkflow.id, + projectId: transfereePersonalProject.id, + role: 'workflow:editor', + }), + ).resolves.not.toBeNull(), + expect( + sharedCredentialsRepository.findOneBy({ + credentialsId: sharedByOtherMemberCredential.id, + projectId: transfereePersonalProject.id, + role: 'credential:user', + }), ), + + // the transferee is still owner of the workflow and credential they shared with the user to delete + expect( + sharedWorkflowRepository.findOneBy({ + workflowId: sharedByTransfereeWorkflow.id, + projectId: transfereePersonalProject.id, + role: 'workflow:owner', + }), + ).resolves.not.toBeNull(), + expect( + sharedCredentialsRepository.findOneBy({ + credentialsId: sharedByTransfereeCredential.id, + projectId: transfereePersonalProject.id, + role: 'credential:owner', + }), + ).resolves.not.toBeNull(), + + // the transferee's relationship to the team project is unchanged + expect( + projectRepository.findOneBy({ + id: teamProject.id, + projectRelations: { + userId: transferee.id, + role: 'project:editor', + }, + }), + ).resolves.not.toBeNull(), + + // the sharing of the team workflow is unchanged + expect( + sharedWorkflowRepository.findOneBy({ + workflowId: teamWorkflow.id, + projectId: teamProject.id, + role: 'workflow:owner', + }), + ).resolves.not.toBeNull(), + + // the sharing of the team credential is unchanged + expect( + sharedCredentialsRepository.findOneBy({ + credentialsId: teamCredential.id, + projectId: teamProject.id, + role: 'credential:owner', + }), + ).resolves.not.toBeNull(), ]); - - const response = await ownerAgent.delete(`/users/${member.id}`).query({ - transferId: owner.id, - }); - - expect(response.statusCode).toBe(200); - - const [user, sharedWorkflow, sharedCredential] = await Promise.all([ - await Container.get(UserRepository).findOneBy({ id: member.id }), - await Container.get(SharedWorkflowRepository).findOneOrFail({ - relations: ['workflow'], - where: { userId: owner.id }, - }), - await Container.get(SharedCredentialsRepository).findOneOrFail({ - relations: ['credentials'], - where: { userId: owner.id }, - }), - ]); - - expect(user).toBeNull(); - expect(sharedWorkflow.workflow.id).toBe(savedWorkflow.id); - expect(sharedCredential.credentials.id).toBe(savedCredential.id); }); test('should fail to delete self', async () => { - const response = await ownerAgent.delete(`/users/${owner.id}`); - - expect(response.statusCode).toBe(400); + await ownerAgent.delete(`/users/${owner.id}`).expect(400); const user = await getUserById(owner.id); expect(user).toBeDefined(); }); - test('should fail to delete if user to delete is transferee', async () => { - const response = await ownerAgent.delete(`/users/${member.id}`).query({ - transferId: member.id, - }); + test('should fail to delete a user that does not exist', async () => { + await ownerAgent.delete(`/users/${uuid()}`).query({ transferId: '' }).expect(404); + }); - expect(response.statusCode).toBe(400); + test('should fail to transfer to a project that does not exist', async () => { + const member = await createMember(); + + await ownerAgent.delete(`/users/${member.id}`).query({ transferId: 'foobar' }).expect(404); + + const user = await Container.get(UserRepository).findOneBy({ id: member.id }); + + expect(user).toBeDefined(); + }); + + test('should fail to delete if user to delete is transferee', async () => { + const member = await createMember(); + const personalProject = await getPersonalProject(member); + + await ownerAgent + .delete(`/users/${member.id}`) + .query({ transferId: personalProject.id }) + .expect(400); const user = await Container.get(UserRepository).findOneBy({ id: member.id }); @@ -355,8 +599,6 @@ describe('PATCH /users/:id/role', () => { const { NO_ADMIN_ON_OWNER, NO_USER, NO_OWNER_ON_OWNER } = UsersController.ERROR_MESSAGES.CHANGE_ROLE; - const UNAUTHORIZED = 'Unauthorized'; - beforeAll(async () => { await testDb.truncate(['User']); @@ -400,66 +642,66 @@ describe('PATCH /users/:id/role', () => { describe('member', () => { test('should fail to demote owner to member', async () => { - const response = await memberAgent.patch(`/users/${owner.id}/role`).send({ - newRoleName: 'global:member', - }); - - expect(response.statusCode).toBe(403); - expect(response.body.message).toBe(UNAUTHORIZED); + await memberAgent + .patch(`/users/${owner.id}/role`) + .send({ + newRoleName: 'global:member', + }) + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); test('should fail to demote owner to admin', async () => { - const response = await memberAgent.patch(`/users/${owner.id}/role`).send({ - newRoleName: 'global:admin', - }); - - expect(response.statusCode).toBe(403); - expect(response.body.message).toBe(UNAUTHORIZED); + await memberAgent + .patch(`/users/${owner.id}/role`) + .send({ + newRoleName: 'global:admin', + }) + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); test('should fail to demote admin to member', async () => { - const response = await memberAgent.patch(`/users/${admin.id}/role`).send({ - newRoleName: 'global:member', - }); - - expect(response.statusCode).toBe(403); - expect(response.body.message).toBe(UNAUTHORIZED); + await memberAgent + .patch(`/users/${admin.id}/role`) + .send({ + newRoleName: 'global:member', + }) + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); test('should fail to promote other member to owner', async () => { - const response = await memberAgent.patch(`/users/${otherMember.id}/role`).send({ - newRoleName: 'global:owner', - }); - - expect(response.statusCode).toBe(403); - expect(response.body.message).toBe(UNAUTHORIZED); + await memberAgent + .patch(`/users/${otherMember.id}/role`) + .send({ + newRoleName: 'global:owner', + }) + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); test('should fail to promote other member to admin', async () => { - const response = await memberAgent.patch(`/users/${otherMember.id}/role`).send({ - newRoleName: 'global:admin', - }); - - expect(response.statusCode).toBe(403); - expect(response.body.message).toBe(UNAUTHORIZED); + await memberAgent + .patch(`/users/${otherMember.id}/role`) + .send({ + newRoleName: 'global:admin', + }) + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); test('should fail to promote self to admin', async () => { - const response = await memberAgent.patch(`/users/${member.id}/role`).send({ - newRoleName: 'global:admin', - }); - - expect(response.statusCode).toBe(403); - expect(response.body.message).toBe(UNAUTHORIZED); + await memberAgent + .patch(`/users/${member.id}/role`) + .send({ + newRoleName: 'global:admin', + }) + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); test('should fail to promote self to owner', async () => { - const response = await memberAgent.patch(`/users/${member.id}/role`).send({ - newRoleName: 'global:owner', - }); - - expect(response.statusCode).toBe(403); - expect(response.body.message).toBe(UNAUTHORIZED); + await memberAgent + .patch(`/users/${member.id}/role`) + .send({ + newRoleName: 'global:owner', + }) + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); }); @@ -625,4 +867,40 @@ describe('PATCH /users/:id/role', () => { adminAgent = testServer.authAgentFor(admin); }); }); + + test("should clear credential external secrets usability cache when changing a user's role", async () => { + const user = await createAdmin(); + + const [project1, project2] = await Promise.all([ + createTeamProject(undefined, user), + createTeamProject(), + ]); + + const [credential1, credential2, credential3] = await Promise.all([ + saveCredential(randomCredentialPayload(), { + user, + role: 'credential:owner', + }), + saveCredential(randomCredentialPayload(), { + project: project1, + role: 'credential:owner', + }), + saveCredential(randomCredentialPayload(), { + project: project2, + role: 'credential:owner', + }), + linkUserToProject(user, project2, 'project:editor'), + ]); + + const deleteSpy = jest.spyOn(Container.get(CacheService), 'deleteMany'); + const response = await ownerAgent.patch(`/users/${user.id}/role`).send({ + newRoleName: 'global:member', + }); + + expect(deleteSpy).toBeCalledTimes(2); + deleteSpy.mockClear(); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toStrictEqual({ success: true }); + }); }); diff --git a/packages/cli/test/integration/workflowHistoryManager.test.ts b/packages/cli/test/integration/workflowHistoryManager.test.ts index 0b20f77c4..85a114abe 100644 --- a/packages/cli/test/integration/workflowHistoryManager.test.ts +++ b/packages/cli/test/integration/workflowHistoryManager.test.ts @@ -34,6 +34,10 @@ describe('Workflow History Manager', () => { license.getWorkflowHistoryPruneLimit.mockReturnValue(-1); }); + afterAll(async () => { + await testDb.terminate(); + }); + test('should prune on interval', () => { const pruneSpy = jest.spyOn(manager, 'prune'); const currentCount = pruneSpy.mock.calls.length; diff --git a/packages/cli/test/integration/workflows/workflow.service.ee.test.ts b/packages/cli/test/integration/workflows/workflow.service.ee.test.ts index 4504775f2..55287c5f2 100644 --- a/packages/cli/test/integration/workflows/workflow.service.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflow.service.ee.test.ts @@ -29,6 +29,7 @@ describe('EnterpriseWorkflowService', () => { Container.get(WorkflowRepository), Container.get(CredentialsRepository), mock(), + mock(), ); }); diff --git a/packages/cli/test/integration/workflows/workflow.service.test.ts b/packages/cli/test/integration/workflows/workflow.service.test.ts index 7927c094d..8c9e35983 100644 --- a/packages/cli/test/integration/workflows/workflow.service.test.ts +++ b/packages/cli/test/integration/workflows/workflow.service.test.ts @@ -23,7 +23,6 @@ beforeAll(async () => { await testDb.init(); workflowService = new WorkflowService( - mock(), mock(), Container.get(SharedWorkflowRepository), Container.get(WorkflowRepository), @@ -35,6 +34,10 @@ beforeAll(async () => { orchestrationService, mock(), activeWorkflowManager, + mock(), + mock(), + mock(), + mock(), ); }); @@ -43,10 +46,6 @@ afterEach(async () => { jest.restoreAllMocks(); }); -afterAll(async () => { - await testDb.terminate(); -}); - describe('update()', () => { test('should remove and re-add to active workflows on `active: true` payload', async () => { const owner = await createOwner(); diff --git a/packages/cli/test/integration/workflows/workflowSharing.service.test.ts b/packages/cli/test/integration/workflows/workflowSharing.service.test.ts new file mode 100644 index 000000000..1907770fb --- /dev/null +++ b/packages/cli/test/integration/workflows/workflowSharing.service.test.ts @@ -0,0 +1,117 @@ +import Container from 'typedi'; + +import type { User } from '@db/entities/User'; +import { WorkflowSharingService } from '@/workflows/workflowSharing.service'; + +import * as testDb from '../shared/testDb'; +import { createUser } from '../shared/db/users'; +import { createWorkflow, shareWorkflowWithUsers } from '../shared/db/workflows'; +import { ProjectService } from '@/services/project.service'; +import { LicenseMocker } from '../shared/license'; +import { License } from '@/License'; + +let owner: User; +let member: User; +let anotherMember: User; +let workflowSharingService: WorkflowSharingService; +let projectService: ProjectService; + +beforeAll(async () => { + await testDb.init(); + owner = await createUser({ role: 'global:owner' }); + member = await createUser({ role: 'global:member' }); + anotherMember = await createUser({ role: 'global:member' }); + let license: LicenseMocker; + license = new LicenseMocker(); + license.mock(Container.get(License)); + license.enable('feat:sharing'); + license.setQuota('quota:maxTeamProjects', -1); + workflowSharingService = Container.get(WorkflowSharingService); + projectService = Container.get(ProjectService); +}); + +beforeEach(async () => { + await testDb.truncate(['Workflow', 'SharedWorkflow', 'WorkflowHistory']); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +describe('WorkflowSharingService', () => { + describe('getSharedWorkflowIds', () => { + it('should show all workflows to owners', async () => { + owner.role = 'global:owner'; + const workflow1 = await createWorkflow({}, member); + const workflow2 = await createWorkflow({}, anotherMember); + const sharedWorkflowIds = await workflowSharingService.getSharedWorkflowIds(owner, { + scopes: ['workflow:read'], + }); + expect(sharedWorkflowIds).toHaveLength(2); + expect(sharedWorkflowIds).toContain(workflow1.id); + expect(sharedWorkflowIds).toContain(workflow2.id); + }); + + it('should show shared workflows to users', async () => { + member.role = 'global:member'; + const workflow1 = await createWorkflow({}, anotherMember); + const workflow2 = await createWorkflow({}, anotherMember); + const workflow3 = await createWorkflow({}, anotherMember); + await shareWorkflowWithUsers(workflow1, [member]); + await shareWorkflowWithUsers(workflow3, [member]); + const sharedWorkflowIds = await workflowSharingService.getSharedWorkflowIds(member, { + scopes: ['workflow:read'], + }); + expect(sharedWorkflowIds).toHaveLength(2); + expect(sharedWorkflowIds).toContain(workflow1.id); + expect(sharedWorkflowIds).toContain(workflow3.id); + expect(sharedWorkflowIds).not.toContain(workflow2.id); + }); + + it('should show workflows that the user has access to through a team project they are part of', async () => { + // + // ARRANGE + // + const project = await projectService.createTeamProject('Team Project', member); + await projectService.addUser(project.id, anotherMember.id, 'project:admin'); + const workflow = await createWorkflow(undefined, project); + + // + // ACT + // + const sharedWorkflowIds = await workflowSharingService.getSharedWorkflowIds(anotherMember, { + scopes: ['workflow:read'], + }); + + // + // ASSERT + // + expect(sharedWorkflowIds).toContain(workflow.id); + }); + + it('should show workflows that the user has update access to', async () => { + // + // ARRANGE + // + const project1 = await projectService.createTeamProject('Team Project 1', member); + const workflow1 = await createWorkflow(undefined, project1); + const project2 = await projectService.createTeamProject('Team Project 2', member); + const workflow2 = await createWorkflow(undefined, project2); + await projectService.addUser(project1.id, anotherMember.id, 'project:admin'); + await projectService.addUser(project2.id, anotherMember.id, 'project:viewer'); + + // + // ACT + // + const sharedWorkflowIds = await workflowSharingService.getSharedWorkflowIds(anotherMember, { + scopes: ['workflow:update'], + }); + + // + // ASSERT + // + expect(sharedWorkflowIds).toContain(workflow1.id); + expect(sharedWorkflowIds).not.toContain(workflow2.id); + }); + }); +}); diff --git a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts index 3963fedb3..25d111489 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts @@ -6,7 +6,6 @@ import type { INode } from 'n8n-workflow'; import type { User } from '@db/entities/User'; import { WorkflowHistoryRepository } from '@db/repositories/workflowHistory.repository'; import { ActiveWorkflowManager } from '@/ActiveWorkflowManager'; -import { WorkflowSharingService } from '@/workflows/workflowSharing.service'; import { mockInstance } from '../../shared/mocking'; import * as utils from '../shared/utils/'; @@ -15,20 +14,29 @@ import type { SaveCredentialFunction } from '../shared/types'; import { makeWorkflow } from '../shared/utils/'; import { randomCredentialPayload } from '../shared/random'; import { affixRoleToSaveCredential, shareCredentialWithUsers } from '../shared/db/credentials'; -import { createUser } from '../shared/db/users'; +import { createUser, createUserShell } from '../shared/db/users'; import { createWorkflow, getWorkflowSharing, shareWorkflowWithUsers } from '../shared/db/workflows'; import { License } from '@/License'; import { UserManagementMailer } from '@/UserManagement/email'; import config from '@/config'; +import type { WorkflowWithSharingsMetaDataAndCredentials } from '@/workflows/workflows.types'; +import type { Project } from '@/databases/entities/Project'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { createTag } from '../shared/db/tags'; let owner: User; +let ownerPersonalProject: Project; let member: User; +let memberPersonalProject: Project; let anotherMember: User; +let anotherMemberPersonalProject: Project; let authOwnerAgent: SuperAgentTest; let authMemberAgent: SuperAgentTest; let authAnotherMemberAgent: SuperAgentTest; let saveCredential: SaveCredentialFunction; +let projectRepository: ProjectRepository; + const activeWorkflowManager = mockInstance(ActiveWorkflowManager); const sharingSpy = jest.spyOn(License.prototype, 'isSharingEnabled').mockReturnValue(true); @@ -40,9 +48,16 @@ const license = testServer.license; const mailer = mockInstance(UserManagementMailer); beforeAll(async () => { + projectRepository = Container.get(ProjectRepository); + owner = await createUser({ role: 'global:owner' }); + ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); member = await createUser({ role: 'global:member' }); + memberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(member.id); anotherMember = await createUser({ role: 'global:member' }); + anotherMemberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + anotherMember.id, + ); authOwnerAgent = testServer.authAgentFor(owner); authMemberAgent = testServer.authAgentFor(member); @@ -57,7 +72,7 @@ beforeEach(async () => { activeWorkflowManager.add.mockReset(); activeWorkflowManager.remove.mockReset(); - await testDb.truncate(['Workflow', 'SharedWorkflow', 'WorkflowHistory']); + await testDb.truncate(['Workflow', 'SharedWorkflow', 'WorkflowHistory', 'Tag']); }); afterEach(() => { @@ -77,14 +92,14 @@ describe('router should switch based on flag', () => { await authOwnerAgent .put(`/workflows/${savedWorkflowId}/share`) - .send({ shareWithIds: [member.id] }) + .send({ shareWithIds: [memberPersonalProject.id] }) .expect(404); }); test('when sharing is enabled', async () => { await authOwnerAgent .put(`/workflows/${savedWorkflowId}/share`) - .send({ shareWithIds: [member.id] }) + .send({ shareWithIds: [memberPersonalProject.id] }) .expect(200); }); }); @@ -95,13 +110,20 @@ describe('PUT /workflows/:id', () => { const response = await authOwnerAgent .put(`/workflows/${workflow.id}/share`) - .send({ shareWithIds: [member.id] }); + .send({ shareWithIds: [memberPersonalProject.id] }); expect(response.statusCode).toBe(200); const sharedWorkflows = await getWorkflowSharing(workflow); expect(sharedWorkflows).toHaveLength(2); expect(mailer.notifyWorkflowShared).toHaveBeenCalledTimes(1); + expect(mailer.notifyWorkflowShared).toHaveBeenCalledWith( + expect.objectContaining({ + newShareeIds: [member.id], + sharer: expect.objectContaining({ id: owner.id }), + workflow: expect.objectContaining({ id: workflow.id }), + }), + ); }); test('PUT /workflows/:id/share should succeed when sharing with invalid user-id', async () => { @@ -117,12 +139,30 @@ describe('PUT /workflows/:id', () => { expect(sharedWorkflows).toHaveLength(1); }); + test('PUT /workflows/:id/share should allow sharing with pending users', async () => { + const workflow = await createWorkflow({}, owner); + const memberShell = await createUserShell('global:member'); + const memberShellPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + memberShell.id, + ); + + const response = await authOwnerAgent + .put(`/workflows/${workflow.id}/share`) + .send({ shareWithIds: [memberShellPersonalProject.id] }); + + expect(response.statusCode).toBe(200); + + const sharedWorkflows = await getWorkflowSharing(workflow); + expect(sharedWorkflows).toHaveLength(2); + expect(mailer.notifyWorkflowShared).toHaveBeenCalledTimes(1); + }); + test('PUT /workflows/:id/share should allow sharing with multiple users', async () => { const workflow = await createWorkflow({}, owner); const response = await authOwnerAgent .put(`/workflows/${workflow.id}/share`) - .send({ shareWithIds: [member.id, anotherMember.id] }); + .send({ shareWithIds: [memberPersonalProject.id, anotherMemberPersonalProject.id] }); expect(response.statusCode).toBe(200); @@ -136,7 +176,7 @@ describe('PUT /workflows/:id', () => { const response = await authOwnerAgent .put(`/workflows/${workflow.id}/share`) - .send({ shareWithIds: [member.id, anotherMember.id] }); + .send({ shareWithIds: [memberPersonalProject.id, anotherMemberPersonalProject.id] }); expect(response.statusCode).toBe(200); @@ -145,7 +185,7 @@ describe('PUT /workflows/:id', () => { const secondResponse = await authOwnerAgent .put(`/workflows/${workflow.id}/share`) - .send({ shareWithIds: [member.id] }); + .send({ shareWithIds: [memberPersonalProject.id] }); expect(secondResponse.statusCode).toBe(200); const secondSharedWorkflows = await getWorkflowSharing(workflow); @@ -158,7 +198,7 @@ describe('PUT /workflows/:id', () => { const response = await authMemberAgent .put(`/workflows/${workflow.id}/share`) - .send({ shareWithIds: [anotherMember.id] }); + .send({ shareWithIds: [anotherMemberPersonalProject.id] }); expect(response.statusCode).toBe(200); @@ -172,7 +212,7 @@ describe('PUT /workflows/:id', () => { const response = await authOwnerAgent .put(`/workflows/${workflow.id}/share`) - .send({ shareWithIds: [anotherMember.id] }); + .send({ shareWithIds: [anotherMemberPersonalProject.id] }); expect(response.statusCode).toBe(200); @@ -188,7 +228,7 @@ describe('PUT /workflows/:id', () => { const response = await authAnotherMemberAgent .put(`/workflows/${workflow.id}/share`) - .send({ shareWithIds: [anotherMember.id, owner.id] }); + .send({ shareWithIds: [anotherMemberPersonalProject.id, ownerPersonalProject.id] }); expect(response.statusCode).toBe(403); @@ -202,7 +242,7 @@ describe('PUT /workflows/:id', () => { const response = await authAnotherMemberAgent .put(`/workflows/${workflow.id}/share`) - .send({ shareWithIds: [anotherMember.id] }); + .send({ shareWithIds: [anotherMemberPersonalProject.id] }); expect(response.statusCode).toBe(403); @@ -215,10 +255,13 @@ describe('PUT /workflows/:id', () => { const workflow = await createWorkflow({}, member); const tempUser = await createUser({ role: 'global:member' }); + const tempUserPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + tempUser.id, + ); const response = await authAnotherMemberAgent .put(`/workflows/${workflow.id}/share`) - .send({ shareWithIds: [tempUser.id] }); + .send({ shareWithIds: [tempUserPersonalProject.id] }); expect(response.statusCode).toBe(403); @@ -234,7 +277,7 @@ describe('PUT /workflows/:id', () => { const response = await authOwnerAgent .put(`/workflows/${workflow.id}/share`) - .send({ shareWithIds: [member.id] }); + .send({ shareWithIds: [memberPersonalProject.id] }); expect(response.statusCode).toBe(200); @@ -275,39 +318,49 @@ describe('GET /workflows/:id', () => { test('GET should return a workflow with owner', async () => { const workflow = await createWorkflow({}, owner); - const response = await authOwnerAgent.get(`/workflows/${workflow.id}`); + const response = await authOwnerAgent.get(`/workflows/${workflow.id}`).expect(200); + const responseWorkflow: WorkflowWithSharingsMetaDataAndCredentials = response.body.data; - expect(response.statusCode).toBe(200); - expect(response.body.data.ownedBy).toMatchObject({ - id: owner.id, - email: owner.email, - firstName: owner.firstName, - lastName: owner.lastName, + expect(responseWorkflow.homeProject).toMatchObject({ + id: ownerPersonalProject.id, + name: owner.createPersonalProjectName(), + type: 'personal', }); - expect(response.body.data.sharedWith).toHaveLength(0); + expect(responseWorkflow.sharedWithProjects).toHaveLength(0); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((responseWorkflow as any).shared).toBeUndefined(); + }); + + test('should return tags', async () => { + const tag = await createTag({ name: 'A' }); + const workflow = await createWorkflow({ tags: [tag] }, owner); + + const response = await authOwnerAgent.get(`/workflows/${workflow.id}`).expect(200); + + expect(response.body.data).toMatchObject({ + tags: [expect.objectContaining({ id: tag.id, name: tag.name })], + }); }); test('GET should return shared workflow with user data', async () => { const workflow = await createWorkflow({}, owner); await shareWorkflowWithUsers(workflow, [member]); - const response = await authOwnerAgent.get(`/workflows/${workflow.id}`); + const response = await authOwnerAgent.get(`/workflows/${workflow.id}`).expect(200); + const responseWorkflow: WorkflowWithSharingsMetaDataAndCredentials = response.body.data; - expect(response.statusCode).toBe(200); - expect(response.body.data.ownedBy).toMatchObject({ - id: owner.id, - email: owner.email, - firstName: owner.firstName, - lastName: owner.lastName, + expect(responseWorkflow.homeProject).toMatchObject({ + id: ownerPersonalProject.id, + name: owner.createPersonalProjectName(), + type: 'personal', }); - expect(response.body.data.sharedWith).toHaveLength(1); - expect(response.body.data.sharedWith[0]).toMatchObject({ - id: member.id, - email: member.email, - firstName: member.firstName, - lastName: member.lastName, + expect(responseWorkflow.sharedWithProjects).toHaveLength(1); + expect(responseWorkflow.sharedWithProjects[0]).toMatchObject({ + id: memberPersonalProject.id, + name: member.createPersonalProjectName(), + type: 'personal', }); }); @@ -315,17 +368,16 @@ describe('GET /workflows/:id', () => { const workflow = await createWorkflow({}, owner); await shareWorkflowWithUsers(workflow, [member, anotherMember]); - const response = await authOwnerAgent.get(`/workflows/${workflow.id}`); + const response = await authOwnerAgent.get(`/workflows/${workflow.id}`).expect(200); + const responseWorkflow: WorkflowWithSharingsMetaDataAndCredentials = response.body.data; - expect(response.statusCode).toBe(200); - expect(response.body.data.ownedBy).toMatchObject({ - id: owner.id, - email: owner.email, - firstName: owner.firstName, - lastName: owner.lastName, + expect(responseWorkflow.homeProject).toMatchObject({ + id: ownerPersonalProject.id, + name: owner.createPersonalProjectName(), + type: 'personal', }); - expect(response.body.data.sharedWith).toHaveLength(2); + expect(responseWorkflow.sharedWithProjects).toHaveLength(2); }); test('GET should return workflow with credentials owned by user', async () => { @@ -337,10 +389,11 @@ describe('GET /workflows/:id', () => { }); const workflow = await createWorkflow(workflowPayload, owner); - const response = await authOwnerAgent.get(`/workflows/${workflow.id}`); + const response = await authOwnerAgent.get(`/workflows/${workflow.id}`).expect(200); + const responseWorkflow: WorkflowWithSharingsMetaDataAndCredentials = response.body.data; expect(response.statusCode).toBe(200); - expect(response.body.data.usedCredentials).toMatchObject([ + expect(responseWorkflow.usedCredentials).toMatchObject([ { id: savedCredential.id, name: savedCredential.name, @@ -348,7 +401,7 @@ describe('GET /workflows/:id', () => { }, ]); - expect(response.body.data.sharedWith).toHaveLength(0); + expect(responseWorkflow.sharedWithProjects).toHaveLength(0); }); test('GET should return workflow with credentials saying owner does not have access when not shared', async () => { @@ -360,10 +413,10 @@ describe('GET /workflows/:id', () => { }); const workflow = await createWorkflow(workflowPayload, owner); - const response = await authOwnerAgent.get(`/workflows/${workflow.id}`); + const response = await authOwnerAgent.get(`/workflows/${workflow.id}`).expect(200); + const responseWorkflow: WorkflowWithSharingsMetaDataAndCredentials = response.body.data; - expect(response.statusCode).toBe(200); - expect(response.body.data.usedCredentials).toMatchObject([ + expect(responseWorkflow.usedCredentials).toMatchObject([ { id: savedCredential.id, name: savedCredential.name, @@ -371,7 +424,7 @@ describe('GET /workflows/:id', () => { }, ]); - expect(response.body.data.sharedWith).toHaveLength(0); + expect(responseWorkflow.sharedWithProjects).toHaveLength(0); }); test('GET should return workflow with credentials for all users with or without access', async () => { @@ -384,27 +437,31 @@ describe('GET /workflows/:id', () => { const workflow = await createWorkflow(workflowPayload, member); await shareWorkflowWithUsers(workflow, [anotherMember]); - const responseMember1 = await authMemberAgent.get(`/workflows/${workflow.id}`); - expect(responseMember1.statusCode).toBe(200); - expect(responseMember1.body.data.usedCredentials).toMatchObject([ + const responseMember1 = await authMemberAgent.get(`/workflows/${workflow.id}`).expect(200); + const member1Workflow: WorkflowWithSharingsMetaDataAndCredentials = responseMember1.body.data; + + expect(member1Workflow.usedCredentials).toMatchObject([ { id: savedCredential.id, name: savedCredential.name, currentUserHasAccess: true, // one user has access }, ]); - expect(responseMember1.body.data.sharedWith).toHaveLength(1); + expect(member1Workflow.sharedWithProjects).toHaveLength(1); - const responseMember2 = await authAnotherMemberAgent.get(`/workflows/${workflow.id}`); - expect(responseMember2.statusCode).toBe(200); - expect(responseMember2.body.data.usedCredentials).toMatchObject([ + const responseMember2 = await authAnotherMemberAgent + .get(`/workflows/${workflow.id}`) + .expect(200); + const member2Workflow: WorkflowWithSharingsMetaDataAndCredentials = responseMember2.body.data; + + expect(member2Workflow.usedCredentials).toMatchObject([ { id: savedCredential.id, name: savedCredential.name, currentUserHasAccess: false, // the other one doesn't }, ]); - expect(responseMember2.body.data.sharedWith).toHaveLength(1); + expect(member2Workflow.sharedWithProjects).toHaveLength(1); }); test('GET should return workflow with credentials for all users with access', async () => { @@ -419,27 +476,32 @@ describe('GET /workflows/:id', () => { const workflow = await createWorkflow(workflowPayload, member); await shareWorkflowWithUsers(workflow, [anotherMember]); - const responseMember1 = await authMemberAgent.get(`/workflows/${workflow.id}`); - expect(responseMember1.statusCode).toBe(200); - expect(responseMember1.body.data.usedCredentials).toMatchObject([ - { - id: savedCredential.id, - name: savedCredential.name, - currentUserHasAccess: true, - }, - ]); - expect(responseMember1.body.data.sharedWith).toHaveLength(1); + const responseMember1 = await authMemberAgent.get(`/workflows/${workflow.id}`).expect(200); + const member1Workflow: WorkflowWithSharingsMetaDataAndCredentials = responseMember1.body.data; - const responseMember2 = await authAnotherMemberAgent.get(`/workflows/${workflow.id}`); - expect(responseMember2.statusCode).toBe(200); - expect(responseMember2.body.data.usedCredentials).toMatchObject([ + expect(member1Workflow.usedCredentials).toMatchObject([ { id: savedCredential.id, name: savedCredential.name, currentUserHasAccess: true, }, ]); - expect(responseMember2.body.data.sharedWith).toHaveLength(1); + expect(member1Workflow.sharedWithProjects).toHaveLength(1); + + const responseMember2 = await authAnotherMemberAgent + .get(`/workflows/${workflow.id}`) + .expect(200); + const member2Workflow: WorkflowWithSharingsMetaDataAndCredentials = responseMember2.body.data; + + expect(responseMember2.statusCode).toBe(200); + expect(member2Workflow.usedCredentials).toMatchObject([ + { + id: savedCredential.id, + name: savedCredential.name, + currentUserHasAccess: true, + }, + ]); + expect(member2Workflow.sharedWithProjects).toHaveLength(1); }); }); @@ -739,7 +801,7 @@ describe('PATCH /workflows/:id - validate credential permissions to user', () => }, ], }); - expect(response.statusCode).toBe(400); + expect(response.statusCode).toBe(403); }); it('Should succeed but prevent modifying node attributes other than position, name and disabled', async () => { @@ -814,7 +876,10 @@ describe('PATCH /workflows/:id - validate credential permissions to user', () => const createResponse = await authMemberAgent.post('/workflows').send(workflow); const { id, versionId } = createResponse.body.data; - await authMemberAgent.put(`/workflows/${id}/share`).send({ shareWithIds: [anotherMember.id] }); + await authMemberAgent + .put(`/workflows/${id}/share`) + .send({ shareWithIds: [anotherMemberPersonalProject.id] }) + .expect(200); const response = await authAnotherMemberAgent.patch(`/workflows/${id}`).send({ versionId, @@ -832,7 +897,9 @@ describe('PATCH /workflows/:id - validate interim updates', () => { const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); const { id, versionId: ownerVersionId } = createResponse.body.data; - await authOwnerAgent.put(`/workflows/${id}/share`).send({ shareWithIds: [member.id] }); + await authOwnerAgent + .put(`/workflows/${id}/share`) + .send({ shareWithIds: [memberPersonalProject.id] }); // member accesses and updates workflow name @@ -865,7 +932,9 @@ describe('PATCH /workflows/:id - validate interim updates', () => { const { versionId: ownerSecondVersionId } = updateResponse.body.data; - await authOwnerAgent.put(`/workflows/${id}/share`).send({ shareWithIds: [member.id] }); + await authOwnerAgent + .put(`/workflows/${id}/share`) + .send({ shareWithIds: [memberPersonalProject.id] }); // member accesses workflow @@ -893,7 +962,9 @@ describe('PATCH /workflows/:id - validate interim updates', () => { const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); const { id, versionId: ownerVersionId } = createResponse.body.data; - await authOwnerAgent.put(`/workflows/${id}/share`).send({ shareWithIds: [member.id] }); + await authOwnerAgent + .put(`/workflows/${id}/share`) + .send({ shareWithIds: [memberPersonalProject.id] }); // member accesses and activates workflow @@ -923,7 +994,9 @@ describe('PATCH /workflows/:id - validate interim updates', () => { .send({ name: 'Update by owner', versionId: ownerFirstVersionId }); const { versionId: ownerSecondVersionId } = updateResponse.body.data; - await authOwnerAgent.put(`/workflows/${id}/share`).send({ shareWithIds: [member.id] }); + await authOwnerAgent + .put(`/workflows/${id}/share`) + .send({ shareWithIds: [memberPersonalProject.id] }); // member accesses workflow @@ -951,7 +1024,9 @@ describe('PATCH /workflows/:id - validate interim updates', () => { const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); const { id, versionId: ownerVersionId } = createResponse.body.data; - await authOwnerAgent.put(`/workflows/${id}/share`).send({ shareWithIds: [member.id] }); + await authOwnerAgent + .put(`/workflows/${id}/share`) + .send({ shareWithIds: [memberPersonalProject.id] }); // member accesses workflow @@ -979,11 +1054,13 @@ describe('PATCH /workflows/:id - validate interim updates', () => { const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); const { id, versionId: ownerVersionId } = createResponse.body.data; - await authOwnerAgent.put(`/workflows/${id}/share`).send({ shareWithIds: [member.id] }); + await authOwnerAgent + .put(`/workflows/${id}/share`) + .send({ shareWithIds: [memberPersonalProject.id] }); // member accesses workflow - const memberGetResponse = await authMemberAgent.get(`/workflows/${id}`); + const memberGetResponse = await authMemberAgent.get(`/workflows/${id}`).expect(200); const { versionId: memberVersionId } = memberGetResponse.body.data; // owner updates workflow settings @@ -1003,33 +1080,6 @@ describe('PATCH /workflows/:id - validate interim updates', () => { }); }); -describe('getSharedWorkflowIds', () => { - it('should show all workflows to owners', async () => { - owner.role = 'global:owner'; - const workflow1 = await createWorkflow({}, member); - const workflow2 = await createWorkflow({}, anotherMember); - const sharedWorkflowIds = - await Container.get(WorkflowSharingService).getSharedWorkflowIds(owner); - expect(sharedWorkflowIds).toHaveLength(2); - expect(sharedWorkflowIds).toContain(workflow1.id); - expect(sharedWorkflowIds).toContain(workflow2.id); - }); - - it('should show shared workflows to users', async () => { - member.role = 'global:member'; - const workflow1 = await createWorkflow({}, anotherMember); - const workflow2 = await createWorkflow({}, anotherMember); - const workflow3 = await createWorkflow({}, anotherMember); - await shareWorkflowWithUsers(workflow1, [member]); - await shareWorkflowWithUsers(workflow3, [member]); - const sharedWorkflowIds = - await Container.get(WorkflowSharingService).getSharedWorkflowIds(member); - expect(sharedWorkflowIds).toHaveLength(2); - expect(sharedWorkflowIds).toContain(workflow1.id); - expect(sharedWorkflowIds).toContain(workflow3.id); - }); -}); - describe('PATCH /workflows/:id - workflow history', () => { test('Should create workflow history version when licensed', async () => { license.enable('feat:workflowHistory'); diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index fa1e3cba7..0c1a22ae8 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -17,13 +17,22 @@ import * as testDb from '../shared/testDb'; import { makeWorkflow, MOCK_PINDATA } from '../shared/utils/'; import { randomCredentialPayload } from '../shared/random'; import { saveCredential } from '../shared/db/credentials'; -import { createOwner } from '../shared/db/users'; -import { createWorkflow } from '../shared/db/workflows'; +import { createManyUsers, createMember, createOwner } from '../shared/db/users'; +import { createWorkflow, shareWorkflowWithProjects } from '../shared/db/workflows'; import { createTag } from '../shared/db/tags'; import { License } from '@/License'; +import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { ProjectService } from '@/services/project.service'; +import { createTeamProject, linkUserToProject } from '../shared/db/projects'; +import type { Scope } from '@n8n/permissions'; let owner: User; +let member: User; +let anotherMember: User; + let authOwnerAgent: SuperAgentTest; +let authMemberAgent: SuperAgentTest; jest.spyOn(License.prototype, 'isSharingEnabled').mockReturnValue(false); @@ -34,9 +43,15 @@ const { objectContaining, arrayContaining, any } = expect; const activeWorkflowManagerLike = mockInstance(ActiveWorkflowManager); +let projectRepository: ProjectRepository; + beforeAll(async () => { + projectRepository = Container.get(ProjectRepository); owner = await createOwner(); authOwnerAgent = testServer.authAgentFor(owner); + member = await createMember(); + authMemberAgent = testServer.authAgentFor(member); + anotherMember = await createMember(); }); beforeEach(async () => { @@ -62,6 +77,52 @@ describe('POST /workflows', () => { expect(pinData).toBeNull(); }); + test('should return scopes on created workflow', async () => { + const payload = { + name: 'testing', + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + ], + connections: {}, + staticData: null, + settings: { + saveExecutionProgress: true, + saveManualExecutions: true, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + active: false, + }; + + const response = await authMemberAgent.post('/workflows').send(payload); + + expect(response.statusCode).toBe(200); + + const { + data: { id, scopes }, + } = response.body; + + expect(id).toBeDefined(); + expect(scopes).toEqual( + [ + 'workflow:delete', + 'workflow:execute', + 'workflow:read', + 'workflow:share', + 'workflow:update', + ].sort(), + ); + }); + test('should create workflow history version when licensed', async () => { license.enable('feat:workflowHistory'); const payload = { @@ -151,6 +212,151 @@ describe('POST /workflows', () => { await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), ).toBe(0); }); + + test('create workflow in personal project by default', async () => { + // + // ARRANGE + // + const tag = await createTag({ name: 'A' }); + const workflow = makeWorkflow(); + const personalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); + + // + // ACT + // + const response = await authOwnerAgent + .post('/workflows') + .send({ ...workflow, tags: [tag.id] }) + .expect(200); + + // + // ASSERT + // + await Container.get(SharedWorkflowRepository).findOneOrFail({ + where: { + projectId: personalProject.id, + workflowId: response.body.data.id, + }, + }); + expect(response.body.data).toMatchObject({ + active: false, + id: expect.any(String), + name: workflow.name, + sharedWithProjects: [], + usedCredentials: [], + homeProject: { + id: personalProject.id, + name: personalProject.name, + type: personalProject.type, + }, + tags: [{ id: tag.id, name: tag.name }], + }); + expect(response.body.data.shared).toBeUndefined(); + }); + + test('creates workflow in a specific project if the projectId is passed', async () => { + // + // ARRANGE + // + const tag = await createTag({ name: 'A' }); + const workflow = makeWorkflow(); + const project = await projectRepository.save( + projectRepository.create({ + name: 'Team Project', + type: 'team', + }), + ); + await Container.get(ProjectService).addUser(project.id, owner.id, 'project:admin'); + + // + // ACT + // + const response = await authOwnerAgent + .post('/workflows') + .send({ ...workflow, projectId: project.id, tags: [tag.id] }) + .expect(200); + + // + // ASSERT + // + await Container.get(SharedWorkflowRepository).findOneOrFail({ + where: { + projectId: project.id, + workflowId: response.body.data.id, + }, + }); + expect(response.body.data).toMatchObject({ + active: false, + id: expect.any(String), + name: workflow.name, + sharedWithProjects: [], + usedCredentials: [], + homeProject: { + id: project.id, + name: project.name, + type: project.type, + }, + tags: [{ id: tag.id, name: tag.name }], + }); + expect(response.body.data.shared).toBeUndefined(); + }); + + test('does not create the workflow in a specific project if the user is not part of the project', async () => { + // + // ARRANGE + // + const workflow = makeWorkflow(); + const project = await projectRepository.save( + projectRepository.create({ + name: 'Team Project', + type: 'team', + }), + ); + + // + // ACT + // + await testServer + .authAgentFor(member) + .post('/workflows') + .send({ ...workflow, projectId: project.id }) + // + // ASSERT + // + .expect(400, { + code: 400, + message: "You don't have the permissions to save the workflow in this project.", + }); + }); + + test('does not create the workflow in a specific project if the user does not have the right role to do so', async () => { + // + // ARRANGE + // + const workflow = makeWorkflow(); + const project = await projectRepository.save( + projectRepository.create({ + name: 'Team Project', + type: 'team', + }), + ); + await Container.get(ProjectService).addUser(project.id, member.id, 'project:viewer'); + + // + // ACT + // + await testServer + .authAgentFor(member) + .post('/workflows') + .send({ ...workflow, projectId: project.id }) + // + // ASSERT + // + .expect(400, { + code: 400, + message: "You don't have the permissions to save the workflow in this project.", + }); + }); }); describe('GET /workflows/:id', () => { @@ -165,6 +371,17 @@ describe('GET /workflows/:id', () => { const { pinData } = workflowRetrievalResponse.body.data as { pinData: IPinData }; expect(pinData).toMatchObject(MOCK_PINDATA); }); + + test('should return tags', async () => { + const tag = await createTag({ name: 'A' }); + const workflow = await createWorkflow({ tags: [tag] }, owner); + + const response = await authOwnerAgent.get(`/workflows/${workflow.id}`).expect(200); + + expect(response.body.data).toMatchObject({ + tags: [expect.objectContaining({ id: tag.id, name: tag.name })], + }); + }); }); describe('GET /workflows', () => { @@ -179,6 +396,7 @@ describe('GET /workflows', () => { user: owner, role: 'credential:owner', }); + const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); const nodes: INode[] = [ { @@ -215,13 +433,12 @@ describe('GET /workflows', () => { updatedAt: any(String), tags: [{ id: any(String), name: 'A' }], versionId: any(String), - ownedBy: { - id: owner.id, - email: any(String), - firstName: any(String), - lastName: any(String), + homeProject: { + id: ownerPersonalProject.id, + name: owner.createPersonalProjectName(), + type: ownerPersonalProject.type, }, - sharedWith: [], + sharedWithProjects: [], }), objectContaining({ id: any(String), @@ -231,13 +448,12 @@ describe('GET /workflows', () => { updatedAt: any(String), tags: [], versionId: any(String), - ownedBy: { - id: owner.id, - email: any(String), - firstName: any(String), - lastName: any(String), + homeProject: { + id: ownerPersonalProject.id, + name: owner.createPersonalProjectName(), + type: ownerPersonalProject.type, }, - sharedWith: [], + sharedWithProjects: [], }), ]), }); @@ -247,10 +463,142 @@ describe('GET /workflows', () => { ); expect(found.nodes).toBeUndefined(); - expect(found.sharedWith).toHaveLength(0); + expect(found.sharedWithProjects).toHaveLength(0); expect(found.usedCredentials).toBeUndefined(); }); + test('should return workflows with scopes when ?includeScopes=true', async () => { + const [member1, member2] = await createManyUsers(2, { + role: 'global:member', + }); + + const teamProject = await createTeamProject(undefined, member1); + await linkUserToProject(member2, teamProject, 'project:editor'); + + const credential = await saveCredential(randomCredentialPayload(), { + user: owner, + role: 'credential:owner', + }); + + const nodes: INode[] = [ + { + id: uuid(), + name: 'Action Network', + type: 'n8n-nodes-base.actionNetwork', + parameters: {}, + typeVersion: 1, + position: [0, 0], + credentials: { + actionNetworkApi: { + id: credential.id, + name: credential.name, + }, + }, + }, + ]; + + const tag = await createTag({ name: 'A' }); + + const [savedWorkflow1, savedWorkflow2] = await Promise.all([ + createWorkflow({ name: 'First', nodes, tags: [tag] }, teamProject), + createWorkflow({ name: 'Second' }, member2), + ]); + + await shareWorkflowWithProjects(savedWorkflow2, [{ project: teamProject }]); + + { + const response = await testServer.authAgentFor(member1).get('/workflows?includeScopes=true'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + + const workflows = response.body.data as Array; + const wf1 = workflows.find((w) => w.id === savedWorkflow1.id)!; + const wf2 = workflows.find((w) => w.id === savedWorkflow2.id)!; + + // Team workflow + expect(wf1.id).toBe(savedWorkflow1.id); + expect(wf1.scopes).toEqual( + ['workflow:read', 'workflow:update', 'workflow:delete', 'workflow:execute'].sort(), + ); + + // Shared workflow + expect(wf2.id).toBe(savedWorkflow2.id); + expect(wf2.scopes).toEqual(['workflow:read', 'workflow:update', 'workflow:execute'].sort()); + } + + { + const response = await testServer.authAgentFor(member2).get('/workflows?includeScopes=true'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + + const workflows = response.body.data as Array; + const wf1 = workflows.find((w) => w.id === savedWorkflow1.id)!; + const wf2 = workflows.find((w) => w.id === savedWorkflow2.id)!; + + // Team workflow + expect(wf1.id).toBe(savedWorkflow1.id); + expect(wf1.scopes).toEqual([ + 'workflow:delete', + 'workflow:execute', + 'workflow:read', + 'workflow:update', + ]); + + // Shared workflow + expect(wf2.id).toBe(savedWorkflow2.id); + expect(wf2.scopes).toEqual( + [ + 'workflow:read', + 'workflow:update', + 'workflow:delete', + 'workflow:execute', + 'workflow:share', + ].sort(), + ); + } + + { + const response = await testServer.authAgentFor(owner).get('/workflows?includeScopes=true'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + + const workflows = response.body.data as Array; + const wf1 = workflows.find((w) => w.id === savedWorkflow1.id)!; + const wf2 = workflows.find((w) => w.id === savedWorkflow2.id)!; + + // Team workflow + expect(wf1.id).toBe(savedWorkflow1.id); + expect(wf1.scopes).toEqual( + [ + 'workflow:create', + 'workflow:read', + 'workflow:update', + 'workflow:delete', + 'workflow:list', + 'workflow:share', + 'workflow:execute', + ].sort(), + ); + + // Shared workflow + expect(wf2.id).toBe(savedWorkflow2.id); + expect(wf2.scopes).toEqual( + [ + 'workflow:create', + 'workflow:read', + 'workflow:update', + 'workflow:delete', + 'workflow:list', + 'workflow:share', + 'workflow:execute', + ].sort(), + ); + } + }); + describe('filter', () => { test('should filter workflows by field: name', async () => { await createWorkflow({ name: 'First' }, owner); @@ -298,6 +646,26 @@ describe('GET /workflows', () => { data: [objectContaining({ name: 'First', tags: [{ id: any(String), name: 'A' }] })], }); }); + + test('should filter workflows by projectId', async () => { + const workflow = await createWorkflow({ name: 'First' }, owner); + const pp = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(owner.id); + + const response1 = await authOwnerAgent + .get('/workflows') + .query(`filter={ "projectId": "${pp.id}" }`) + .expect(200); + + expect(response1.body.data).toHaveLength(1); + expect(response1.body.data[0].id).toBe(workflow.id); + + const response2 = await authOwnerAgent + .get('/workflows') + .query('filter={ "projectId": "Non-Existing Project ID" }') + .expect(200); + + expect(response2.body.data).toHaveLength(0); + }); }); describe('select', () => { @@ -419,6 +787,9 @@ describe('GET /workflows', () => { test('should select workflow field: ownedBy', async () => { await createWorkflow({}, owner); await createWorkflow({}, owner); + const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + owner.id, + ); const response = await authOwnerAgent .get('/workflows') @@ -430,23 +801,21 @@ describe('GET /workflows', () => { data: arrayContaining([ { id: any(String), - ownedBy: { - id: owner.id, - email: any(String), - firstName: any(String), - lastName: any(String), + homeProject: { + id: ownerPersonalProject.id, + name: owner.createPersonalProjectName(), + type: ownerPersonalProject.type, }, - sharedWith: [], + sharedWithProjects: [], }, { id: any(String), - ownedBy: { - id: owner.id, - email: any(String), - firstName: any(String), - lastName: any(String), + homeProject: { + id: ownerPersonalProject.id, + name: owner.createPersonalProjectName(), + type: ownerPersonalProject.type, }, - sharedWith: [], + sharedWithProjects: [], }, ]), }); @@ -645,7 +1014,7 @@ describe('POST /workflows/run', () => { test('should prevent tampering if sharing is enabled', async () => { sharingSpy.mockReturnValue(true); - await authOwnerAgent.post('/workflows/run').send({ workflowData: workflow }); + await authOwnerAgent.post(`/workflows/${workflow.id}/run`).send({ workflowData: workflow }); expect(tamperingSpy).toHaveBeenCalledTimes(1); }); @@ -653,8 +1022,70 @@ describe('POST /workflows/run', () => { test('should skip tampering prevention if sharing is disabled', async () => { sharingSpy.mockReturnValue(false); - await authOwnerAgent.post('/workflows/run').send({ workflowData: workflow }); + await authOwnerAgent.post(`/workflows/${workflow.id}/run`).send({ workflowData: workflow }); expect(tamperingSpy).not.toHaveBeenCalled(); }); }); + +describe('DELETE /workflows/:id', () => { + test('deletes a workflow owned by the user', async () => { + const workflow = await createWorkflow({}, owner); + + await authOwnerAgent.delete(`/workflows/${workflow.id}`).send().expect(200); + + const workflowInDb = await Container.get(WorkflowRepository).findById(workflow.id); + const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({ + workflowId: workflow.id, + }); + + expect(workflowInDb).toBeNull(); + expect(sharedWorkflowsInDb).toHaveLength(0); + }); + + test('deletes a workflow owned by the user, even if the user is just a member', async () => { + const workflow = await createWorkflow({}, member); + + await testServer.authAgentFor(member).delete(`/workflows/${workflow.id}`).send().expect(200); + + const workflowInDb = await Container.get(WorkflowRepository).findById(workflow.id); + const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({ + workflowId: workflow.id, + }); + + expect(workflowInDb).toBeNull(); + expect(sharedWorkflowsInDb).toHaveLength(0); + }); + + test('does not delete a workflow that is not owned by the user', async () => { + const workflow = await createWorkflow({}, member); + + await testServer + .authAgentFor(anotherMember) + .delete(`/workflows/${workflow.id}`) + .send() + .expect(403); + + const workflowsInDb = await Container.get(WorkflowRepository).findById(workflow.id); + const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({ + workflowId: workflow.id, + }); + + expect(workflowsInDb).not.toBeNull(); + expect(sharedWorkflowsInDb).toHaveLength(1); + }); + + test("allows the owner to delete workflows they don't own", async () => { + const workflow = await createWorkflow({}, member); + + await authOwnerAgent.delete(`/workflows/${workflow.id}`).send().expect(200); + + const workflowsInDb = await Container.get(WorkflowRepository).findById(workflow.id); + const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({ + workflowId: workflow.id, + }); + + expect(workflowsInDb).toBeNull(); + expect(sharedWorkflowsInDb).toHaveLength(0); + }); +}); diff --git a/packages/cli/test/unit/InternalHooks.test.ts b/packages/cli/test/unit/InternalHooks.test.ts index 46ea31623..6dbb4ab5f 100644 --- a/packages/cli/test/unit/InternalHooks.test.ts +++ b/packages/cli/test/unit/InternalHooks.test.ts @@ -25,6 +25,8 @@ describe('InternalHooks', () => { mock(), mock(), license, + mock(), + mock(), ); beforeEach(() => jest.clearAllMocks()); diff --git a/packages/cli/test/unit/Ldap/helpers.test.ts b/packages/cli/test/unit/Ldap/helpers.test.ts new file mode 100644 index 000000000..b5c8c25a6 --- /dev/null +++ b/packages/cli/test/unit/Ldap/helpers.test.ts @@ -0,0 +1,40 @@ +import { UserRepository } from '@/databases/repositories/user.repository'; +import { mockInstance } from '../../shared/mocking'; +import * as helpers from '@/Ldap/helpers'; +import { AuthIdentity } from '@/databases/entities/AuthIdentity'; +import { User } from '@/databases/entities/User'; +import { generateNanoId } from '@/databases/utils/generators'; + +const userRepository = mockInstance(UserRepository); + +describe('Ldap/helpers', () => { + describe('updateLdapUserOnLocalDb', () => { + // We need to use `save` so that that the subscriber in + // packages/cli/src/databases/entities/Project.ts receives the full user. + // With `update` it would only receive the updated fields, e.g. the `id` + // would be missing. + test('does not use `Repository.update`, but `Repository.save` instead', async () => { + // + // ARRANGE + // + const user = Object.assign(new User(), { id: generateNanoId() } as User); + const authIdentity = Object.assign(new AuthIdentity(), { + user: { id: user.id }, + } as AuthIdentity); + const data: Partial = { firstName: 'Nathan', lastName: 'Nathaniel' }; + + userRepository.findOneBy.mockResolvedValueOnce(user); + + // + // ACT + // + await helpers.updateLdapUserOnLocalDb(authIdentity, data); + + // + // ASSERT + // + expect(userRepository.save).toHaveBeenCalledWith({ ...user, ...data }, { transaction: true }); + expect(userRepository.update).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/test/unit/PermissionChecker.test.ts b/packages/cli/test/unit/PermissionChecker.test.ts deleted file mode 100644 index 8ddb0754b..000000000 --- a/packages/cli/test/unit/PermissionChecker.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { INode } from 'n8n-workflow'; -import { mock } from 'jest-mock-extended'; -import type { User } from '@db/entities/User'; -import type { UserRepository } from '@db/repositories/user.repository'; -import type { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; -import type { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; -import type { License } from '@/License'; -import { PermissionChecker } from '@/UserManagement/PermissionChecker'; - -describe('PermissionChecker', () => { - const user = mock(); - const userRepo = mock(); - const sharedCredentialsRepo = mock(); - const sharedWorkflowRepo = mock(); - const license = mock(); - const permissionChecker = new PermissionChecker( - userRepo, - sharedCredentialsRepo, - sharedWorkflowRepo, - mock(), - license, - ); - - const workflowId = '1'; - const nodes: INode[] = [ - { - id: 'node-id', - name: 'HTTP Request', - type: 'n8n-nodes-base.httpRequest', - parameters: {}, - typeVersion: 1, - position: [0, 0], - credentials: { - oAuth2Api: { - id: 'cred-id', - name: 'Custom oAuth2', - }, - }, - }, - ]; - - beforeEach(() => jest.clearAllMocks()); - - describe('check', () => { - it('should throw if no user is found', async () => { - userRepo.findOneOrFail.mockRejectedValue(new Error('Fail')); - await expect(permissionChecker.check(workflowId, '123', nodes)).rejects.toThrow(); - expect(license.isSharingEnabled).not.toHaveBeenCalled(); - expect(sharedWorkflowRepo.getSharedUserIds).not.toBeCalled(); - expect(sharedCredentialsRepo.getOwnedCredentialIds).not.toHaveBeenCalled(); - expect(sharedCredentialsRepo.getAccessibleCredentialIds).not.toHaveBeenCalled(); - }); - - it('should allow a user if they have a global `workflow:execute` scope', async () => { - userRepo.findOneOrFail.mockResolvedValue(user); - user.hasGlobalScope.calledWith('workflow:execute').mockReturnValue(true); - await expect(permissionChecker.check(workflowId, user.id, nodes)).resolves.not.toThrow(); - expect(license.isSharingEnabled).not.toHaveBeenCalled(); - expect(sharedWorkflowRepo.getSharedUserIds).not.toBeCalled(); - expect(sharedCredentialsRepo.getOwnedCredentialIds).not.toHaveBeenCalled(); - expect(sharedCredentialsRepo.getAccessibleCredentialIds).not.toHaveBeenCalled(); - }); - - describe('When sharing is disabled', () => { - beforeEach(() => { - userRepo.findOneOrFail.mockResolvedValue(user); - user.hasGlobalScope.calledWith('workflow:execute').mockReturnValue(false); - license.isSharingEnabled.mockReturnValue(false); - }); - - it('should validate credential access using only owned credentials', async () => { - sharedCredentialsRepo.getOwnedCredentialIds.mockResolvedValue(['cred-id']); - - await expect(permissionChecker.check(workflowId, user.id, nodes)).resolves.not.toThrow(); - - expect(sharedWorkflowRepo.getSharedUserIds).not.toBeCalled(); - expect(sharedCredentialsRepo.getOwnedCredentialIds).toBeCalledWith([user.id]); - expect(sharedCredentialsRepo.getAccessibleCredentialIds).not.toHaveBeenCalled(); - }); - - it('should throw when the user does not have access to the credential', async () => { - sharedCredentialsRepo.getOwnedCredentialIds.mockResolvedValue(['cred-id2']); - - await expect(permissionChecker.check(workflowId, user.id, nodes)).rejects.toThrow( - 'Node has no access to credential', - ); - - expect(sharedWorkflowRepo.getSharedUserIds).not.toBeCalled(); - expect(sharedCredentialsRepo.getOwnedCredentialIds).toBeCalledWith([user.id]); - expect(sharedCredentialsRepo.getAccessibleCredentialIds).not.toHaveBeenCalled(); - }); - }); - - describe('When sharing is enabled', () => { - beforeEach(() => { - userRepo.findOneOrFail.mockResolvedValue(user); - user.hasGlobalScope.calledWith('workflow:execute').mockReturnValue(false); - license.isSharingEnabled.mockReturnValue(true); - sharedWorkflowRepo.getSharedUserIds.mockResolvedValue([user.id, 'another-user']); - }); - - it('should validate credential access using only owned credentials', async () => { - sharedCredentialsRepo.getAccessibleCredentialIds.mockResolvedValue(['cred-id']); - - await expect(permissionChecker.check(workflowId, user.id, nodes)).resolves.not.toThrow(); - - expect(sharedWorkflowRepo.getSharedUserIds).toBeCalledWith(workflowId); - expect(sharedCredentialsRepo.getAccessibleCredentialIds).toBeCalledWith([ - user.id, - 'another-user', - ]); - expect(sharedCredentialsRepo.getOwnedCredentialIds).not.toHaveBeenCalled(); - }); - - it('should throw when the user does not have access to the credential', async () => { - sharedCredentialsRepo.getAccessibleCredentialIds.mockResolvedValue(['cred-id2']); - - await expect(permissionChecker.check(workflowId, user.id, nodes)).rejects.toThrow( - 'Node has no access to credential', - ); - - expect(sharedWorkflowRepo.find).not.toBeCalled(); - expect(sharedCredentialsRepo.getAccessibleCredentialIds).toBeCalledWith([ - user.id, - 'another-user', - ]); - expect(sharedCredentialsRepo.getOwnedCredentialIds).not.toHaveBeenCalled(); - }); - }); - }); -}); diff --git a/packages/cli/test/unit/databases/entities/user.entity.test.ts b/packages/cli/test/unit/databases/entities/user.entity.test.ts index 005e45df2..7fac71c5f 100644 --- a/packages/cli/test/unit/databases/entities/user.entity.test.ts +++ b/packages/cli/test/unit/databases/entities/user.entity.test.ts @@ -17,4 +17,22 @@ describe('User Entity', () => { ); }); }); + + describe('createPersonalProjectName', () => { + test.each([ + ['Nathan', 'Nathaniel', 'nathan@nathaniel.n8n', 'Nathan Nathaniel '], + [undefined, 'Nathaniel', 'nathan@nathaniel.n8n', ''], + ['Nathan', undefined, 'nathan@nathaniel.n8n', ''], + [undefined, undefined, 'nathan@nathaniel.n8n', ''], + [undefined, undefined, undefined, 'Unnamed Project'], + ['Nathan', 'Nathaniel', undefined, 'Unnamed Project'], + ])( + 'given fistName: %s, lastName: %s and email: %s this gives the projectName: "%s"', + async (firstName, lastName, email, projectName) => { + const user = new User(); + Object.assign(user, { firstName, lastName, email }); + expect(user.createPersonalProjectName()).toBe(projectName); + }, + ); + }); }); diff --git a/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts b/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts index 1624cf5e6..8afc8bb12 100644 --- a/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts +++ b/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts @@ -1,4 +1,5 @@ import { Container } from 'typedi'; +import { In } from '@n8n/typeorm'; import { mock } from 'jest-mock-extended'; import { hasScope } from '@n8n/permissions'; @@ -6,7 +7,7 @@ import type { User } from '@db/entities/User'; import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; import { SharedCredentials } from '@db/entities/SharedCredentials'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; -import { memberPermissions, ownerPermissions } from '@/permissions/roles'; +import { GLOBAL_MEMBER_SCOPES, GLOBAL_OWNER_SCOPES } from '@/permissions/global-roles'; import { mockEntityManager } from '../../shared/mocking'; describe('SharedCredentialsRepository', () => { @@ -21,7 +22,7 @@ describe('SharedCredentialsRepository', () => { isOwner: true, hasGlobalScope: (scope) => hasScope(scope, { - global: ownerPermissions, + global: GLOBAL_OWNER_SCOPES, }), }); const member = mock({ @@ -29,7 +30,7 @@ describe('SharedCredentialsRepository', () => { id: 'test', hasGlobalScope: (scope) => hasScope(scope, { - global: memberPermissions, + global: GLOBAL_MEMBER_SCOPES, }), }); @@ -39,9 +40,11 @@ describe('SharedCredentialsRepository', () => { test('should allow instance owner access to all credentials', async () => { entityManager.findOne.mockResolvedValueOnce(sharedCredential); - const credential = await repository.findCredentialForUser(credentialsId, owner); + const credential = await repository.findCredentialForUser(credentialsId, owner, [ + 'credential:read', + ]); expect(entityManager.findOne).toHaveBeenCalledWith(SharedCredentials, { - relations: ['credentials'], + relations: { credentials: { shared: { project: { projectRelations: { user: true } } } } }, where: { credentialsId }, }); expect(credential).toEqual(sharedCredential.credentials); @@ -49,20 +52,42 @@ describe('SharedCredentialsRepository', () => { test('should allow members', async () => { entityManager.findOne.mockResolvedValueOnce(sharedCredential); - const credential = await repository.findCredentialForUser(credentialsId, member); + const credential = await repository.findCredentialForUser(credentialsId, member, [ + 'credential:read', + ]); expect(entityManager.findOne).toHaveBeenCalledWith(SharedCredentials, { - relations: ['credentials'], - where: { credentialsId, userId: member.id }, + relations: { credentials: { shared: { project: { projectRelations: { user: true } } } } }, + where: { + credentialsId, + role: In(['credential:owner', 'credential:user']), + project: { + projectRelations: { + role: In(['project:admin', 'project:personalOwner', 'project:editor']), + userId: member.id, + }, + }, + }, }); expect(credential).toEqual(sharedCredential.credentials); }); test('should return null when no shared credential is found', async () => { entityManager.findOne.mockResolvedValueOnce(null); - const credential = await repository.findCredentialForUser(credentialsId, member); + const credential = await repository.findCredentialForUser(credentialsId, member, [ + 'credential:read', + ]); expect(entityManager.findOne).toHaveBeenCalledWith(SharedCredentials, { - relations: ['credentials'], - where: { credentialsId, userId: member.id }, + relations: { credentials: { shared: { project: { projectRelations: { user: true } } } } }, + where: { + credentialsId, + role: In(['credential:owner', 'credential:user']), + project: { + projectRelations: { + role: In(['project:admin', 'project:personalOwner', 'project:editor']), + userId: member.id, + }, + }, + }, }); expect(credential).toEqual(null); }); diff --git a/packages/cli/test/unit/services/activeWorkflows.service.test.ts b/packages/cli/test/unit/services/activeWorkflows.service.test.ts index 7432d2249..2089c9469 100644 --- a/packages/cli/test/unit/services/activeWorkflows.service.test.ts +++ b/packages/cli/test/unit/services/activeWorkflows.service.test.ts @@ -5,6 +5,7 @@ import type { WorkflowRepository } from '@db/repositories/workflow.repository'; import { ActiveWorkflowsService } from '@/services/activeWorkflows.service'; import { mock } from 'jest-mock-extended'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; describe('ActiveWorkflowsService', () => { const user = mock(); @@ -61,20 +62,24 @@ describe('ActiveWorkflowsService', () => { const workflowId = 'workflowId'; it('should throw a BadRequestError a user does not have access to the workflow id', async () => { - sharedWorkflowRepository.hasAccess.mockResolvedValue(false); + sharedWorkflowRepository.findWorkflowForUser.mockResolvedValue(null); await expect(service.getActivationError(workflowId, user)).rejects.toThrow(BadRequestError); - expect(sharedWorkflowRepository.hasAccess).toHaveBeenCalledWith(workflowId, user); + expect(sharedWorkflowRepository.findWorkflowForUser).toHaveBeenCalledWith(workflowId, user, [ + 'workflow:read', + ]); expect(activationErrorsService.get).not.toHaveBeenCalled(); }); it('should return the error when the user has access', async () => { - sharedWorkflowRepository.hasAccess.mockResolvedValue(true); + sharedWorkflowRepository.findWorkflowForUser.mockResolvedValue(new WorkflowEntity()); activationErrorsService.get.mockResolvedValue('some-error'); const error = await service.getActivationError(workflowId, user); expect(error).toEqual('some-error'); - expect(sharedWorkflowRepository.hasAccess).toHaveBeenCalledWith(workflowId, user); + expect(sharedWorkflowRepository.findWorkflowForUser).toHaveBeenCalledWith(workflowId, user, [ + 'workflow:read', + ]); expect(activationErrorsService.get).toHaveBeenCalledWith(workflowId); }); }); diff --git a/packages/cli/test/unit/services/events.service.test.ts b/packages/cli/test/unit/services/events.service.test.ts index afdd4091d..7330b619e 100644 --- a/packages/cli/test/unit/services/events.service.test.ts +++ b/packages/cli/test/unit/services/events.service.test.ts @@ -16,10 +16,12 @@ import { EventsService } from '@/services/events.service'; import { UserService } from '@/services/user.service'; import { OwnershipService } from '@/services/ownership.service'; import { mockInstance } from '../../shared/mocking'; +import type { Project } from '@/databases/entities/Project'; describe('EventsService', () => { const dbType = config.getEnv('database.type'); const fakeUser = mock({ id: 'abcde-fghij' }); + const fakeProject = mock({ id: '12345-67890', type: 'personal' }); const ownershipService = mockInstance(OwnershipService); const userService = mockInstance(UserService); @@ -35,7 +37,8 @@ describe('EventsService', () => { config.set('diagnostics.enabled', true); config.set('deployment.type', 'n8n-testing'); - mocked(ownershipService.getWorkflowOwnerCached).mockResolvedValue(fakeUser); + mocked(ownershipService.getWorkflowProjectCached).mockResolvedValue(fakeProject); + mocked(ownershipService.getProjectOwnerCached).mockResolvedValue(fakeUser); const updateSettingsMock = jest.spyOn(userService, 'updateSettings').mockImplementation(); const eventsService = new EventsService( @@ -89,6 +92,7 @@ describe('EventsService', () => { expect(updateSettingsMock).toHaveBeenCalledTimes(1); expect(onFirstProductionWorkflowSuccess).toBeCalledTimes(1); expect(onFirstProductionWorkflowSuccess).toHaveBeenNthCalledWith(1, { + project_id: fakeProject.id, user_id: fakeUser.id, workflow_id: workflow.id, }); @@ -156,6 +160,7 @@ describe('EventsService', () => { expect(onFirstWorkflowDataLoad).toBeCalledTimes(1); expect(onFirstWorkflowDataLoad).toHaveBeenNthCalledWith(1, { user_id: fakeUser.id, + project_id: fakeProject.id, workflow_id: workflowId, node_type: node.type, node_id: node.id, @@ -183,6 +188,7 @@ describe('EventsService', () => { expect(onFirstWorkflowDataLoad).toBeCalledTimes(1); expect(onFirstWorkflowDataLoad).toHaveBeenNthCalledWith(1, { user_id: fakeUser.id, + project_id: fakeProject.id, workflow_id: workflowId, node_type: node.type, node_id: node.id, diff --git a/packages/cli/test/unit/services/ownership.service.test.ts b/packages/cli/test/unit/services/ownership.service.test.ts index 3fed4b8ce..d1a722da1 100644 --- a/packages/cli/test/unit/services/ownership.service.test.ts +++ b/packages/cli/test/unit/services/ownership.service.test.ts @@ -7,120 +7,179 @@ import { mockInstance } from '../../shared/mocking'; import { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; import { UserRepository } from '@/databases/repositories/user.repository'; import { mock } from 'jest-mock-extended'; -import { mockCredential, mockUser } from '../shared/mockObjects'; +import { Project } from '@/databases/entities/Project'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; +import { ProjectRelation } from '@/databases/entities/ProjectRelation'; +import { mockCredential, mockProject } from '../shared/mockObjects'; describe('OwnershipService', () => { const userRepository = mockInstance(UserRepository); const sharedWorkflowRepository = mockInstance(SharedWorkflowRepository); - const ownershipService = new OwnershipService(mock(), userRepository, sharedWorkflowRepository); + const projectRelationRepository = mockInstance(ProjectRelationRepository); + const ownershipService = new OwnershipService( + mock(), + userRepository, + mock(), + projectRelationRepository, + sharedWorkflowRepository, + ); beforeEach(() => { jest.clearAllMocks(); }); - describe('getWorkflowOwner()', () => { - test('should retrieve a workflow owner', async () => { - const mockOwner = new User(); - const mockNonOwner = new User(); + describe('getWorkflowProjectCached()', () => { + test('should retrieve a workflow owner project', async () => { + const mockProject = new Project(); const sharedWorkflow = Object.assign(new SharedWorkflow(), { role: 'workflow:owner', - user: mockOwner, + project: mockProject, }); sharedWorkflowRepository.findOneOrFail.mockResolvedValueOnce(sharedWorkflow); - const returnedOwner = await ownershipService.getWorkflowOwnerCached('some-workflow-id'); + const returnedProject = await ownershipService.getWorkflowProjectCached('some-workflow-id'); - expect(returnedOwner).toBe(mockOwner); - expect(returnedOwner).not.toBe(mockNonOwner); + expect(returnedProject).toBe(mockProject); }); - test('should throw if no workflow owner found', async () => { + test('should throw if no workflow owner project found', async () => { sharedWorkflowRepository.findOneOrFail.mockRejectedValue(new Error()); - await expect(ownershipService.getWorkflowOwnerCached('some-workflow-id')).rejects.toThrow(); + await expect(ownershipService.getWorkflowProjectCached('some-workflow-id')).rejects.toThrow(); + }); + }); + + describe('getProjectOwnerCached()', () => { + test('should retrieve a project owner', async () => { + const mockProject = new Project(); + const mockOwner = new User(); + + const projectRelation = Object.assign(new ProjectRelation(), { + role: 'project:personalOwner', + project: mockProject, + user: mockOwner, + }); + + projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([projectRelation]); + + const returnedOwner = await ownershipService.getProjectOwnerCached('some-project-id'); + + expect(returnedOwner).toBe(mockOwner); + }); + + test('should not throw if no project owner found, should return null instead', async () => { + projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([]); + + const owner = await ownershipService.getProjectOwnerCached('some-project-id'); + + expect(owner).toBeNull(); + }); + }); + + describe('getProjectOwnerCached()', () => { + test('should retrieve a project owner', async () => { + const mockProject = new Project(); + const mockOwner = new User(); + + const projectRelation = Object.assign(new ProjectRelation(), { + role: 'project:personalOwner', + project: mockProject, + user: mockOwner, + }); + + projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([projectRelation]); + + const returnedOwner = await ownershipService.getProjectOwnerCached('some-project-id'); + + expect(returnedOwner).toBe(mockOwner); + }); + + test('should not throw if no project owner found, should return null instead', async () => { + projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([]); + + const owner = await ownershipService.getProjectOwnerCached('some-project-id'); + + expect(owner).toBeNull(); }); }); describe('addOwnedByAndSharedWith()', () => { test('should add `ownedBy` and `sharedWith` to credential', async () => { - const owner = mockUser(); - const editor = mockUser(); + const ownerProject = mockProject(); + const editorProject = mockProject(); const credential = mockCredential(); credential.shared = [ - { role: 'credential:owner', user: owner }, - { role: 'credential:editor', user: editor }, + { role: 'credential:owner', project: ownerProject }, + { role: 'credential:editor', project: editorProject }, ] as SharedCredentials[]; - const { ownedBy, sharedWith } = ownershipService.addOwnedByAndSharedWith(credential); + const { homeProject, sharedWithProjects } = + ownershipService.addOwnedByAndSharedWith(credential); - expect(ownedBy).toStrictEqual({ - id: owner.id, - email: owner.email, - firstName: owner.firstName, - lastName: owner.lastName, + expect(homeProject).toMatchObject({ + id: ownerProject.id, + name: ownerProject.name, + type: ownerProject.type, }); - expect(sharedWith).toStrictEqual([ + expect(sharedWithProjects).toMatchObject([ { - id: editor.id, - email: editor.email, - firstName: editor.firstName, - lastName: editor.lastName, + id: editorProject.id, + name: editorProject.name, + type: editorProject.type, }, ]); }); test('should add `ownedBy` and `sharedWith` to workflow', async () => { - const owner = mockUser(); - const editor = mockUser(); + const projectOwner = mockProject(); + const projectEditor = mockProject(); const workflow = new WorkflowEntity(); workflow.shared = [ - { role: 'workflow:owner', user: owner }, - { role: 'workflow:editor', user: editor }, + { role: 'workflow:owner', project: projectOwner }, + { role: 'workflow:editor', project: projectEditor }, ] as SharedWorkflow[]; - const { ownedBy, sharedWith } = ownershipService.addOwnedByAndSharedWith(workflow); + const { homeProject, sharedWithProjects } = + ownershipService.addOwnedByAndSharedWith(workflow); - expect(ownedBy).toStrictEqual({ - id: owner.id, - email: owner.email, - firstName: owner.firstName, - lastName: owner.lastName, + expect(homeProject).toMatchObject({ + id: projectOwner.id, + name: projectOwner.name, + type: projectOwner.type, }); - - expect(sharedWith).toStrictEqual([ + expect(sharedWithProjects).toMatchObject([ { - id: editor.id, - email: editor.email, - firstName: editor.firstName, - lastName: editor.lastName, + id: projectEditor.id, + name: projectEditor.name, + type: projectEditor.type, }, ]); }); test('should produce an empty sharedWith if no sharee', async () => { - const owner = mockUser(); - const credential = mockCredential(); - credential.shared = [{ role: 'credential:owner', user: owner }] as SharedCredentials[]; + const project = mockProject(); - const { ownedBy, sharedWith } = ownershipService.addOwnedByAndSharedWith(credential); + credential.shared = [{ role: 'credential:owner', project }] as SharedCredentials[]; - expect(ownedBy).toStrictEqual({ - id: owner.id, - email: owner.email, - firstName: owner.firstName, - lastName: owner.lastName, + const { homeProject, sharedWithProjects } = + ownershipService.addOwnedByAndSharedWith(credential); + + expect(homeProject).toMatchObject({ + id: project.id, + name: project.name, + type: project.type, }); - expect(sharedWith).toHaveLength(0); + expect(sharedWithProjects).toHaveLength(0); }); }); diff --git a/packages/cli/test/unit/services/user.service.test.ts b/packages/cli/test/unit/services/user.service.test.ts index fe5a7c2a8..5dabdf664 100644 --- a/packages/cli/test/unit/services/user.service.test.ts +++ b/packages/cli/test/unit/services/user.service.test.ts @@ -4,10 +4,13 @@ import { v4 as uuid } from 'uuid'; import { User } from '@db/entities/User'; import { UserService } from '@/services/user.service'; import { UrlService } from '@/services/url.service'; +import { mockInstance } from '../../shared/mocking'; +import { UserRepository } from '@/databases/repositories/user.repository'; describe('UserService', () => { const urlService = new UrlService(); - const userService = new UserService(mock(), mock(), mock(), urlService); + const userRepository = mockInstance(UserRepository); + const userService = new UserService(mock(), userRepository, mock(), urlService); const commonMockUser = Object.assign(new User(), { id: uuid(), @@ -66,4 +69,28 @@ describe('UserService', () => { expect(url.searchParams.get('inviteeId')).toBe(secondUser.id); }); }); + + describe('update', () => { + // We need to use `save` so that that the subscriber in + // packages/cli/src/databases/entities/Project.ts receives the full user. + // With `update` it would only receive the updated fields, e.g. the `id` + // would be missing. + it('should use `save` instead of `update`', async () => { + const user = new User(); + user.firstName = 'Not Nathan'; + user.lastName = 'Nathaniel'; + + const userId = '1234'; + const data = { + firstName: 'Nathan', + }; + + userRepository.findOneBy.mockResolvedValueOnce(user); + + await userService.update(userId, data); + + expect(userRepository.save).toHaveBeenCalledWith({ ...user, ...data }, { transaction: true }); + expect(userRepository.update).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/test/unit/shared/mockObjects.ts b/packages/cli/test/unit/shared/mockObjects.ts index baa6cf474..ccc85eb72 100644 --- a/packages/cli/test/unit/shared/mockObjects.ts +++ b/packages/cli/test/unit/shared/mockObjects.ts @@ -6,7 +6,9 @@ import { randomEmail, randomInteger, randomName, + uniqueId, } from '../../integration/shared/random'; +import { Project } from '@/databases/entities/Project'; export const mockCredential = (): CredentialsEntity => Object.assign(new CredentialsEntity(), randomCredentialPayload()); @@ -18,3 +20,10 @@ export const mockUser = (): User => firstName: randomName(), lastName: randomName(), }); + +export const mockProject = (): Project => + Object.assign(new Project(), { + id: uniqueId(), + type: 'personal', + name: 'Nathan Fillion ', + }); diff --git a/packages/cli/test/unit/sso/saml/samlHelpers.test.ts b/packages/cli/test/unit/sso/saml/samlHelpers.test.ts new file mode 100644 index 000000000..f6c35ff67 --- /dev/null +++ b/packages/cli/test/unit/sso/saml/samlHelpers.test.ts @@ -0,0 +1,55 @@ +import { User } from '@/databases/entities/User'; +import { generateNanoId } from '@/databases/utils/generators'; +import * as helpers from '@/sso/saml/samlHelpers'; +import type { SamlUserAttributes } from '@/sso/saml/types/samlUserAttributes'; +import { mockInstance } from '../../../shared/mocking'; +import { UserRepository } from '@/databases/repositories/user.repository'; +import type { AuthIdentity } from '@/databases/entities/AuthIdentity'; +import { AuthIdentityRepository } from '@/databases/repositories/authIdentity.repository'; + +const userRepository = mockInstance(UserRepository); +mockInstance(AuthIdentityRepository); + +describe('sso/saml/samlHelpers', () => { + describe('updateUserFromSamlAttributes', () => { + // We need to use `save` so that that the subscriber in + // packages/cli/src/databases/entities/Project.ts receives the full user. + // With `update` it would only receive the updated fields, e.g. the `id` + // would be missing. + test('does not user `Repository.update`, but `Repository.save` instead', async () => { + // + // ARRANGE + // + const user = Object.assign(new User(), { + id: generateNanoId(), + authIdentities: [] as AuthIdentity[], + } as User); + const samlUserAttributes: SamlUserAttributes = { + firstName: 'Nathan', + lastName: 'Nathaniel', + email: 'n@8.n', + userPrincipalName: 'Huh?', + }; + + userRepository.save.mockImplementationOnce(async (user) => user as User); + + // + // ACT + // + await helpers.updateUserFromSamlAttributes(user, samlUserAttributes); + + // + // ASSERT + // + expect(userRepository.save).toHaveBeenCalledWith( + { + ...user, + firstName: samlUserAttributes.firstName, + lastName: samlUserAttributes.lastName, + }, + { transaction: false }, + ); + expect(userRepository.update).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/design-system/src/components/N8nButton/Button.vue b/packages/design-system/src/components/N8nButton/Button.vue index c293deab5..5c6800e6e 100644 --- a/packages/design-system/src/components/N8nButton/Button.vue +++ b/packages/design-system/src/components/N8nButton/Button.vue @@ -7,7 +7,10 @@ :aria-busy="ariaBusy" :href="href" aria-live="polite" - v-bind="$attrs" + v-bind="{ + ...$attrs, + ...(props.nativeType ? { type: props.nativeType } : {}), + }" > diff --git a/packages/design-system/src/components/N8nMenu/Menu.vue b/packages/design-system/src/components/N8nMenu/Menu.vue index ebf0a9a0c..96bc59668 100644 --- a/packages/design-system/src/components/N8nMenu/Menu.vue +++ b/packages/design-system/src/components/N8nMenu/Menu.vue @@ -133,11 +133,18 @@ const onSelect = (item: IMenuItem): void => { background-color: var(--menu-background, var(--color-background-xlight)); } +.menuHeader { + display: flex; + flex-direction: column; + flex: 0 1 auto; + overflow-y: auto; +} + .menuContent { display: flex; flex-direction: column; justify-content: space-between; - flex-grow: 1; + flex: 1 1 auto; & > div > :global(.el-menu) { background: none; diff --git a/packages/design-system/src/components/N8nMenuItem/MenuItem.vue b/packages/design-system/src/components/N8nMenuItem/MenuItem.vue index 31c2b9af8..8922525f7 100644 --- a/packages/design-system/src/components/N8nMenuItem/MenuItem.vue +++ b/packages/design-system/src/components/N8nMenuItem/MenuItem.vue @@ -19,9 +19,12 @@ :icon="item.icon" :size="item.customIconSize || 'large'" /> - {{ item.label }} + {{ item.label }} + {{ + getInitials(item.label) + }} - - {{ item.label }} + {{ item.label }} + {{ + getInitials(item.label) + }} + @@ -141,6 +149,16 @@ const isItemActive = (item: IMenuItem): boolean => { Array.isArray(item.children) && item.children.some((child) => isActive(child)); return isActive(item) || hasActiveChild; }; + +const getInitials = (label: string): string => { + const words = label.split(' '); + + if (words.length === 1) { + return words[0].substring(0, 2); + } else { + return words[0].charAt(0) + words[1].charAt(0); + } +}; diff --git a/packages/editor-ui/src/components/layouts/PageViewLayout.vue b/packages/editor-ui/src/components/layouts/PageViewLayout.vue index 7f18b4203..523fdfc28 100644 --- a/packages/editor-ui/src/components/layouts/PageViewLayout.vue +++ b/packages/editor-ui/src/components/layouts/PageViewLayout.vue @@ -1,13 +1,9 @@ @@ -31,54 +27,18 @@ export default defineComponent({ diff --git a/packages/editor-ui/src/components/layouts/PageViewLayoutList.vue b/packages/editor-ui/src/components/layouts/PageViewLayoutList.vue index 84246d22d..263a5b4ca 100644 --- a/packages/editor-ui/src/components/layouts/PageViewLayoutList.vue +++ b/packages/editor-ui/src/components/layouts/PageViewLayoutList.vue @@ -26,8 +26,7 @@ export default defineComponent({ diff --git a/packages/editor-ui/src/components/layouts/ResourcesListLayout.vue b/packages/editor-ui/src/components/layouts/ResourcesListLayout.vue index 81f201c45..274dbe305 100644 --- a/packages/editor-ui/src/components/layouts/ResourcesListLayout.vue +++ b/packages/editor-ui/src/components/layouts/ResourcesListLayout.vue @@ -1,35 +1,8 @@