mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -08:00
feat: RBAC (#8922)
Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> Co-authored-by: Val <68596159+valya@users.noreply.github.com> Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in> Co-authored-by: Valya Bullions <valya@n8n.io> Co-authored-by: Danny Martini <danny@n8n.io> Co-authored-by: Danny Martini <despair.blue@gmail.com> Co-authored-by: Iván Ovejero <ivov.src@gmail.com> Co-authored-by: Omar Ajoue <krynble@gmail.com> Co-authored-by: oleg <me@olegivaniv.com> Co-authored-by: Michael Kret <michael.k@radency.com> Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com> Co-authored-by: Elias Meire <elias@meire.dev> Co-authored-by: Giulio Andreini <andreini@netseven.it> Co-authored-by: Giulio Andreini <g.andreini@gmail.com> Co-authored-by: Ayato Hayashi <go12limchangyong@gmail.com>
This commit is contained in:
parent
b1f977ebd0
commit
596c472ecc
18
cypress/composables/projects.ts
Normal file
18
cypress/composables/projects.ts
Normal file
|
@ -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();
|
||||
};
|
|
@ -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)
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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 });
|
||||
|
||||
|
|
|
@ -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,15 +22,24 @@ 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) => {
|
||||
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(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]+/);
|
||||
});
|
||||
|
@ -41,6 +50,6 @@ describe('Workflow templates', () => {
|
|||
cy.visit(templatesPage.url);
|
||||
cy.origin('https://n8n.io', () => {
|
||||
cy.url().should('include', 'https://n8n.io/workflows');
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
|
|
151
cypress/e2e/39-projects.cy.ts
Normal file
151
cypress/e2e/39-projects.cy.ts
Normal file
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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'),
|
||||
};
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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<JQuery<HTMLElement>>, field: 'key' | 'value', value: string) => {
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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<string>;
|
||||
|
|
|
@ -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: {
|
||||
|
|
23
packages/@n8n/permissions/src/combineScopes.ts
Normal file
23
packages/@n8n/permissions/src/combineScopes.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import type { Scope, ScopeLevels, GlobalScopes, MaskLevels } from './types';
|
||||
|
||||
export function combineScopes(userScopes: GlobalScopes, masks?: MaskLevels): Set<Scope>;
|
||||
export function combineScopes(userScopes: ScopeLevels, masks?: MaskLevels): Set<Scope>;
|
||||
export function combineScopes(
|
||||
userScopes: GlobalScopes | ScopeLevels,
|
||||
masks?: MaskLevels,
|
||||
): Set<Scope> {
|
||||
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());
|
||||
}
|
|
@ -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));
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
export type * from './types';
|
||||
export * from './hasScope';
|
||||
export * from './combineScopes';
|
||||
|
|
|
@ -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<T extends MaskLevel> = Record<T, Scope[]>;
|
||||
export type SharingMasks = GetMaskLevel<'sharing'>;
|
||||
export type MaskLevels = SharingMasks;
|
||||
|
||||
export type ScopeMode = 'oneOf' | 'allOf';
|
||||
export type ScopeOptions = { mode: ScopeMode };
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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<Credentials> {
|
||||
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,19 +451,27 @@ export class CredentialsHelper extends ICredentialsHelper {
|
|||
await this.credentialsRepository.update(findQuery, newCredentialsData);
|
||||
}
|
||||
|
||||
async credentialOwnedByOwner(nodeCredential: INodeCredentialsDetails): Promise<boolean> {
|
||||
async credentialCanUseExternalSecrets(nodeCredential: INodeCredentialsDetails): Promise<boolean> {
|
||||
if (!nodeCredential.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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: 'global:owner',
|
||||
role: In(['global:owner', 'global:admin']),
|
||||
},
|
||||
},
|
||||
},
|
||||
credentials: {
|
||||
id: nodeCredential.id,
|
||||
id: nodeCredential.id!,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -479,6 +481,9 @@ export class CredentialsHelper extends ICredentialsHelper {
|
|||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
})) ?? false
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -535,7 +535,8 @@ export interface IWorkflowExecutionDataProcess {
|
|||
pushRef?: string;
|
||||
startNodes?: StartNodeData[];
|
||||
workflowData: IWorkflowBase;
|
||||
userId: string;
|
||||
userId?: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export interface IWorkflowExecuteProcess {
|
||||
|
|
|
@ -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<void> {
|
||||
async onWorkflowCreated(
|
||||
user: User,
|
||||
workflow: IWorkflowBase,
|
||||
project: Project,
|
||||
publicApi: boolean,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -93,7 +93,7 @@ export const getAuthIdentityByLdapId = async (
|
|||
idAttributeValue: string,
|
||||
): Promise<AuthIdentity | null> => {
|
||||
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<string[]> => {
|
|||
|
||||
export const getLdapUsers = async (): Promise<User[]> => {
|
||||
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<void> => {
|
||||
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<User>, ldapId: string) => {
|
||||
const user = await Container.get(UserRepository).save(
|
||||
{
|
||||
const { user } = await Container.get(UserRepository).createUserWithProject({
|
||||
password: randomPassword(),
|
||||
role: 'global:member',
|
||||
...data,
|
||||
},
|
||||
{ transaction: false },
|
||||
);
|
||||
});
|
||||
await createLdapAuthIdentity(user, ldapId);
|
||||
return user;
|
||||
};
|
||||
|
@ -281,7 +289,11 @@ export const createLdapUserOnLocalDb = async (data: Partial<User>, ldapId: strin
|
|||
export const updateLdapUserOnLocalDb = async (identity: AuthIdentity, data: Partial<User>) => {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
|
|
|
@ -25,10 +25,16 @@ export class MfaService {
|
|||
secret,
|
||||
recoveryCodes,
|
||||
);
|
||||
return await this.userRepository.update(userId, {
|
||||
|
||||
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, {
|
||||
const user = await this.userRepository.findOneBy({ id: userId });
|
||||
|
||||
if (user) {
|
||||
Object.assign(user, {
|
||||
mfaEnabled: false,
|
||||
mfaSecret: null,
|
||||
mfaRecoveryCodes: [],
|
||||
});
|
||||
await this.userRepository.save(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<string, string>>;
|
||||
}
|
||||
|
||||
export type OperationID = 'getUsers' | 'getUser';
|
||||
|
|
|
@ -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<Response> => {
|
||||
try {
|
||||
const { SecurityAuditService } = await import('@/security-audit/SecurityAudit.service');
|
||||
|
|
|
@ -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<express.Response> => {
|
||||
const { credentialTypeName } = req.params;
|
||||
|
||||
|
|
|
@ -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<ICredentialsDb | null> {
|
||||
|
@ -28,7 +29,7 @@ export async function getSharedCredentials(
|
|||
): Promise<SharedCredentials | null> {
|
||||
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<SharedCredentials>(newSharedCredential);
|
||||
|
|
|
@ -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<express.Response> => {
|
||||
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<express.Response> => {
|
||||
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<express.Response> => {
|
||||
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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<express.Response> => {
|
||||
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<express.Response> => {
|
||||
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<express.Response> => {
|
||||
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<express.Response> => {
|
||||
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<express.Response> => {
|
||||
const { id } = req.params;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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<express.Response> => {
|
||||
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<express.Response> => {
|
||||
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<express.Response> => {
|
||||
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<express.Response> => {
|
||||
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<express.Response> => {
|
||||
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<express.Response> => {
|
||||
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<express.Response> => {
|
||||
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<express.Response> => {
|
||||
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<express.Response> => {
|
||||
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
|
||||
|
|
|
@ -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<string[]> {
|
||||
const where = ['global:owner', 'global:admin'].includes(user.role) ? {} : { userId: user.id };
|
||||
const sharedWorkflows = await Container.get(SharedWorkflowRepository).find({
|
||||
where,
|
||||
select: ['workflowId'],
|
||||
export async function getSharedWorkflowIds(user: User, scopes: Scope[]): Promise<string[]> {
|
||||
if (Container.get(License).isSharingEnabled()) {
|
||||
return await Container.get(WorkflowSharingService).getSharedWorkflowIds(user, {
|
||||
scopes,
|
||||
});
|
||||
return sharedWorkflows.map(({ workflowId }) => workflowId);
|
||||
} 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<WorkflowEntity | null
|
|||
export async function createWorkflow(
|
||||
workflow: WorkflowEntity,
|
||||
user: User,
|
||||
personalProject: Project,
|
||||
role: WorkflowSharingRole,
|
||||
): Promise<WorkflowEntity> {
|
||||
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<SharedWorkflow>(newSharedWorkflow);
|
||||
|
|
|
@ -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<express.Response | void> => {
|
||||
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,
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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`,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
let project: Project | undefined = undefined;
|
||||
try {
|
||||
user = await Container.get(OwnershipService).getWorkflowOwnerCached(workflowData.id);
|
||||
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<IN8nHttpFullResponse> | undefined;
|
||||
|
|
|
@ -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<IWorkflowExecutionDataProcess> {
|
||||
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<IWorkflowExecuteAdditionalData> {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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<CredentialsEntity>, user: User) {
|
||||
private async storeCredential(credential: Partial<CredentialsEntity>, 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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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<void> {
|
||||
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',
|
||||
}),
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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<NumericLicenseFeature, number> = {
|
||||
[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<NumericLicenseFeature> = (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({
|
||||
if (owner?.mfaSecret && owner.mfaRecoveryCodes?.length) {
|
||||
const { encryptedRecoveryCodes, encryptedSecret } =
|
||||
this.mfaService.encryptSecretAndRecoveryCodes(owner.mfaSecret, owner.mfaRecoveryCodes);
|
||||
owner.mfaSecret = encryptedSecret;
|
||||
owner.mfaRecoveryCodes = encryptedRecoveryCodes;
|
||||
}
|
||||
|
||||
const userCreatePromises = [
|
||||
this.userRepository.createUserWithProject({
|
||||
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;
|
||||
}
|
||||
|
||||
const adminUser = this.userRepo.create({
|
||||
userCreatePromises.push(
|
||||
this.userRepository.createUserWithProject({
|
||||
id: uuid(),
|
||||
...admin,
|
||||
password: await this.passwordUtility.hash(admin.password),
|
||||
role: 'global:admin',
|
||||
});
|
||||
|
||||
const users = [];
|
||||
|
||||
users.push(instanceOwner, adminUser);
|
||||
}),
|
||||
);
|
||||
|
||||
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' },
|
||||
|
|
|
@ -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.',
|
||||
);
|
||||
}
|
||||
|
|
|
@ -47,6 +47,7 @@ export abstract class AbstractOAuthController {
|
|||
const credential = await this.sharedCredentialsRepository.findCredentialForUser(
|
||||
credentialId,
|
||||
req.user,
|
||||
['credential:read'],
|
||||
);
|
||||
|
||||
if (!credential) {
|
||||
|
|
|
@ -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 });
|
||||
|
|
221
packages/cli/src/controllers/project.controller.ts
Normal file
221
packages/cli/src/controllers/project.controller.ts
Normal file
|
@ -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<Project[]> {
|
||||
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<Project> {
|
||||
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<ProjectRequest.GetMyProjectsResponse> {
|
||||
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<ProjectRequest.ProjectWithRelations> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
22
packages/cli/src/controllers/role.controller.ts
Normal file
22
packages/cli/src/controllers/role.controller.ts
Normal file
|
@ -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),
|
||||
})),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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 (!transfereePersonalProject) {
|
||||
throw new NotFoundError(
|
||||
'Request to delete a user failed because the transferee project was not found in DB',
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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(
|
||||
const storedCredential = await this.sharedCredentialsRepository.findCredentialForUser(
|
||||
credentials.id,
|
||||
req.user,
|
||||
credentialId,
|
||||
['credential:read'],
|
||||
);
|
||||
|
||||
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 });
|
||||
if (!storedCredential) {
|
||||
throw new ForbiddenError();
|
||||
}
|
||||
|
||||
const mergedCredentials = deepCopy(credentials);
|
||||
if (mergedCredentials.data && sharing?.credentials) {
|
||||
const decryptedData = this.credentialsService.decrypt(sharing.credentials);
|
||||
mergedCredentials.data = this.credentialsService.unredact(
|
||||
mergedCredentials.data,
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
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 mergedCredentials = deepCopy(credentials);
|
||||
if (mergedCredentials.data && sharing?.credentials) {
|
||||
const decryptedData = this.credentialsService.decrypt(sharing.credentials);
|
||||
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(
|
||||
const credential = await this.sharedCredentialsRepository.findCredentialForUser(
|
||||
credentialId,
|
||||
req.user,
|
||||
credentialId,
|
||||
{
|
||||
allowGlobalScope: true,
|
||||
globalScope: 'credential:share',
|
||||
},
|
||||
['credential:share'],
|
||||
);
|
||||
credential = sharedRes?.credentials;
|
||||
}
|
||||
if (!credential) {
|
||||
throw new UnauthorizedError('Forbidden');
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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<SharedCredentials> = { 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 };
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<ICredentialsDb>, 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<SharedCredentials> = { credentials: true },
|
||||
): Promise<SharedCredentials | null> {
|
||||
const where: FindOptionsWhere<SharedCredentials> = { credentialsId: credentialId };
|
||||
let where: FindOptionsWhere<SharedCredentials> = { 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();
|
||||
|
||||
Object.assign(newSharedCredential, {
|
||||
role: 'credential:owner',
|
||||
const project =
|
||||
projectId === undefined
|
||||
? await this.projectRepository.getPersonalProjectForUserOrFail(user.id)
|
||||
: await this.projectService.getProjectWithScope(
|
||||
user,
|
||||
projectId,
|
||||
['credential:create'],
|
||||
transactionManager,
|
||||
);
|
||||
|
||||
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',
|
||||
credentials: savedCredential,
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
await transactionManager.save<SharedCredentials>(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<Scope[]> {
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
25
packages/cli/src/databases/entities/Project.ts
Normal file
25
packages/cli/src/databases/entities/Project.ts
Normal file
|
@ -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[];
|
||||
}
|
25
packages/cli/src/databases/entities/ProjectRelation.ts
Normal file
25
packages/cli/src/databases/entities/ProjectRelation.ts
Normal file
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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<GlobalRole, 'global:owner'>;
|
||||
|
||||
const STATIC_SCOPE_MAP: Record<GlobalRole, Scope[]> = {
|
||||
'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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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<Pick<User, 'id' | 'firstName' | 'lastName' | 'email'>>(
|
||||
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);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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<CredentialsEntity> {
|
|||
super(CredentialsEntity, dataSource.manager);
|
||||
}
|
||||
|
||||
async pruneSharings(
|
||||
transaction: EntityManager,
|
||||
credentialId: string,
|
||||
userIds: string[],
|
||||
): Promise<DeleteResult> {
|
||||
const conditions: FindOptionsWhere<SharedCredentials> = {
|
||||
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<CredentialsEntity> {
|
|||
|
||||
type Select = Array<keyof CredentialsEntity>;
|
||||
|
||||
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<CredentialsEntity> {
|
|||
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<CredentialsEntity> {
|
|||
const findManyOptions: FindManyOptions<CredentialsEntity> = { where: { id: In(ids) } };
|
||||
|
||||
if (withSharings) {
|
||||
findManyOptions.relations = ['shared', 'shared.user'];
|
||||
findManyOptions.relations = {
|
||||
shared: {
|
||||
project: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return await this.find(findManyOptions);
|
||||
|
|
|
@ -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<Project> {
|
||||
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' } }),
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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<ProjectRelation> {
|
||||
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<ProjectRole, number>,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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<SharedCredentials> {
|
||||
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<SharedCredentials>,
|
||||
) {
|
||||
let where: FindOptionsWhere<SharedCredentials> = { 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<SharedCredentials> {
|
|||
|
||||
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<SharedCredentials> {
|
|||
});
|
||||
}
|
||||
|
||||
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: {
|
||||
role: In(credentialRoles),
|
||||
project: {
|
||||
projectRelations: {
|
||||
userId: In(userIds),
|
||||
role: In(roles),
|
||||
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<string[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<SharedWorkflow> {
|
||||
constructor(dataSource: DataSource) {
|
||||
constructor(
|
||||
dataSource: DataSource,
|
||||
private roleService: RoleService,
|
||||
) {
|
||||
super(SharedWorkflow, dataSource.manager);
|
||||
}
|
||||
|
||||
async hasAccess(workflowId: string, user: User) {
|
||||
const where: FindOptionsWhere<SharedWorkflow> = {
|
||||
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<SharedWorkflow> {
|
|||
|
||||
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<SharedWorkflow> {
|
|||
userId: string,
|
||||
workflowId: string,
|
||||
): Promise<WorkflowSharingRole | undefined> {
|
||||
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<SharedWorkflow> = {
|
||||
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<SharedWorkflow | null> {
|
||||
const where: FindOptionsWhere<SharedWorkflow> = { 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[];
|
||||
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,
|
||||
},
|
||||
): Promise<SharedWorkflow[]> {
|
||||
return await this.find({
|
||||
where: {
|
||||
...(!['global:owner', 'global:admin'].includes(user.role) && { userId: user.id }),
|
||||
...(options.workflowIds && { workflowId: In(options.workflowIds) }),
|
||||
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<SharedWorkflow[]>((acc, user) => {
|
||||
if (user.isPending) {
|
||||
return acc;
|
||||
async makeOwnerOfAllWorkflows(project: Project) {
|
||||
return await this.update(
|
||||
{
|
||||
projectId: Not(project.id),
|
||||
role: 'workflow:owner',
|
||||
},
|
||||
{ project },
|
||||
);
|
||||
}
|
||||
const entity: Partial<SharedWorkflow> = {
|
||||
workflowId: workflow.id,
|
||||
userId: user.id,
|
||||
role: 'workflow:editor',
|
||||
};
|
||||
acc.push(this.create(entity));
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
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<SharedWorkflow> {
|
|||
});
|
||||
}
|
||||
|
||||
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<SharedWorkflow> = { 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<SharedWorkflow> = {};
|
||||
|
||||
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<string[]>((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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<User> {
|
||||
constructor(dataSource: DataSource) {
|
||||
|
@ -16,6 +18,19 @@ export class UserRepository extends Repository<User> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @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<Repository<User>['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<User> {
|
|||
where: { id: In(userIds), password: Not(IsNull()) },
|
||||
});
|
||||
}
|
||||
|
||||
async createUserWithProject(
|
||||
user: DeepPartial<User>,
|
||||
transactionManager?: EntityManager,
|
||||
): Promise<{ user: User; project: Project }> {
|
||||
const createInner = async (entityManager: EntityManager) => {
|
||||
const newUser = entityManager.create(User, user);
|
||||
const savedUser = await entityManager.save<User>(newUser);
|
||||
const savedProject = await entityManager.save<Project>(
|
||||
entityManager.create(Project, {
|
||||
type: 'personal',
|
||||
name: savedUser.createPersonalProjectName(),
|
||||
}),
|
||||
);
|
||||
await entityManager.save<ProjectRelation>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<WorkflowEntity> {
|
|||
super(WorkflowEntity, dataSource.manager);
|
||||
}
|
||||
|
||||
async get(where: FindOptionsWhere<WorkflowEntity>, options?: { relations: string[] }) {
|
||||
async get(
|
||||
where: FindOptionsWhere<WorkflowEntity>,
|
||||
options?: { relations: string[] | FindOptionsRelations<WorkflowEntity> },
|
||||
) {
|
||||
return await this.findOne({
|
||||
where,
|
||||
relations: options?.relations,
|
||||
|
@ -35,7 +35,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
|
|||
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<WorkflowEntity> {
|
|||
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<WorkflowEntity> {
|
|||
return totalTriggerCount ?? 0;
|
||||
}
|
||||
|
||||
async getSharings(
|
||||
transaction: EntityManager,
|
||||
workflowId: string,
|
||||
relations = ['shared'],
|
||||
): Promise<SharedWorkflow[]> {
|
||||
const workflow = await transaction.findOne(WorkflowEntity, {
|
||||
where: { id: workflowId },
|
||||
relations,
|
||||
});
|
||||
return workflow?.shared ?? [];
|
||||
}
|
||||
|
||||
async pruneSharings(
|
||||
transaction: EntityManager,
|
||||
workflowId: string,
|
||||
userIds: string[],
|
||||
): Promise<DeleteResult> {
|
||||
return await transaction.delete(SharedWorkflow, {
|
||||
workflowId,
|
||||
userId: Not(In(userIds)),
|
||||
});
|
||||
}
|
||||
|
||||
async updateWorkflowTriggerCount(id: string, triggerCount: number): Promise<UpdateResult> {
|
||||
const qb = this.createQueryBuilder('workflow');
|
||||
return await qb
|
||||
|
@ -114,6 +91,11 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
|
|||
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<WorkflowEntity> = {
|
||||
...options?.filter,
|
||||
id: In(sharedWorkflowIds),
|
||||
|
@ -135,7 +117,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
|
|||
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<WorkflowEntity> {
|
|||
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}%`);
|
||||
|
|
|
@ -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<WorkflowStatistics>
|
|||
}
|
||||
|
||||
async queryNumWorkflowsUserHasWithFiveOrMoreProdExecs(userId: User['id']): Promise<number> {
|
||||
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),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
73
packages/cli/src/databases/subscribers/UserSubscriber.ts
Normal file
73
packages/cli/src/databases/subscribers/UserSubscriber.ts
Normal file
|
@ -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<User> {
|
||||
listenTo() {
|
||||
return User;
|
||||
}
|
||||
|
||||
async afterUpdate(event: UpdateEvent<User>): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
5
packages/cli/src/databases/subscribers/index.ts
Normal file
5
packages/cli/src/databases/subscribers/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { UserSubscriber } from './UserSubscriber';
|
||||
|
||||
export const subscribers = {
|
||||
UserSubscriber,
|
||||
};
|
60
packages/cli/src/decorators/Scoped.ts
Normal file
60
packages/cli/src/decorators/Scoped.ts
Normal file
|
@ -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);
|
||||
};
|
|
@ -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);
|
||||
};
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue