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:
Csaba Tuncsik 2024-05-17 10:53:15 +02:00 committed by GitHub
parent b1f977ebd0
commit 596c472ecc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
292 changed files with 14129 additions and 3989 deletions

View 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();
};

View file

@ -30,7 +30,7 @@ const workflowSharingModal = new WorkflowSharingModal();
const ndv = new NDV(); const ndv = new NDV();
describe('Sharing', { disableAutoLogin: true }, () => { describe('Sharing', { disableAutoLogin: true }, () => {
before(() => cy.enableFeature('sharing', true)); before(() => cy.enableFeature('sharing'));
let workflowW2Url = ''; let workflowW2Url = '';
it('should create C1, W1, W2, share W1 with U3, as U2', () => { 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'); cy.get('input').should('not.have.length');
credentialsModal.actions.changeTab('Sharing'); credentialsModal.actions.changeTab('Sharing');
cy.contains( 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'); ).should('be.visible');
credentialsModal.getters.usersSelect().click(); credentialsModal.getters.usersSelect().click();
cy.getByTestId('user-email') cy.getByTestId('project-sharing-info')
.filter(':visible') .filter(':visible')
.should('have.length', 3) .should('have.length', 3)
.contains(INSTANCE_ADMIN.email) .contains(INSTANCE_ADMIN.email)

View file

@ -501,7 +501,7 @@ describe('Execution', () => {
workflowPage.getters.clearExecutionDataButton().should('be.visible'); workflowPage.getters.clearExecutionDataButton().should('be.visible');
cy.intercept('POST', '/rest/workflows/run').as('workflowRun'); cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun');
workflowPage.getters workflowPage.getters
.canvasNodeByName('do something with them') .canvasNodeByName('do something with them')
@ -525,7 +525,7 @@ describe('Execution', () => {
workflowPage.getters.zoomToFitButton().click(); workflowPage.getters.zoomToFitButton().click();
cy.intercept('POST', '/rest/workflows/run').as('workflowRun'); cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun');
workflowPage.getters workflowPage.getters
.canvasNodeByName('If') .canvasNodeByName('If')
@ -545,7 +545,7 @@ describe('Execution', () => {
workflowPage.getters.clearExecutionDataButton().should('be.visible'); workflowPage.getters.clearExecutionDataButton().should('be.visible');
cy.intercept('POST', '/rest/workflows/run').as('workflowRun'); cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun');
workflowPage.getters workflowPage.getters
.canvasNodeByName('NoOp2') .canvasNodeByName('NoOp2')
@ -576,7 +576,7 @@ describe('Execution', () => {
'My test workflow', '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.zoomToFitButton().click();
workflowPage.getters.executeWorkflowButton().click(); workflowPage.getters.executeWorkflowButton().click();
@ -599,7 +599,7 @@ describe('Execution', () => {
'My test workflow', '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.zoomToFitButton().click();
workflowPage.getters.executeWorkflowButton().click(); workflowPage.getters.executeWorkflowButton().click();

View file

@ -4,7 +4,7 @@ const variablesPage = new VariablesPage();
describe('Variables', () => { describe('Variables', () => {
it('should show the unlicensed action box when the feature is disabled', () => { it('should show the unlicensed action box when the feature is disabled', () => {
cy.disableFeature('variables', false); cy.disableFeature('variables');
cy.visit(variablesPage.url); cy.visit(variablesPage.url);
variablesPage.getters.unavailableResourcesList().should('be.visible'); variablesPage.getters.unavailableResourcesList().should('be.visible');
@ -18,14 +18,15 @@ describe('Variables', () => {
beforeEach(() => { beforeEach(() => {
cy.intercept('GET', '/rest/variables').as('loadVariables'); cy.intercept('GET', '/rest/variables').as('loadVariables');
cy.intercept('GET', '/rest/login').as('login');
cy.visit(variablesPage.url); 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', () => { it('should show the licensed action box when the feature is enabled', () => {
variablesPage.getters.emptyResourcesList().should('be.visible'); 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', () => { it('should create a new variable using empty state row', () => {

View file

@ -19,7 +19,7 @@ describe('Debug', () => {
it('should be able to debug executions', () => { it('should be able to debug executions', () => {
cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions'); cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions');
cy.intercept('GET', '/rest/executions/*').as('getExecution'); 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 }); cy.signin({ email: INSTANCE_OWNER.email, password: INSTANCE_OWNER.password });

View file

@ -10,7 +10,7 @@ describe('Workflow templates', () => {
beforeEach(() => { beforeEach(() => {
cy.intercept('GET', '**/rest/settings', (req) => { cy.intercept('GET', '**/rest/settings', (req) => {
// Disable cache // Disable cache
delete req.headers['if-none-match'] delete req.headers['if-none-match'];
req.reply((res) => { req.reply((res) => {
if (res.body.data) { if (res.body.data) {
// Disable custom templates host if it has been overridden by another intercept // Disable custom templates host if it has been overridden by another intercept
@ -22,18 +22,27 @@ describe('Workflow templates', () => {
it('Opens website when clicking templates sidebar link', () => { it('Opens website when clicking templates sidebar link', () => {
cy.visit(workflowsPage.url); 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 // 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 // Link should contain instance address and n8n version
mainSidebar.getters.templates().parent('a').then(($a) => { mainSidebar.getters
const href = $a.attr('href'); .templates()
const params = new URLSearchParams(href); .parent('a')
// Link should have all mandatory parameters expected on the website .then(($a) => {
expect(decodeURIComponent(`${params.get('utm_instance')}`)).to.include(window.location.origin); const href = $a.attr('href');
expect(params.get('utm_n8n_version')).to.match(/[0-9]+\.[0-9]+\.[0-9]+/); const params = new URLSearchParams(href);
expect(params.get('utm_awc')).to.match(/[0-9]+/); // Link should have all mandatory parameters expected on the website
}); expect(decodeURIComponent(`${params.get('utm_instance')}`)).to.include(
window.location.origin,
);
expect(params.get('utm_n8n_version')).to.match(/[0-9]+\.[0-9]+\.[0-9]+/);
expect(params.get('utm_awc')).to.match(/[0-9]+/);
});
mainSidebar.getters.templates().parent('a').should('have.attr', 'target', '_blank'); mainSidebar.getters.templates().parent('a').should('have.attr', 'target', '_blank');
}); });
@ -41,6 +50,6 @@ describe('Workflow templates', () => {
cy.visit(templatesPage.url); cy.visit(templatesPage.url);
cy.origin('https://n8n.io', () => { cy.origin('https://n8n.io', () => {
cy.url().should('include', 'https://n8n.io/workflows'); cy.url().should('include', 'https://n8n.io/workflows');
}) });
}); });
}); });

View file

@ -148,7 +148,7 @@ describe('Editor actions should work', () => {
it('after switching between Editor and Debug', () => { it('after switching between Editor and Debug', () => {
cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions'); cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions');
cy.intercept('GET', '/rest/executions/*').as('getExecution'); cy.intercept('GET', '/rest/executions/*').as('getExecution');
cy.intercept('POST', '/rest/workflows/run').as('postWorkflowRun'); cy.intercept('POST', '/rest/workflows/**/run').as('postWorkflowRun');
editWorkflowAndDeactivate(); editWorkflowAndDeactivate();
workflowPage.actions.executeWorkflow(); 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/*/version/*').as('getVersion');
cy.intercept('GET', '/rest/workflow-history/workflow/*').as('getHistory'); cy.intercept('GET', '/rest/workflow-history/workflow/*').as('getHistory');
cy.intercept('GET', '/rest/users').as('getUsers'); 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/active-workflows').as('getActiveWorkflows');
cy.intercept('GET', '/rest/credentials').as('getCredentials'); cy.intercept('GET', '/rest/credentials?*').as('getCredentials');
switchBetweenEditorAndHistory(); switchBetweenEditorAndHistory();
zoomInAndCheckNodes(); zoomInAndCheckNodes();

View 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');
});
});
});

View file

@ -697,7 +697,7 @@ describe('NDV', () => {
}); });
it('Stop listening for trigger event from 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', { workflowPage.actions.addInitialNodeToCanvas('Local File Trigger', {
keepNdvOpen: true, keepNdvOpen: true,
action: 'On Changes To A Specific File', action: 'On Changes To A Specific File',

View file

@ -1,7 +1,7 @@
import { BasePage } from './base'; import { BasePage } from './base';
export class CredentialsPage extends BasePage { export class CredentialsPage extends BasePage {
url = '/credentials'; url = '/home/credentials';
getters = { getters = {
emptyListCreateCredentialButton: () => cy.getByTestId('empty-resources-list').find('button'), emptyListCreateCredentialButton: () => cy.getByTestId('empty-resources-list').find('button'),
createCredentialButton: () => cy.getByTestId('resources-list-add'), createCredentialButton: () => cy.getByTestId('resources-list-add'),

View file

@ -25,7 +25,7 @@ export class CredentialsModal extends BasePage {
credentialInputs: () => cy.getByTestId('credential-connection-parameter'), credentialInputs: () => cy.getByTestId('credential-connection-parameter'),
menu: () => this.getters.editCredentialModal().get('.menu-container'), menu: () => this.getters.editCredentialModal().get('.menu-container'),
menuItem: (name: string) => this.getters.menu().get('.n8n-menu-item').contains(name), 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'), testSuccessTag: () => cy.getByTestId('credentials-config-container-test-success'),
}; };
actions = { actions = {

View file

@ -3,7 +3,7 @@ import { BasePage } from '../base';
export class WorkflowSharingModal extends BasePage { export class WorkflowSharingModal extends BasePage {
getters = { getters = {
modal: () => cy.getByTestId('workflowShare-modal', { timeout: 5000 }), 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'), saveButton: () => cy.getByTestId('workflow-sharing-modal-save-button'),
closeButton: () => this.getters.modal().find('.el-dialog__close').first(), closeButton: () => this.getters.modal().find('.el-dialog__close').first(),
}; };

View file

@ -41,10 +41,10 @@ export class SettingsUsersPage extends BasePage {
workflowPage.actions.visit(); workflowPage.actions.visit();
mainSidebar.actions.goToSettings(); mainSidebar.actions.goToSettings();
if (isOwner) { if (isOwner) {
settingsSidebar.getters.menuItem('Users').click(); settingsSidebar.getters.users().click();
cy.url().should('match', new RegExp(this.url)); cy.url().should('match', new RegExp(this.url));
} else { } 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 // Should be redirected to workflows page if trying to access UM url
cy.visit('/settings/users'); cy.visit('/settings/users');
cy.url().should('match', new RegExp(workflowsPage.url)); cy.url().should('match', new RegExp(workflowsPage.url));

View file

@ -5,14 +5,13 @@ const workflowsPage = new WorkflowsPage();
export class MainSidebar extends BasePage { export class MainSidebar extends BasePage {
getters = { getters = {
menuItem: (menuLabel: string) => menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id),
cy.getByTestId('menu-item').filter(`:contains("${menuLabel}")`), settings: () => this.getters.menuItem('settings'),
settings: () => this.getters.menuItem('Settings'), templates: () => this.getters.menuItem('templates'),
templates: () => this.getters.menuItem('Templates'), workflows: () => this.getters.menuItem('workflows'),
workflows: () => this.getters.menuItem('Workflows'), credentials: () => this.getters.menuItem('credentials'),
credentials: () => this.getters.menuItem('Credentials'), executions: () => this.getters.menuItem('executions'),
executions: () => this.getters.menuItem('Executions'), adminPanel: () => this.getters.menuItem('cloud-admin'),
adminPanel: () => this.getters.menuItem('Admin Panel'),
userMenu: () => cy.get('div[class="action-dropdown-container"]'), userMenu: () => cy.get('div[class="action-dropdown-container"]'),
logo: () => cy.getByTestId('n8n-logo'), logo: () => cy.getByTestId('n8n-logo'),
}; };

View file

@ -2,9 +2,8 @@ import { BasePage } from '../base';
export class SettingsSidebar extends BasePage { export class SettingsSidebar extends BasePage {
getters = { getters = {
menuItem: (menuLabel: string) => menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id),
cy.getByTestId('menu-item').filter(`:contains("${menuLabel}")`), users: () => this.getters.menuItem('settings-users'),
users: () => this.getters.menuItem('Users'),
back: () => cy.getByTestId('settings-back'), back: () => cy.getByTestId('settings-back'),
}; };
actions = { actions = {

View file

@ -35,7 +35,7 @@ export class VariablesPage extends BasePage {
deleteVariable: (key: string) => { deleteVariable: (key: string) => {
const row = this.getters.variableRow(key); const row = this.getters.variableRow(key);
row.within(() => { 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"]'); const modal = cy.get('[role="dialog"]');
@ -53,7 +53,7 @@ export class VariablesPage extends BasePage {
editRow: (key: string) => { editRow: (key: string) => {
const row = this.getters.variableRow(key); const row = this.getters.variableRow(key);
row.within(() => { 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) => { setRowValue: (row: Chainable<JQuery<HTMLElement>>, field: 'key' | 'value', value: string) => {

View file

@ -32,7 +32,7 @@ export class WorkflowExecutionsTab extends BasePage {
}, },
createManualExecutions: (count: number) => { createManualExecutions: (count: number) => {
for (let i = 0; i < count; i++) { 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(); workflowPage.actions.executeWorkflow();
cy.wait('@workflowExecution'); cy.wait('@workflowExecution');
} }

View file

@ -1,7 +1,7 @@
import { BasePage } from './base'; import { BasePage } from './base';
export class WorkflowsPage extends BasePage { export class WorkflowsPage extends BasePage {
url = '/workflows'; url = '/home/workflows';
getters = { getters = {
newWorkflowButtonCard: () => cy.getByTestId('new-workflow-card'), newWorkflowButtonCard: () => cy.getByTestId('new-workflow-card'),
newWorkflowTemplateCard: () => cy.getByTestId('new-workflow-template-card'), newWorkflowTemplateCard: () => cy.getByTestId('new-workflow-template-card'),

View file

@ -65,7 +65,7 @@ Cypress.Commands.add('signout', () => {
cy.request({ cy.request({
method: 'POST', method: 'POST',
url: `${BACKEND_BASE_URL}/rest/logout`, 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'); cy.getCookie(N8N_AUTH_COOKIE).should('not.exist');
}); });
@ -80,12 +80,19 @@ const setFeature = (feature: string, enabled: boolean) =>
enabled, enabled,
}); });
const setQuota = (feature: string, value: number) =>
cy.request('PATCH', `${BACKEND_BASE_URL}/rest/e2e/quota`, {
feature: `quota:${feature}`,
value,
});
const setQueueMode = (enabled: boolean) => const setQueueMode = (enabled: boolean) =>
cy.request('PATCH', `${BACKEND_BASE_URL}/rest/e2e/queue-mode`, { cy.request('PATCH', `${BACKEND_BASE_URL}/rest/e2e/queue-mode`, {
enabled, enabled,
}); });
Cypress.Commands.add('enableFeature', (feature: string) => setFeature(feature, true)); 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('disableFeature', (feature: string) => setFeature(feature, false));
Cypress.Commands.add('enableQueueMode', () => setQueueMode(true)); Cypress.Commands.add('enableQueueMode', () => setQueueMode(true));
Cypress.Commands.add('disableQueueMode', () => setQueueMode(false)); Cypress.Commands.add('disableQueueMode', () => setQueueMode(false));

View file

@ -30,6 +30,7 @@ declare global {
disableFeature(feature: string): void; disableFeature(feature: string): void;
enableQueueMode(): void; enableQueueMode(): void;
disableQueueMode(): void; disableQueueMode(): void;
changeQuota(feature: string, value: number): void;
waitForLoad(waitForIntercepts?: boolean): void; waitForLoad(waitForIntercepts?: boolean): void;
grantBrowserPermissions(...permissions: string[]): void; grantBrowserPermissions(...permissions: string[]): void;
readClipboard(): Chainable<string>; readClipboard(): Chainable<string>;

View file

@ -29,7 +29,7 @@ export function createMockNodeExecutionData(
]; ];
return acc; return acc;
}, {}) }, {})
: data, : data,
source: [null], source: [null],
...rest, ...rest,
@ -88,7 +88,7 @@ export function runMockWorkflowExcution({
}) { }) {
const executionId = Math.random().toString(36).substring(4); const executionId = Math.random().toString(36).substring(4);
cy.intercept('POST', '/rest/workflows/run', { cy.intercept('POST', '/rest/workflows/**/run', {
statusCode: 201, statusCode: 201,
body: { body: {
data: { data: {

View 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());
}

View file

@ -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( export function hasScope(
scope: Scope | Scope[], scope: Scope | Scope[],
userScopes: GlobalScopes, userScopes: GlobalScopes,
masks?: MaskLevels,
options?: ScopeOptions, options?: ScopeOptions,
): boolean; ): boolean;
export function hasScope( export function hasScope(
scope: Scope | Scope[], scope: Scope | Scope[],
userScopes: ScopeLevels, userScopes: ScopeLevels,
masks?: MaskLevels,
options?: ScopeOptions, options?: ScopeOptions,
): boolean; ): boolean;
export function hasScope( export function hasScope(
scope: Scope | Scope[], scope: Scope | Scope[],
userScopes: GlobalScopes | ScopeLevels, userScopes: GlobalScopes | ScopeLevels,
masks?: MaskLevels,
options: ScopeOptions = { mode: 'oneOf' }, options: ScopeOptions = { mode: 'oneOf' },
): boolean { ): boolean {
if (!Array.isArray(scope)) { if (!Array.isArray(scope)) {
scope = [scope]; scope = [scope];
} }
const userScopeSet = new Set(Object.values(userScopes).flat()); const userScopeSet = combineScopes(userScopes, masks);
if (options.mode === 'allOf') { if (options.mode === 'allOf') {
return !!scope.length && scope.every((s) => userScopeSet.has(s)); return !!scope.length && scope.every((s) => userScopeSet.has(s));

View file

@ -1,2 +1,3 @@
export type * from './types'; export type * from './types';
export * from './hasScope'; export * from './hasScope';
export * from './combineScopes';

View file

@ -12,8 +12,10 @@ export type Resource =
| 'license' | 'license'
| 'logStreaming' | 'logStreaming'
| 'orchestration' | 'orchestration'
| 'sourceControl' | 'project'
| 'saml' | 'saml'
| 'securityAudit'
| 'sourceControl'
| 'tag' | 'tag'
| 'user' | 'user'
| 'variable' | 'variable'
@ -48,7 +50,9 @@ export type LdapScope = ResourceScope<'ldap', 'manage' | 'sync'>;
export type LicenseScope = ResourceScope<'license', 'manage'>; export type LicenseScope = ResourceScope<'license', 'manage'>;
export type LogStreamingScope = ResourceScope<'logStreaming', 'manage'>; export type LogStreamingScope = ResourceScope<'logStreaming', 'manage'>;
export type OrchestrationScope = ResourceScope<'orchestration', 'read' | 'list'>; export type OrchestrationScope = ResourceScope<'orchestration', 'read' | 'list'>;
export type ProjectScope = ResourceScope<'project'>;
export type SamlScope = ResourceScope<'saml', 'manage'>; export type SamlScope = ResourceScope<'saml', 'manage'>;
export type SecurityAuditScope = ResourceScope<'securityAudit', 'generate'>;
export type SourceControlScope = ResourceScope<'sourceControl', 'pull' | 'push' | 'manage'>; export type SourceControlScope = ResourceScope<'sourceControl', 'pull' | 'push' | 'manage'>;
export type TagScope = ResourceScope<'tag'>; export type TagScope = ResourceScope<'tag'>;
export type UserScope = ResourceScope<'user', DefaultOperations | 'resetPassword' | 'changeRole'>; export type UserScope = ResourceScope<'user', DefaultOperations | 'resetPassword' | 'changeRole'>;
@ -69,7 +73,9 @@ export type Scope =
| LicenseScope | LicenseScope
| LogStreamingScope | LogStreamingScope
| OrchestrationScope | OrchestrationScope
| ProjectScope
| SamlScope | SamlScope
| SecurityAuditScope
| SourceControlScope | SourceControlScope
| TagScope | TagScope
| UserScope | UserScope
@ -84,5 +90,10 @@ export type ProjectScopes = GetScopeLevel<'project'>;
export type ResourceScopes = GetScopeLevel<'resource'>; export type ResourceScopes = GetScopeLevel<'resource'>;
export type ScopeLevels = GlobalScopes & (ProjectScopes | (ProjectScopes & ResourceScopes)); 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 ScopeMode = 'oneOf' | 'allOf';
export type ScopeOptions = { mode: ScopeMode }; export type ScopeOptions = { mode: ScopeMode };

View file

@ -33,6 +33,7 @@ describe('hasScope', () => {
{ {
global: memberPermissions, global: memberPermissions,
}, },
undefined,
{ mode: 'oneOf' }, { mode: 'oneOf' },
), ),
).toBe(true); ).toBe(true);
@ -43,6 +44,7 @@ describe('hasScope', () => {
{ {
global: memberPermissions, global: memberPermissions,
}, },
undefined,
{ mode: 'allOf' }, { mode: 'allOf' },
), ),
).toBe(true); ).toBe(true);
@ -53,6 +55,7 @@ describe('hasScope', () => {
{ {
global: memberPermissions, global: memberPermissions,
}, },
undefined,
{ mode: 'oneOf' }, { mode: 'oneOf' },
), ),
).toBe(false); ).toBe(false);
@ -63,6 +66,7 @@ describe('hasScope', () => {
{ {
global: memberPermissions, global: memberPermissions,
}, },
undefined,
{ mode: 'allOf' }, { mode: 'allOf' },
), ),
).toBe(false); ).toBe(false);
@ -95,6 +99,7 @@ describe('hasScope', () => {
{ {
global: ownerPermissions, global: ownerPermissions,
}, },
undefined,
{ mode: 'allOf' }, { mode: 'allOf' },
), ),
).toBe(true); ).toBe(true);
@ -105,6 +110,7 @@ describe('hasScope', () => {
{ {
global: memberPermissions, global: memberPermissions,
}, },
undefined,
{ mode: 'allOf' }, { mode: 'allOf' },
), ),
).toBe(false); ).toBe(false);
@ -115,6 +121,7 @@ describe('hasScope', () => {
{ {
global: memberPermissions, global: memberPermissions,
}, },
undefined,
{ mode: 'allOf' }, { mode: 'allOf' },
), ),
).toBe(false); ).toBe(false);
@ -125,8 +132,127 @@ describe('hasScope', () => {
{ {
global: memberPermissions, global: memberPermissions,
}, },
undefined,
{ mode: 'allOf' }, { mode: 'allOf' },
), ),
).toBe(false); ).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);
});
});

View file

@ -35,4 +35,20 @@ module.exports = {
'@typescript-eslint/no-unsafe-enum-comparison': 'warn', '@typescript-eslint/no-unsafe-enum-comparison': 'warn',
'@typescript-eslint/no-unsafe-declaration-merging': 'warn', '@typescript-eslint/no-unsafe-declaration-merging': 'warn',
}, },
overrides: [
{
files: ['./src/decorators/**/*.ts'],
rules: {
'@typescript-eslint/ban-types': [
'warn',
{
types: {
Function: false,
},
},
],
},
},
],
}; };

View file

@ -84,7 +84,7 @@ export class ActiveWebhooks implements IWebhookManager {
const workflowData = await this.workflowRepository.findOne({ const workflowData = await this.workflowRepository.findOne({
where: { id: webhook.workflowId }, where: { id: webhook.workflowId },
relations: ['shared', 'shared.user'], relations: { shared: { project: { projectRelations: true } } },
}); });
if (workflowData === null) { if (workflowData === null) {
@ -102,9 +102,7 @@ export class ActiveWebhooks implements IWebhookManager {
settings: workflowData.settings, settings: workflowData.settings,
}); });
const additionalData = await WorkflowExecuteAdditionalData.getBase( const additionalData = await WorkflowExecuteAdditionalData.getBase();
workflowData.shared[0].user.id,
);
const webhookData = NodeHelpers.getNodeWebhooks( const webhookData = NodeHelpers.getNodeWebhooks(
workflow, workflow,

View file

@ -229,7 +229,6 @@ export class ActiveWorkflowManager {
async clearWebhooks(workflowId: string) { async clearWebhooks(workflowId: string) {
const workflowData = await this.workflowRepository.findOne({ const workflowData = await this.workflowRepository.findOne({
where: { id: workflowId }, where: { id: workflowId },
relations: ['shared', 'shared.user'],
}); });
if (workflowData === null) { if (workflowData === null) {
@ -249,9 +248,7 @@ export class ActiveWorkflowManager {
const mode = 'internal'; const mode = 'internal';
const additionalData = await WorkflowExecuteAdditionalData.getBase( const additionalData = await WorkflowExecuteAdditionalData.getBase();
workflowData.shared[0].user.id,
);
const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData, undefined, true); 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'); const additionalData = await WorkflowExecuteAdditionalData.getBase();
if (!sharing) {
throw new WorkflowActivationError(`Workflow ${dbWorkflow.display()} has no owner`);
}
const additionalData = await WorkflowExecuteAdditionalData.getBase(sharing.user.id);
if (shouldAddWebhooks) { if (shouldAddWebhooks) {
await this.addWebhooks(workflow, additionalData, 'trigger', activationMode); await this.addWebhooks(workflow, additionalData, 'trigger', activationMode);
@ -711,6 +702,7 @@ export class ActiveWorkflowManager {
* @param {string} workflowId The id of the workflow to deactivate * @param {string} workflowId The id of the workflow to deactivate
*/ */
// TODO: this should happen in a transaction // TODO: this should happen in a transaction
// maybe, see: https://github.com/n8n-io/n8n/pull/8904#discussion_r1530150510
async remove(workflowId: string) { async remove(workflowId: string) {
if (this.orchestrationService.isMultiMainSetupEnabled) { if (this.orchestrationService.isMultiMainSetupEnabled) {
try { try {

View file

@ -30,15 +30,15 @@ import { ICredentialsHelper, NodeHelpers, Workflow, ApplicationError } from 'n8n
import type { ICredentialsDb } from '@/Interfaces'; import type { ICredentialsDb } from '@/Interfaces';
import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
import { NodeTypes } from '@/NodeTypes';
import { CredentialTypes } from '@/CredentialTypes'; import { CredentialTypes } from '@/CredentialTypes';
import { CredentialsOverwrites } from '@/CredentialsOverwrites'; import { CredentialsOverwrites } from '@/CredentialsOverwrites';
import { RESPONSE_ERROR_MESSAGES } from './constants'; import { RESPONSE_ERROR_MESSAGES } from './constants';
import { Logger } from '@/Logger';
import { CredentialsRepository } from '@db/repositories/credentials.repository'; import { CredentialsRepository } from '@db/repositories/credentials.repository';
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
import { CredentialNotFoundError } from './errors/credential-not-found.error'; import { CredentialNotFoundError } from './errors/credential-not-found.error';
import { In } from '@n8n/typeorm';
import { CacheService } from './services/cache/cache.service';
const mockNode = { const mockNode = {
name: '', name: '',
@ -77,12 +77,11 @@ const mockNodeTypes: INodeTypes = {
@Service() @Service()
export class CredentialsHelper extends ICredentialsHelper { export class CredentialsHelper extends ICredentialsHelper {
constructor( constructor(
private readonly logger: Logger,
private readonly credentialTypes: CredentialTypes, private readonly credentialTypes: CredentialTypes,
private readonly nodeTypes: NodeTypes,
private readonly credentialsOverwrites: CredentialsOverwrites, private readonly credentialsOverwrites: CredentialsOverwrites,
private readonly credentialsRepository: CredentialsRepository, private readonly credentialsRepository: CredentialsRepository,
private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly cacheService: CacheService,
) { ) {
super(); super();
} }
@ -245,7 +244,6 @@ export class CredentialsHelper extends ICredentialsHelper {
async getCredentials( async getCredentials(
nodeCredential: INodeCredentialsDetails, nodeCredential: INodeCredentialsDetails,
type: string, type: string,
userId?: string,
): Promise<Credentials> { ): Promise<Credentials> {
if (!nodeCredential.id) { if (!nodeCredential.id) {
throw new ApplicationError('Found credential with no ID.', { throw new ApplicationError('Found credential with no ID.', {
@ -257,14 +255,10 @@ export class CredentialsHelper extends ICredentialsHelper {
let credential: CredentialsEntity; let credential: CredentialsEntity;
try { try {
credential = userId credential = await this.credentialsRepository.findOneByOrFail({
? await this.sharedCredentialsRepository id: nodeCredential.id,
.findOneOrFail({ type,
relations: ['credentials'], });
where: { credentials: { id: nodeCredential.id, type }, userId },
})
.then((shared) => shared.credentials)
: await this.credentialsRepository.findOneByOrFail({ id: nodeCredential.id, type });
} catch (error) { } catch (error) {
throw new CredentialNotFoundError(nodeCredential.id, type); throw new CredentialNotFoundError(nodeCredential.id, type);
} }
@ -338,7 +332,7 @@ export class CredentialsHelper extends ICredentialsHelper {
await additionalData?.secretsHelpers?.waitForInit(); await additionalData?.secretsHelpers?.waitForInit();
const canUseSecrets = await this.credentialOwnedByOwner(nodeCredentials); const canUseSecrets = await this.credentialCanUseExternalSecrets(nodeCredentials);
return this.applyDefaultsAndOverwrites( return this.applyDefaultsAndOverwrites(
additionalData, additionalData,
@ -457,28 +451,39 @@ export class CredentialsHelper extends ICredentialsHelper {
await this.credentialsRepository.update(findQuery, newCredentialsData); await this.credentialsRepository.update(findQuery, newCredentialsData);
} }
async credentialOwnedByOwner(nodeCredential: INodeCredentialsDetails): Promise<boolean> { async credentialCanUseExternalSecrets(nodeCredential: INodeCredentialsDetails): Promise<boolean> {
if (!nodeCredential.id) { if (!nodeCredential.id) {
return false; return false;
} }
const credential = await this.sharedCredentialsRepository.findOne({ return (
where: { (await this.cacheService.get(`credential-can-use-secrets:${nodeCredential.id}`, {
role: 'credential:owner', refreshFn: async () => {
user: { const credential = await this.sharedCredentialsRepository.findOne({
role: 'global:owner', where: {
}, role: 'credential:owner',
credentials: { project: {
id: nodeCredential.id, projectRelations: {
}, role: In(['project:personalOwner', 'project:admin']),
}, user: {
}); role: In(['global:owner', 'global:admin']),
},
},
},
credentials: {
id: nodeCredential.id!,
},
},
});
if (!credential) { if (!credential) {
return false; return false;
} }
return true; return true;
},
})) ?? false
);
} }
} }

View file

@ -535,7 +535,8 @@ export interface IWorkflowExecutionDataProcess {
pushRef?: string; pushRef?: string;
startNodes?: StartNodeData[]; startNodes?: StartNodeData[];
workflowData: IWorkflowBase; workflowData: IWorkflowBase;
userId: string; userId?: string;
projectId?: string;
} }
export interface IWorkflowExecuteProcess { export interface IWorkflowExecuteProcess {

View file

@ -34,6 +34,10 @@ import { License } from '@/License';
import { EventsService } from '@/services/events.service'; import { EventsService } from '@/services/events.service';
import { NodeTypes } from '@/NodeTypes'; import { NodeTypes } from '@/NodeTypes';
import { Telemetry } from '@/telemetry'; 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): { function userToPayload(user: User): {
userId: string; userId: string;
@ -62,6 +66,8 @@ export class InternalHooks {
private readonly instanceSettings: InstanceSettings, private readonly instanceSettings: InstanceSettings,
private readonly eventBus: MessageEventBus, private readonly eventBus: MessageEventBus,
private readonly license: License, private readonly license: License,
private readonly projectRelationRepository: ProjectRelationRepository,
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
) { ) {
eventsService.on( eventsService.on(
'telemetry.onFirstProductionWorkflowSuccess', '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); const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes);
void Promise.all([ void Promise.all([
this.eventBus.sendAuditEvent({ this.eventBus.sendAuditEvent({
@ -180,6 +191,8 @@ export class InternalHooks {
workflow_id: workflow.id, workflow_id: workflow.id,
node_graph_string: JSON.stringify(nodeGraph), node_graph_string: JSON.stringify(nodeGraph),
public_api: publicApi, public_api: publicApi,
project_id: project.id,
project_type: project.type,
}), }),
]); ]);
} }
@ -208,19 +221,32 @@ export class InternalHooks {
isCloudDeployment, 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 notesCount = Object.keys(nodeGraph.notes).length;
const overlappingCount = Object.values(nodeGraph.notes).filter( const overlappingCount = Object.values(nodeGraph.notes).filter(
(note) => note.overlapping, (note) => note.overlapping,
).length; ).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([ void Promise.all([
this.eventBus.sendAuditEvent({ this.eventBus.sendAuditEvent({
eventName: 'n8n.audit.workflow.updated', eventName: 'n8n.audit.workflow.updated',
@ -865,6 +891,9 @@ export class InternalHooks {
credential_id: string; credential_id: string;
public_api: boolean; public_api: boolean;
}): Promise<void> { }): Promise<void> {
const project = await this.sharedCredentialsRepository.findCredentialOwningProject(
userCreatedCredentialsData.credential_id,
);
void Promise.all([ void Promise.all([
this.eventBus.sendAuditEvent({ this.eventBus.sendAuditEvent({
eventName: 'n8n.audit.user.credentials.created', eventName: 'n8n.audit.user.credentials.created',
@ -880,6 +909,8 @@ export class InternalHooks {
credential_type: userCreatedCredentialsData.credential_type, credential_type: userCreatedCredentialsData.credential_type,
credential_id: userCreatedCredentialsData.credential_id, credential_id: userCreatedCredentialsData.credential_id,
instance_id: this.instanceSettings.instanceId, instance_id: this.instanceSettings.instanceId,
project_id: project?.id,
project_type: project?.type,
}), }),
]); ]);
} }
@ -1207,4 +1238,27 @@ export class InternalHooks {
}): Promise<void> { }): Promise<void> {
return await this.telemetry.track('User updated external secrets settings', saveData); 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);
}
} }

View file

@ -93,7 +93,7 @@ export const getAuthIdentityByLdapId = async (
idAttributeValue: string, idAttributeValue: string,
): Promise<AuthIdentity | null> => { ): Promise<AuthIdentity | null> => {
return await Container.get(AuthIdentityRepository).findOne({ return await Container.get(AuthIdentityRepository).findOne({
relations: ['user'], relations: { user: true },
where: { where: {
providerId: idAttributeValue, providerId: idAttributeValue,
providerType: 'ldap', providerType: 'ldap',
@ -140,7 +140,7 @@ export const getLdapIds = async (): Promise<string[]> => {
export const getLdapUsers = async (): Promise<User[]> => { export const getLdapUsers = async (): Promise<User[]> => {
const identities = await Container.get(AuthIdentityRepository).find({ const identities = await Container.get(AuthIdentityRepository).find({
relations: ['user'], relations: { user: true },
where: { where: {
providerType: 'ldap', providerType: 'ldap',
}, },
@ -179,10 +179,15 @@ export const processUsers = async (
toUpdateUsers: Array<[string, User]>, toUpdateUsers: Array<[string, User]>,
toDisableUsers: string[], toDisableUsers: string[],
): Promise<void> => { ): Promise<void> => {
const userRepository = Container.get(UserRepository);
await Db.transaction(async (transactionManager) => { await Db.transaction(async (transactionManager) => {
return await Promise.all([ return await Promise.all([
...toCreateUsers.map(async ([ldapId, user]) => { ...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); return await transactionManager.save(authIdentity);
}), }),
...toUpdateUsers.map(async ([ldapId, user]) => { ...toUpdateUsers.map(async ([ldapId, user]) => {
@ -202,7 +207,13 @@ export const processUsers = async (
providerId: ldapId, providerId: ldapId,
}); });
if (authIdentity?.userId) { 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 }); 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) => { 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(),
password: randomPassword(), role: 'global:member',
role: 'global:member', ...data,
...data, });
},
{ transaction: false },
);
await createLdapAuthIdentity(user, ldapId); await createLdapAuthIdentity(user, ldapId);
return user; return user;
}; };
@ -281,7 +289,11 @@ export const createLdapUserOnLocalDb = async (data: Partial<User>, ldapId: strin
export const updateLdapUserOnLocalDb = async (identity: AuthIdentity, data: Partial<User>) => { export const updateLdapUserOnLocalDb = async (identity: AuthIdentity, data: Partial<User>) => {
const userId = identity?.user?.id; const userId = identity?.user?.id;
if (userId) { 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 });
}
} }
}; };

View file

@ -349,7 +349,7 @@ export class LdapService {
localAdUsers, localAdUsers,
); );
this.logger.debug('LDAP - Users processed', { this.logger.debug('LDAP - Users to process', {
created: usersToCreate.length, created: usersToCreate.length,
updated: usersToUpdate.length, updated: usersToUpdate.length,
disabled: usersToDisable.length, disabled: usersToDisable.length,

View file

@ -289,6 +289,18 @@ export class License {
return this.isFeatureEnabled(LICENSE_FEATURES.WORKER_VIEW); 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() { getCurrentEntitlements() {
return this.manager?.getCurrentEntitlements() ?? []; return this.manager?.getCurrentEntitlements() ?? [];
} }
@ -341,6 +353,10 @@ export class License {
); );
} }
getTeamProjectLimit() {
return this.getFeatureValue(LICENSE_QUOTAS.TEAM_PROJECT_LIMIT) ?? 0;
}
getPlanName(): string { getPlanName(): string {
return this.getFeatureValue('planName') ?? 'Community'; return this.getFeatureValue('planName') ?? 'Community';
} }

View file

@ -25,10 +25,16 @@ export class MfaService {
secret, secret,
recoveryCodes, recoveryCodes,
); );
return await this.userRepository.update(userId, {
mfaSecret: encryptedSecret, const user = await this.userRepository.findOneBy({ id: userId });
mfaRecoveryCodes: encryptedRecoveryCodes, if (user) {
}); Object.assign(user, {
mfaSecret: encryptedSecret,
mfaRecoveryCodes: encryptedRecoveryCodes,
});
await this.userRepository.save(user);
}
} }
public encryptSecretAndRecoveryCodes(rawSecret: string, rawRecoveryCodes: string[]) { public encryptSecretAndRecoveryCodes(rawSecret: string, rawRecoveryCodes: string[]) {
@ -56,7 +62,12 @@ export class MfaService {
} }
public async enableMfa(userId: string) { 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[]) { public encryptRecoveryCodes(mfaRecoveryCodes: string[]) {
@ -64,10 +75,15 @@ export class MfaService {
} }
public async disableMfa(userId: string) { public async disableMfa(userId: string) {
await this.userRepository.update(userId, { const user = await this.userRepository.findOneBy({ id: userId });
mfaEnabled: false,
mfaSecret: null, if (user) {
mfaRecoveryCodes: [], Object.assign(user, {
}); mfaEnabled: false,
mfaSecret: null,
mfaRecoveryCodes: [],
});
await this.userRepository.save(user);
}
} }
} }

View file

@ -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 { WorkflowEntity } from '@db/entities/WorkflowEntity';
import type { TagEntity } from '@db/entities/TagEntity'; import type { TagEntity } from '@db/entities/TagEntity';
import type { Risk } from '@/security-audit/types'; import type { Risk } from '@/security-audit/types';
@ -127,7 +128,14 @@ export declare namespace UserRequest {
} }
export declare namespace CredentialRequest { 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'; export type OperationID = 'getUsers' | 'getUser';

View file

@ -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 { Response } from 'express';
import type { AuditRequest } from '@/PublicApi/types'; import type { AuditRequest } from '@/PublicApi/types';
import Container from 'typedi'; import Container from 'typedi';
export = { export = {
generateAudit: [ generateAudit: [
authorize(['global:owner', 'global:admin']), globalScope('securityAudit:generate'),
async (req: AuditRequest.Generate, res: Response): Promise<Response> => { async (req: AuditRequest.Generate, res: Response): Promise<Response> => {
try { try {
const { SecurityAuditService } = await import('@/security-audit/SecurityAudit.service'); const { SecurityAuditService } = await import('@/security-audit/SecurityAudit.service');

View file

@ -4,9 +4,8 @@ import type express from 'express';
import { CredentialsHelper } from '@/CredentialsHelper'; import { CredentialsHelper } from '@/CredentialsHelper';
import { CredentialTypes } from '@/CredentialTypes'; import { CredentialTypes } from '@/CredentialTypes';
import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
import type { CredentialRequest } from '@/requests'; import type { CredentialTypeRequest, CredentialRequest } from '../../../types';
import type { CredentialTypeRequest } from '../../../types'; import { projectScope } from '../../shared/middlewares/global.middleware';
import { authorize } from '../../shared/middlewares/global.middleware';
import { validCredentialsProperties, validCredentialType } from './credentials.middleware'; import { validCredentialsProperties, validCredentialType } from './credentials.middleware';
import { import {
@ -23,7 +22,6 @@ import { Container } from 'typedi';
export = { export = {
createCredential: [ createCredential: [
authorize(['global:owner', 'global:admin', 'global:member']),
validCredentialType, validCredentialType,
validCredentialsProperties, validCredentialsProperties,
async ( async (
@ -47,7 +45,7 @@ export = {
}, },
], ],
deleteCredential: [ deleteCredential: [
authorize(['global:owner', 'global:admin', 'global:member']), projectScope('credential:delete', 'credential'),
async ( async (
req: CredentialRequest.Delete, req: CredentialRequest.Delete,
res: express.Response, res: express.Response,
@ -75,7 +73,6 @@ export = {
], ],
getCredentialType: [ getCredentialType: [
authorize(['global:owner', 'global:admin', 'global:member']),
async (req: CredentialTypeRequest.Get, res: express.Response): Promise<express.Response> => { async (req: CredentialTypeRequest.Get, res: express.Response): Promise<express.Response> => {
const { credentialTypeName } = req.params; const { credentialTypeName } = req.params;

View file

@ -16,6 +16,7 @@ import type { CredentialRequest } from '@/requests';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { CredentialsRepository } from '@db/repositories/credentials.repository'; import { CredentialsRepository } from '@db/repositories/credentials.repository';
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
import { ProjectRepository } from '@/databases/repositories/project.repository';
import { InternalHooks } from '@/InternalHooks'; import { InternalHooks } from '@/InternalHooks';
export async function getCredentials(credentialId: string): Promise<ICredentialsDb | null> { export async function getCredentials(credentialId: string): Promise<ICredentialsDb | null> {
@ -28,7 +29,7 @@ export async function getSharedCredentials(
): Promise<SharedCredentials | null> { ): Promise<SharedCredentials | null> {
return await Container.get(SharedCredentialsRepository).findOne({ return await Container.get(SharedCredentialsRepository).findOne({
where: { where: {
userId, project: { projectRelations: { userId } },
credentialsId: credentialId, credentialsId: credentialId,
}, },
relations: ['credentials'], relations: ['credentials'],
@ -66,10 +67,14 @@ export async function saveCredential(
const newSharedCredential = new SharedCredentials(); const newSharedCredential = new SharedCredentials();
const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
user.id,
);
Object.assign(newSharedCredential, { Object.assign(newSharedCredential, {
role: 'credential:owner', role: 'credential:owner',
user,
credentials: savedCredential, credentials: savedCredential,
projectId: personalProject.id,
}); });
await transactionManager.save<SharedCredentials>(newSharedCredential); await transactionManager.save<SharedCredentials>(newSharedCredential);

View file

@ -3,7 +3,7 @@ import { Container } from 'typedi';
import { replaceCircularReferences } from 'n8n-workflow'; import { replaceCircularReferences } from 'n8n-workflow';
import { ActiveExecutions } from '@/ActiveExecutions'; 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 type { ExecutionRequest } from '../../../types';
import { getSharedWorkflowIds } from '../workflows/workflows.service'; import { getSharedWorkflowIds } from '../workflows/workflows.service';
import { encodeNextCursor } from '../../shared/services/pagination.service'; import { encodeNextCursor } from '../../shared/services/pagination.service';
@ -12,9 +12,8 @@ import { ExecutionRepository } from '@db/repositories/execution.repository';
export = { export = {
deleteExecution: [ deleteExecution: [
authorize(['global:owner', 'global:admin', 'global:member']),
async (req: ExecutionRequest.Delete, res: express.Response): Promise<express.Response> => { 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 // user does not have workflows hence no executions
// or the execution they are trying to access belongs to a workflow they do not own // or the execution they are trying to access belongs to a workflow they do not own
@ -44,9 +43,8 @@ export = {
}, },
], ],
getExecution: [ getExecution: [
authorize(['global:owner', 'global:admin', 'global:member']),
async (req: ExecutionRequest.Get, res: express.Response): Promise<express.Response> => { 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 // user does not have workflows hence no executions
// or the execution they are trying to access belongs to a workflow they do not own // or the execution they are trying to access belongs to a workflow they do not own
@ -75,7 +73,6 @@ export = {
}, },
], ],
getExecutions: [ getExecutions: [
authorize(['global:owner', 'global:admin', 'global:member']),
validCursor, validCursor,
async (req: ExecutionRequest.GetAll, res: express.Response): Promise<express.Response> => { async (req: ExecutionRequest.GetAll, res: express.Response): Promise<express.Response> => {
const { const {
@ -86,7 +83,7 @@ export = {
workflowId = undefined, workflowId = undefined,
} = req.query; } = req.query;
const sharedWorkflowsIds = await getSharedWorkflowIds(req.user); const sharedWorkflowsIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
// user does not have workflows hence no executions // user does not have workflows hence no executions
// or the execution they are trying to access belongs to a workflow they do not own // or the execution they are trying to access belongs to a workflow they do not own

View file

@ -2,7 +2,7 @@ import type express from 'express';
import { Container } from 'typedi'; import { Container } from 'typedi';
import type { StatusResult } from 'simple-git'; import type { StatusResult } from 'simple-git';
import type { PublicSourceControlRequest } from '../../../types'; 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 type { ImportResult } from '@/environments/sourceControl/types/importResult';
import { SourceControlService } from '@/environments/sourceControl/sourceControl.service.ee'; import { SourceControlService } from '@/environments/sourceControl/sourceControl.service.ee';
import { SourceControlPreferencesService } from '@/environments/sourceControl/sourceControlPreferences.service.ee'; import { SourceControlPreferencesService } from '@/environments/sourceControl/sourceControlPreferences.service.ee';
@ -14,7 +14,7 @@ import { InternalHooks } from '@/InternalHooks';
export = { export = {
pull: [ pull: [
authorize(['global:owner', 'global:admin']), globalScope('sourceControl:pull'),
async ( async (
req: PublicSourceControlRequest.Pull, req: PublicSourceControlRequest.Pull,
res: express.Response, res: express.Response,

View file

@ -1,7 +1,7 @@
import type express from 'express'; import type express from 'express';
import type { TagEntity } from '@db/entities/TagEntity'; 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 type { TagRequest } from '../../../types';
import { encodeNextCursor } from '../../shared/services/pagination.service'; import { encodeNextCursor } from '../../shared/services/pagination.service';
@ -12,7 +12,7 @@ import { TagService } from '@/services/tag.service';
export = { export = {
createTag: [ createTag: [
authorize(['global:owner', 'global:admin', 'global:member']), globalScope('tag:create'),
async (req: TagRequest.Create, res: express.Response): Promise<express.Response> => { async (req: TagRequest.Create, res: express.Response): Promise<express.Response> => {
const { name } = req.body; const { name } = req.body;
@ -27,7 +27,7 @@ export = {
}, },
], ],
updateTag: [ updateTag: [
authorize(['global:owner', 'global:admin', 'global:member']), globalScope('tag:update'),
async (req: TagRequest.Update, res: express.Response): Promise<express.Response> => { async (req: TagRequest.Update, res: express.Response): Promise<express.Response> => {
const { id } = req.params; const { id } = req.params;
const { name } = req.body; const { name } = req.body;
@ -49,7 +49,7 @@ export = {
}, },
], ],
deleteTag: [ deleteTag: [
authorize(['global:owner', 'global:admin']), globalScope('tag:delete'),
async (req: TagRequest.Delete, res: express.Response): Promise<express.Response> => { async (req: TagRequest.Delete, res: express.Response): Promise<express.Response> => {
const { id } = req.params; const { id } = req.params;
@ -65,7 +65,7 @@ export = {
}, },
], ],
getTags: [ getTags: [
authorize(['global:owner', 'global:admin', 'global:member']), globalScope('tag:read'),
validCursor, validCursor,
async (req: TagRequest.GetAll, res: express.Response): Promise<express.Response> => { async (req: TagRequest.GetAll, res: express.Response): Promise<express.Response> => {
const { offset = 0, limit = 100 } = req.query; const { offset = 0, limit = 100 } = req.query;
@ -88,7 +88,7 @@ export = {
}, },
], ],
getTag: [ getTag: [
authorize(['global:owner', 'global:admin', 'global:member']), globalScope('tag:read'),
async (req: TagRequest.Get, res: express.Response): Promise<express.Response> => { async (req: TagRequest.Get, res: express.Response): Promise<express.Response> => {
const { id } = req.params; const { id } = req.params;

View file

@ -5,7 +5,7 @@ import { clean, getAllUsersAndCount, getUser } from './users.service.ee';
import { encodeNextCursor } from '../../shared/services/pagination.service'; import { encodeNextCursor } from '../../shared/services/pagination.service';
import { import {
authorize, globalScope,
validCursor, validCursor,
validLicenseWithUserQuota, validLicenseWithUserQuota,
} from '../../shared/middlewares/global.middleware'; } from '../../shared/middlewares/global.middleware';
@ -15,7 +15,7 @@ import { InternalHooks } from '@/InternalHooks';
export = { export = {
getUser: [ getUser: [
validLicenseWithUserQuota, validLicenseWithUserQuota,
authorize(['global:owner', 'global:admin']), globalScope('user:read'),
async (req: UserRequest.Get, res: express.Response) => { async (req: UserRequest.Get, res: express.Response) => {
const { includeRole = false } = req.query; const { includeRole = false } = req.query;
const { id } = req.params; const { id } = req.params;
@ -41,7 +41,7 @@ export = {
getUsers: [ getUsers: [
validLicenseWithUserQuota, validLicenseWithUserQuota,
validCursor, validCursor,
authorize(['global:owner', 'global:admin']), globalScope(['user:list', 'user:read']),
async (req: UserRequest.Get, res: express.Response) => { async (req: UserRequest.Get, res: express.Response) => {
const { offset = 0, limit = 100, includeRole = false } = req.query; const { offset = 0, limit = 100, includeRole = false } = req.query;

View file

@ -11,11 +11,10 @@ import { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { ExternalHooks } from '@/ExternalHooks'; import { ExternalHooks } from '@/ExternalHooks';
import { addNodeIds, replaceInvalidCredentials } from '@/WorkflowHelpers'; import { addNodeIds, replaceInvalidCredentials } from '@/WorkflowHelpers';
import type { WorkflowRequest } from '../../../types'; 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 { encodeNextCursor } from '../../shared/services/pagination.service';
import { import {
getWorkflowById, getWorkflowById,
getSharedWorkflow,
setWorkflowAsActive, setWorkflowAsActive,
setWorkflowAsInactive, setWorkflowAsInactive,
updateWorkflow, updateWorkflow,
@ -30,10 +29,10 @@ import { WorkflowHistoryService } from '@/workflows/workflowHistory/workflowHist
import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository';
import { TagRepository } from '@/databases/repositories/tag.repository'; import { TagRepository } from '@/databases/repositories/tag.repository';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import { ProjectRepository } from '@/databases/repositories/project.repository';
export = { export = {
createWorkflow: [ createWorkflow: [
authorize(['global:owner', 'global:admin', 'global:member']),
async (req: WorkflowRequest.Create, res: express.Response): Promise<express.Response> => { async (req: WorkflowRequest.Create, res: express.Response): Promise<express.Response> => {
const workflow = req.body; const workflow = req.body;
@ -44,7 +43,10 @@ export = {
addNodeIds(workflow); 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( await Container.get(WorkflowHistoryService).saveVersion(
req.user, req.user,
@ -53,13 +55,13 @@ export = {
); );
await Container.get(ExternalHooks).run('workflow.afterCreate', [createdWorkflow]); 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); return res.json(createdWorkflow);
}, },
], ],
deleteWorkflow: [ deleteWorkflow: [
authorize(['global:owner', 'global:admin', 'global:member']), projectScope('workflow:delete', 'workflow'),
async (req: WorkflowRequest.Get, res: express.Response): Promise<express.Response> => { async (req: WorkflowRequest.Get, res: express.Response): Promise<express.Response> => {
const { id: workflowId } = req.params; const { id: workflowId } = req.params;
@ -74,15 +76,21 @@ export = {
}, },
], ],
getWorkflow: [ getWorkflow: [
authorize(['global:owner', 'global:admin', 'global:member']), projectScope('workflow:read', 'workflow'),
async (req: WorkflowRequest.Get, res: express.Response): Promise<express.Response> => { async (req: WorkflowRequest.Get, res: express.Response): Promise<express.Response> => {
const { id } = req.params; 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 // 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' }); return res.status(404).json({ message: 'Not Found' });
} }
@ -91,11 +99,10 @@ export = {
public_api: true, public_api: true,
}); });
return res.json(sharedWorkflow.workflow); return res.json(workflow);
}, },
], ],
getWorkflows: [ getWorkflows: [
authorize(['global:owner', 'global:admin', 'global:member']),
validCursor, validCursor,
async (req: WorkflowRequest.GetAll, res: express.Response): Promise<express.Response> => { async (req: WorkflowRequest.GetAll, res: express.Response): Promise<express.Response> => {
const { offset = 0, limit = 100, active, tags, name } = req.query; 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, 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({ return res.status(200).json({
data: [], data: [],
nextCursor: null, nextCursor: null,
}); });
} }
const workflowsIds = sharedWorkflows.map((shareWorkflow) => shareWorkflow.workflowId); const workflowsIds = workflows.map((wf) => wf.id);
where.id = In(workflowsIds); where.id = In(workflowsIds);
} }
@ -160,7 +172,7 @@ export = {
}, },
], ],
updateWorkflow: [ updateWorkflow: [
authorize(['global:owner', 'global:admin', 'global:member']), projectScope('workflow:update', 'workflow'),
async (req: WorkflowRequest.Update, res: express.Response): Promise<express.Response> => { async (req: WorkflowRequest.Update, res: express.Response): Promise<express.Response> => {
const { id } = req.params; const { id } = req.params;
const updateData = new WorkflowEntity(); const updateData = new WorkflowEntity();
@ -168,9 +180,13 @@ export = {
updateData.id = id; updateData.id = id;
updateData.versionId = uuid(); 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 // user trying to access a workflow they do not own
// or workflow does not exist // or workflow does not exist
return res.status(404).json({ message: 'Not Found' }); return res.status(404).json({ message: 'Not Found' });
@ -181,23 +197,23 @@ export = {
const workflowManager = Container.get(ActiveWorkflowManager); 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 // When workflow gets saved always remove it as the triggers could have been
// changed and so the changes would not take effect // changed and so the changes would not take effect
await workflowManager.remove(id); await workflowManager.remove(id);
} }
try { try {
await updateWorkflow(sharedWorkflow.workflowId, updateData); await updateWorkflow(workflow.id, updateData);
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
return res.status(400).json({ message: error.message }); return res.status(400).json({ message: error.message });
} }
} }
if (sharedWorkflow.workflow.active) { if (workflow.active) {
try { try {
await workflowManager.add(sharedWorkflow.workflowId, 'update'); await workflowManager.add(workflow.id, 'update');
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
return res.status(400).json({ message: error.message }); 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) { if (updatedWorkflow) {
await Container.get(WorkflowHistoryService).saveVersion( await Container.get(WorkflowHistoryService).saveVersion(
req.user, req.user,
updatedWorkflow, updatedWorkflow,
sharedWorkflow.workflowId, workflow.id,
); );
} }
@ -222,21 +238,25 @@ export = {
}, },
], ],
activateWorkflow: [ activateWorkflow: [
authorize(['global:owner', 'global:admin', 'global:member']), projectScope('workflow:update', 'workflow'),
async (req: WorkflowRequest.Activate, res: express.Response): Promise<express.Response> => { async (req: WorkflowRequest.Activate, res: express.Response): Promise<express.Response> => {
const { id } = req.params; 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 // user trying to access a workflow they do not own
// or workflow does not exist // or workflow does not exist
return res.status(404).json({ message: 'Not Found' }); return res.status(404).json({ message: 'Not Found' });
} }
if (!sharedWorkflow.workflow.active) { if (!workflow.active) {
try { try {
await Container.get(ActiveWorkflowManager).add(sharedWorkflow.workflowId, 'activate'); await Container.get(ActiveWorkflowManager).add(workflow.id, 'activate');
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
return res.status(400).json({ message: error.message }); return res.status(400).json({ message: error.message });
@ -244,25 +264,29 @@ export = {
} }
// change the status to active in the DB // 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 // nothing to do as the workflow is already active
return res.json(sharedWorkflow.workflow); return res.json(workflow);
}, },
], ],
deactivateWorkflow: [ deactivateWorkflow: [
authorize(['global:owner', 'global:admin', 'global:member']), projectScope('workflow:update', 'workflow'),
async (req: WorkflowRequest.Activate, res: express.Response): Promise<express.Response> => { async (req: WorkflowRequest.Activate, res: express.Response): Promise<express.Response> => {
const { id } = req.params; 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 // user trying to access a workflow they do not own
// or workflow does not exist // or workflow does not exist
return res.status(404).json({ message: 'Not Found' }); return res.status(404).json({ message: 'Not Found' });
@ -270,22 +294,22 @@ export = {
const activeWorkflowManager = Container.get(ActiveWorkflowManager); const activeWorkflowManager = Container.get(ActiveWorkflowManager);
if (sharedWorkflow.workflow.active) { if (workflow.active) {
await activeWorkflowManager.remove(sharedWorkflow.workflowId); 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 // nothing to do as the workflow is already inactive
return res.json(sharedWorkflow.workflow); return res.json(workflow);
}, },
], ],
getWorkflowTags: [ getWorkflowTags: [
authorize(['global:owner', 'global:admin', 'global:member']), projectScope('workflow:read', 'workflow'),
async (req: WorkflowRequest.GetTags, res: express.Response): Promise<express.Response> => { async (req: WorkflowRequest.GetTags, res: express.Response): Promise<express.Response> => {
const { id } = req.params; const { id } = req.params;
@ -293,9 +317,13 @@ export = {
return res.status(400).json({ message: 'Workflow Tags Disabled' }); 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 // user trying to access a workflow he does not own
// or workflow does not exist // or workflow does not exist
return res.status(404).json({ message: 'Not Found' }); return res.status(404).json({ message: 'Not Found' });
@ -307,7 +335,7 @@ export = {
}, },
], ],
updateWorkflowTags: [ updateWorkflowTags: [
authorize(['global:owner', 'global:admin', 'global:member']), projectScope('workflow:update', 'workflow'),
async (req: WorkflowRequest.UpdateTags, res: express.Response): Promise<express.Response> => { async (req: WorkflowRequest.UpdateTags, res: express.Response): Promise<express.Response> => {
const { id } = req.params; const { id } = req.params;
const newTags = req.body.map((newTag) => newTag.id); const newTags = req.body.map((newTag) => newTag.id);
@ -316,7 +344,11 @@ export = {
return res.status(400).json({ message: 'Workflow Tags Disabled' }); 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) { if (!sharedWorkflow) {
// user trying to access a workflow he does not own // user trying to access a workflow he does not own

View file

@ -4,23 +4,31 @@ import type { User } from '@db/entities/User';
import { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { WorkflowTagMapping } from '@db/entities/WorkflowTagMapping'; import { WorkflowTagMapping } from '@db/entities/WorkflowTagMapping';
import { SharedWorkflow, type WorkflowSharingRole } from '@db/entities/SharedWorkflow'; import { SharedWorkflow, type WorkflowSharingRole } from '@db/entities/SharedWorkflow';
import config from '@/config';
import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository';
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
import type { Project } from '@/databases/entities/Project';
import { WorkflowTagMappingRepository } from '@db/repositories/workflowTagMapping.repository'; import { WorkflowTagMappingRepository } from '@db/repositories/workflowTagMapping.repository';
import { TagRepository } from '@db/repositories/tag.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[] { function insertIf(condition: boolean, elements: string[]): string[] {
return condition ? elements : []; return condition ? elements : [];
} }
export async function getSharedWorkflowIds(user: User): Promise<string[]> { export async function getSharedWorkflowIds(user: User, scopes: Scope[]): Promise<string[]> {
const where = ['global:owner', 'global:admin'].includes(user.role) ? {} : { userId: user.id }; if (Container.get(License).isSharingEnabled()) {
const sharedWorkflows = await Container.get(SharedWorkflowRepository).find({ return await Container.get(WorkflowSharingService).getSharedWorkflowIds(user, {
where, scopes,
select: ['workflowId'], });
}); } else {
return sharedWorkflows.map(({ workflowId }) => workflowId); return await Container.get(WorkflowSharingService).getSharedWorkflowIds(user, {
workflowRoles: ['workflow:owner'],
projectRoles: ['project:personalOwner'],
});
}
} }
export async function getSharedWorkflow( export async function getSharedWorkflow(
@ -45,6 +53,7 @@ export async function getWorkflowById(id: string): Promise<WorkflowEntity | null
export async function createWorkflow( export async function createWorkflow(
workflow: WorkflowEntity, workflow: WorkflowEntity,
user: User, user: User,
personalProject: Project,
role: WorkflowSharingRole, role: WorkflowSharingRole,
): Promise<WorkflowEntity> { ): Promise<WorkflowEntity> {
return await Db.transaction(async (transactionManager) => { return await Db.transaction(async (transactionManager) => {
@ -56,6 +65,7 @@ export async function createWorkflow(
Object.assign(newSharedWorkflow, { Object.assign(newSharedWorkflow, {
role, role,
user, user,
project: personalProject,
workflow: savedWorkflow, workflow: savedWorkflow,
}); });
await transactionManager.save<SharedWorkflow>(newSharedWorkflow); await transactionManager.save<SharedWorkflow>(newSharedWorkflow);

View file

@ -3,27 +3,48 @@ import type express from 'express';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { License } from '@/License'; import { License } from '@/License';
import type { GlobalRole } from '@db/entities/User';
import type { AuthenticatedRequest } from '@/requests'; import type { AuthenticatedRequest } from '@/requests';
import type { PaginatedRequest } from '../../../types'; import type { PaginatedRequest } from '../../../types';
import { decodeCursor } from '../services/pagination.service'; import { decodeCursor } from '../services/pagination.service';
import type { Scope } from '@n8n/permissions';
import { userHasScope } from '@/permissions/checkAccess';
const UNLIMITED_USERS_QUOTA = -1; const UNLIMITED_USERS_QUOTA = -1;
export const authorize = export type ProjectScopeResource = 'workflow' | 'credential';
(authorizedRoles: readonly GlobalRole[]) =>
( const buildScopeMiddleware = (
req: AuthenticatedRequest, scopes: Scope[],
resource?: ProjectScopeResource,
{ globalOnly } = { globalOnly: false },
) => {
return async (
req: AuthenticatedRequest<{ id?: string }>,
res: express.Response, res: express.Response,
next: express.NextFunction, next: express.NextFunction,
): express.Response | void => { ): Promise<express.Response | void> => {
if (!authorizedRoles.includes(req.user.role)) { 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 res.status(403).json({ message: 'Forbidden' });
} }
return next(); 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 = ( export const validCursor = (
req: PaginatedRequest, req: PaginatedRequest,

View file

@ -71,6 +71,8 @@ import { InvitationController } from './controllers/invitation.controller';
// import { CollaborationService } from './collaboration/collaboration.service'; // import { CollaborationService } from './collaboration/collaboration.service';
import { BadRequestError } from './errors/response-errors/bad-request.error'; import { BadRequestError } from './errors/response-errors/bad-request.error';
import { OrchestrationService } from '@/services/orchestration.service'; import { OrchestrationService } from '@/services/orchestration.service';
import { ProjectController } from './controllers/project.controller';
import { RoleController } from './controllers/role.controller';
const exec = promisify(callbackExec); const exec = promisify(callbackExec);
@ -146,6 +148,8 @@ export class Server extends AbstractServer {
ExecutionsController, ExecutionsController,
CredentialsController, CredentialsController,
AIController, AIController,
ProjectController,
RoleController,
]; ];
if ( if (

View file

@ -5,64 +5,47 @@ import { CredentialAccessError, NodeOperationError, WorkflowOperationError } fro
import config from '@/config'; import config from '@/config';
import { License } from '@/License'; import { License } from '@/License';
import { OwnershipService } from '@/services/ownership.service'; import { OwnershipService } from '@/services/ownership.service';
import { UserRepository } from '@db/repositories/user.repository';
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { ProjectService } from '@/services/project.service';
@Service() @Service()
export class PermissionChecker { export class PermissionChecker {
constructor( constructor(
private readonly userRepository: UserRepository,
private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly ownershipService: OwnershipService, private readonly ownershipService: OwnershipService,
private readonly license: License, 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[]) { async check(workflowId: string, nodes: INode[]) {
// allow if no nodes in this workflow use creds 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 credIdsToNodes = this.mapCredIdsToNodes(nodes);
const workflowCredIds = Object.keys(credIdsToNodes); const workflowCredIds = Object.keys(credIdsToNodes);
if (workflowCredIds.length === 0) return; 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({ for (const credentialsId of workflowCredIds) {
where: { id: userId }, if (!accessible.includes(credentialsId)) {
}); const nodeToFlag = credIdsToNodes[credentialsId][0];
throw new CredentialAccessError(nodeToFlag, credentialsId, workflowId);
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);
} }
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( async checkSubworkflowExecutePolicy(
@ -91,14 +74,14 @@ export class PermissionChecker {
} }
const parentWorkflowOwner = 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 = const description =
subworkflowOwner.id === parentWorkflowOwner.id subworkflowOwner.id === parentWorkflowOwner.id
? 'Change the settings of the sub-workflow so it can be called by this one.' ? '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( const errorToThrow = new WorkflowOperationError(
`Target workflow ID ${subworkflow.id} may not be called`, `Target workflow ID ${subworkflow.id} may not be called`,

View file

@ -173,13 +173,13 @@ export class WaitTracker {
throw new ApplicationError('Only saved workflows can be resumed.'); throw new ApplicationError('Only saved workflows can be resumed.');
} }
const workflowId = fullExecutionData.workflowData.id; const workflowId = fullExecutionData.workflowData.id;
const user = await this.ownershipService.getWorkflowOwnerCached(workflowId); const project = await this.ownershipService.getWorkflowProjectCached(workflowId);
const data: IWorkflowExecutionDataProcess = { const data: IWorkflowExecutionDataProcess = {
executionMode: fullExecutionData.mode, executionMode: fullExecutionData.mode,
executionData: fullExecutionData.data, executionData: fullExecutionData.data,
workflowData: fullExecutionData.workflowData, workflowData: fullExecutionData.workflowData,
userId: user.id, projectId: project.id,
}; };
// Start the execution again // Start the execution again

View file

@ -88,19 +88,12 @@ export class WaitingWebhooks implements IWebhookManager {
settings: workflowData.settings, 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); const workflowStartNode = workflow.getNode(lastNodeExecuted);
if (workflowStartNode === null) { if (workflowStartNode === null) {
throw new NotFoundError('Could not find node to process webhook.'); 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( const webhookData = NodeHelpers.getNodeWebhooks(
workflow, workflow,
workflowStartNode, workflowStartNode,

View file

@ -56,8 +56,6 @@ import * as WorkflowHelpers from '@/WorkflowHelpers';
import { WorkflowRunner } from '@/WorkflowRunner'; import { WorkflowRunner } from '@/WorkflowRunner';
import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData';
import { ActiveExecutions } from '@/ActiveExecutions'; 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 { EventsService } from '@/services/events.service';
import { OwnershipService } from './services/ownership.service'; import { OwnershipService } from './services/ownership.service';
import { parseBody } from './middlewares'; import { parseBody } from './middlewares';
@ -65,6 +63,7 @@ import { Logger } from './Logger';
import { NotFoundError } from './errors/response-errors/not-found.error'; import { NotFoundError } from './errors/response-errors/not-found.error';
import { InternalServerError } from './errors/response-errors/internal-server.error'; import { InternalServerError } from './errors/response-errors/internal-server.error';
import { UnprocessableRequestError } from './errors/response-errors/unprocessable.error'; import { UnprocessableRequestError } from './errors/response-errors/unprocessable.error';
import type { Project } from './databases/entities/Project';
export const WEBHOOK_METHODS: IHttpRequestMethods[] = [ export const WEBHOOK_METHODS: IHttpRequestMethods[] = [
'DELETE', 'DELETE',
@ -248,22 +247,15 @@ export async function executeWebhook(
$executionId: executionId, $executionId: executionId,
}; };
let user: User; let project: Project | undefined = undefined;
if ( try {
(workflowData as WorkflowEntity).shared?.length && project = await Container.get(OwnershipService).getWorkflowProjectCached(workflowData.id);
(workflowData as WorkflowEntity).shared[0].user } catch (error) {
) { throw new NotFoundError('Cannot find workflow');
user = (workflowData as WorkflowEntity).shared[0].user;
} else {
try {
user = await Container.get(OwnershipService).getWorkflowOwnerCached(workflowData.id);
} catch (error) {
throw new NotFoundError('Cannot find workflow');
}
} }
// Prepare everything that is needed to run the 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 // Get the responseMode
const responseMode = workflow.expression.getSimpleParameterValue( const responseMode = workflow.expression.getSimpleParameterValue(
@ -546,7 +538,7 @@ export async function executeWebhook(
pushRef, pushRef,
workflowData, workflowData,
pinData, pinData,
userId: user.id, projectId: project?.id,
}; };
let responsePromise: IDeferredPromise<IN8nHttpFullResponse> | undefined; let responsePromise: IDeferredPromise<IN8nHttpFullResponse> | undefined;

View file

@ -195,12 +195,12 @@ export function executeErrorWorkflow(
} }
Container.get(OwnershipService) Container.get(OwnershipService)
.getWorkflowOwnerCached(workflowId) .getWorkflowProjectCached(workflowId)
.then((user) => { .then((project) => {
void Container.get(WorkflowExecutionService).executeErrorWorkflow( void Container.get(WorkflowExecutionService).executeErrorWorkflow(
errorWorkflow, errorWorkflow,
workflowErrorData, workflowErrorData,
user, project,
); );
}) })
.catch((error: Error) => { .catch((error: Error) => {
@ -223,12 +223,12 @@ export function executeErrorWorkflow(
) { ) {
logger.verbose('Start internal error workflow', { executionId, workflowId }); logger.verbose('Start internal error workflow', { executionId, workflowId });
void Container.get(OwnershipService) void Container.get(OwnershipService)
.getWorkflowOwnerCached(workflowId) .getWorkflowProjectCached(workflowId)
.then((user) => { .then((project) => {
void Container.get(WorkflowExecutionService).executeErrorWorkflow( void Container.get(WorkflowExecutionService).executeErrorWorkflow(
workflowId, workflowId,
workflowErrorData, workflowErrorData,
user, project,
); );
}); });
} }
@ -655,7 +655,6 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
export async function getRunData( export async function getRunData(
workflowData: IWorkflowBase, workflowData: IWorkflowBase,
userId: string,
inputData?: INodeExecutionData[], inputData?: INodeExecutionData[],
): Promise<IWorkflowExecutionDataProcess> { ): Promise<IWorkflowExecutionDataProcess> {
const mode = 'integrated'; const mode = 'integrated';
@ -698,7 +697,6 @@ export async function getRunData(
executionData: runExecutionData, executionData: runExecutionData,
// @ts-ignore // @ts-ignore
workflowData, workflowData,
userId,
}; };
return runData; return runData;
@ -784,9 +782,7 @@ async function executeWorkflow(
settings: workflowData.settings, settings: workflowData.settings,
}); });
const runData = const runData = options.loadedRunData ?? (await getRunData(workflowData, options.inputData));
options.loadedRunData ??
(await getRunData(workflowData, additionalData.userId, options.inputData));
let executionId; let executionId;
@ -800,11 +796,7 @@ async function executeWorkflow(
let data; let data;
try { try {
await Container.get(PermissionChecker).check( await Container.get(PermissionChecker).check(workflowData.id, workflowData.nodes);
workflowData.id,
additionalData.userId,
workflowData.nodes,
);
await Container.get(PermissionChecker).checkSubworkflowExecutePolicy( await Container.get(PermissionChecker).checkSubworkflowExecutePolicy(
workflow, workflow,
options.parentWorkflowId, options.parentWorkflowId,
@ -813,7 +805,7 @@ async function executeWorkflow(
// Create new additionalData to have different workflow loaded and to call // Create new additionalData to have different workflow loaded and to call
// different webhooks // different webhooks
const additionalDataIntegrated = await getBase(additionalData.userId); const additionalDataIntegrated = await getBase();
additionalDataIntegrated.hooks = getWorkflowHooksIntegrated( additionalDataIntegrated.hooks = getWorkflowHooksIntegrated(
runData.executionMode, runData.executionMode,
executionId, executionId,
@ -966,7 +958,7 @@ export function sendDataToUI(type: string, data: IDataObject | IDataObject[]) {
* Returns the base additional data without webhooks * Returns the base additional data without webhooks
*/ */
export async function getBase( export async function getBase(
userId: string, userId?: string,
currentNodeParameters?: INodeParameters, currentNodeParameters?: INodeParameters,
executionTimeoutTimestamp?: number, executionTimeoutTimestamp?: number,
): Promise<IWorkflowExecuteAdditionalData> { ): Promise<IWorkflowExecuteAdditionalData> {

View file

@ -161,7 +161,7 @@ export class WorkflowRunner {
const { id: workflowId, nodes } = data.workflowData; const { id: workflowId, nodes } = data.workflowData;
try { try {
await this.permissionChecker.check(workflowId, data.userId, nodes); await this.permissionChecker.check(workflowId, nodes);
} catch (error) { } catch (error) {
// Create a failed execution with the data for the node, save it and abort execution // Create a failed execution with the data for the node, save it and abort execution
const runData = generateFailedExecutionFromError(data.executionMode, error, error.node); const runData = generateFailedExecutionFromError(data.executionMode, error, error.node);

View file

@ -8,7 +8,7 @@ import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES, Time } from '@/constants';
import type { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
import { UserRepository } from '@db/repositories/user.repository'; import { UserRepository } from '@db/repositories/user.repository';
import { AuthError } from '@/errors/response-errors/auth.error'; 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 { License } from '@/License';
import { Logger } from '@/Logger'; import { Logger } from '@/Logger';
import type { AuthenticatedRequest } from '@/requests'; import type { AuthenticatedRequest } from '@/requests';
@ -92,7 +92,7 @@ export class AuthService {
!user.isOwner && !user.isOwner &&
!isWithinUsersLimit !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); const token = this.issueJWT(user, browserId);

View file

@ -6,7 +6,6 @@ import glob from 'fast-glob';
import type { EntityManager } from '@n8n/typeorm'; import type { EntityManager } from '@n8n/typeorm';
import * as Db from '@/Db'; import * as Db from '@/Db';
import type { User } from '@db/entities/User';
import { SharedCredentials } from '@db/entities/SharedCredentials'; import { SharedCredentials } from '@db/entities/SharedCredentials';
import { CredentialsEntity } from '@db/entities/CredentialsEntity'; import { CredentialsEntity } from '@db/entities/CredentialsEntity';
import { disableAutoGeneratedIds } from '@db/utils/commandHelpers'; import { disableAutoGeneratedIds } from '@db/utils/commandHelpers';
@ -15,6 +14,8 @@ import type { ICredentialsEncrypted } from 'n8n-workflow';
import { ApplicationError, jsonParse } from 'n8n-workflow'; import { ApplicationError, jsonParse } from 'n8n-workflow';
import { UM_FIX_INSTRUCTION } from '@/constants'; import { UM_FIX_INSTRUCTION } from '@/constants';
import { UserRepository } from '@db/repositories/user.repository'; 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 { export class ImportCredentialsCommand extends BaseCommand {
static description = 'Import credentials'; static description = 'Import credentials';
@ -23,6 +24,7 @@ export class ImportCredentialsCommand extends BaseCommand {
'$ n8n import:credentials --input=file.json', '$ n8n import:credentials --input=file.json',
'$ n8n import:credentials --separate --input=backups/latest/', '$ 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 --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', '$ 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({ userId: Flags.string({
description: 'The ID of the user to assign the imported credentials to', 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; 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); const credentials = await this.readCredentials(flags.input, flags.separate);
await Db.getConnection().transaction(async (transactionManager) => { await Db.getConnection().transaction(async (transactionManager) => {
this.transactionManager = 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) { if (!result.success) {
throw new ApplicationError(result.message); throw new ApplicationError(result.message);
} }
for (const credential of credentials) { 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 result = await this.transactionManager.upsert(CredentialsEntity, credential, ['id']);
const sharingExists = await this.transactionManager.existsBy(SharedCredentials, { const sharingExists = await this.transactionManager.existsBy(SharedCredentials, {
@ -111,25 +122,34 @@ export class ImportCredentialsCommand extends BaseCommand {
SharedCredentials, SharedCredentials,
{ {
credentialsId: result.identifiers[0].id as string, credentialsId: result.identifiers[0].id as string,
userId: user.id,
role: 'credential:owner', 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' }); const owner = await Container.get(UserRepository).findOneBy({ role: 'global:owner' });
if (!owner) { if (!owner) {
throw new ApplicationError(`Failed to find owner. ${UM_FIX_INSTRUCTION}`); 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) { private async checkRelations(
if (!userId) { credentials: ICredentialsEncrypted[],
projectId?: string,
userId?: string,
) {
// The credential is not supposed to be re-owned.
if (!projectId && !userId) {
return { return {
success: true as const, success: true as const,
message: undefined, message: undefined,
@ -145,15 +165,26 @@ export class ImportCredentialsCommand extends BaseCommand {
continue; continue;
} }
const ownerId = await this.getCredentialOwner(credential.id); const { user, project: ownerProject } = await this.getCredentialOwner(credential.id);
if (!ownerId) {
if (!ownerProject) {
continue; 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 { return {
success: false as const, 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) { private async getCredentialOwner(credentialsId: string) {
const sharedCredential = await this.transactionManager.findOneBy(SharedCredentials, { const sharedCredential = await this.transactionManager.findOne(SharedCredentials, {
credentialsId, where: { credentialsId, role: 'credential:owner' },
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) { private async credentialExists(credentialId: string) {
return await this.transactionManager.existsBy(CredentialsEntity, { id: credentialId }); 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();
}
} }

View file

@ -14,6 +14,7 @@ import type { IWorkflowToImport } from '@/Interfaces';
import { ImportService } from '@/services/import.service'; import { ImportService } from '@/services/import.service';
import { BaseCommand } from '../BaseCommand'; import { BaseCommand } from '../BaseCommand';
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
import { ProjectRepository } from '@/databases/repositories/project.repository';
function assertHasWorkflowsToImport(workflows: unknown): asserts workflows is IWorkflowToImport[] { function assertHasWorkflowsToImport(workflows: unknown): asserts workflows is IWorkflowToImport[] {
if (!Array.isArray(workflows)) { if (!Array.isArray(workflows)) {
@ -40,6 +41,7 @@ export class ImportWorkflowsCommand extends BaseCommand {
'$ n8n import:workflow --input=file.json', '$ n8n import:workflow --input=file.json',
'$ n8n import:workflow --separate --input=backups/latest/', '$ 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 --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', '$ 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({ userId: Flags.string({
description: 'The ID of the user to assign the imported workflows to', 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() { 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 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) { if (!result.success) {
throw new ApplicationError(result.message); throw new ApplicationError(result.message);
} }
this.logger.info(`Importing ${workflows.length} workflows...`); 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); this.reportSuccess(workflows.length);
} }
private async checkRelations(workflows: WorkflowEntity[], userId: string | undefined) { private async checkRelations(workflows: WorkflowEntity[], projectId?: string, userId?: string) {
if (!userId) { // The credential is not supposed to be re-owned.
if (!userId && !projectId) {
return { return {
success: true as const, success: true as const,
message: undefined, message: undefined,
@ -108,15 +121,26 @@ export class ImportWorkflowsCommand extends BaseCommand {
continue; continue;
} }
const ownerId = await this.getWorkflowOwner(workflow); const { user, project: ownerProject } = await this.getWorkflowOwner(workflow);
if (!ownerId) {
if (!ownerProject) {
continue; 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 { return {
success: false as const, 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.'}`); 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' }); const owner = await Container.get(UserRepository).findOneBy({ role: 'global:owner' });
if (!owner) { if (!owner) {
throw new ApplicationError(`Failed to find owner. ${UM_FIX_INSTRUCTION}`); 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) { private async getWorkflowOwner(workflow: WorkflowEntity) {
const sharing = await Container.get(SharedWorkflowRepository).findOneBy({ const sharing = await Container.get(SharedWorkflowRepository).findOne({
workflowId: workflow.id, where: { workflowId: workflow.id, role: 'workflow:owner' },
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) { private async workflowExists(workflow: WorkflowEntity) {
@ -189,4 +228,16 @@ export class ImportWorkflowsCommand extends BaseCommand {
return workflowInstances; 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();
}
} }

View file

@ -5,18 +5,115 @@ import { AuthProviderSyncHistoryRepository } from '@db/repositories/authProvider
import { SettingsRepository } from '@db/repositories/settings.repository'; import { SettingsRepository } from '@db/repositories/settings.repository';
import { UserRepository } from '@db/repositories/user.repository'; import { UserRepository } from '@db/repositories/user.repository';
import { BaseCommand } from '../BaseCommand'; 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 { 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> { 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({ const ldapIdentities = await Container.get(AuthIdentityRepository).find({
where: { providerType: 'ldap' }, where: { providerType: 'ldap' },
select: ['userId'], 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(AuthProviderSyncHistoryRepository).delete({ providerType: 'ldap' });
await Container.get(AuthIdentityRepository).delete({ providerType: 'ldap' }); await Container.get(AuthIdentityRepository).delete({ providerType: 'ldap' });
await Container.get(UserRepository).deleteMany(ldapIdentities.map((i) => i.userId)); 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).delete({ key: LDAP_FEATURE_NAME });
await Container.get(SettingsRepository).insert({ await Container.get(SettingsRepository).insert({
key: LDAP_FEATURE_NAME, key: LDAP_FEATURE_NAME,
@ -27,8 +124,43 @@ export class Reset extends BaseCommand {
this.logger.info('Successfully reset the database to default ldap state.'); 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> { async catch(error: Error): Promise<void> {
this.logger.error('Error resetting database. See log messages for details.'); this.logger.error('Error resetting database. See log messages for details.');
this.logger.error(error.message); 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;
}
} }

View file

@ -27,16 +27,27 @@ export class DisableMFACommand extends BaseCommand {
return; return;
} }
const updateOperationResult = await Container.get(UserRepository).update( const user = await Container.get(UserRepository).findOneBy({ email: flags.email });
{ email: flags.email },
{ mfaSecret: null, mfaRecoveryCodes: [], mfaEnabled: false },
);
if (!updateOperationResult.affected) { if (!user) {
this.reportUserDoesNotExistError(flags.email); this.reportUserDoesNotExistError(flags.email);
return; 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); this.reportSuccess(flags.email);
} }

View file

@ -7,6 +7,7 @@ import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
import { UserRepository } from '@db/repositories/user.repository'; import { UserRepository } from '@db/repositories/user.repository';
import { BaseCommand } from '../BaseCommand'; import { BaseCommand } from '../BaseCommand';
import { ProjectRepository } from '@/databases/repositories/project.repository';
const defaultUserProps = { const defaultUserProps = {
firstName: null, firstName: null,
@ -23,9 +24,12 @@ export class Reset extends BaseCommand {
async run(): Promise<void> { async run(): Promise<void> {
const owner = await this.getInstanceOwner(); const owner = await this.getInstanceOwner();
const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
owner.id,
);
await Container.get(SharedWorkflowRepository).makeOwnerOfAllWorkflows(owner); await Container.get(SharedWorkflowRepository).makeOwnerOfAllWorkflows(personalProject);
await Container.get(SharedCredentialsRepository).makeOwnerOfAllCredentials(owner); await Container.get(SharedCredentialsRepository).makeOwnerOfAllCredentials(personalProject);
await Container.get(UserRepository).deleteAllExcept(owner); await Container.get(UserRepository).deleteAllExcept(owner);
await Container.get(UserRepository).save(Object.assign(owner, defaultUserProps)); await Container.get(UserRepository).save(Object.assign(owner, defaultUserProps));
@ -38,7 +42,7 @@ export class Reset extends BaseCommand {
const newSharedCredentials = danglingCredentials.map((credentials) => const newSharedCredentials = danglingCredentials.map((credentials) =>
Container.get(SharedCredentialsRepository).create({ Container.get(SharedCredentialsRepository).create({
credentials, credentials,
user: owner, projectId: personalProject.id,
role: 'credential:owner', role: 'credential:owner',
}), }),
); );

View file

@ -17,7 +17,6 @@ import { Queue } from '@/Queue';
import { N8N_VERSION } from '@/constants'; import { N8N_VERSION } from '@/constants';
import { ExecutionRepository } from '@db/repositories/execution.repository'; import { ExecutionRepository } from '@db/repositories/execution.repository';
import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository';
import { OwnershipService } from '@/services/ownership.service';
import type { ICredentialsOverwrite } from '@/Interfaces'; import type { ICredentialsOverwrite } from '@/Interfaces';
import { CredentialsOverwrites } from '@/CredentialsOverwrites'; import { CredentialsOverwrites } from '@/CredentialsOverwrites';
import { rawBodyReader, bodyParser } from '@/middlewares'; import { rawBodyReader, bodyParser } from '@/middlewares';
@ -118,8 +117,6 @@ export class Worker extends BaseCommand {
); );
await executionRepository.updateStatus(executionId, 'running'); await executionRepository.updateStatus(executionId, 'running');
const workflowOwner = await Container.get(OwnershipService).getWorkflowOwnerCached(workflowId);
let { staticData } = fullExecutionData.workflowData; let { staticData } = fullExecutionData.workflowData;
if (loadStaticData) { if (loadStaticData) {
const workflowData = await Container.get(WorkflowRepository).findOne({ const workflowData = await Container.get(WorkflowRepository).findOne({
@ -160,7 +157,7 @@ export class Worker extends BaseCommand {
}); });
const additionalData = await WorkflowExecuteAdditionalData.getBase( const additionalData = await WorkflowExecuteAdditionalData.getBase(
workflowOwner.id, undefined,
undefined, undefined,
executionTimeoutTimestamp, executionTimeoutTimestamp,
); );

View file

@ -48,7 +48,8 @@ export const RESPONSE_ERROR_MESSAGES = {
USERS_QUOTA_REACHED: 'Maximum number of users reached', USERS_QUOTA_REACHED: 'Maximum number of users reached',
OAUTH2_CREDENTIAL_TEST_SUCCEEDED: 'Connection Successful!', OAUTH2_CREDENTIAL_TEST_SUCCEEDED: 'Connection Successful!',
OAUTH2_CREDENTIAL_TEST_FAILED: 'This OAuth2 credential was not connected to an account.', 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'; export const AUTH_COOKIE_NAME = 'n8n-auth';
@ -86,6 +87,9 @@ export const LICENSE_FEATURES = {
MULTIPLE_MAIN_INSTANCES: 'feat:multipleMainInstances', MULTIPLE_MAIN_INSTANCES: 'feat:multipleMainInstances',
WORKER_VIEW: 'feat:workerView', WORKER_VIEW: 'feat:workerView',
ADVANCED_PERMISSIONS: 'feat:advancedPermissions', ADVANCED_PERMISSIONS: 'feat:advancedPermissions',
PROJECT_ROLE_ADMIN: 'feat:projectRole:admin',
PROJECT_ROLE_EDITOR: 'feat:projectRole:editor',
PROJECT_ROLE_VIEWER: 'feat:projectRole:viewer',
} as const; } as const;
export const LICENSE_QUOTAS = { export const LICENSE_QUOTAS = {
@ -93,6 +97,7 @@ export const LICENSE_QUOTAS = {
VARIABLES_LIMIT: 'quota:maxVariables', VARIABLES_LIMIT: 'quota:maxVariables',
USERS_LIMIT: 'quota:users', USERS_LIMIT: 'quota:users',
WORKFLOW_HISTORY_PRUNE_LIMIT: 'quota:workflowHistoryPrune', WORKFLOW_HISTORY_PRUNE_LIMIT: 'quota:workflowHistoryPrune',
TEAM_PROJECT_LIMIT: 'quota:maxTeamProjects',
} as const; } as const;
export const UNLIMITED_LICENSE_QUOTA = -1; export const UNLIMITED_LICENSE_QUOTA = -1;

View file

@ -21,7 +21,7 @@ import { MfaService } from '@/Mfa/mfa.service';
import { Logger } from '@/Logger'; import { Logger } from '@/Logger';
import { AuthError } from '@/errors/response-errors/auth.error'; import { AuthError } from '@/errors/response-errors/auth.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.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 { ApplicationError } from 'n8n-workflow';
import { UserRepository } from '@/databases/repositories/user.repository'; import { UserRepository } from '@/databases/repositories/user.repository';
@ -130,7 +130,7 @@ export class AuthController {
inviterId, inviterId,
inviteeId, inviteeId,
}); });
throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
} }
if (!inviterId || !inviteeId) { if (!inviterId || !inviteeId) {

View file

@ -6,10 +6,10 @@ import { UserRepository } from '@db/repositories/user.repository';
import { ActiveWorkflowManager } from '@/ActiveWorkflowManager'; import { ActiveWorkflowManager } from '@/ActiveWorkflowManager';
import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus';
import { License } from '@/License'; 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 { Patch, Post, RestController } from '@/decorators';
import type { UserSetupPayload } from '@/requests'; 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 { MfaService } from '@/Mfa/mfa.service';
import { Push } from '@/push'; import { Push } from '@/push';
import { CacheService } from '@/services/cache/cache.service'; import { CacheService } from '@/services/cache/cache.service';
@ -25,21 +25,23 @@ if (!inE2ETests) {
const tablesToTruncate = [ const tablesToTruncate = [
'auth_identity', 'auth_identity',
'auth_provider_sync_history', 'auth_provider_sync_history',
'event_destinations',
'shared_workflow',
'shared_credentials',
'webhook_entity',
'workflows_tags',
'credentials_entity', 'credentials_entity',
'tag_entity', 'event_destinations',
'workflow_statistics',
'workflow_entity',
'execution_entity', 'execution_entity',
'settings',
'installed_packages',
'installed_nodes', 'installed_nodes',
'installed_packages',
'project',
'project_relation',
'settings',
'shared_credentials',
'shared_workflow',
'tag_entity',
'user', 'user',
'variables', 'variables',
'webhook_entity',
'workflow_entity',
'workflow_statistics',
'workflows_tags',
]; ];
type ResetRequest = Request< type ResetRequest = Request<
@ -81,21 +83,35 @@ export class E2EController {
[LICENSE_FEATURES.MULTIPLE_MAIN_INSTANCES]: false, [LICENSE_FEATURES.MULTIPLE_MAIN_INSTANCES]: false,
[LICENSE_FEATURES.WORKER_VIEW]: false, [LICENSE_FEATURES.WORKER_VIEW]: false,
[LICENSE_FEATURES.ADVANCED_PERMISSIONS]: 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( constructor(
license: License, license: License,
private readonly settingsRepo: SettingsRepository, private readonly settingsRepo: SettingsRepository,
private readonly userRepo: UserRepository,
private readonly workflowRunner: ActiveWorkflowManager, private readonly workflowRunner: ActiveWorkflowManager,
private readonly mfaService: MfaService, private readonly mfaService: MfaService,
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
private readonly push: Push, private readonly push: Push,
private readonly passwordUtility: PasswordUtility, private readonly passwordUtility: PasswordUtility,
private readonly eventBus: MessageEventBus, private readonly eventBus: MessageEventBus,
private readonly userRepository: UserRepository,
) { ) {
license.isFeatureEnabled = (feature: BooleanLicenseFeature) => license.isFeatureEnabled = (feature: BooleanLicenseFeature) =>
this.enabledFeatures[feature] ?? false; 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 }) @Post('/reset', { skipAuth: true })
@ -119,6 +135,12 @@ export class E2EController {
this.enabledFeatures[feature] = enabled; 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 }) @Patch('/queue-mode', { skipAuth: true })
async setQueueMode(req: Request<{}, {}, { enabled: boolean }>) { async setQueueMode(req: Request<{}, {}, { enabled: boolean }>) {
const { enabled } = req.body; const { enabled } = req.body;
@ -163,34 +185,34 @@ export class E2EController {
members: UserSetupPayload[], members: UserSetupPayload[],
admin: UserSetupPayload, admin: UserSetupPayload,
) { ) {
const instanceOwner = this.userRepo.create({
id: uuid(),
...owner,
password: await this.passwordUtility.hash(owner.password),
role: 'global:owner',
});
if (owner?.mfaSecret && owner.mfaRecoveryCodes?.length) { if (owner?.mfaSecret && owner.mfaRecoveryCodes?.length) {
const { encryptedRecoveryCodes, encryptedSecret } = const { encryptedRecoveryCodes, encryptedSecret } =
this.mfaService.encryptSecretAndRecoveryCodes(owner.mfaSecret, owner.mfaRecoveryCodes); this.mfaService.encryptSecretAndRecoveryCodes(owner.mfaSecret, owner.mfaRecoveryCodes);
instanceOwner.mfaSecret = encryptedSecret; owner.mfaSecret = encryptedSecret;
instanceOwner.mfaRecoveryCodes = encryptedRecoveryCodes; owner.mfaRecoveryCodes = encryptedRecoveryCodes;
} }
const adminUser = this.userRepo.create({ const userCreatePromises = [
id: uuid(), this.userRepository.createUserWithProject({
...admin, id: uuid(),
password: await this.passwordUtility.hash(admin.password), ...owner,
role: 'global:admin', password: await this.passwordUtility.hash(owner.password),
}); role: 'global:owner',
}),
];
const users = []; userCreatePromises.push(
this.userRepository.createUserWithProject({
users.push(instanceOwner, adminUser); id: uuid(),
...admin,
password: await this.passwordUtility.hash(admin.password),
role: 'global:admin',
}),
);
for (const { password, ...payload } of members) { for (const { password, ...payload } of members) {
users.push( userCreatePromises.push(
this.userRepo.create({ this.userRepository.createUserWithProject({
id: uuid(), id: uuid(),
...payload, ...payload,
password: await this.passwordUtility.hash(password), 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( await this.settingsRepo.update(
{ key: 'userManagement.isInstanceOwnerSetUp' }, { key: 'userManagement.isInstanceOwnerSetUp' },

View file

@ -15,7 +15,7 @@ import { PostHogClient } from '@/posthog';
import type { User } from '@/databases/entities/User'; import type { User } from '@/databases/entities/User';
import { UserRepository } from '@db/repositories/user.repository'; import { UserRepository } from '@db/repositories/user.repository';
import { BadRequestError } from '@/errors/response-errors/bad-request.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 { InternalHooks } from '@/InternalHooks'; import { InternalHooks } from '@/InternalHooks';
import { ExternalHooks } from '@/ExternalHooks'; import { ExternalHooks } from '@/ExternalHooks';
@ -55,7 +55,7 @@ export class InvitationController {
this.logger.debug( this.logger.debug(
'Request to send email invite(s) to user(s) failed because the user limit quota has been reached', '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')) { if (!config.getEnv('userManagement.isInstanceOwnerSetUp')) {
@ -98,7 +98,7 @@ export class InvitationController {
} }
if (invite.role === 'global:admin' && !this.license.isAdvancedPermissionsLicensed()) { 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.', 'Cannot invite admin user without advanced permissions. Please upgrade to a license that includes this feature.',
); );
} }

View file

@ -47,6 +47,7 @@ export abstract class AbstractOAuthController {
const credential = await this.sharedCredentialsRepository.findCredentialForUser( const credential = await this.sharedCredentialsRepository.findCredentialForUser(
credentialId, credentialId,
req.user, req.user,
['credential:read'],
); );
if (!credential) { if (!credential) {

View file

@ -17,7 +17,7 @@ import { InternalHooks } from '@/InternalHooks';
import { UrlService } from '@/services/url.service'; import { UrlService } from '@/services/url.service';
import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.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 { NotFoundError } from '@/errors/response-errors/not-found.error';
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
import { UserRepository } from '@/databases/repositories/user.repository'; import { UserRepository } from '@/databases/repositories/user.repository';
@ -76,7 +76,7 @@ export class PasswordResetController {
this.logger.debug( this.logger.debug(
'Request to send password reset email failed because the user limit was reached', '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 ( if (
isSamlCurrentAuthenticationMethod() && isSamlCurrentAuthenticationMethod() &&
@ -88,7 +88,7 @@ export class PasswordResetController {
this.logger.debug( this.logger.debug(
'Request to send password reset email failed because login is handled by SAML', '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.', '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', 'Request to resolve password token failed because the user limit was reached',
{ userId: user.id }, { 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 }); this.logger.info('Reset-password token resolved successfully', { userId: user.id });

View 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,
});
}
}

View 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),
})),
]),
);
}
}

View file

@ -2,8 +2,6 @@ import { plainToInstance } from 'class-transformer';
import { AuthService } from '@/auth/auth.service'; import { AuthService } from '@/auth/auth.service';
import { User } from '@db/entities/User'; 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 { GlobalScope, Delete, Get, RestController, Patch, Licensed } from '@/decorators';
import { import {
ListQuery, ListQuery,
@ -11,7 +9,6 @@ import {
UserRoleChangePayload, UserRoleChangePayload,
UserSettingsUpdatePayload, UserSettingsUpdatePayload,
} from '@/requests'; } from '@/requests';
import { ActiveWorkflowManager } from '@/ActiveWorkflowManager';
import type { PublicUser, ITelemetryUserDeletionData } from '@/Interfaces'; import type { PublicUser, ITelemetryUserDeletionData } from '@/Interfaces';
import { AuthIdentity } from '@db/entities/AuthIdentity'; import { AuthIdentity } from '@db/entities/AuthIdentity';
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; 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 { UserService } from '@/services/user.service';
import { listQueryMiddleware } from '@/middlewares'; import { listQueryMiddleware } from '@/middlewares';
import { Logger } from '@/Logger'; 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 { NotFoundError } from '@/errors/response-errors/not-found.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ExternalHooks } from '@/ExternalHooks'; import { ExternalHooks } from '@/ExternalHooks';
import { InternalHooks } from '@/InternalHooks'; import { InternalHooks } from '@/InternalHooks';
import { validateEntity } from '@/GenericHelpers'; 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') @RestController('/users')
export class UsersController { export class UsersController {
@ -36,9 +38,12 @@ export class UsersController {
private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly activeWorkflowManager: ActiveWorkflowManager,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly userService: UserService, private readonly userService: UserService,
private readonly projectRepository: ProjectRepository,
private readonly workflowService: WorkflowService,
private readonly credentialsService: CredentialsService,
private readonly projectService: ProjectService,
) {} ) {}
static ERROR_MESSAGES = { static ERROR_MESSAGES = {
@ -151,131 +156,92 @@ export class UsersController {
const { transferId } = req.query; 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( throw new BadRequestError(
'Request to delete a user failed because the user to delete and the transferee are the same user', '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 = { const telemetryData: ITelemetryUserDeletionData = {
user_id: req.user.id, user_id: req.user.id,
target_user_old_status: userToDelete.isPending ? 'invited' : 'active', target_user_old_status: userToDelete.isPending ? 'invited' : 'active',
target_user_id: idToDelete, target_user_id: idToDelete,
migration_strategy: transferId ? 'transfer_data' : 'delete_data',
}; };
telemetryData.migration_strategy = transferId ? 'transfer_data' : 'delete_data';
if (transferId) { if (transferId) {
telemetryData.migration_user_id = transferId; const transfereePersonalProject = await this.projectRepository.findOneBy({ id: transferId });
}
if (transferId) { if (!transfereePersonalProject) {
const transferee = users.find((user) => user.id === transferId); throw new NotFoundError(
'Request to delete a user failed because the transferee project was not found in DB',
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 const transferee = await this.userRepository.findOneByOrFail({
await transactionManager.update( projectRelations: {
SharedWorkflow, projectId: transfereePersonalProject.id,
{ user: userToDelete, role: 'workflow:owner' }, role: 'project:personalOwner',
{ 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 });
}); });
void this.internalHooks.onUserDeletion({ telemetryData.migration_user_id = transferee.id;
user: req.user,
telemetryData, await this.userService.getManager().transaction(async (trx) => {
publicApi: false, 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([ const [ownedSharedWorkflows, ownedSharedCredentials] = await Promise.all([
this.sharedWorkflowRepository.find({ this.sharedWorkflowRepository.find({
relations: ['workflow'], select: { workflowId: true },
where: { userId: userToDelete.id, role: 'workflow:owner' }, where: { projectId: personalProjectToDelete.id, role: 'workflow:owner' },
}), }),
this.sharedCredentialsRepository.find({ this.sharedCredentialsRepository.find({
relations: ['credentials'], relations: { credentials: true },
where: { userId: userToDelete.id, role: 'credential:owner' }, where: { projectId: personalProjectToDelete.id, role: 'credential:owner' },
}), }),
]); ]);
await this.userService.getManager().transaction(async (transactionManager) => { const ownedCredentials = ownedSharedCredentials.map(({ credentials }) => credentials);
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));
await transactionManager.delete(AuthIdentity, { userId: userToDelete.id }); for (const { workflowId } of ownedSharedWorkflows) {
await transactionManager.delete(User, { id: userToDelete.id }); 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({ void this.internalHooks.onUserDeletion({
@ -285,6 +251,7 @@ export class UsersController {
}); });
await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]); await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]);
return { success: true }; return { success: true };
} }
@ -308,11 +275,11 @@ export class UsersController {
} }
if (req.user.role === 'global:admin' && targetUser.role === 'global:owner') { 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') { 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 }); await this.userService.update(targetUser.id, { role: payload.newRoleName });
@ -324,6 +291,13 @@ export class UsersController {
public_api: false, 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 }; return { success: true };
} }
} }

View file

@ -29,13 +29,15 @@ export class WorkflowStatisticsController {
*/ */
// TODO: move this into a new decorator `@ValidateWorkflowPermission` // TODO: move this into a new decorator `@ValidateWorkflowPermission`
@Middleware() @Middleware()
async hasWorkflowAccess(req: StatisticsRequest.GetOne, res: Response, next: NextFunction) { async hasWorkflowAccess(req: StatisticsRequest.GetOne, _res: Response, next: NextFunction) {
const { user } = req; const { user } = req;
const workflowId = req.params.id; 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(); next();
} else { } else {
this.logger.verbose('User attempted to read a workflow without permissions', { this.logger.verbose('User attempted to read a workflow without permissions', {

View file

@ -1,41 +1,53 @@
import { deepCopy } from 'n8n-workflow'; import { deepCopy } from 'n8n-workflow';
import config from '@/config'; import config from '@/config';
import { CredentialsService } from './credentials.service'; import { CredentialsService } from './credentials.service';
import { CredentialRequest, ListQuery } from '@/requests'; import { CredentialRequest } from '@/requests';
import { InternalHooks } from '@/InternalHooks'; import { InternalHooks } from '@/InternalHooks';
import { Logger } from '@/Logger'; import { Logger } from '@/Logger';
import { NotFoundError } from '@/errors/response-errors/not-found.error'; 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 { NamingService } from '@/services/naming.service';
import { License } from '@/License'; import { License } from '@/License';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
import { OwnershipService } from '@/services/ownership.service';
import { EnterpriseCredentialsService } from './credentials.service.ee'; 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 { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { UserManagementMailer } from '@/UserManagement/email'; import { UserManagementMailer } from '@/UserManagement/email';
import * as Db from '@/Db'; import * as Db from '@/Db';
import * as utils from '@/utils'; import * as utils from '@/utils';
import { listQueryMiddleware } from '@/middlewares'; 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') @RestController('/credentials')
export class CredentialsController { export class CredentialsController {
constructor( constructor(
private readonly credentialsService: CredentialsService, private readonly credentialsService: CredentialsService,
private readonly enterpriseCredentialsService: EnterpriseCredentialsService, private readonly enterpriseCredentialsService: EnterpriseCredentialsService,
private readonly credentialsRepository: CredentialsRepository,
private readonly namingService: NamingService, private readonly namingService: NamingService,
private readonly license: License, private readonly license: License,
private readonly logger: Logger, private readonly logger: Logger,
private readonly ownershipService: OwnershipService,
private readonly internalHooks: InternalHooks, private readonly internalHooks: InternalHooks,
private readonly userManagementMailer: UserManagementMailer, private readonly userManagementMailer: UserManagementMailer,
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly projectRelationRepository: ProjectRelationRepository,
) {} ) {}
@Get('/', { middlewares: listQueryMiddleware }) @Get('/', { middlewares: listQueryMiddleware })
async getMany(req: ListQuery.Request) { async getMany(req: CredentialRequest.GetMany) {
return await this.credentialsService.getMany(req.user, { return await this.credentialsService.getMany(req.user, {
listQueryOptions: req.listQueryOptions, 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) { async getOne(req: CredentialRequest.Get) {
if (this.license.isSharingEnabled()) { if (this.license.isSharingEnabled()) {
const { id: credentialId } = req.params; const credentials = await this.enterpriseCredentialsService.getOne(
const includeDecryptedData = req.query.includeData === 'true'; req.user,
req.params.credentialId,
let credential = await this.credentialsRepository.findOne({ // TODO: editor-ui is always sending this, maybe we can just rely on the
where: { id: credentialId }, // the scopes and always decrypt the data if the user has the permissions
relations: ['shared', 'shared.user'], // to do so.
}); req.query.includeData === 'true',
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,
); );
return { data: decryptedData, ...rest }; const scopes = await this.credentialsService.getCredentialScopes(
req.user,
req.params.credentialId,
);
return { ...credentials, scopes };
} }
// non-enterprise // non-enterprise
const { id: credentialId } = req.params; const credentials = await this.credentialsService.getOne(
const includeDecryptedData = req.query.includeData === 'true';
const sharing = await this.credentialsService.getSharing(
req.user, req.user,
credentialId, req.params.credentialId,
{ allowGlobalScope: true, globalScope: 'credential:read' }, req.query.includeData === 'true',
['credentials'],
); );
if (!sharing) { const scopes = await this.credentialsService.getCredentialScopes(
throw new NotFoundError(`Credential with ID "${credentialId}" could not be found.`); req.user,
} req.params.credentialId,
const { credentials: credential } = sharing;
const { data: _, ...rest } = credential;
if (!includeDecryptedData) {
return { ...rest };
}
const decryptedData = this.credentialsService.redact(
this.credentialsService.decrypt(credential),
credential,
); );
return { data: decryptedData, ...rest }; return { ...credentials, scopes };
} }
// TODO: Write at least test cases for the failure paths.
@Post('/test') @Post('/test')
async testCredentials(req: CredentialRequest.Test) { async testCredentials(req: CredentialRequest.Test) {
if (this.license.isSharingEnabled()) {
const { credentials } = req.body;
const credentialId = credentials.id;
const { ownsCredential } = await this.enterpriseCredentialsService.isOwned(
req.user,
credentialId,
);
const sharing = await this.enterpriseCredentialsService.getSharing(req.user, credentialId, {
allowGlobalScope: true,
globalScope: 'credential:read',
});
if (!ownsCredential) {
if (!sharing) {
throw new UnauthorizedError('Forbidden');
}
const decryptedData = this.credentialsService.decrypt(sharing.credentials);
Object.assign(credentials, { data: decryptedData });
}
const mergedCredentials = deepCopy(credentials);
if (mergedCredentials.data && sharing?.credentials) {
const decryptedData = this.credentialsService.decrypt(sharing.credentials);
mergedCredentials.data = this.credentialsService.unredact(
mergedCredentials.data,
decryptedData,
);
}
return await this.credentialsService.test(req.user, mergedCredentials);
}
// non-enterprise
const { credentials } = req.body; const { credentials } = req.body;
const sharing = await this.credentialsService.getSharing(req.user, credentials.id, { const storedCredential = await this.sharedCredentialsRepository.findCredentialForUser(
allowGlobalScope: true, credentials.id,
globalScope: 'credential:read', req.user,
}); ['credential:read'],
);
if (!storedCredential) {
throw new ForbiddenError();
}
const mergedCredentials = deepCopy(credentials); const mergedCredentials = deepCopy(credentials);
if (mergedCredentials.data && sharing?.credentials) { const decryptedData = this.credentialsService.decrypt(storedCredential);
const decryptedData = this.credentialsService.decrypt(sharing.credentials);
// When a sharee opens a credential, the fields and the credential data are missing
// so the payload will be empty
// We need to replace the credential contents with the db version if that's the case
// So the credential can be tested properly
this.credentialsService.replaceCredentialContentsForSharee(
req.user,
storedCredential,
decryptedData,
mergedCredentials,
);
if (mergedCredentials.data && storedCredential) {
mergedCredentials.data = this.credentialsService.unredact( mergedCredentials.data = this.credentialsService.unredact(
mergedCredentials.data, mergedCredentials.data,
decryptedData, decryptedData,
@ -184,7 +141,12 @@ export class CredentialsController {
const newCredential = await this.credentialsService.prepareCreateData(req.body); const newCredential = await this.credentialsService.prepareCreateData(req.body);
const encryptedData = this.credentialsService.createEncryptedData(null, newCredential); 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({ void this.internalHooks.onUserCreatedCredentials({
user: req.user, user: req.user,
@ -194,24 +156,23 @@ export class CredentialsController {
public_api: false, 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) { async updateCredentials(req: CredentialRequest.Update) {
const { id: credentialId } = req.params; const { credentialId } = req.params;
const sharing = await this.credentialsService.getSharing( const credential = await this.sharedCredentialsRepository.findCredentialForUser(
req.user,
credentialId, credentialId,
{ req.user,
allowGlobalScope: true, ['credential:update'],
globalScope: 'credential:update',
},
['credentials'],
); );
if (!sharing) { if (!credential) {
this.logger.info('Attempt to update credential blocked due to lack of permissions', { this.logger.info('Attempt to update credential blocked due to lack of permissions', {
credentialId, credentialId,
userId: req.user.id, 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 decryptedData = this.credentialsService.decrypt(credential);
const preparedCredentialData = await this.credentialsService.prepareUpdateData( const preparedCredentialData = await this.credentialsService.prepareUpdateData(
req.body, req.body,
@ -259,24 +210,23 @@ export class CredentialsController {
credential_id: credential.id, 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) { async deleteCredentials(req: CredentialRequest.Delete) {
const { id: credentialId } = req.params; const { credentialId } = req.params;
const sharing = await this.credentialsService.getSharing( const credential = await this.sharedCredentialsRepository.findCredentialForUser(
req.user,
credentialId, credentialId,
{ req.user,
allowGlobalScope: true, ['credential:delete'],
globalScope: 'credential:delete',
},
['credentials'],
); );
if (!sharing) { if (!credential) {
this.logger.info('Attempt to delete credential blocked due to lack of permissions', { this.logger.info('Attempt to delete credential blocked due to lack of permissions', {
credentialId, credentialId,
userId: req.user.id, 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); await this.credentialsService.delete(credential);
void this.internalHooks.onUserDeletedCredentials({ void this.internalHooks.onUserDeletedCredentials({
@ -309,9 +249,10 @@ export class CredentialsController {
} }
@Licensed('feat:sharing') @Licensed('feat:sharing')
@Put('/:id/share') @Put('/:credentialId/share')
@ProjectScope('credential:share')
async shareCredentials(req: CredentialRequest.Share) { async shareCredentials(req: CredentialRequest.Share) {
const { id: credentialId } = req.params; const { credentialId } = req.params;
const { shareWithIds } = req.body; const { shareWithIds } = req.body;
if ( if (
@ -321,59 +262,45 @@ export class CredentialsController {
throw new BadRequestError('Bad request'); throw new BadRequestError('Bad request');
} }
const isOwnedRes = await this.enterpriseCredentialsService.isOwned(req.user, credentialId); const credential = await this.sharedCredentialsRepository.findCredentialForUser(
const { ownsCredential } = isOwnedRes; credentialId,
let { credential } = isOwnedRes; req.user,
if (!ownsCredential || !credential) { ['credential:share'],
credential = undefined; );
// Allow owners/admins to share
if (req.user.hasGlobalScope('credential:share')) {
const sharedRes = await this.enterpriseCredentialsService.getSharing(
req.user,
credentialId,
{
allowGlobalScope: true,
globalScope: 'credential:share',
},
);
credential = sharedRes?.credentials;
}
if (!credential) {
throw new UnauthorizedError('Forbidden');
}
}
const ownerIds = ( if (!credential) {
await this.enterpriseCredentialsService.getSharings( throw new ForbiddenError();
Db.getConnection().createEntityManager(), }
credentialId,
['shared'],
)
)
.filter((e) => e.role === 'credential:owner')
.map((e) => e.userId);
let amountRemoved: number | null = null; let amountRemoved: number | null = null;
let newShareeIds: string[] = []; let newShareeIds: string[] = [];
await Db.transaction(async (trx) => { await Db.transaction(async (trx) => {
// remove all sharings that are not supposed to exist anymore const currentPersonalProjectIDs = credential.shared
const { affected } = await this.credentialsRepository.pruneSharings(trx, credentialId, [ .filter((sc) => sc.role === 'credential:user')
...ownerIds, .map((sc) => sc.projectId);
...shareWithIds, const newPersonalProjectIds = shareWithIds;
]);
if (affected) amountRemoved = affected;
const sharings = await this.enterpriseCredentialsService.getSharings(trx, credentialId); const toShare = utils.rightDiff(
[currentPersonalProjectIDs, (id) => id],
// extract the new sharings that need to be added [newPersonalProjectIds, (id) => id],
newShareeIds = utils.rightDiff( );
[sharings, (sharing) => sharing.userId], const toUnshare = utils.rightDiff(
[shareWithIds, (shareeId) => shareeId], [newPersonalProjectIds, (id) => id],
[currentPersonalProjectIDs, (id) => id],
); );
if (newShareeIds.length) { const deleteResult = await trx.delete(SharedCredentials, {
await this.enterpriseCredentialsService.share(trx, credential, newShareeIds); credentialsId: credentialId,
projectId: In(toUnshare),
});
await this.enterpriseCredentialsService.shareWithProjects(credential, toShare, trx);
if (deleteResult.affected) {
amountRemoved = deleteResult.affected;
} }
newShareeIds = toShare;
}); });
void this.internalHooks.onUserSharedCredentials({ void this.internalHooks.onUserSharedCredentials({
@ -386,9 +313,14 @@ export class CredentialsController {
sharees_removed: amountRemoved, sharees_removed: amountRemoved,
}); });
const projectsRelations = await this.projectRelationRepository.findBy({
projectId: In(newShareeIds),
role: 'project:personalOwner',
});
await this.userManagementMailer.notifyCredentialsShared({ await this.userManagementMailer.notifyCredentialsShared({
sharer: req.user, sharer: req.user,
newShareeIds, newShareeIds: projectsRelations.map((pr) => pr.userId),
credentialsName: credential.name, credentialsName: credential.name,
}); });
} }

View file

@ -1,77 +1,94 @@
import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm'; import { In, type EntityManager } from '@n8n/typeorm';
import type { SharedCredentials } from '@db/entities/SharedCredentials';
import type { User } from '@db/entities/User'; 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 { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
import { UserRepository } from '@/databases/repositories/user.repository'; import type { CredentialsEntity } from '@/databases/entities/CredentialsEntity';
import { CredentialsEntity } from '@/databases/entities/CredentialsEntity';
import { Service } from 'typedi'; 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() @Service()
export class EnterpriseCredentialsService { export class EnterpriseCredentialsService {
constructor( constructor(
private readonly userRepository: UserRepository,
private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly ownershipService: OwnershipService,
private readonly credentialsService: CredentialsService,
) {} ) {}
async isOwned(user: User, credentialId: string) { async shareWithProjects(
const sharing = await this.getSharing(user, credentialId, { allowGlobalScope: false }, [ credential: CredentialsEntity,
'credentials', shareWithIds: string[],
]); entityManager?: EntityManager,
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'],
) { ) {
const where: FindOptionsWhere<SharedCredentials> = { credentialsId: credentialId }; const em = entityManager ?? this.sharedCredentialsRepository.manager;
// Omit user from where if the requesting user has relevant const projects = await em.find(Project, {
// global credential permissions. This allows the user to where: { id: In(shareWithIds), type: 'personal' },
// 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,
}); });
return credential?.shared ?? []; const newSharedCredentials = projects
} // We filter by role === 'project:personalOwner' above and there should
// always only be one owner.
async share(transaction: EntityManager, credential: CredentialsEntity, shareWithIds: string[]) { .map((project) =>
const users = await this.userRepository.getByIds(transaction, shareWithIds);
const newSharedCredentials = users
.filter((user) => !user.isPending)
.map((user) =>
this.sharedCredentialsRepository.create({ this.sharedCredentialsRepository.create({
credentialsId: credential.id, credentialsId: credential.id,
userId: user.id,
role: 'credential:user', 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 };
} }
} }

View file

@ -5,8 +5,13 @@ import type {
ICredentialType, ICredentialType,
INodeProperties, INodeProperties,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { CREDENTIAL_EMPTY_VALUE, deepCopy, NodeHelpers } from 'n8n-workflow'; import { ApplicationError, CREDENTIAL_EMPTY_VALUE, deepCopy, NodeHelpers } from 'n8n-workflow';
import type { FindOptionsWhere } from '@n8n/typeorm'; import {
In,
type EntityManager,
type FindOptionsRelations,
type FindOptionsWhere,
} from '@n8n/typeorm';
import type { Scope } from '@n8n/permissions'; import type { Scope } from '@n8n/permissions';
import * as Db from '@/Db'; import * as Db from '@/Db';
import type { ICredentialsDb } from '@/Interfaces'; import type { ICredentialsDb } from '@/Interfaces';
@ -25,6 +30,12 @@ import { CredentialsRepository } from '@db/repositories/credentials.repository';
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
import { Service } from 'typedi'; import { Service } from 'typedi';
import { CredentialsTester } from '@/services/credentials-tester.service'; 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 = export type CredentialsGetSharedOptions =
| { allowGlobalScope: true; globalScope: Scope } | { allowGlobalScope: true; globalScope: Scope }
@ -40,62 +51,129 @@ export class CredentialsService {
private readonly credentialsTester: CredentialsTester, private readonly credentialsTester: CredentialsTester,
private readonly externalHooks: ExternalHooks, private readonly externalHooks: ExternalHooks,
private readonly credentialTypes: CredentialTypes, 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( async getMany(
user: User, 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 returnAll = user.hasGlobalScope('credential:list') && !options.onlyOwn;
const isDefaultSelect = !options.listQueryOptions?.select; const isDefaultSelect = !options.listQueryOptions?.select;
if (returnAll) { let projectRelations: ProjectRelation[] | undefined = undefined;
const credentials = await this.credentialsRepository.findMany(options.listQueryOptions); if (options.includeScopes) {
projectRelations = await this.projectService.getProjectRelationsForUser(user);
return isDefaultSelect if (options.listQueryOptions?.filter?.projectId && user.hasGlobalScope('credential:list')) {
? credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c)) // Only instance owners and admins have the credential:list scope
: credentials; // 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, options.listQueryOptions,
ids, // only accessible credentials ids, // only accessible credentials
); );
return isDefaultSelect if (isDefaultSelect) {
? credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c)) credentials = credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c));
: credentials; }
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. * Retrieve the sharing that matches a user and a credential.
*/ */
// TODO: move to SharedCredentialsService
async getSharing( async getSharing(
user: User, user: User,
credentialId: string, credentialId: string,
options: CredentialsGetSharedOptions, globalScopes: Scope[],
relations: string[] = ['credentials'], relations: FindOptionsRelations<SharedCredentials> = { credentials: true },
): Promise<SharedCredentials | null> { ): 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 if (!user.hasGlobalScope(globalScopes, { mode: 'allOf' })) {
// global credential permissions. This allows the user to where = {
// access credentials they don't own. ...where,
if (!options.allowGlobalScope || !user.hasGlobalScope(options.globalScope)) { role: 'credential:owner',
where.userId = user.id; project: {
where.role = 'credential:owner'; projectRelations: {
role: 'project:personalOwner',
userId: user.id,
},
},
};
} }
return await this.sharedCredentialsRepository.findOne({ where, relations }); return await this.sharedCredentialsRepository.findOne({
where,
relations,
});
} }
async prepareCreateData( async prepareCreateData(
@ -128,7 +206,7 @@ export class CredentialsService {
await validateEntity(updateData); await validateEntity(updateData);
// Do not overwrite the oauth data else data like the access or refresh token would get lost // 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) { if (decryptedData.oauthTokenData) {
// @ts-ignore // @ts-ignore
updateData.data.oauthTokenData = decryptedData.oauthTokenData; updateData.data.oauthTokenData = decryptedData.oauthTokenData;
@ -165,7 +243,12 @@ export class CredentialsService {
return await this.credentialsRepository.findOneBy({ id: credentialId }); 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 // To avoid side effects
const newCredential = new CredentialsEntity(); const newCredential = new CredentialsEntity();
Object.assign(newCredential, credential, encryptedData); Object.assign(newCredential, credential, encryptedData);
@ -177,12 +260,31 @@ export class CredentialsService {
savedCredential.data = newCredential.data; savedCredential.data = newCredential.data;
const newSharedCredential = new SharedCredentials(); const project =
projectId === undefined
? await this.projectRepository.getPersonalProjectForUserOrFail(user.id)
: await this.projectService.getProjectWithScope(
user,
projectId,
['credential:create'],
transactionManager,
);
Object.assign(newSharedCredential, { if (typeof projectId === 'string' && project === null) {
throw new BadRequestError(
"You don't have the permissions to save the workflow in this project.",
);
}
// Safe guard in case the personal project does not exist for whatever reason.
if (project === null) {
throw new ApplicationError('No personal project found');
}
const newSharedCredential = this.sharedCredentialsRepository.create({
role: 'credential:owner', role: 'credential:owner',
user,
credentials: savedCredential, credentials: savedCredential,
projectId: project.id,
}); });
await transactionManager.save<SharedCredentials>(newSharedCredential); await transactionManager.save<SharedCredentials>(newSharedCredential);
@ -295,4 +397,134 @@ export class CredentialsService {
this.unredactRestoreValues(mergedData, savedData); this.unredactRestoreValues(mergedData, savedData);
return mergedData; 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;
}
});
}
}
});
}
} }

View file

@ -11,6 +11,7 @@ import { ApplicationError } from 'n8n-workflow';
import config from '@/config'; import config from '@/config';
import { entities } from './entities'; import { entities } from './entities';
import { subscribers } from './subscribers';
import { mysqlMigrations } from './migrations/mysqldb'; import { mysqlMigrations } from './migrations/mysqldb';
import { postgresMigrations } from './migrations/postgresdb'; import { postgresMigrations } from './migrations/postgresdb';
import { sqliteMigrations } from './migrations/sqlite'; import { sqliteMigrations } from './migrations/sqlite';
@ -32,6 +33,7 @@ const getCommonOptions = () => {
return { return {
entityPrefix, entityPrefix,
entities: Object.values(entities), entities: Object.values(entities),
subscribers: Object.values(subscribers),
migrationsTableName: `${entityPrefix}migrations`, migrationsTableName: `${entityPrefix}migrations`,
migrationsRun: false, migrationsRun: false,
synchronize: false, synchronize: false,

View file

@ -94,9 +94,11 @@ export class Column {
options.type = isPostgres ? 'timestamptz' : 'datetime'; options.type = isPostgres ? 'timestamptz' : 'datetime';
} else if (type === 'json' && isSqlite) { } else if (type === 'json' && isSqlite) {
options.type = 'text'; options.type = 'text';
} else if (type === 'uuid' && isMysql) { } else if (type === 'uuid') {
// mysql does not support uuid type // 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') { if ((type === 'varchar' || type === 'timestamp') && length !== 'auto') {

View file

@ -46,7 +46,13 @@ export class CreateTable extends TableOperation {
withForeignKey( withForeignKey(
columnName: string, 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 = { const foreignKey: TableForeignKeyOptions = {
columnNames: [columnName], columnNames: [columnName],
@ -55,6 +61,7 @@ export class CreateTable extends TableOperation {
}; };
if (ref.onDelete) foreignKey.onDelete = ref.onDelete; if (ref.onDelete) foreignKey.onDelete = ref.onDelete;
if (ref.onUpdate) foreignKey.onUpdate = ref.onUpdate; if (ref.onUpdate) foreignKey.onUpdate = ref.onUpdate;
if (ref.name) foreignKey.name = ref.name;
this.foreignKeys.add(foreignKey); this.foreignKeys.add(foreignKey);
return this; return this;
} }

View 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[];
}

View 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;
}

View file

@ -1,7 +1,7 @@
import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import { CredentialsEntity } from './CredentialsEntity'; import { CredentialsEntity } from './CredentialsEntity';
import { User } from './User';
import { WithTimestamps } from './AbstractEntity'; import { WithTimestamps } from './AbstractEntity';
import { Project } from './Project';
export type CredentialSharingRole = 'credential:owner' | 'credential:user'; export type CredentialSharingRole = 'credential:owner' | 'credential:user';
@ -10,15 +10,15 @@ export class SharedCredentials extends WithTimestamps {
@Column() @Column()
role: CredentialSharingRole; role: CredentialSharingRole;
@ManyToOne('User', 'sharedCredentials')
user: User;
@PrimaryColumn()
userId: string;
@ManyToOne('CredentialsEntity', 'shared') @ManyToOne('CredentialsEntity', 'shared')
credentials: CredentialsEntity; credentials: CredentialsEntity;
@PrimaryColumn() @PrimaryColumn()
credentialsId: string; credentialsId: string;
@ManyToOne('Project', 'sharedCredentials')
project: Project;
@PrimaryColumn()
projectId: string;
} }

View file

@ -1,24 +1,24 @@
import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import { WorkflowEntity } from './WorkflowEntity'; import { WorkflowEntity } from './WorkflowEntity';
import { User } from './User';
import { WithTimestamps } from './AbstractEntity'; 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() @Entity()
export class SharedWorkflow extends WithTimestamps { export class SharedWorkflow extends WithTimestamps {
@Column() @Column()
role: WorkflowSharingRole; role: WorkflowSharingRole;
@ManyToOne('User', 'sharedWorkflows')
user: User;
@PrimaryColumn()
userId: string;
@ManyToOne('WorkflowEntity', 'shared') @ManyToOne('WorkflowEntity', 'shared')
workflow: WorkflowEntity; workflow: WorkflowEntity;
@PrimaryColumn() @PrimaryColumn()
workflowId: string; workflowId: string;
@ManyToOne('Project', 'sharedWorkflows')
project: Project;
@PrimaryColumn()
projectId: string;
} }

View file

@ -18,16 +18,21 @@ import { objectRetriever, lowerCaser } from '../utils/transformers';
import { WithTimestamps, jsonColumnType } from './AbstractEntity'; import { WithTimestamps, jsonColumnType } from './AbstractEntity';
import type { IPersonalizationSurveyAnswers } from '@/Interfaces'; import type { IPersonalizationSurveyAnswers } from '@/Interfaces';
import type { AuthIdentity } from './AuthIdentity'; 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 { hasScope, type ScopeOptions, type Scope } from '@n8n/permissions';
import type { ProjectRelation } from './ProjectRelation';
export type GlobalRole = 'global:owner' | 'global:admin' | 'global:member'; export type GlobalRole = 'global:owner' | 'global:admin' | 'global:member';
export type AssignableRole = Exclude<GlobalRole, 'global:owner'>; export type AssignableRole = Exclude<GlobalRole, 'global:owner'>;
const STATIC_SCOPE_MAP: Record<GlobalRole, Scope[]> = { const STATIC_SCOPE_MAP: Record<GlobalRole, Scope[]> = {
'global:owner': ownerPermissions, 'global:owner': GLOBAL_OWNER_SCOPES,
'global:member': memberPermissions, 'global:member': GLOBAL_MEMBER_SCOPES,
'global:admin': adminPermissions, 'global:admin': GLOBAL_ADMIN_SCOPES,
}; };
@Entity() @Entity()
@ -85,6 +90,9 @@ export class User extends WithTimestamps implements IUser {
@OneToMany('SharedCredentials', 'user') @OneToMany('SharedCredentials', 'user')
sharedCredentials: SharedCredentials[]; sharedCredentials: SharedCredentials[];
@OneToMany('ProjectRelation', 'user')
projectRelations: ProjectRelation[];
@Column({ type: Boolean, default: false }) @Column({ type: Boolean, default: false })
disabled: boolean; disabled: boolean;
@ -138,6 +146,7 @@ export class User extends WithTimestamps implements IUser {
{ {
global: this.globalScopes, global: this.globalScopes,
}, },
undefined,
scopeOptions, scopeOptions,
); );
} }
@ -146,4 +155,14 @@ export class User extends WithTimestamps implements IUser {
const { password, apiKey, mfaSecret, mfaRecoveryCodes, ...rest } = this; const { password, apiKey, mfaSecret, mfaRecoveryCodes, ...rest } = this;
return rest; 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';
}
}
} }

View file

@ -19,6 +19,8 @@ import { WorkflowStatistics } from './WorkflowStatistics';
import { ExecutionMetadata } from './ExecutionMetadata'; import { ExecutionMetadata } from './ExecutionMetadata';
import { ExecutionData } from './ExecutionData'; import { ExecutionData } from './ExecutionData';
import { WorkflowHistory } from './WorkflowHistory'; import { WorkflowHistory } from './WorkflowHistory';
import { Project } from './Project';
import { ProjectRelation } from './ProjectRelation';
export const entities = { export const entities = {
AuthIdentity, AuthIdentity,
@ -41,4 +43,6 @@ export const entities = {
ExecutionMetadata, ExecutionMetadata,
ExecutionData, ExecutionData,
WorkflowHistory, WorkflowHistory,
Project,
ProjectRelation,
}; };

View file

@ -35,6 +35,11 @@ export class MoveSshKeysToDatabase1711390882123 implements ReversibleMigration {
return; return;
} }
if (!privateKey && !publicKey) {
logger.info(`[${migrationName}] No SSH keys in filesystem, skipping`);
return;
}
const settings = escape.tableName('settings'); const settings = escape.tableName('settings');
const key = escape.columnName('key'); const key = escape.columnName('key');
const value = escape.columnName('value'); const value = escape.columnName('value');

View file

@ -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);
}
}

View file

@ -51,6 +51,7 @@ import { ExecutionSoftDelete1693491613982 } from '../common/1693491613982-Execut
import { AddWorkflowMetadata1695128658538 } from '../common/1695128658538-AddWorkflowMetadata'; import { AddWorkflowMetadata1695128658538 } from '../common/1695128658538-AddWorkflowMetadata';
import { ModifyWorkflowHistoryNodesAndConnections1695829275184 } from '../common/1695829275184-ModifyWorkflowHistoryNodesAndConnections'; import { ModifyWorkflowHistoryNodesAndConnections1695829275184 } from '../common/1695829275184-ModifyWorkflowHistoryNodesAndConnections';
import { AddGlobalAdminRole1700571993961 } from '../common/1700571993961-AddGlobalAdminRole'; import { AddGlobalAdminRole1700571993961 } from '../common/1700571993961-AddGlobalAdminRole';
import { CreateProject1714133768519 } from '../common/1714133768519-CreateProject';
import { DropRoleMapping1705429061930 } from '../common/1705429061930-DropRoleMapping'; import { DropRoleMapping1705429061930 } from '../common/1705429061930-DropRoleMapping';
import { RemoveFailedExecutionStatus1711018413374 } from '../common/1711018413374-RemoveFailedExecutionStatus'; import { RemoveFailedExecutionStatus1711018413374 } from '../common/1711018413374-RemoveFailedExecutionStatus';
import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase'; import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase';
@ -113,4 +114,5 @@ export const mysqlMigrations: Migration[] = [
RemoveFailedExecutionStatus1711018413374, RemoveFailedExecutionStatus1711018413374,
MoveSshKeysToDatabase1711390882123, MoveSshKeysToDatabase1711390882123,
RemoveNodesAccess1712044305787, RemoveNodesAccess1712044305787,
CreateProject1714133768519,
]; ];

View file

@ -50,6 +50,7 @@ import { AddWorkflowMetadata1695128658538 } from '../common/1695128658538-AddWor
import { MigrateToTimestampTz1694091729095 } from './1694091729095-MigrateToTimestampTz'; import { MigrateToTimestampTz1694091729095 } from './1694091729095-MigrateToTimestampTz';
import { ModifyWorkflowHistoryNodesAndConnections1695829275184 } from '../common/1695829275184-ModifyWorkflowHistoryNodesAndConnections'; import { ModifyWorkflowHistoryNodesAndConnections1695829275184 } from '../common/1695829275184-ModifyWorkflowHistoryNodesAndConnections';
import { AddGlobalAdminRole1700571993961 } from '../common/1700571993961-AddGlobalAdminRole'; import { AddGlobalAdminRole1700571993961 } from '../common/1700571993961-AddGlobalAdminRole';
import { CreateProject1714133768519 } from '../common/1714133768519-CreateProject';
import { DropRoleMapping1705429061930 } from '../common/1705429061930-DropRoleMapping'; import { DropRoleMapping1705429061930 } from '../common/1705429061930-DropRoleMapping';
import { RemoveFailedExecutionStatus1711018413374 } from '../common/1711018413374-RemoveFailedExecutionStatus'; import { RemoveFailedExecutionStatus1711018413374 } from '../common/1711018413374-RemoveFailedExecutionStatus';
import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase'; import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase';
@ -111,4 +112,5 @@ export const postgresMigrations: Migration[] = [
RemoveFailedExecutionStatus1711018413374, RemoveFailedExecutionStatus1711018413374,
MoveSshKeysToDatabase1711390882123, MoveSshKeysToDatabase1711390882123,
RemoveNodesAccess1712044305787, RemoveNodesAccess1712044305787,
CreateProject1714133768519,
]; ];

View file

@ -48,6 +48,7 @@ import { ExecutionSoftDelete1693491613982 } from './1693491613982-ExecutionSoftD
import { AddWorkflowMetadata1695128658538 } from '../common/1695128658538-AddWorkflowMetadata'; import { AddWorkflowMetadata1695128658538 } from '../common/1695128658538-AddWorkflowMetadata';
import { ModifyWorkflowHistoryNodesAndConnections1695829275184 } from '../common/1695829275184-ModifyWorkflowHistoryNodesAndConnections'; import { ModifyWorkflowHistoryNodesAndConnections1695829275184 } from '../common/1695829275184-ModifyWorkflowHistoryNodesAndConnections';
import { AddGlobalAdminRole1700571993961 } from '../common/1700571993961-AddGlobalAdminRole'; import { AddGlobalAdminRole1700571993961 } from '../common/1700571993961-AddGlobalAdminRole';
import { CreateProject1714133768519 } from '../common/1714133768519-CreateProject';
import { DropRoleMapping1705429061930 } from './1705429061930-DropRoleMapping'; import { DropRoleMapping1705429061930 } from './1705429061930-DropRoleMapping';
import { RemoveFailedExecutionStatus1711018413374 } from '../common/1711018413374-RemoveFailedExecutionStatus'; import { RemoveFailedExecutionStatus1711018413374 } from '../common/1711018413374-RemoveFailedExecutionStatus';
import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase'; import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase';
@ -107,6 +108,7 @@ const sqliteMigrations: Migration[] = [
RemoveFailedExecutionStatus1711018413374, RemoveFailedExecutionStatus1711018413374,
MoveSshKeysToDatabase1711390882123, MoveSshKeysToDatabase1711390882123,
RemoveNodesAccess1712044305787, RemoveNodesAccess1712044305787,
CreateProject1714133768519,
]; ];
export { sqliteMigrations }; export { sqliteMigrations };

View file

@ -1,8 +1,7 @@
import { Service } from 'typedi'; import { Service } from 'typedi';
import { DataSource, In, Not, Repository, Like } from '@n8n/typeorm'; import { DataSource, In, Repository, Like } from '@n8n/typeorm';
import type { FindManyOptions, DeleteResult, EntityManager, FindOptionsWhere } from '@n8n/typeorm'; import type { FindManyOptions } from '@n8n/typeorm';
import { CredentialsEntity } from '../entities/CredentialsEntity'; import { CredentialsEntity } from '../entities/CredentialsEntity';
import { SharedCredentials } from '../entities/SharedCredentials';
import type { ListQuery } from '@/requests'; import type { ListQuery } from '@/requests';
@Service() @Service()
@ -11,18 +10,6 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
super(CredentialsEntity, dataSource.manager); 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) { async findStartingWith(credentialName: string) {
return await this.find({ return await this.find({
select: ['name'], select: ['name'],
@ -45,7 +32,7 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
type Select = Array<keyof CredentialsEntity>; type Select = Array<keyof CredentialsEntity>;
const defaultRelations = ['shared', 'shared.user']; const defaultRelations = ['shared', 'shared.project'];
const defaultSelect: Select = ['id', 'name', 'type', 'createdAt', 'updatedAt']; const defaultSelect: Select = ['id', 'name', 'type', 'createdAt', 'updatedAt'];
if (!listQueryOptions) return { select: defaultSelect, relations: defaultRelations }; if (!listQueryOptions) return { select: defaultSelect, relations: defaultRelations };
@ -60,6 +47,11 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
filter.type = Like(`%${filter.type}%`); 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 (filter) findManyOptions.where = filter;
if (select) findManyOptions.select = select; if (select) findManyOptions.select = select;
if (take) findManyOptions.take = take; if (take) findManyOptions.take = take;
@ -81,7 +73,11 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
const findManyOptions: FindManyOptions<CredentialsEntity> = { where: { id: In(ids) } }; const findManyOptions: FindManyOptions<CredentialsEntity> = { where: { id: In(ids) } };
if (withSharings) { if (withSharings) {
findManyOptions.relations = ['shared', 'shared.user']; findManyOptions.relations = {
shared: {
project: true,
},
};
} }
return await this.find(findManyOptions); return await this.find(findManyOptions);

View file

@ -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' } }),
};
}
}

View file

@ -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>,
);
}
}

View file

@ -1,22 +1,53 @@
import { Service } from 'typedi'; 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 { DataSource, In, Not, Repository } from '@n8n/typeorm';
import { type CredentialSharingRole, SharedCredentials } from '../entities/SharedCredentials'; import { type CredentialSharingRole, SharedCredentials } from '../entities/SharedCredentials';
import type { User } from '../entities/User'; 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() @Service()
export class SharedCredentialsRepository extends Repository<SharedCredentials> { export class SharedCredentialsRepository extends Repository<SharedCredentials> {
constructor(dataSource: DataSource) { constructor(
dataSource: DataSource,
private readonly roleService: RoleService,
) {
super(SharedCredentials, dataSource.manager); super(SharedCredentials, dataSource.manager);
} }
/** Get a credential if it has been shared with a user */ /** 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({ const sharedCredential = await this.findOne({
relations: ['credentials'], where,
where: { // TODO: write a small relations merger and use that one here
credentialsId, relations: {
...(!user.hasGlobalScope('credential:read') ? { userId: user.id } : {}), credentials: {
shared: { project: { projectRelations: { user: true } } },
},
}, },
}); });
if (!sharedCredential) return null; if (!sharedCredential) return null;
@ -25,7 +56,7 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {
async findByCredentialIds(credentialIds: string[], role: CredentialSharingRole) { async findByCredentialIds(credentialIds: string[], role: CredentialSharingRole) {
return await this.find({ return await this.find({
relations: ['credentials', 'user'], relations: { credentials: true, project: { projectRelations: { user: true } } },
where: { where: {
credentialsId: In(credentialIds), credentialsId: In(credentialIds),
role, role,
@ -33,37 +64,91 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {
}); });
} }
async makeOwnerOfAllCredentials(user: User) { async makeOwnerOfAllCredentials(project: Project) {
return await this.update({ userId: Not(user.id), role: 'credential:owner' }, { user }); return await this.update(
{
projectId: Not(project.id),
role: 'credential:owner',
},
{ project },
);
} }
/** Get the IDs of all credentials owned by a user */ async makeOwner(credentialIds: string[], projectId: string, trx?: EntityManager) {
async getOwnedCredentialIds(userIds: string[]) { trx = trx ?? this.manager;
return await this.getCredentialIdsByUserAndRole(userIds, ['credential:owner']); 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 getCredentialIdsByUserAndRole(
async getAccessibleCredentialIds(userIds: string[]) { userIds: string[],
return await this.getCredentialIdsByUserAndRole(userIds, [ options:
'credential:owner', | { scopes: Scope[] }
'credential:user', | { 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({ const sharings = await this.find({
where: { where: {
userId: In(userIds), role: In(credentialRoles),
role: In(roles), project: {
projectRelations: {
userId: In(userIds),
role: In(projectRoles),
},
},
}, },
}); });
return sharings.map((s) => s.credentialsId); return sharings.map((s) => s.credentialsId);
} }
async deleteByIds(transaction: EntityManager, sharedCredentialsIds: string[], user?: User) { async deleteByIds(sharedCredentialsIds: string[], projectId: string, trx?: EntityManager) {
return await transaction.delete(SharedCredentials, { trx = trx ?? this.manager;
user,
return await trx.delete(SharedCredentials, {
projectId,
credentialsId: In(sharedCredentialsIds), 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;
}
} }

View file

@ -4,33 +4,18 @@ import type { EntityManager, FindManyOptions, FindOptionsWhere } from '@n8n/type
import { SharedWorkflow, type WorkflowSharingRole } from '../entities/SharedWorkflow'; import { SharedWorkflow, type WorkflowSharingRole } from '../entities/SharedWorkflow';
import { type User } from '../entities/User'; import { type User } from '../entities/User';
import type { Scope } from '@n8n/permissions'; 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() @Service()
export class SharedWorkflowRepository extends Repository<SharedWorkflow> { export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
constructor(dataSource: DataSource) { constructor(
dataSource: DataSource,
private roleService: RoleService,
) {
super(SharedWorkflow, dataSource.manager); 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[]) { async getSharedWorkflowIds(workflowIds: string[]) {
const sharedWorkflows = await this.find({ const sharedWorkflows = await this.find({
select: ['workflowId'], select: ['workflowId'],
@ -43,11 +28,11 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
async findByWorkflowIds(workflowIds: string[]) { async findByWorkflowIds(workflowIds: string[]) {
return await this.find({ return await this.find({
relations: ['user'],
where: { where: {
role: 'workflow:owner', role: 'workflow:owner',
workflowId: In(workflowIds), workflowId: In(workflowIds),
}, },
relations: { project: { projectRelations: { user: true } } },
}); });
} }
@ -55,90 +40,49 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
userId: string, userId: string,
workflowId: string, workflowId: string,
): Promise<WorkflowSharingRole | undefined> { ): Promise<WorkflowSharingRole | undefined> {
return await this.findOne({ const sharing = await this.findOne({
select: ['role'], // 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:
where: { workflowId, userId }, // QueryFailedError: SQLITE_ERROR: no such column: distinctAlias.SharedWorkflow_...
}).then((shared) => shared?.role); select: {
} role: true,
workflowId: true,
async findSharing( projectId: true,
workflowId: string, },
user: User, where: {
scope: Scope, workflowId,
{ roles, extraRelations }: { roles?: WorkflowSharingRole[]; extraRelations?: string[] } = {}, project: { projectRelations: { role: 'project:personalOwner', userId } },
) {
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[];
},
): Promise<SharedWorkflow[]> {
return await this.find({
where: {
...(!['global:owner', 'global:admin'].includes(user.role) && { userId: user.id }),
...(options.workflowIds && { workflowId: In(options.workflowIds) }),
}, },
...(options.relations && { relations: options.relations }),
}); });
return sharing?.role;
} }
async share(transaction: EntityManager, workflow: WorkflowEntity, users: User[]) { async makeOwnerOfAllWorkflows(project: Project) {
const newSharedWorkflows = users.reduce<SharedWorkflow[]>((acc, user) => { return await this.update(
if (user.isPending) { {
return acc; projectId: Not(project.id),
} role: 'workflow:owner',
const entity: Partial<SharedWorkflow> = { },
workflowId: workflow.id, { project },
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( async findWithFields(
@ -153,10 +97,107 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
}); });
} }
async deleteByIds(transaction: EntityManager, sharedWorkflowIds: string[], user?: User) { async deleteByIds(sharedWorkflowIds: string[], projectId: string, trx?: EntityManager) {
return await transaction.delete(SharedWorkflow, { trx = trx ?? this.manager;
user,
return await trx.delete(SharedWorkflow, {
projectId,
workflowId: In(sharedWorkflowIds), 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;
}
} }

View file

@ -1,9 +1,11 @@
import { Service } from 'typedi'; 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 { DataSource, In, IsNull, Not, Repository } from '@n8n/typeorm';
import type { ListQuery } from '@/requests'; import type { ListQuery } from '@/requests';
import { type GlobalRole, User } from '../entities/User'; import { type GlobalRole, User } from '../entities/User';
import { Project } from '../entities/Project';
import { ProjectRelation } from '../entities/ProjectRelation';
@Service() @Service()
export class UserRepository extends Repository<User> { export class UserRepository extends Repository<User> {
constructor(dataSource: DataSource) { 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) { async deleteAllExcept(user: User) {
await this.delete({ id: Not(user.id) }); await this.delete({ id: Not(user.id) });
} }
@ -104,4 +119,34 @@ export class UserRepository extends Repository<User> {
where: { id: In(userIds), password: Not(IsNull()) }, 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);
}
} }

View file

@ -8,15 +8,12 @@ import {
type FindOptionsWhere, type FindOptionsWhere,
type FindOptionsSelect, type FindOptionsSelect,
type FindManyOptions, type FindManyOptions,
type EntityManager, type FindOptionsRelations,
type DeleteResult,
Not,
} from '@n8n/typeorm'; } from '@n8n/typeorm';
import type { ListQuery } from '@/requests'; import type { ListQuery } from '@/requests';
import { isStringArray } from '@/utils'; import { isStringArray } from '@/utils';
import config from '@/config'; import config from '@/config';
import { WorkflowEntity } from '../entities/WorkflowEntity'; import { WorkflowEntity } from '../entities/WorkflowEntity';
import { SharedWorkflow } from '../entities/SharedWorkflow';
import { WebhookEntity } from '../entities/WebhookEntity'; import { WebhookEntity } from '../entities/WebhookEntity';
@Service() @Service()
@ -25,7 +22,10 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
super(WorkflowEntity, dataSource.manager); 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({ return await this.findOne({
where, where,
relations: options?.relations, relations: options?.relations,
@ -35,7 +35,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
async getAllActive() { async getAllActive() {
return await this.find({ return await this.find({
where: { active: true }, 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) { async findById(workflowId: string) {
return await this.findOne({ return await this.findOne({
where: { id: workflowId }, 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; 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> { async updateWorkflowTriggerCount(id: string, triggerCount: number): Promise<UpdateResult> {
const qb = this.createQueryBuilder('workflow'); const qb = this.createQueryBuilder('workflow');
return await qb return await qb
@ -114,6 +91,11 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
async getMany(sharedWorkflowIds: string[], options?: ListQuery.Options) { async getMany(sharedWorkflowIds: string[], options?: ListQuery.Options) {
if (sharedWorkflowIds.length === 0) return { workflows: [], count: 0 }; 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> = { const where: FindOptionsWhere<WorkflowEntity> = {
...options?.filter, ...options?.filter,
id: In(sharedWorkflowIds), id: In(sharedWorkflowIds),
@ -135,7 +117,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
versionId: true, versionId: true,
shared: { userId: true, role: true }, shared: { role: true },
}; };
delete select?.ownedBy; // remove non-entity field, handled after query 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 }; 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 !== '') { if (typeof where.name === 'string' && where.name !== '') {
where.name = Like(`%${where.name}%`); where.name = Like(`%${where.name}%`);

View file

@ -1,10 +1,8 @@
import { Service } from 'typedi'; import { Service } from 'typedi';
import { DataSource, QueryFailedError, Repository } from '@n8n/typeorm'; import { DataSource, MoreThanOrEqual, QueryFailedError, Repository } from '@n8n/typeorm';
import config from '@/config'; import config from '@/config';
import { StatisticsNames, WorkflowStatistics } from '../entities/WorkflowStatistics'; import { StatisticsNames, WorkflowStatistics } from '../entities/WorkflowStatistics';
import type { User } from '@/databases/entities/User'; 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 StatisticsInsertResult = 'insert' | 'failed' | 'alreadyExists';
type StatisticsUpsertResult = StatisticsInsertResult | 'update'; type StatisticsUpsertResult = StatisticsInsertResult | 'update';
@ -102,18 +100,18 @@ export class WorkflowStatisticsRepository extends Repository<WorkflowStatistics>
} }
async queryNumWorkflowsUserHasWithFiveOrMoreProdExecs(userId: User['id']): Promise<number> { async queryNumWorkflowsUserHasWithFiveOrMoreProdExecs(userId: User['id']): Promise<number> {
return await this.createQueryBuilder('workflow_statistics') return await this.count({
.innerJoin(WorkflowEntity, 'workflow', 'workflow.id = workflow_statistics.workflowId') where: {
.innerJoin( workflow: {
SharedWorkflow, shared: {
'shared_workflow', role: 'workflow:owner',
'shared_workflow.workflowId = workflow_statistics.workflowId', project: { projectRelations: { userId, role: 'project:personalOwner' } },
) },
.where('shared_workflow.userId = :userId', { userId }) active: true,
.andWhere('workflow.active = :isActive', { isActive: true }) },
.andWhere('workflow_statistics.name = :name', { name: StatisticsNames.productionSuccess }) name: StatisticsNames.productionSuccess,
.andWhere('workflow_statistics.count >= 5') count: MoreThanOrEqual(5),
.andWhere('role = :roleName', { roleName: 'workflow:owner' }) },
.getCount(); });
} }
} }

View 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);
}
}
}
}
}

View file

@ -0,0 +1,5 @@
import { UserSubscriber } from './UserSubscriber';
export const subscribers = {
UserSubscriber,
};

View 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);
};

View file

@ -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