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
co-authored by Val कारतोफ्फेलस्क्रिप्ट™ Valya Bullions Danny Martini Danny Martini Iván Ovejero Omar Ajoue oleg Michael Kret Michael Kret Elias Meire Giulio Andreini Giulio Andreini Ayato Hayashi
parent b1f977ebd0
commit 596c472ecc
292 changed files with 14128 additions and 3988 deletions
+18
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();
};
+3 -3
View File
@@ -30,7 +30,7 @@ const workflowSharingModal = new WorkflowSharingModal();
const ndv = new NDV();
describe('Sharing', { disableAutoLogin: true }, () => {
before(() => cy.enableFeature('sharing', true));
before(() => cy.enableFeature('sharing'));
let workflowW2Url = '';
it('should create C1, W1, W2, share W1 with U3, as U2', () => {
@@ -171,11 +171,11 @@ describe('Sharing', { disableAutoLogin: true }, () => {
cy.get('input').should('not.have.length');
credentialsModal.actions.changeTab('Sharing');
cy.contains(
'You can view this credential because you have permission to read and share',
'Sharing a credential allows people to use it in their workflows. They cannot access credential details.',
).should('be.visible');
credentialsModal.getters.usersSelect().click();
cy.getByTestId('user-email')
cy.getByTestId('project-sharing-info')
.filter(':visible')
.should('have.length', 3)
.contains(INSTANCE_ADMIN.email)
+5 -5
View File
@@ -501,7 +501,7 @@ describe('Execution', () => {
workflowPage.getters.clearExecutionDataButton().should('be.visible');
cy.intercept('POST', '/rest/workflows/run').as('workflowRun');
cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun');
workflowPage.getters
.canvasNodeByName('do something with them')
@@ -525,7 +525,7 @@ describe('Execution', () => {
workflowPage.getters.zoomToFitButton().click();
cy.intercept('POST', '/rest/workflows/run').as('workflowRun');
cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun');
workflowPage.getters
.canvasNodeByName('If')
@@ -545,7 +545,7 @@ describe('Execution', () => {
workflowPage.getters.clearExecutionDataButton().should('be.visible');
cy.intercept('POST', '/rest/workflows/run').as('workflowRun');
cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun');
workflowPage.getters
.canvasNodeByName('NoOp2')
@@ -576,7 +576,7 @@ describe('Execution', () => {
'My test workflow',
);
cy.intercept('POST', '/rest/workflows/run').as('workflowRun');
cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun');
workflowPage.getters.zoomToFitButton().click();
workflowPage.getters.executeWorkflowButton().click();
@@ -599,7 +599,7 @@ describe('Execution', () => {
'My test workflow',
);
cy.intercept('POST', '/rest/workflows/run').as('workflowRun');
cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun');
workflowPage.getters.zoomToFitButton().click();
workflowPage.getters.executeWorkflowButton().click();
+4 -3
View File
@@ -4,7 +4,7 @@ const variablesPage = new VariablesPage();
describe('Variables', () => {
it('should show the unlicensed action box when the feature is disabled', () => {
cy.disableFeature('variables', false);
cy.disableFeature('variables');
cy.visit(variablesPage.url);
variablesPage.getters.unavailableResourcesList().should('be.visible');
@@ -18,14 +18,15 @@ describe('Variables', () => {
beforeEach(() => {
cy.intercept('GET', '/rest/variables').as('loadVariables');
cy.intercept('GET', '/rest/login').as('login');
cy.visit(variablesPage.url);
cy.wait(['@loadVariables', '@loadSettings']);
cy.wait(['@loadVariables', '@loadSettings', '@login']);
});
it('should show the licensed action box when the feature is enabled', () => {
variablesPage.getters.emptyResourcesList().should('be.visible');
variablesPage.getters.createVariableButton().should('be.visible');
variablesPage.getters.emptyResourcesListNewVariableButton().should('be.visible');
});
it('should create a new variable using empty state row', () => {
+1 -1
View File
@@ -19,7 +19,7 @@ describe('Debug', () => {
it('should be able to debug executions', () => {
cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions');
cy.intercept('GET', '/rest/executions/*').as('getExecution');
cy.intercept('POST', '/rest/workflows/run').as('postWorkflowRun');
cy.intercept('POST', '/rest/workflows/**/run').as('postWorkflowRun');
cy.signin({ email: INSTANCE_OWNER.email, password: INSTANCE_OWNER.password });
+21 -12
View File
@@ -10,7 +10,7 @@ describe('Workflow templates', () => {
beforeEach(() => {
cy.intercept('GET', '**/rest/settings', (req) => {
// Disable cache
delete req.headers['if-none-match']
delete req.headers['if-none-match'];
req.reply((res) => {
if (res.body.data) {
// Disable custom templates host if it has been overridden by another intercept
@@ -22,18 +22,27 @@ describe('Workflow templates', () => {
it('Opens website when clicking templates sidebar link', () => {
cy.visit(workflowsPage.url);
mainSidebar.getters.menuItem('Templates').should('be.visible');
mainSidebar.getters.templates().should('be.visible');
// Templates should be a link to the website
mainSidebar.getters.templates().parent('a').should('have.attr', 'href').and('include', 'https://n8n.io/workflows');
mainSidebar.getters
.templates()
.parent('a')
.should('have.attr', 'href')
.and('include', 'https://n8n.io/workflows');
// Link should contain instance address and n8n version
mainSidebar.getters.templates().parent('a').then(($a) => {
const href = $a.attr('href');
const params = new URLSearchParams(href);
// Link should have all mandatory parameters expected on the website
expect(decodeURIComponent(`${params.get('utm_instance')}`)).to.include(window.location.origin);
expect(params.get('utm_n8n_version')).to.match(/[0-9]+\.[0-9]+\.[0-9]+/);
expect(params.get('utm_awc')).to.match(/[0-9]+/);
});
mainSidebar.getters
.templates()
.parent('a')
.then(($a) => {
const href = $a.attr('href');
const params = new URLSearchParams(href);
// Link should have all mandatory parameters expected on the website
expect(decodeURIComponent(`${params.get('utm_instance')}`)).to.include(
window.location.origin,
);
expect(params.get('utm_n8n_version')).to.match(/[0-9]+\.[0-9]+\.[0-9]+/);
expect(params.get('utm_awc')).to.match(/[0-9]+/);
});
mainSidebar.getters.templates().parent('a').should('have.attr', 'target', '_blank');
});
@@ -41,6 +50,6 @@ describe('Workflow templates', () => {
cy.visit(templatesPage.url);
cy.origin('https://n8n.io', () => {
cy.url().should('include', 'https://n8n.io/workflows');
})
});
});
});
@@ -148,7 +148,7 @@ describe('Editor actions should work', () => {
it('after switching between Editor and Debug', () => {
cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions');
cy.intercept('GET', '/rest/executions/*').as('getExecution');
cy.intercept('POST', '/rest/workflows/run').as('postWorkflowRun');
cy.intercept('POST', '/rest/workflows/**/run').as('postWorkflowRun');
editWorkflowAndDeactivate();
workflowPage.actions.executeWorkflow();
@@ -196,9 +196,9 @@ describe('Editor zoom should work after route changes', () => {
cy.intercept('GET', '/rest/workflow-history/workflow/*/version/*').as('getVersion');
cy.intercept('GET', '/rest/workflow-history/workflow/*').as('getHistory');
cy.intercept('GET', '/rest/users').as('getUsers');
cy.intercept('GET', '/rest/workflows').as('getWorkflows');
cy.intercept('GET', '/rest/workflows?*').as('getWorkflows');
cy.intercept('GET', '/rest/active-workflows').as('getActiveWorkflows');
cy.intercept('GET', '/rest/credentials').as('getCredentials');
cy.intercept('GET', '/rest/credentials?*').as('getCredentials');
switchBetweenEditorAndHistory();
zoomInAndCheckNodes();
+151
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');
});
});
});
+1 -1
View File
@@ -697,7 +697,7 @@ describe('NDV', () => {
});
it('Stop listening for trigger event from NDV', () => {
cy.intercept('POST', '/rest/workflows/run').as('workflowRun');
cy.intercept('POST', '/rest/workflows/**/run').as('workflowRun');
workflowPage.actions.addInitialNodeToCanvas('Local File Trigger', {
keepNdvOpen: true,
action: 'On Changes To A Specific File',
+1 -1
View File
@@ -1,7 +1,7 @@
import { BasePage } from './base';
export class CredentialsPage extends BasePage {
url = '/credentials';
url = '/home/credentials';
getters = {
emptyListCreateCredentialButton: () => cy.getByTestId('empty-resources-list').find('button'),
createCredentialButton: () => cy.getByTestId('resources-list-add'),
+1 -1
View File
@@ -25,7 +25,7 @@ export class CredentialsModal extends BasePage {
credentialInputs: () => cy.getByTestId('credential-connection-parameter'),
menu: () => this.getters.editCredentialModal().get('.menu-container'),
menuItem: (name: string) => this.getters.menu().get('.n8n-menu-item').contains(name),
usersSelect: () => cy.getByTestId('credential-sharing-modal-users-select'),
usersSelect: () => cy.getByTestId('project-sharing-select').filter(':visible'),
testSuccessTag: () => cy.getByTestId('credentials-config-container-test-success'),
};
actions = {
@@ -3,7 +3,7 @@ import { BasePage } from '../base';
export class WorkflowSharingModal extends BasePage {
getters = {
modal: () => cy.getByTestId('workflowShare-modal', { timeout: 5000 }),
usersSelect: () => cy.getByTestId('workflow-sharing-modal-users-select'),
usersSelect: () => cy.getByTestId('project-sharing-select'),
saveButton: () => cy.getByTestId('workflow-sharing-modal-save-button'),
closeButton: () => this.getters.modal().find('.el-dialog__close').first(),
};
+2 -2
View File
@@ -41,10 +41,10 @@ export class SettingsUsersPage extends BasePage {
workflowPage.actions.visit();
mainSidebar.actions.goToSettings();
if (isOwner) {
settingsSidebar.getters.menuItem('Users').click();
settingsSidebar.getters.users().click();
cy.url().should('match', new RegExp(this.url));
} else {
settingsSidebar.getters.menuItem('Users').should('not.exist');
settingsSidebar.getters.users().should('not.exist');
// Should be redirected to workflows page if trying to access UM url
cy.visit('/settings/users');
cy.url().should('match', new RegExp(workflowsPage.url));
+7 -8
View File
@@ -5,14 +5,13 @@ const workflowsPage = new WorkflowsPage();
export class MainSidebar extends BasePage {
getters = {
menuItem: (menuLabel: string) =>
cy.getByTestId('menu-item').filter(`:contains("${menuLabel}")`),
settings: () => this.getters.menuItem('Settings'),
templates: () => this.getters.menuItem('Templates'),
workflows: () => this.getters.menuItem('Workflows'),
credentials: () => this.getters.menuItem('Credentials'),
executions: () => this.getters.menuItem('Executions'),
adminPanel: () => this.getters.menuItem('Admin Panel'),
menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id),
settings: () => this.getters.menuItem('settings'),
templates: () => this.getters.menuItem('templates'),
workflows: () => this.getters.menuItem('workflows'),
credentials: () => this.getters.menuItem('credentials'),
executions: () => this.getters.menuItem('executions'),
adminPanel: () => this.getters.menuItem('cloud-admin'),
userMenu: () => cy.get('div[class="action-dropdown-container"]'),
logo: () => cy.getByTestId('n8n-logo'),
};
+2 -3
View File
@@ -2,9 +2,8 @@ import { BasePage } from '../base';
export class SettingsSidebar extends BasePage {
getters = {
menuItem: (menuLabel: string) =>
cy.getByTestId('menu-item').filter(`:contains("${menuLabel}")`),
users: () => this.getters.menuItem('Users'),
menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id),
users: () => this.getters.menuItem('settings-users'),
back: () => cy.getByTestId('settings-back'),
};
actions = {
+2 -2
View File
@@ -35,7 +35,7 @@ export class VariablesPage extends BasePage {
deleteVariable: (key: string) => {
const row = this.getters.variableRow(key);
row.within(() => {
cy.getByTestId('variable-row-delete-button').click();
cy.getByTestId('variable-row-delete-button').should('not.be.disabled').click();
});
const modal = cy.get('[role="dialog"]');
@@ -53,7 +53,7 @@ export class VariablesPage extends BasePage {
editRow: (key: string) => {
const row = this.getters.variableRow(key);
row.within(() => {
cy.getByTestId('variable-row-edit-button').click();
cy.getByTestId('variable-row-edit-button').should('not.be.disabled').click();
});
},
setRowValue: (row: Chainable<JQuery<HTMLElement>>, field: 'key' | 'value', value: string) => {
+1 -1
View File
@@ -32,7 +32,7 @@ export class WorkflowExecutionsTab extends BasePage {
},
createManualExecutions: (count: number) => {
for (let i = 0; i < count; i++) {
cy.intercept('POST', '/rest/workflows/run').as('workflowExecution');
cy.intercept('POST', '/rest/workflows/**/run').as('workflowExecution');
workflowPage.actions.executeWorkflow();
cy.wait('@workflowExecution');
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { BasePage } from './base';
export class WorkflowsPage extends BasePage {
url = '/workflows';
url = '/home/workflows';
getters = {
newWorkflowButtonCard: () => cy.getByTestId('new-workflow-card'),
newWorkflowTemplateCard: () => cy.getByTestId('new-workflow-template-card'),
+8 -1
View File
@@ -65,7 +65,7 @@ Cypress.Commands.add('signout', () => {
cy.request({
method: 'POST',
url: `${BACKEND_BASE_URL}/rest/logout`,
headers: { 'browser-id': localStorage.getItem('n8n-browserId') }
headers: { 'browser-id': localStorage.getItem('n8n-browserId') },
});
cy.getCookie(N8N_AUTH_COOKIE).should('not.exist');
});
@@ -80,12 +80,19 @@ const setFeature = (feature: string, enabled: boolean) =>
enabled,
});
const setQuota = (feature: string, value: number) =>
cy.request('PATCH', `${BACKEND_BASE_URL}/rest/e2e/quota`, {
feature: `quota:${feature}`,
value,
});
const setQueueMode = (enabled: boolean) =>
cy.request('PATCH', `${BACKEND_BASE_URL}/rest/e2e/queue-mode`, {
enabled,
});
Cypress.Commands.add('enableFeature', (feature: string) => setFeature(feature, true));
Cypress.Commands.add('changeQuota', (feature: string, value: number) => setQuota(feature, value));
Cypress.Commands.add('disableFeature', (feature: string) => setFeature(feature, false));
Cypress.Commands.add('enableQueueMode', () => setQueueMode(true));
Cypress.Commands.add('disableQueueMode', () => setQueueMode(false));
+1
View File
@@ -30,6 +30,7 @@ declare global {
disableFeature(feature: string): void;
enableQueueMode(): void;
disableQueueMode(): void;
changeQuota(feature: string, value: number): void;
waitForLoad(waitForIntercepts?: boolean): void;
grantBrowserPermissions(...permissions: string[]): void;
readClipboard(): Chainable<string>;
+2 -2
View File
@@ -29,7 +29,7 @@ export function createMockNodeExecutionData(
];
return acc;
}, {})
}, {})
: data,
source: [null],
...rest,
@@ -88,7 +88,7 @@ export function runMockWorkflowExcution({
}) {
const executionId = Math.random().toString(36).substring(4);
cy.intercept('POST', '/rest/workflows/run', {
cy.intercept('POST', '/rest/workflows/**/run', {
statusCode: 201,
body: {
data: {
@@ -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());
}
+6 -2
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(
scope: Scope | Scope[],
userScopes: GlobalScopes,
masks?: MaskLevels,
options?: ScopeOptions,
): boolean;
export function hasScope(
scope: Scope | Scope[],
userScopes: ScopeLevels,
masks?: MaskLevels,
options?: ScopeOptions,
): boolean;
export function hasScope(
scope: Scope | Scope[],
userScopes: GlobalScopes | ScopeLevels,
masks?: MaskLevels,
options: ScopeOptions = { mode: 'oneOf' },
): boolean {
if (!Array.isArray(scope)) {
scope = [scope];
}
const userScopeSet = new Set(Object.values(userScopes).flat());
const userScopeSet = combineScopes(userScopes, masks);
if (options.mode === 'allOf') {
return !!scope.length && scope.every((s) => userScopeSet.has(s));
+1
View File
@@ -1,2 +1,3 @@
export type * from './types';
export * from './hasScope';
export * from './combineScopes';
+12 -1
View File
@@ -12,8 +12,10 @@ export type Resource =
| 'license'
| 'logStreaming'
| 'orchestration'
| 'sourceControl'
| 'project'
| 'saml'
| 'securityAudit'
| 'sourceControl'
| 'tag'
| 'user'
| 'variable'
@@ -48,7 +50,9 @@ export type LdapScope = ResourceScope<'ldap', 'manage' | 'sync'>;
export type LicenseScope = ResourceScope<'license', 'manage'>;
export type LogStreamingScope = ResourceScope<'logStreaming', 'manage'>;
export type OrchestrationScope = ResourceScope<'orchestration', 'read' | 'list'>;
export type ProjectScope = ResourceScope<'project'>;
export type SamlScope = ResourceScope<'saml', 'manage'>;
export type SecurityAuditScope = ResourceScope<'securityAudit', 'generate'>;
export type SourceControlScope = ResourceScope<'sourceControl', 'pull' | 'push' | 'manage'>;
export type TagScope = ResourceScope<'tag'>;
export type UserScope = ResourceScope<'user', DefaultOperations | 'resetPassword' | 'changeRole'>;
@@ -69,7 +73,9 @@ export type Scope =
| LicenseScope
| LogStreamingScope
| OrchestrationScope
| ProjectScope
| SamlScope
| SecurityAuditScope
| SourceControlScope
| TagScope
| UserScope
@@ -84,5 +90,10 @@ export type ProjectScopes = GetScopeLevel<'project'>;
export type ResourceScopes = GetScopeLevel<'resource'>;
export type ScopeLevels = GlobalScopes & (ProjectScopes | (ProjectScopes & ResourceScopes));
export type MaskLevel = 'sharing';
export type GetMaskLevel<T extends MaskLevel> = Record<T, Scope[]>;
export type SharingMasks = GetMaskLevel<'sharing'>;
export type MaskLevels = SharingMasks;
export type ScopeMode = 'oneOf' | 'allOf';
export type ScopeOptions = { mode: ScopeMode };
@@ -33,6 +33,7 @@ describe('hasScope', () => {
{
global: memberPermissions,
},
undefined,
{ mode: 'oneOf' },
),
).toBe(true);
@@ -43,6 +44,7 @@ describe('hasScope', () => {
{
global: memberPermissions,
},
undefined,
{ mode: 'allOf' },
),
).toBe(true);
@@ -53,6 +55,7 @@ describe('hasScope', () => {
{
global: memberPermissions,
},
undefined,
{ mode: 'oneOf' },
),
).toBe(false);
@@ -63,6 +66,7 @@ describe('hasScope', () => {
{
global: memberPermissions,
},
undefined,
{ mode: 'allOf' },
),
).toBe(false);
@@ -95,6 +99,7 @@ describe('hasScope', () => {
{
global: ownerPermissions,
},
undefined,
{ mode: 'allOf' },
),
).toBe(true);
@@ -105,6 +110,7 @@ describe('hasScope', () => {
{
global: memberPermissions,
},
undefined,
{ mode: 'allOf' },
),
).toBe(false);
@@ -115,6 +121,7 @@ describe('hasScope', () => {
{
global: memberPermissions,
},
undefined,
{ mode: 'allOf' },
),
).toBe(false);
@@ -125,8 +132,127 @@ describe('hasScope', () => {
{
global: memberPermissions,
},
undefined,
{ mode: 'allOf' },
),
).toBe(false);
});
});
describe('hasScope masking', () => {
test('should return true without mask when scopes present', () => {
expect(
hasScope('workflow:read', {
global: ['user:list'],
project: ['workflow:read'],
resource: [],
}),
).toBe(true);
});
test('should return false without mask when scopes are not present', () => {
expect(
hasScope('workflow:update', {
global: ['user:list'],
project: ['workflow:read'],
resource: [],
}),
).toBe(false);
});
test('should return false when mask does not include scope but scopes list does contain required scope', () => {
expect(
hasScope(
'workflow:update',
{
global: ['user:list'],
project: ['workflow:read', 'workflow:update'],
resource: [],
},
{
sharing: ['workflow:read'],
},
),
).toBe(false);
});
test('should return true when mask does include scope and scope list includes scope', () => {
expect(
hasScope(
'workflow:update',
{
global: ['user:list'],
project: ['workflow:read', 'workflow:update'],
resource: [],
},
{
sharing: ['workflow:read', 'workflow:update'],
},
),
).toBe(true);
});
test('should return true when mask does include scope and scopes list includes scope on multiple levels', () => {
expect(
hasScope(
'workflow:update',
{
global: ['user:list'],
project: ['workflow:read', 'workflow:update'],
resource: ['workflow:update'],
},
{
sharing: ['workflow:read', 'workflow:update'],
},
),
).toBe(true);
});
test('should not mask out global scopes', () => {
expect(
hasScope(
'workflow:update',
{
global: ['workflow:read', 'workflow:update'],
project: ['workflow:read'],
resource: ['workflow:read'],
},
{
sharing: ['workflow:read'],
},
),
).toBe(true);
});
test('should return false when scope is not in mask or scope list', () => {
expect(
hasScope(
'workflow:update',
{
global: ['workflow:read'],
project: ['workflow:read'],
resource: ['workflow:read'],
},
{
sharing: ['workflow:read'],
},
),
).toBe(false);
});
test('should return false when scope is in mask or not scope list', () => {
expect(
hasScope(
'workflow:update',
{
global: ['workflow:read'],
project: ['workflow:read'],
resource: ['workflow:read'],
},
{
sharing: ['workflow:read', 'workflow:update'],
},
),
).toBe(false);
});
});
+16
View File
@@ -35,4 +35,20 @@ module.exports = {
'@typescript-eslint/no-unsafe-enum-comparison': 'warn',
'@typescript-eslint/no-unsafe-declaration-merging': 'warn',
},
overrides: [
{
files: ['./src/decorators/**/*.ts'],
rules: {
'@typescript-eslint/ban-types': [
'warn',
{
types: {
Function: false,
},
},
],
},
},
],
};
+2 -4
View File
@@ -84,7 +84,7 @@ export class ActiveWebhooks implements IWebhookManager {
const workflowData = await this.workflowRepository.findOne({
where: { id: webhook.workflowId },
relations: ['shared', 'shared.user'],
relations: { shared: { project: { projectRelations: true } } },
});
if (workflowData === null) {
@@ -102,9 +102,7 @@ export class ActiveWebhooks implements IWebhookManager {
settings: workflowData.settings,
});
const additionalData = await WorkflowExecuteAdditionalData.getBase(
workflowData.shared[0].user.id,
);
const additionalData = await WorkflowExecuteAdditionalData.getBase();
const webhookData = NodeHelpers.getNodeWebhooks(
workflow,
+3 -11
View File
@@ -229,7 +229,6 @@ export class ActiveWorkflowManager {
async clearWebhooks(workflowId: string) {
const workflowData = await this.workflowRepository.findOne({
where: { id: workflowId },
relations: ['shared', 'shared.user'],
});
if (workflowData === null) {
@@ -249,9 +248,7 @@ export class ActiveWorkflowManager {
const mode = 'internal';
const additionalData = await WorkflowExecuteAdditionalData.getBase(
workflowData.shared[0].user.id,
);
const additionalData = await WorkflowExecuteAdditionalData.getBase();
const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData, undefined, true);
@@ -570,13 +567,7 @@ export class ActiveWorkflowManager {
);
}
const sharing = dbWorkflow.shared.find((shared) => shared.role === 'workflow:owner');
if (!sharing) {
throw new WorkflowActivationError(`Workflow ${dbWorkflow.display()} has no owner`);
}
const additionalData = await WorkflowExecuteAdditionalData.getBase(sharing.user.id);
const additionalData = await WorkflowExecuteAdditionalData.getBase();
if (shouldAddWebhooks) {
await this.addWebhooks(workflow, additionalData, 'trigger', activationMode);
@@ -711,6 +702,7 @@ export class ActiveWorkflowManager {
* @param {string} workflowId The id of the workflow to deactivate
*/
// TODO: this should happen in a transaction
// maybe, see: https://github.com/n8n-io/n8n/pull/8904#discussion_r1530150510
async remove(workflowId: string) {
if (this.orchestrationService.isMultiMainSetupEnabled) {
try {
+35 -30
View File
@@ -30,15 +30,15 @@ import { ICredentialsHelper, NodeHelpers, Workflow, ApplicationError } from 'n8n
import type { ICredentialsDb } from '@/Interfaces';
import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
import { NodeTypes } from '@/NodeTypes';
import { CredentialTypes } from '@/CredentialTypes';
import { CredentialsOverwrites } from '@/CredentialsOverwrites';
import { RESPONSE_ERROR_MESSAGES } from './constants';
import { Logger } from '@/Logger';
import { CredentialsRepository } from '@db/repositories/credentials.repository';
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
import { CredentialNotFoundError } from './errors/credential-not-found.error';
import { In } from '@n8n/typeorm';
import { CacheService } from './services/cache/cache.service';
const mockNode = {
name: '',
@@ -77,12 +77,11 @@ const mockNodeTypes: INodeTypes = {
@Service()
export class CredentialsHelper extends ICredentialsHelper {
constructor(
private readonly logger: Logger,
private readonly credentialTypes: CredentialTypes,
private readonly nodeTypes: NodeTypes,
private readonly credentialsOverwrites: CredentialsOverwrites,
private readonly credentialsRepository: CredentialsRepository,
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly cacheService: CacheService,
) {
super();
}
@@ -245,7 +244,6 @@ export class CredentialsHelper extends ICredentialsHelper {
async getCredentials(
nodeCredential: INodeCredentialsDetails,
type: string,
userId?: string,
): Promise<Credentials> {
if (!nodeCredential.id) {
throw new ApplicationError('Found credential with no ID.', {
@@ -257,14 +255,10 @@ export class CredentialsHelper extends ICredentialsHelper {
let credential: CredentialsEntity;
try {
credential = userId
? await this.sharedCredentialsRepository
.findOneOrFail({
relations: ['credentials'],
where: { credentials: { id: nodeCredential.id, type }, userId },
})
.then((shared) => shared.credentials)
: await this.credentialsRepository.findOneByOrFail({ id: nodeCredential.id, type });
credential = await this.credentialsRepository.findOneByOrFail({
id: nodeCredential.id,
type,
});
} catch (error) {
throw new CredentialNotFoundError(nodeCredential.id, type);
}
@@ -338,7 +332,7 @@ export class CredentialsHelper extends ICredentialsHelper {
await additionalData?.secretsHelpers?.waitForInit();
const canUseSecrets = await this.credentialOwnedByOwner(nodeCredentials);
const canUseSecrets = await this.credentialCanUseExternalSecrets(nodeCredentials);
return this.applyDefaultsAndOverwrites(
additionalData,
@@ -457,28 +451,39 @@ export class CredentialsHelper extends ICredentialsHelper {
await this.credentialsRepository.update(findQuery, newCredentialsData);
}
async credentialOwnedByOwner(nodeCredential: INodeCredentialsDetails): Promise<boolean> {
async credentialCanUseExternalSecrets(nodeCredential: INodeCredentialsDetails): Promise<boolean> {
if (!nodeCredential.id) {
return false;
}
const credential = await this.sharedCredentialsRepository.findOne({
where: {
role: 'credential:owner',
user: {
role: 'global:owner',
},
credentials: {
id: nodeCredential.id,
},
},
});
return (
(await this.cacheService.get(`credential-can-use-secrets:${nodeCredential.id}`, {
refreshFn: async () => {
const credential = await this.sharedCredentialsRepository.findOne({
where: {
role: 'credential:owner',
project: {
projectRelations: {
role: In(['project:personalOwner', 'project:admin']),
user: {
role: In(['global:owner', 'global:admin']),
},
},
},
credentials: {
id: nodeCredential.id!,
},
},
});
if (!credential) {
return false;
}
if (!credential) {
return false;
}
return true;
return true;
},
})) ?? false
);
}
}
+2 -1
View File
@@ -535,7 +535,8 @@ export interface IWorkflowExecutionDataProcess {
pushRef?: string;
startNodes?: StartNodeData[];
workflowData: IWorkflowBase;
userId: string;
userId?: string;
projectId?: string;
}
export interface IWorkflowExecuteProcess {
+63 -9
View File
@@ -34,6 +34,10 @@ import { License } from '@/License';
import { EventsService } from '@/services/events.service';
import { NodeTypes } from '@/NodeTypes';
import { Telemetry } from '@/telemetry';
import type { Project } from '@db/entities/Project';
import type { ProjectRole } from '@db/entities/ProjectRelation';
import { ProjectRelationRepository } from './databases/repositories/projectRelation.repository';
import { SharedCredentialsRepository } from './databases/repositories/sharedCredentials.repository';
function userToPayload(user: User): {
userId: string;
@@ -62,6 +66,8 @@ export class InternalHooks {
private readonly instanceSettings: InstanceSettings,
private readonly eventBus: MessageEventBus,
private readonly license: License,
private readonly projectRelationRepository: ProjectRelationRepository,
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
) {
eventsService.on(
'telemetry.onFirstProductionWorkflowSuccess',
@@ -164,7 +170,12 @@ export class InternalHooks {
);
}
async onWorkflowCreated(user: User, workflow: IWorkflowBase, publicApi: boolean): Promise<void> {
async onWorkflowCreated(
user: User,
workflow: IWorkflowBase,
project: Project,
publicApi: boolean,
): Promise<void> {
const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes);
void Promise.all([
this.eventBus.sendAuditEvent({
@@ -180,6 +191,8 @@ export class InternalHooks {
workflow_id: workflow.id,
node_graph_string: JSON.stringify(nodeGraph),
public_api: publicApi,
project_id: project.id,
project_type: project.type,
}),
]);
}
@@ -208,19 +221,32 @@ export class InternalHooks {
isCloudDeployment,
});
let userRole: 'owner' | 'sharee' | 'member' | undefined = undefined;
const role = await this.sharedWorkflowRepository.findSharingRole(user.id, workflow.id);
if (role) {
userRole = role === 'workflow:owner' ? 'owner' : 'sharee';
} else {
const workflowOwner = await this.sharedWorkflowRepository.getWorkflowOwningProject(
workflow.id,
);
if (workflowOwner) {
const projectRole = await this.projectRelationRepository.findProjectRole({
userId: user.id,
projectId: workflowOwner.id,
});
if (projectRole && projectRole !== 'project:personalOwner') {
userRole = 'member';
}
}
}
const notesCount = Object.keys(nodeGraph.notes).length;
const overlappingCount = Object.values(nodeGraph.notes).filter(
(note) => note.overlapping,
).length;
let userRole: 'owner' | 'sharee' | undefined = undefined;
if (user.id && workflow.id) {
const role = await this.sharedWorkflowRepository.findSharingRole(user.id, workflow.id);
if (role) {
userRole = role === 'workflow:owner' ? 'owner' : 'sharee';
}
}
void Promise.all([
this.eventBus.sendAuditEvent({
eventName: 'n8n.audit.workflow.updated',
@@ -865,6 +891,9 @@ export class InternalHooks {
credential_id: string;
public_api: boolean;
}): Promise<void> {
const project = await this.sharedCredentialsRepository.findCredentialOwningProject(
userCreatedCredentialsData.credential_id,
);
void Promise.all([
this.eventBus.sendAuditEvent({
eventName: 'n8n.audit.user.credentials.created',
@@ -880,6 +909,8 @@ export class InternalHooks {
credential_type: userCreatedCredentialsData.credential_type,
credential_id: userCreatedCredentialsData.credential_id,
instance_id: this.instanceSettings.instanceId,
project_id: project?.id,
project_type: project?.type,
}),
]);
}
@@ -1207,4 +1238,27 @@ export class InternalHooks {
}): Promise<void> {
return await this.telemetry.track('User updated external secrets settings', saveData);
}
async onTeamProjectCreated(data: { user_id: string; role: GlobalRole }) {
return await this.telemetry.track('User created project', data);
}
async onTeamProjectDeleted(data: {
user_id: string;
role: GlobalRole;
project_id: string;
removal_type: 'delete' | 'transfer';
target_project_id?: string;
}) {
return await this.telemetry.track('User deleted project', data);
}
async onTeamProjectUpdated(data: {
user_id: string;
role: GlobalRole;
project_id: string;
members: Array<{ user_id: string; role: ProjectRole }>;
}) {
return await this.telemetry.track('Project settings updated', data);
}
}
+25 -13
View File
@@ -93,7 +93,7 @@ export const getAuthIdentityByLdapId = async (
idAttributeValue: string,
): Promise<AuthIdentity | null> => {
return await Container.get(AuthIdentityRepository).findOne({
relations: ['user'],
relations: { user: true },
where: {
providerId: idAttributeValue,
providerType: 'ldap',
@@ -140,7 +140,7 @@ export const getLdapIds = async (): Promise<string[]> => {
export const getLdapUsers = async (): Promise<User[]> => {
const identities = await Container.get(AuthIdentityRepository).find({
relations: ['user'],
relations: { user: true },
where: {
providerType: 'ldap',
},
@@ -179,10 +179,15 @@ export const processUsers = async (
toUpdateUsers: Array<[string, User]>,
toDisableUsers: string[],
): Promise<void> => {
const userRepository = Container.get(UserRepository);
await Db.transaction(async (transactionManager) => {
return await Promise.all([
...toCreateUsers.map(async ([ldapId, user]) => {
const authIdentity = AuthIdentity.create(await transactionManager.save(user), ldapId);
const { user: savedUser } = await userRepository.createUserWithProject(
user,
transactionManager,
);
const authIdentity = AuthIdentity.create(savedUser, ldapId);
return await transactionManager.save(authIdentity);
}),
...toUpdateUsers.map(async ([ldapId, user]) => {
@@ -202,7 +207,13 @@ export const processUsers = async (
providerId: ldapId,
});
if (authIdentity?.userId) {
await transactionManager.update(User, { id: authIdentity?.userId }, { disabled: true });
const user = await transactionManager.findOneBy(User, { id: authIdentity.userId });
if (user) {
user.disabled = true;
await transactionManager.save(user);
}
await transactionManager.delete(AuthIdentity, { userId: authIdentity?.userId });
}
}),
@@ -266,14 +277,11 @@ export const createLdapAuthIdentity = async (user: User, ldapId: string) => {
};
export const createLdapUserOnLocalDb = async (data: Partial<User>, ldapId: string) => {
const user = await Container.get(UserRepository).save(
{
password: randomPassword(),
role: 'global:member',
...data,
},
{ transaction: false },
);
const { user } = await Container.get(UserRepository).createUserWithProject({
password: randomPassword(),
role: 'global:member',
...data,
});
await createLdapAuthIdentity(user, ldapId);
return user;
};
@@ -281,7 +289,11 @@ export const createLdapUserOnLocalDb = async (data: Partial<User>, ldapId: strin
export const updateLdapUserOnLocalDb = async (identity: AuthIdentity, data: Partial<User>) => {
const userId = identity?.user?.id;
if (userId) {
await Container.get(UserRepository).update({ id: userId }, data);
const user = await Container.get(UserRepository).findOneBy({ id: userId });
if (user) {
await Container.get(UserRepository).save({ id: userId, ...data }, { transaction: true });
}
}
};
+1 -1
View File
@@ -349,7 +349,7 @@ export class LdapService {
localAdUsers,
);
this.logger.debug('LDAP - Users processed', {
this.logger.debug('LDAP - Users to process', {
created: usersToCreate.length,
updated: usersToUpdate.length,
disabled: usersToDisable.length,
+16
View File
@@ -289,6 +289,18 @@ export class License {
return this.isFeatureEnabled(LICENSE_FEATURES.WORKER_VIEW);
}
isProjectRoleAdminLicensed() {
return this.isFeatureEnabled(LICENSE_FEATURES.PROJECT_ROLE_ADMIN);
}
isProjectRoleEditorLicensed() {
return this.isFeatureEnabled(LICENSE_FEATURES.PROJECT_ROLE_EDITOR);
}
isProjectRoleViewerLicensed() {
return this.isFeatureEnabled(LICENSE_FEATURES.PROJECT_ROLE_VIEWER);
}
getCurrentEntitlements() {
return this.manager?.getCurrentEntitlements() ?? [];
}
@@ -341,6 +353,10 @@ export class License {
);
}
getTeamProjectLimit() {
return this.getFeatureValue(LICENSE_QUOTAS.TEAM_PROJECT_LIMIT) ?? 0;
}
getPlanName(): string {
return this.getFeatureValue('planName') ?? 'Community';
}
+26 -10
View File
@@ -25,10 +25,16 @@ export class MfaService {
secret,
recoveryCodes,
);
return await this.userRepository.update(userId, {
mfaSecret: encryptedSecret,
mfaRecoveryCodes: encryptedRecoveryCodes,
});
const user = await this.userRepository.findOneBy({ id: userId });
if (user) {
Object.assign(user, {
mfaSecret: encryptedSecret,
mfaRecoveryCodes: encryptedRecoveryCodes,
});
await this.userRepository.save(user);
}
}
public encryptSecretAndRecoveryCodes(rawSecret: string, rawRecoveryCodes: string[]) {
@@ -56,7 +62,12 @@ export class MfaService {
}
public async enableMfa(userId: string) {
await this.userRepository.update(userId, { mfaEnabled: true });
const user = await this.userRepository.findOneBy({ id: userId });
if (user) {
user.mfaEnabled = true;
await this.userRepository.save(user);
}
}
public encryptRecoveryCodes(mfaRecoveryCodes: string[]) {
@@ -64,10 +75,15 @@ export class MfaService {
}
public async disableMfa(userId: string) {
await this.userRepository.update(userId, {
mfaEnabled: false,
mfaSecret: null,
mfaRecoveryCodes: [],
});
const user = await this.userRepository.findOneBy({ id: userId });
if (user) {
Object.assign(user, {
mfaEnabled: false,
mfaSecret: null,
mfaRecoveryCodes: [],
});
await this.userRepository.save(user);
}
}
}
+10 -2
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 { TagEntity } from '@db/entities/TagEntity';
import type { Risk } from '@/security-audit/types';
@@ -127,7 +128,14 @@ export declare namespace UserRequest {
}
export declare namespace CredentialRequest {
type Create = AuthenticatedRequest<{}, {}, { type: string; name: string; data: IDataObject }, {}>;
type Create = AuthenticatedRequest<
{},
{},
{ type: string; name: string; data: ICredentialDataDecryptedObject },
{}
>;
type Delete = AuthenticatedRequest<{ id: string }, {}, {}, Record<string, string>>;
}
export type OperationID = 'getUsers' | 'getUser';
@@ -1,11 +1,11 @@
import { authorize } from '@/PublicApi/v1/shared/middlewares/global.middleware';
import { globalScope } from '@/PublicApi/v1/shared/middlewares/global.middleware';
import type { Response } from 'express';
import type { AuditRequest } from '@/PublicApi/types';
import Container from 'typedi';
export = {
generateAudit: [
authorize(['global:owner', 'global:admin']),
globalScope('securityAudit:generate'),
async (req: AuditRequest.Generate, res: Response): Promise<Response> => {
try {
const { SecurityAuditService } = await import('@/security-audit/SecurityAudit.service');
@@ -4,9 +4,8 @@ import type express from 'express';
import { CredentialsHelper } from '@/CredentialsHelper';
import { CredentialTypes } from '@/CredentialTypes';
import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
import type { CredentialRequest } from '@/requests';
import type { CredentialTypeRequest } from '../../../types';
import { authorize } from '../../shared/middlewares/global.middleware';
import type { CredentialTypeRequest, CredentialRequest } from '../../../types';
import { projectScope } from '../../shared/middlewares/global.middleware';
import { validCredentialsProperties, validCredentialType } from './credentials.middleware';
import {
@@ -23,7 +22,6 @@ import { Container } from 'typedi';
export = {
createCredential: [
authorize(['global:owner', 'global:admin', 'global:member']),
validCredentialType,
validCredentialsProperties,
async (
@@ -47,7 +45,7 @@ export = {
},
],
deleteCredential: [
authorize(['global:owner', 'global:admin', 'global:member']),
projectScope('credential:delete', 'credential'),
async (
req: CredentialRequest.Delete,
res: express.Response,
@@ -75,7 +73,6 @@ export = {
],
getCredentialType: [
authorize(['global:owner', 'global:admin', 'global:member']),
async (req: CredentialTypeRequest.Get, res: express.Response): Promise<express.Response> => {
const { credentialTypeName } = req.params;
@@ -16,6 +16,7 @@ import type { CredentialRequest } from '@/requests';
import { Container } from 'typedi';
import { CredentialsRepository } from '@db/repositories/credentials.repository';
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
import { ProjectRepository } from '@/databases/repositories/project.repository';
import { InternalHooks } from '@/InternalHooks';
export async function getCredentials(credentialId: string): Promise<ICredentialsDb | null> {
@@ -28,7 +29,7 @@ export async function getSharedCredentials(
): Promise<SharedCredentials | null> {
return await Container.get(SharedCredentialsRepository).findOne({
where: {
userId,
project: { projectRelations: { userId } },
credentialsId: credentialId,
},
relations: ['credentials'],
@@ -66,10 +67,14 @@ export async function saveCredential(
const newSharedCredential = new SharedCredentials();
const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
user.id,
);
Object.assign(newSharedCredential, {
role: 'credential:owner',
user,
credentials: savedCredential,
projectId: personalProject.id,
});
await transactionManager.save<SharedCredentials>(newSharedCredential);
@@ -3,7 +3,7 @@ import { Container } from 'typedi';
import { replaceCircularReferences } from 'n8n-workflow';
import { ActiveExecutions } from '@/ActiveExecutions';
import { authorize, validCursor } from '../../shared/middlewares/global.middleware';
import { validCursor } from '../../shared/middlewares/global.middleware';
import type { ExecutionRequest } from '../../../types';
import { getSharedWorkflowIds } from '../workflows/workflows.service';
import { encodeNextCursor } from '../../shared/services/pagination.service';
@@ -12,9 +12,8 @@ import { ExecutionRepository } from '@db/repositories/execution.repository';
export = {
deleteExecution: [
authorize(['global:owner', 'global:admin', 'global:member']),
async (req: ExecutionRequest.Delete, res: express.Response): Promise<express.Response> => {
const sharedWorkflowsIds = await getSharedWorkflowIds(req.user);
const sharedWorkflowsIds = await getSharedWorkflowIds(req.user, ['workflow:delete']);
// user does not have workflows hence no executions
// or the execution they are trying to access belongs to a workflow they do not own
@@ -44,9 +43,8 @@ export = {
},
],
getExecution: [
authorize(['global:owner', 'global:admin', 'global:member']),
async (req: ExecutionRequest.Get, res: express.Response): Promise<express.Response> => {
const sharedWorkflowsIds = await getSharedWorkflowIds(req.user);
const sharedWorkflowsIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
// user does not have workflows hence no executions
// or the execution they are trying to access belongs to a workflow they do not own
@@ -75,7 +73,6 @@ export = {
},
],
getExecutions: [
authorize(['global:owner', 'global:admin', 'global:member']),
validCursor,
async (req: ExecutionRequest.GetAll, res: express.Response): Promise<express.Response> => {
const {
@@ -86,7 +83,7 @@ export = {
workflowId = undefined,
} = req.query;
const sharedWorkflowsIds = await getSharedWorkflowIds(req.user);
const sharedWorkflowsIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
// user does not have workflows hence no executions
// or the execution they are trying to access belongs to a workflow they do not own
@@ -2,7 +2,7 @@ import type express from 'express';
import { Container } from 'typedi';
import type { StatusResult } from 'simple-git';
import type { PublicSourceControlRequest } from '../../../types';
import { authorize } from '../../shared/middlewares/global.middleware';
import { globalScope } from '../../shared/middlewares/global.middleware';
import type { ImportResult } from '@/environments/sourceControl/types/importResult';
import { SourceControlService } from '@/environments/sourceControl/sourceControl.service.ee';
import { SourceControlPreferencesService } from '@/environments/sourceControl/sourceControlPreferences.service.ee';
@@ -14,7 +14,7 @@ import { InternalHooks } from '@/InternalHooks';
export = {
pull: [
authorize(['global:owner', 'global:admin']),
globalScope('sourceControl:pull'),
async (
req: PublicSourceControlRequest.Pull,
res: express.Response,
@@ -1,7 +1,7 @@
import type express from 'express';
import type { TagEntity } from '@db/entities/TagEntity';
import { authorize, validCursor } from '../../shared/middlewares/global.middleware';
import { globalScope, validCursor } from '../../shared/middlewares/global.middleware';
import type { TagRequest } from '../../../types';
import { encodeNextCursor } from '../../shared/services/pagination.service';
@@ -12,7 +12,7 @@ import { TagService } from '@/services/tag.service';
export = {
createTag: [
authorize(['global:owner', 'global:admin', 'global:member']),
globalScope('tag:create'),
async (req: TagRequest.Create, res: express.Response): Promise<express.Response> => {
const { name } = req.body;
@@ -27,7 +27,7 @@ export = {
},
],
updateTag: [
authorize(['global:owner', 'global:admin', 'global:member']),
globalScope('tag:update'),
async (req: TagRequest.Update, res: express.Response): Promise<express.Response> => {
const { id } = req.params;
const { name } = req.body;
@@ -49,7 +49,7 @@ export = {
},
],
deleteTag: [
authorize(['global:owner', 'global:admin']),
globalScope('tag:delete'),
async (req: TagRequest.Delete, res: express.Response): Promise<express.Response> => {
const { id } = req.params;
@@ -65,7 +65,7 @@ export = {
},
],
getTags: [
authorize(['global:owner', 'global:admin', 'global:member']),
globalScope('tag:read'),
validCursor,
async (req: TagRequest.GetAll, res: express.Response): Promise<express.Response> => {
const { offset = 0, limit = 100 } = req.query;
@@ -88,7 +88,7 @@ export = {
},
],
getTag: [
authorize(['global:owner', 'global:admin', 'global:member']),
globalScope('tag:read'),
async (req: TagRequest.Get, res: express.Response): Promise<express.Response> => {
const { id } = req.params;
@@ -5,7 +5,7 @@ import { clean, getAllUsersAndCount, getUser } from './users.service.ee';
import { encodeNextCursor } from '../../shared/services/pagination.service';
import {
authorize,
globalScope,
validCursor,
validLicenseWithUserQuota,
} from '../../shared/middlewares/global.middleware';
@@ -15,7 +15,7 @@ import { InternalHooks } from '@/InternalHooks';
export = {
getUser: [
validLicenseWithUserQuota,
authorize(['global:owner', 'global:admin']),
globalScope('user:read'),
async (req: UserRequest.Get, res: express.Response) => {
const { includeRole = false } = req.query;
const { id } = req.params;
@@ -41,7 +41,7 @@ export = {
getUsers: [
validLicenseWithUserQuota,
validCursor,
authorize(['global:owner', 'global:admin']),
globalScope(['user:list', 'user:read']),
async (req: UserRequest.Get, res: express.Response) => {
const { offset = 0, limit = 100, includeRole = false } = req.query;
@@ -11,11 +11,10 @@ import { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { ExternalHooks } from '@/ExternalHooks';
import { addNodeIds, replaceInvalidCredentials } from '@/WorkflowHelpers';
import type { WorkflowRequest } from '../../../types';
import { authorize, validCursor } from '../../shared/middlewares/global.middleware';
import { projectScope, validCursor } from '../../shared/middlewares/global.middleware';
import { encodeNextCursor } from '../../shared/services/pagination.service';
import {
getWorkflowById,
getSharedWorkflow,
setWorkflowAsActive,
setWorkflowAsInactive,
updateWorkflow,
@@ -30,10 +29,10 @@ import { WorkflowHistoryService } from '@/workflows/workflowHistory/workflowHist
import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository';
import { TagRepository } from '@/databases/repositories/tag.repository';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import { ProjectRepository } from '@/databases/repositories/project.repository';
export = {
createWorkflow: [
authorize(['global:owner', 'global:admin', 'global:member']),
async (req: WorkflowRequest.Create, res: express.Response): Promise<express.Response> => {
const workflow = req.body;
@@ -44,7 +43,10 @@ export = {
addNodeIds(workflow);
const createdWorkflow = await createWorkflow(workflow, req.user, 'workflow:owner');
const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
req.user.id,
);
const createdWorkflow = await createWorkflow(workflow, req.user, project, 'workflow:owner');
await Container.get(WorkflowHistoryService).saveVersion(
req.user,
@@ -53,13 +55,13 @@ export = {
);
await Container.get(ExternalHooks).run('workflow.afterCreate', [createdWorkflow]);
void Container.get(InternalHooks).onWorkflowCreated(req.user, createdWorkflow, true);
void Container.get(InternalHooks).onWorkflowCreated(req.user, createdWorkflow, project, true);
return res.json(createdWorkflow);
},
],
deleteWorkflow: [
authorize(['global:owner', 'global:admin', 'global:member']),
projectScope('workflow:delete', 'workflow'),
async (req: WorkflowRequest.Get, res: express.Response): Promise<express.Response> => {
const { id: workflowId } = req.params;
@@ -74,15 +76,21 @@ export = {
},
],
getWorkflow: [
authorize(['global:owner', 'global:admin', 'global:member']),
projectScope('workflow:read', 'workflow'),
async (req: WorkflowRequest.Get, res: express.Response): Promise<express.Response> => {
const { id } = req.params;
const sharedWorkflow = await getSharedWorkflow(req.user, id);
const workflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser(
id,
req.user,
['workflow:read'],
{ includeTags: !config.getEnv('workflowTagsDisabled') },
);
if (!sharedWorkflow) {
if (!workflow) {
// user trying to access a workflow they do not own
// or workflow does not exist
// and was not shared to them
// Or does not exist.
return res.status(404).json({ message: 'Not Found' });
}
@@ -91,11 +99,10 @@ export = {
public_api: true,
});
return res.json(sharedWorkflow.workflow);
return res.json(workflow);
},
],
getWorkflows: [
authorize(['global:owner', 'global:admin', 'global:member']),
validCursor,
async (req: WorkflowRequest.GetAll, res: express.Response): Promise<express.Response> => {
const { offset = 0, limit = 100, active, tags, name } = req.query;
@@ -121,19 +128,24 @@ export = {
);
}
const sharedWorkflows = await Container.get(SharedWorkflowRepository).getSharedWorkflows(
let workflows = await Container.get(SharedWorkflowRepository).findAllWorkflowsForUser(
req.user,
options,
['workflow:read'],
);
if (!sharedWorkflows.length) {
if (options.workflowIds) {
const workflowIds = options.workflowIds;
workflows = workflows.filter((wf) => workflowIds.includes(wf.id));
}
if (!workflows.length) {
return res.status(200).json({
data: [],
nextCursor: null,
});
}
const workflowsIds = sharedWorkflows.map((shareWorkflow) => shareWorkflow.workflowId);
const workflowsIds = workflows.map((wf) => wf.id);
where.id = In(workflowsIds);
}
@@ -160,7 +172,7 @@ export = {
},
],
updateWorkflow: [
authorize(['global:owner', 'global:admin', 'global:member']),
projectScope('workflow:update', 'workflow'),
async (req: WorkflowRequest.Update, res: express.Response): Promise<express.Response> => {
const { id } = req.params;
const updateData = new WorkflowEntity();
@@ -168,9 +180,13 @@ export = {
updateData.id = id;
updateData.versionId = uuid();
const sharedWorkflow = await getSharedWorkflow(req.user, id);
const workflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser(
id,
req.user,
['workflow:update'],
);
if (!sharedWorkflow) {
if (!workflow) {
// user trying to access a workflow they do not own
// or workflow does not exist
return res.status(404).json({ message: 'Not Found' });
@@ -181,23 +197,23 @@ export = {
const workflowManager = Container.get(ActiveWorkflowManager);
if (sharedWorkflow.workflow.active) {
if (workflow.active) {
// When workflow gets saved always remove it as the triggers could have been
// changed and so the changes would not take effect
await workflowManager.remove(id);
}
try {
await updateWorkflow(sharedWorkflow.workflowId, updateData);
await updateWorkflow(workflow.id, updateData);
} catch (error) {
if (error instanceof Error) {
return res.status(400).json({ message: error.message });
}
}
if (sharedWorkflow.workflow.active) {
if (workflow.active) {
try {
await workflowManager.add(sharedWorkflow.workflowId, 'update');
await workflowManager.add(workflow.id, 'update');
} catch (error) {
if (error instanceof Error) {
return res.status(400).json({ message: error.message });
@@ -205,13 +221,13 @@ export = {
}
}
const updatedWorkflow = await getWorkflowById(sharedWorkflow.workflowId);
const updatedWorkflow = await getWorkflowById(workflow.id);
if (updatedWorkflow) {
await Container.get(WorkflowHistoryService).saveVersion(
req.user,
updatedWorkflow,
sharedWorkflow.workflowId,
workflow.id,
);
}
@@ -222,21 +238,25 @@ export = {
},
],
activateWorkflow: [
authorize(['global:owner', 'global:admin', 'global:member']),
projectScope('workflow:update', 'workflow'),
async (req: WorkflowRequest.Activate, res: express.Response): Promise<express.Response> => {
const { id } = req.params;
const sharedWorkflow = await getSharedWorkflow(req.user, id);
const workflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser(
id,
req.user,
['workflow:update'],
);
if (!sharedWorkflow) {
if (!workflow) {
// user trying to access a workflow they do not own
// or workflow does not exist
return res.status(404).json({ message: 'Not Found' });
}
if (!sharedWorkflow.workflow.active) {
if (!workflow.active) {
try {
await Container.get(ActiveWorkflowManager).add(sharedWorkflow.workflowId, 'activate');
await Container.get(ActiveWorkflowManager).add(workflow.id, 'activate');
} catch (error) {
if (error instanceof Error) {
return res.status(400).json({ message: error.message });
@@ -244,25 +264,29 @@ export = {
}
// change the status to active in the DB
await setWorkflowAsActive(sharedWorkflow.workflow);
await setWorkflowAsActive(workflow);
sharedWorkflow.workflow.active = true;
workflow.active = true;
return res.json(sharedWorkflow.workflow);
return res.json(workflow);
}
// nothing to do as the workflow is already active
return res.json(sharedWorkflow.workflow);
return res.json(workflow);
},
],
deactivateWorkflow: [
authorize(['global:owner', 'global:admin', 'global:member']),
projectScope('workflow:update', 'workflow'),
async (req: WorkflowRequest.Activate, res: express.Response): Promise<express.Response> => {
const { id } = req.params;
const sharedWorkflow = await getSharedWorkflow(req.user, id);
const workflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser(
id,
req.user,
['workflow:update'],
);
if (!sharedWorkflow) {
if (!workflow) {
// user trying to access a workflow they do not own
// or workflow does not exist
return res.status(404).json({ message: 'Not Found' });
@@ -270,22 +294,22 @@ export = {
const activeWorkflowManager = Container.get(ActiveWorkflowManager);
if (sharedWorkflow.workflow.active) {
await activeWorkflowManager.remove(sharedWorkflow.workflowId);
if (workflow.active) {
await activeWorkflowManager.remove(workflow.id);
await setWorkflowAsInactive(sharedWorkflow.workflow);
await setWorkflowAsInactive(workflow);
sharedWorkflow.workflow.active = false;
workflow.active = false;
return res.json(sharedWorkflow.workflow);
return res.json(workflow);
}
// nothing to do as the workflow is already inactive
return res.json(sharedWorkflow.workflow);
return res.json(workflow);
},
],
getWorkflowTags: [
authorize(['global:owner', 'global:admin', 'global:member']),
projectScope('workflow:read', 'workflow'),
async (req: WorkflowRequest.GetTags, res: express.Response): Promise<express.Response> => {
const { id } = req.params;
@@ -293,9 +317,13 @@ export = {
return res.status(400).json({ message: 'Workflow Tags Disabled' });
}
const sharedWorkflow = await getSharedWorkflow(req.user, id);
const workflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser(
id,
req.user,
['workflow:read'],
);
if (!sharedWorkflow) {
if (!workflow) {
// user trying to access a workflow he does not own
// or workflow does not exist
return res.status(404).json({ message: 'Not Found' });
@@ -307,7 +335,7 @@ export = {
},
],
updateWorkflowTags: [
authorize(['global:owner', 'global:admin', 'global:member']),
projectScope('workflow:update', 'workflow'),
async (req: WorkflowRequest.UpdateTags, res: express.Response): Promise<express.Response> => {
const { id } = req.params;
const newTags = req.body.map((newTag) => newTag.id);
@@ -316,7 +344,11 @@ export = {
return res.status(400).json({ message: 'Workflow Tags Disabled' });
}
const sharedWorkflow = await getSharedWorkflow(req.user, id);
const sharedWorkflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser(
id,
req.user,
['workflow:update'],
);
if (!sharedWorkflow) {
// user trying to access a workflow he does not own
@@ -4,23 +4,31 @@ import type { User } from '@db/entities/User';
import { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { WorkflowTagMapping } from '@db/entities/WorkflowTagMapping';
import { SharedWorkflow, type WorkflowSharingRole } from '@db/entities/SharedWorkflow';
import config from '@/config';
import { WorkflowRepository } from '@db/repositories/workflow.repository';
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
import type { Project } from '@/databases/entities/Project';
import { WorkflowTagMappingRepository } from '@db/repositories/workflowTagMapping.repository';
import { TagRepository } from '@db/repositories/tag.repository';
import { License } from '@/License';
import { WorkflowSharingService } from '@/workflows/workflowSharing.service';
import type { Scope } from '@n8n/permissions';
import config from '@/config';
function insertIf(condition: boolean, elements: string[]): string[] {
return condition ? elements : [];
}
export async function getSharedWorkflowIds(user: User): Promise<string[]> {
const where = ['global:owner', 'global:admin'].includes(user.role) ? {} : { userId: user.id };
const sharedWorkflows = await Container.get(SharedWorkflowRepository).find({
where,
select: ['workflowId'],
});
return sharedWorkflows.map(({ workflowId }) => workflowId);
export async function getSharedWorkflowIds(user: User, scopes: Scope[]): Promise<string[]> {
if (Container.get(License).isSharingEnabled()) {
return await Container.get(WorkflowSharingService).getSharedWorkflowIds(user, {
scopes,
});
} else {
return await Container.get(WorkflowSharingService).getSharedWorkflowIds(user, {
workflowRoles: ['workflow:owner'],
projectRoles: ['project:personalOwner'],
});
}
}
export async function getSharedWorkflow(
@@ -45,6 +53,7 @@ export async function getWorkflowById(id: string): Promise<WorkflowEntity | null
export async function createWorkflow(
workflow: WorkflowEntity,
user: User,
personalProject: Project,
role: WorkflowSharingRole,
): Promise<WorkflowEntity> {
return await Db.transaction(async (transactionManager) => {
@@ -56,6 +65,7 @@ export async function createWorkflow(
Object.assign(newSharedWorkflow, {
role,
user,
project: personalProject,
workflow: savedWorkflow,
});
await transactionManager.save<SharedWorkflow>(newSharedWorkflow);
@@ -3,27 +3,48 @@ import type express from 'express';
import { Container } from 'typedi';
import { License } from '@/License';
import type { GlobalRole } from '@db/entities/User';
import type { AuthenticatedRequest } from '@/requests';
import type { PaginatedRequest } from '../../../types';
import { decodeCursor } from '../services/pagination.service';
import type { Scope } from '@n8n/permissions';
import { userHasScope } from '@/permissions/checkAccess';
const UNLIMITED_USERS_QUOTA = -1;
export const authorize =
(authorizedRoles: readonly GlobalRole[]) =>
(
req: AuthenticatedRequest,
export type ProjectScopeResource = 'workflow' | 'credential';
const buildScopeMiddleware = (
scopes: Scope[],
resource?: ProjectScopeResource,
{ globalOnly } = { globalOnly: false },
) => {
return async (
req: AuthenticatedRequest<{ id?: string }>,
res: express.Response,
next: express.NextFunction,
): express.Response | void => {
if (!authorizedRoles.includes(req.user.role)) {
): Promise<express.Response | void> => {
const params: { credentialId?: string; workflowId?: string } = {};
if (req.params.id) {
if (resource === 'workflow') {
params.workflowId = req.params.id;
} else if (resource === 'credential') {
params.credentialId = req.params.id;
}
}
if (!(await userHasScope(req.user, scopes, globalOnly, params))) {
return res.status(403).json({ message: 'Forbidden' });
}
return next();
};
};
export const globalScope = (scopes: Scope | Scope[]) =>
buildScopeMiddleware(Array.isArray(scopes) ? scopes : [scopes], undefined, { globalOnly: true });
export const projectScope = (scopes: Scope | Scope[], resource: ProjectScopeResource) =>
buildScopeMiddleware(Array.isArray(scopes) ? scopes : [scopes], resource, { globalOnly: false });
export const validCursor = (
req: PaginatedRequest,
+4
View File
@@ -71,6 +71,8 @@ import { InvitationController } from './controllers/invitation.controller';
// import { CollaborationService } from './collaboration/collaboration.service';
import { BadRequestError } from './errors/response-errors/bad-request.error';
import { OrchestrationService } from '@/services/orchestration.service';
import { ProjectController } from './controllers/project.controller';
import { RoleController } from './controllers/role.controller';
const exec = promisify(callbackExec);
@@ -146,6 +148,8 @@ export class Server extends AbstractServer {
ExecutionsController,
CredentialsController,
AIController,
ProjectController,
RoleController,
];
if (
@@ -5,64 +5,47 @@ import { CredentialAccessError, NodeOperationError, WorkflowOperationError } fro
import config from '@/config';
import { License } from '@/License';
import { OwnershipService } from '@/services/ownership.service';
import { UserRepository } from '@db/repositories/user.repository';
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
import { ProjectService } from '@/services/project.service';
@Service()
export class PermissionChecker {
constructor(
private readonly userRepository: UserRepository,
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly ownershipService: OwnershipService,
private readonly license: License,
private readonly projectService: ProjectService,
) {}
/**
* Check if a user is permitted to execute a workflow.
* Check if a workflow has the ability to execute based on the projects it's apart of.
*/
async check(workflowId: string, userId: string, nodes: INode[]) {
// allow if no nodes in this workflow use creds
async check(workflowId: string, nodes: INode[]) {
const homeProject = await this.ownershipService.getWorkflowProjectCached(workflowId);
const homeProjectOwner = await this.ownershipService.getProjectOwnerCached(homeProject.id);
if (homeProject.type === 'personal' && homeProjectOwner?.hasGlobalScope('credential:list')) {
// Workflow belongs to a project by a user with privileges
// so all credentials are usable. Skip credential checks.
return;
}
const projectIds = await this.projectService.findProjectsWorkflowIsIn(workflowId);
const credIdsToNodes = this.mapCredIdsToNodes(nodes);
const workflowCredIds = Object.keys(credIdsToNodes);
if (workflowCredIds.length === 0) return;
// allow if requesting user is instance owner
const accessible = await this.sharedCredentialsRepository.getFilteredAccessibleCredentials(
projectIds,
workflowCredIds,
);
const user = await this.userRepository.findOneOrFail({
where: { id: userId },
});
if (user.hasGlobalScope('workflow:execute')) return;
const isSharingEnabled = this.license.isSharingEnabled();
// allow if all creds used in this workflow are a subset of
// all creds accessible to users who have access to this workflow
let workflowUserIds = [userId];
if (workflowId && isSharingEnabled) {
workflowUserIds = await this.sharedWorkflowRepository.getSharedUserIds(workflowId);
for (const credentialsId of workflowCredIds) {
if (!accessible.includes(credentialsId)) {
const nodeToFlag = credIdsToNodes[credentialsId][0];
throw new CredentialAccessError(nodeToFlag, credentialsId, workflowId);
}
}
const accessibleCredIds = isSharingEnabled
? await this.sharedCredentialsRepository.getAccessibleCredentialIds(workflowUserIds)
: await this.sharedCredentialsRepository.getOwnedCredentialIds(workflowUserIds);
const inaccessibleCredIds = workflowCredIds.filter((id) => !accessibleCredIds.includes(id));
if (inaccessibleCredIds.length === 0) return;
// if disallowed, flag only first node using first inaccessible cred
const inaccessibleCredId = inaccessibleCredIds[0];
const nodeToFlag = credIdsToNodes[inaccessibleCredId][0];
throw new CredentialAccessError(nodeToFlag, inaccessibleCredId, workflowId);
}
async checkSubworkflowExecutePolicy(
@@ -91,14 +74,14 @@ export class PermissionChecker {
}
const parentWorkflowOwner =
await this.ownershipService.getWorkflowOwnerCached(parentWorkflowId);
await this.ownershipService.getWorkflowProjectCached(parentWorkflowId);
const subworkflowOwner = await this.ownershipService.getWorkflowOwnerCached(subworkflow.id);
const subworkflowOwner = await this.ownershipService.getWorkflowProjectCached(subworkflow.id);
const description =
subworkflowOwner.id === parentWorkflowOwner.id
? 'Change the settings of the sub-workflow so it can be called by this one.'
: `${subworkflowOwner.firstName} (${subworkflowOwner.email}) can make this change. You may need to tell them the ID of the sub-workflow, which is ${subworkflow.id}`;
: `An admin for the ${subworkflowOwner.name} project can make this change. You may need to tell them the ID of the sub-workflow, which is ${subworkflow.id}`;
const errorToThrow = new WorkflowOperationError(
`Target workflow ID ${subworkflow.id} may not be called`,
+2 -2
View File
@@ -173,13 +173,13 @@ export class WaitTracker {
throw new ApplicationError('Only saved workflows can be resumed.');
}
const workflowId = fullExecutionData.workflowData.id;
const user = await this.ownershipService.getWorkflowOwnerCached(workflowId);
const project = await this.ownershipService.getWorkflowProjectCached(workflowId);
const data: IWorkflowExecutionDataProcess = {
executionMode: fullExecutionData.mode,
executionData: fullExecutionData.data,
workflowData: fullExecutionData.workflowData,
userId: user.id,
projectId: project.id,
};
// Start the execution again
+1 -8
View File
@@ -88,19 +88,12 @@ export class WaitingWebhooks implements IWebhookManager {
settings: workflowData.settings,
});
let workflowOwner;
try {
workflowOwner = await this.ownershipService.getWorkflowOwnerCached(workflowData.id);
} catch (error) {
throw new NotFoundError('Could not find workflow');
}
const workflowStartNode = workflow.getNode(lastNodeExecuted);
if (workflowStartNode === null) {
throw new NotFoundError('Could not find node to process webhook.');
}
const additionalData = await WorkflowExecuteAdditionalData.getBase(workflowOwner.id);
const additionalData = await WorkflowExecuteAdditionalData.getBase();
const webhookData = NodeHelpers.getNodeWebhooks(
workflow,
workflowStartNode,
+8 -16
View File
@@ -56,8 +56,6 @@ import * as WorkflowHelpers from '@/WorkflowHelpers';
import { WorkflowRunner } from '@/WorkflowRunner';
import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData';
import { ActiveExecutions } from '@/ActiveExecutions';
import type { User } from '@db/entities/User';
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { EventsService } from '@/services/events.service';
import { OwnershipService } from './services/ownership.service';
import { parseBody } from './middlewares';
@@ -65,6 +63,7 @@ import { Logger } from './Logger';
import { NotFoundError } from './errors/response-errors/not-found.error';
import { InternalServerError } from './errors/response-errors/internal-server.error';
import { UnprocessableRequestError } from './errors/response-errors/unprocessable.error';
import type { Project } from './databases/entities/Project';
export const WEBHOOK_METHODS: IHttpRequestMethods[] = [
'DELETE',
@@ -248,22 +247,15 @@ export async function executeWebhook(
$executionId: executionId,
};
let user: User;
if (
(workflowData as WorkflowEntity).shared?.length &&
(workflowData as WorkflowEntity).shared[0].user
) {
user = (workflowData as WorkflowEntity).shared[0].user;
} else {
try {
user = await Container.get(OwnershipService).getWorkflowOwnerCached(workflowData.id);
} catch (error) {
throw new NotFoundError('Cannot find workflow');
}
let project: Project | undefined = undefined;
try {
project = await Container.get(OwnershipService).getWorkflowProjectCached(workflowData.id);
} catch (error) {
throw new NotFoundError('Cannot find workflow');
}
// Prepare everything that is needed to run the workflow
const additionalData = await WorkflowExecuteAdditionalData.getBase(user.id);
const additionalData = await WorkflowExecuteAdditionalData.getBase();
// Get the responseMode
const responseMode = workflow.expression.getSimpleParameterValue(
@@ -546,7 +538,7 @@ export async function executeWebhook(
pushRef,
workflowData,
pinData,
userId: user.id,
projectId: project?.id,
};
let responsePromise: IDeferredPromise<IN8nHttpFullResponse> | undefined;
@@ -195,12 +195,12 @@ export function executeErrorWorkflow(
}
Container.get(OwnershipService)
.getWorkflowOwnerCached(workflowId)
.then((user) => {
.getWorkflowProjectCached(workflowId)
.then((project) => {
void Container.get(WorkflowExecutionService).executeErrorWorkflow(
errorWorkflow,
workflowErrorData,
user,
project,
);
})
.catch((error: Error) => {
@@ -223,12 +223,12 @@ export function executeErrorWorkflow(
) {
logger.verbose('Start internal error workflow', { executionId, workflowId });
void Container.get(OwnershipService)
.getWorkflowOwnerCached(workflowId)
.then((user) => {
.getWorkflowProjectCached(workflowId)
.then((project) => {
void Container.get(WorkflowExecutionService).executeErrorWorkflow(
workflowId,
workflowErrorData,
user,
project,
);
});
}
@@ -655,7 +655,6 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
export async function getRunData(
workflowData: IWorkflowBase,
userId: string,
inputData?: INodeExecutionData[],
): Promise<IWorkflowExecutionDataProcess> {
const mode = 'integrated';
@@ -698,7 +697,6 @@ export async function getRunData(
executionData: runExecutionData,
// @ts-ignore
workflowData,
userId,
};
return runData;
@@ -784,9 +782,7 @@ async function executeWorkflow(
settings: workflowData.settings,
});
const runData =
options.loadedRunData ??
(await getRunData(workflowData, additionalData.userId, options.inputData));
const runData = options.loadedRunData ?? (await getRunData(workflowData, options.inputData));
let executionId;
@@ -800,11 +796,7 @@ async function executeWorkflow(
let data;
try {
await Container.get(PermissionChecker).check(
workflowData.id,
additionalData.userId,
workflowData.nodes,
);
await Container.get(PermissionChecker).check(workflowData.id, workflowData.nodes);
await Container.get(PermissionChecker).checkSubworkflowExecutePolicy(
workflow,
options.parentWorkflowId,
@@ -813,7 +805,7 @@ async function executeWorkflow(
// Create new additionalData to have different workflow loaded and to call
// different webhooks
const additionalDataIntegrated = await getBase(additionalData.userId);
const additionalDataIntegrated = await getBase();
additionalDataIntegrated.hooks = getWorkflowHooksIntegrated(
runData.executionMode,
executionId,
@@ -966,7 +958,7 @@ export function sendDataToUI(type: string, data: IDataObject | IDataObject[]) {
* Returns the base additional data without webhooks
*/
export async function getBase(
userId: string,
userId?: string,
currentNodeParameters?: INodeParameters,
executionTimeoutTimestamp?: number,
): Promise<IWorkflowExecuteAdditionalData> {
+1 -1
View File
@@ -161,7 +161,7 @@ export class WorkflowRunner {
const { id: workflowId, nodes } = data.workflowData;
try {
await this.permissionChecker.check(workflowId, data.userId, nodes);
await this.permissionChecker.check(workflowId, nodes);
} catch (error) {
// Create a failed execution with the data for the node, save it and abort execution
const runData = generateFailedExecutionFromError(data.executionMode, error, error.node);
+2 -2
View File
@@ -8,7 +8,7 @@ import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES, Time } from '@/constants';
import type { User } from '@db/entities/User';
import { UserRepository } from '@db/repositories/user.repository';
import { AuthError } from '@/errors/response-errors/auth.error';
import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { License } from '@/License';
import { Logger } from '@/Logger';
import type { AuthenticatedRequest } from '@/requests';
@@ -92,7 +92,7 @@ export class AuthService {
!user.isOwner &&
!isWithinUsersLimit
) {
throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
}
const token = this.issueJWT(user, browserId);
+73 -29
View File
@@ -6,7 +6,6 @@ import glob from 'fast-glob';
import type { EntityManager } from '@n8n/typeorm';
import * as Db from '@/Db';
import type { User } from '@db/entities/User';
import { SharedCredentials } from '@db/entities/SharedCredentials';
import { CredentialsEntity } from '@db/entities/CredentialsEntity';
import { disableAutoGeneratedIds } from '@db/utils/commandHelpers';
@@ -15,6 +14,8 @@ import type { ICredentialsEncrypted } from 'n8n-workflow';
import { ApplicationError, jsonParse } from 'n8n-workflow';
import { UM_FIX_INSTRUCTION } from '@/constants';
import { UserRepository } from '@db/repositories/user.repository';
import { ProjectRepository } from '@/databases/repositories/project.repository';
import type { Project } from '@/databases/entities/Project';
export class ImportCredentialsCommand extends BaseCommand {
static description = 'Import credentials';
@@ -23,6 +24,7 @@ export class ImportCredentialsCommand extends BaseCommand {
'$ n8n import:credentials --input=file.json',
'$ n8n import:credentials --separate --input=backups/latest/',
'$ n8n import:credentials --input=file.json --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae',
'$ n8n import:credentials --input=file.json --projectId=Ox8O54VQrmBrb4qL',
'$ n8n import:credentials --separate --input=backups/latest/ --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae',
];
@@ -38,6 +40,9 @@ export class ImportCredentialsCommand extends BaseCommand {
userId: Flags.string({
description: 'The ID of the user to assign the imported credentials to',
}),
projectId: Flags.string({
description: 'The ID of the project to assign the imported credential to',
}),
};
private transactionManager: EntityManager;
@@ -64,21 +69,27 @@ export class ImportCredentialsCommand extends BaseCommand {
}
}
const user = flags.userId ? await this.getAssignee(flags.userId) : await this.getOwner();
if (flags.projectId && flags.userId) {
throw new ApplicationError(
'You cannot use `--userId` and `--projectId` together. Use one or the other.',
);
}
const project = await this.getProject(flags.userId, flags.projectId);
const credentials = await this.readCredentials(flags.input, flags.separate);
await Db.getConnection().transaction(async (transactionManager) => {
this.transactionManager = transactionManager;
const result = await this.checkRelations(credentials, flags.userId);
const result = await this.checkRelations(credentials, flags.projectId, flags.userId);
if (!result.success) {
throw new ApplicationError(result.message);
}
for (const credential of credentials) {
await this.storeCredential(credential, user);
await this.storeCredential(credential, project);
}
});
@@ -98,7 +109,7 @@ export class ImportCredentialsCommand extends BaseCommand {
);
}
private async storeCredential(credential: Partial<CredentialsEntity>, user: User) {
private async storeCredential(credential: Partial<CredentialsEntity>, project: Project) {
const result = await this.transactionManager.upsert(CredentialsEntity, credential, ['id']);
const sharingExists = await this.transactionManager.existsBy(SharedCredentials, {
@@ -111,25 +122,34 @@ export class ImportCredentialsCommand extends BaseCommand {
SharedCredentials,
{
credentialsId: result.identifiers[0].id as string,
userId: user.id,
role: 'credential:owner',
projectId: project.id,
},
['credentialsId', 'userId'],
['credentialsId', 'projectId'],
);
}
}
private async getOwner() {
private async getOwnerProject() {
const owner = await Container.get(UserRepository).findOneBy({ role: 'global:owner' });
if (!owner) {
throw new ApplicationError(`Failed to find owner. ${UM_FIX_INSTRUCTION}`);
}
return owner;
const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
owner.id,
);
return project;
}
private async checkRelations(credentials: ICredentialsEncrypted[], userId?: string) {
if (!userId) {
private async checkRelations(
credentials: ICredentialsEncrypted[],
projectId?: string,
userId?: string,
) {
// The credential is not supposed to be re-owned.
if (!projectId && !userId) {
return {
success: true as const,
message: undefined,
@@ -145,15 +165,26 @@ export class ImportCredentialsCommand extends BaseCommand {
continue;
}
const ownerId = await this.getCredentialOwner(credential.id);
if (!ownerId) {
const { user, project: ownerProject } = await this.getCredentialOwner(credential.id);
if (!ownerProject) {
continue;
}
if (ownerId !== userId) {
if (ownerProject.id !== projectId) {
const currentOwner =
ownerProject.type === 'personal'
? `the user with the ID "${user.id}"`
: `the project with the ID "${ownerProject.id}"`;
const newOwner = userId
? // The user passed in `--userId`, so let's use the user ID in the error
// message as opposed to the project ID.
`the user with the ID "${userId}"`
: `the project with the ID "${projectId}"`;
return {
success: false as const,
message: `The credential with id "${credential.id}" is already owned by the user with the id "${ownerId}". It can't be re-owned by the user with the id "${userId}"`,
message: `The credential with ID "${credential.id}" is already owned by ${currentOwner}. It can't be re-owned by ${newOwner}.`,
};
}
}
@@ -206,26 +237,39 @@ export class ImportCredentialsCommand extends BaseCommand {
});
}
private async getAssignee(userId: string) {
const user = await Container.get(UserRepository).findOneBy({ id: userId });
if (!user) {
throw new ApplicationError('Failed to find user', { extra: { userId } });
}
return user;
}
private async getCredentialOwner(credentialsId: string) {
const sharedCredential = await this.transactionManager.findOneBy(SharedCredentials, {
credentialsId,
role: 'credential:owner',
const sharedCredential = await this.transactionManager.findOne(SharedCredentials, {
where: { credentialsId, role: 'credential:owner' },
relations: { project: true },
});
return sharedCredential?.userId;
if (sharedCredential && sharedCredential.project.type === 'personal') {
const user = await Container.get(UserRepository).findOneByOrFail({
projectRelations: {
role: 'project:personalOwner',
projectId: sharedCredential.projectId,
},
});
return { user, project: sharedCredential.project };
}
return {};
}
private async credentialExists(credentialId: string) {
return await this.transactionManager.existsBy(CredentialsEntity, { id: credentialId });
}
private async getProject(userId?: string, projectId?: string) {
if (projectId) {
return await Container.get(ProjectRepository).findOneByOrFail({ id: projectId });
}
if (userId) {
return await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId);
}
return await this.getOwnerProject();
}
}
+66 -15
View File
@@ -14,6 +14,7 @@ import type { IWorkflowToImport } from '@/Interfaces';
import { ImportService } from '@/services/import.service';
import { BaseCommand } from '../BaseCommand';
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
import { ProjectRepository } from '@/databases/repositories/project.repository';
function assertHasWorkflowsToImport(workflows: unknown): asserts workflows is IWorkflowToImport[] {
if (!Array.isArray(workflows)) {
@@ -40,6 +41,7 @@ export class ImportWorkflowsCommand extends BaseCommand {
'$ n8n import:workflow --input=file.json',
'$ n8n import:workflow --separate --input=backups/latest/',
'$ n8n import:workflow --input=file.json --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae',
'$ n8n import:workflow --input=file.json --projectId=Ox8O54VQrmBrb4qL',
'$ n8n import:workflow --separate --input=backups/latest/ --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae',
];
@@ -55,6 +57,9 @@ export class ImportWorkflowsCommand extends BaseCommand {
userId: Flags.string({
description: 'The ID of the user to assign the imported workflows to',
}),
projectId: Flags.string({
description: 'The ID of the project to assign the imported workflows to',
}),
};
async init() {
@@ -79,24 +84,32 @@ export class ImportWorkflowsCommand extends BaseCommand {
}
}
const owner = await this.getOwner();
if (flags.projectId && flags.userId) {
throw new ApplicationError(
'You cannot use `--userId` and `--projectId` together. Use one or the other.',
);
}
const project = await this.getProject(flags.userId, flags.projectId);
const workflows = await this.readWorkflows(flags.input, flags.separate);
const result = await this.checkRelations(workflows, flags.userId);
const result = await this.checkRelations(workflows, flags.projectId, flags.userId);
if (!result.success) {
throw new ApplicationError(result.message);
}
this.logger.info(`Importing ${workflows.length} workflows...`);
await Container.get(ImportService).importWorkflows(workflows, flags.userId ?? owner.id);
await Container.get(ImportService).importWorkflows(workflows, project.id);
this.reportSuccess(workflows.length);
}
private async checkRelations(workflows: WorkflowEntity[], userId: string | undefined) {
if (!userId) {
private async checkRelations(workflows: WorkflowEntity[], projectId?: string, userId?: string) {
// The credential is not supposed to be re-owned.
if (!userId && !projectId) {
return {
success: true as const,
message: undefined,
@@ -108,15 +121,26 @@ export class ImportWorkflowsCommand extends BaseCommand {
continue;
}
const ownerId = await this.getWorkflowOwner(workflow);
if (!ownerId) {
const { user, project: ownerProject } = await this.getWorkflowOwner(workflow);
if (!ownerProject) {
continue;
}
if (ownerId !== userId) {
if (ownerProject.id !== projectId) {
const currentOwner =
ownerProject.type === 'personal'
? `the user with the ID "${user.id}"`
: `the project with the ID "${ownerProject.id}"`;
const newOwner = userId
? // The user passed in `--userId`, so let's use the user ID in the error
// message as opposed to the project ID.
`the user with the ID "${userId}"`
: `the project with the ID "${projectId}"`;
return {
success: false as const,
message: `The credential with id "${workflow.id}" is already owned by the user with the id "${ownerId}". It can't be re-owned by the user with the id "${userId}"`,
message: `The credential with ID "${workflow.id}" is already owned by ${currentOwner}. It can't be re-owned by ${newOwner}.`,
};
}
}
@@ -136,22 +160,37 @@ export class ImportWorkflowsCommand extends BaseCommand {
this.logger.info(`Successfully imported ${total} ${total === 1 ? 'workflow.' : 'workflows.'}`);
}
private async getOwner() {
private async getOwnerProject() {
const owner = await Container.get(UserRepository).findOneBy({ role: 'global:owner' });
if (!owner) {
throw new ApplicationError(`Failed to find owner. ${UM_FIX_INSTRUCTION}`);
}
return owner;
const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
owner.id,
);
return project;
}
private async getWorkflowOwner(workflow: WorkflowEntity) {
const sharing = await Container.get(SharedWorkflowRepository).findOneBy({
workflowId: workflow.id,
role: 'workflow:owner',
const sharing = await Container.get(SharedWorkflowRepository).findOne({
where: { workflowId: workflow.id, role: 'workflow:owner' },
relations: { project: true },
});
return sharing?.userId;
if (sharing && sharing.project.type === 'personal') {
const user = await Container.get(UserRepository).findOneByOrFail({
projectRelations: {
role: 'project:personalOwner',
projectId: sharing.projectId,
},
});
return { user, project: sharing.project };
}
return {};
}
private async workflowExists(workflow: WorkflowEntity) {
@@ -189,4 +228,16 @@ export class ImportWorkflowsCommand extends BaseCommand {
return workflowInstances;
}
}
private async getProject(userId?: string, projectId?: string) {
if (projectId) {
return await Container.get(ProjectRepository).findOneByOrFail({ id: projectId });
}
if (userId) {
return await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId);
}
return await this.getOwnerProject();
}
}
+133 -1
View File
@@ -5,18 +5,115 @@ import { AuthProviderSyncHistoryRepository } from '@db/repositories/authProvider
import { SettingsRepository } from '@db/repositories/settings.repository';
import { UserRepository } from '@db/repositories/user.repository';
import { BaseCommand } from '../BaseCommand';
import { Flags } from '@oclif/core';
import { ApplicationError } from 'n8n-workflow';
import { ProjectRepository } from '@/databases/repositories/project.repository';
import { WorkflowService } from '@/workflows/workflow.service';
import { In } from '@n8n/typeorm';
import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository';
import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository';
import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository';
import { CredentialsService } from '@/credentials/credentials.service';
import { UM_FIX_INSTRUCTION } from '@/constants';
const wrongFlagsError =
'You must use exactly one of `--userId`, `--projectId` or `--deleteWorkflowsAndCredentials`.';
export class Reset extends BaseCommand {
static description = '\nResets the database to the default ldap state';
static description =
'\nResets the database to the default ldap state.\n\nTHIS DELETES ALL LDAP MANAGED USERS.';
static examples = [
'$ n8n ldap:reset --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae',
'$ n8n ldap:reset --projectId=Ox8O54VQrmBrb4qL',
'$ n8n ldap:reset --deleteWorkflowsAndCredentials',
];
static flags = {
help: Flags.help({ char: 'h' }),
userId: Flags.string({
description:
'The ID of the user to assign the workflows and credentials owned by the deleted LDAP users to',
}),
projectId: Flags.string({
description:
'The ID of the project to assign the workflows and credentials owned by the deleted LDAP users to',
}),
deleteWorkflowsAndCredentials: Flags.boolean({
description:
'Delete all workflows and credentials owned by the users that were created by the users managed via LDAP.',
}),
};
async run(): Promise<void> {
const { flags } = await this.parse(Reset);
const numberOfOptions =
Number(!!flags.userId) +
Number(!!flags.projectId) +
Number(!!flags.deleteWorkflowsAndCredentials);
if (numberOfOptions !== 1) {
throw new ApplicationError(wrongFlagsError);
}
const owner = await this.getOwner();
const ldapIdentities = await Container.get(AuthIdentityRepository).find({
where: { providerType: 'ldap' },
select: ['userId'],
});
const personalProjectIds = await Container.get(
ProjectRelationRepository,
).getPersonalProjectsForUsers(ldapIdentities.map((i) => i.userId));
// Migrate all workflows and credentials to another project.
if (flags.projectId ?? flags.userId) {
if (flags.userId && ldapIdentities.some((i) => i.userId === flags.userId)) {
throw new ApplicationError(
`Can't migrate workflows and credentials to the user with the ID ${flags.userId}. That user was created via LDAP and will be deleted as well.`,
);
}
if (flags.projectId && personalProjectIds.includes(flags.projectId)) {
throw new ApplicationError(
`Can't migrate workflows and credentials to the project with the ID ${flags.projectId}. That project is a personal project belonging to a user that was created via LDAP and will be deleted as well.`,
);
}
const project = await this.getProject(flags.userId, flags.projectId);
await Container.get(UserRepository).manager.transaction(async (trx) => {
for (const projectId of personalProjectIds) {
await Container.get(WorkflowService).transferAll(projectId, project.id, trx);
await Container.get(CredentialsService).transferAll(projectId, project.id, trx);
}
});
}
const [ownedSharedWorkflows, ownedSharedCredentials] = await Promise.all([
Container.get(SharedWorkflowRepository).find({
select: { workflowId: true },
where: { projectId: In(personalProjectIds), role: 'workflow:owner' },
}),
Container.get(SharedCredentialsRepository).find({
relations: { credentials: true },
where: { projectId: In(personalProjectIds), role: 'credential:owner' },
}),
]);
const ownedCredentials = ownedSharedCredentials.map(({ credentials }) => credentials);
for (const { workflowId } of ownedSharedWorkflows) {
await Container.get(WorkflowService).delete(owner, workflowId);
}
for (const credential of ownedCredentials) {
await Container.get(CredentialsService).delete(credential);
}
await Container.get(AuthProviderSyncHistoryRepository).delete({ providerType: 'ldap' });
await Container.get(AuthIdentityRepository).delete({ providerType: 'ldap' });
await Container.get(UserRepository).deleteMany(ldapIdentities.map((i) => i.userId));
await Container.get(ProjectRepository).delete({ id: In(personalProjectIds) });
await Container.get(SettingsRepository).delete({ key: LDAP_FEATURE_NAME });
await Container.get(SettingsRepository).insert({
key: LDAP_FEATURE_NAME,
@@ -27,8 +124,43 @@ export class Reset extends BaseCommand {
this.logger.info('Successfully reset the database to default ldap state.');
}
async getProject(userId?: string, projectId?: string) {
if (projectId) {
const project = await Container.get(ProjectRepository).findOneBy({ id: projectId });
if (project === null) {
throw new ApplicationError(`Could not find the project with the ID ${projectId}.`);
}
return project;
}
if (userId) {
const project = await Container.get(ProjectRepository).getPersonalProjectForUser(userId);
if (project === null) {
throw new ApplicationError(
`Could not find the user with the ID ${userId} or their personalProject.`,
);
}
return project;
}
throw new ApplicationError(wrongFlagsError);
}
async catch(error: Error): Promise<void> {
this.logger.error('Error resetting database. See log messages for details.');
this.logger.error(error.message);
}
private async getOwner() {
const owner = await Container.get(UserRepository).findOneBy({ role: 'global:owner' });
if (!owner) {
throw new ApplicationError(`Failed to find owner. ${UM_FIX_INSTRUCTION}`);
}
return owner;
}
}
+16 -5
View File
@@ -27,16 +27,27 @@ export class DisableMFACommand extends BaseCommand {
return;
}
const updateOperationResult = await Container.get(UserRepository).update(
{ email: flags.email },
{ mfaSecret: null, mfaRecoveryCodes: [], mfaEnabled: false },
);
const user = await Container.get(UserRepository).findOneBy({ email: flags.email });
if (!updateOperationResult.affected) {
if (!user) {
this.reportUserDoesNotExistError(flags.email);
return;
}
if (
user.mfaSecret === null &&
Array.isArray(user.mfaRecoveryCodes) &&
user.mfaRecoveryCodes.length === 0 &&
!user.mfaEnabled
) {
this.reportUserDoesNotExistError(flags.email);
return;
}
Object.assign(user, { mfaSecret: null, mfaRecoveryCodes: [], mfaEnabled: false });
await Container.get(UserRepository).save(user);
this.reportSuccess(flags.email);
}
@@ -7,6 +7,7 @@ import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
import { UserRepository } from '@db/repositories/user.repository';
import { BaseCommand } from '../BaseCommand';
import { ProjectRepository } from '@/databases/repositories/project.repository';
const defaultUserProps = {
firstName: null,
@@ -23,9 +24,12 @@ export class Reset extends BaseCommand {
async run(): Promise<void> {
const owner = await this.getInstanceOwner();
const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
owner.id,
);
await Container.get(SharedWorkflowRepository).makeOwnerOfAllWorkflows(owner);
await Container.get(SharedCredentialsRepository).makeOwnerOfAllCredentials(owner);
await Container.get(SharedWorkflowRepository).makeOwnerOfAllWorkflows(personalProject);
await Container.get(SharedCredentialsRepository).makeOwnerOfAllCredentials(personalProject);
await Container.get(UserRepository).deleteAllExcept(owner);
await Container.get(UserRepository).save(Object.assign(owner, defaultUserProps));
@@ -38,7 +42,7 @@ export class Reset extends BaseCommand {
const newSharedCredentials = danglingCredentials.map((credentials) =>
Container.get(SharedCredentialsRepository).create({
credentials,
user: owner,
projectId: personalProject.id,
role: 'credential:owner',
}),
);
+1 -4
View File
@@ -17,7 +17,6 @@ import { Queue } from '@/Queue';
import { N8N_VERSION } from '@/constants';
import { ExecutionRepository } from '@db/repositories/execution.repository';
import { WorkflowRepository } from '@db/repositories/workflow.repository';
import { OwnershipService } from '@/services/ownership.service';
import type { ICredentialsOverwrite } from '@/Interfaces';
import { CredentialsOverwrites } from '@/CredentialsOverwrites';
import { rawBodyReader, bodyParser } from '@/middlewares';
@@ -118,8 +117,6 @@ export class Worker extends BaseCommand {
);
await executionRepository.updateStatus(executionId, 'running');
const workflowOwner = await Container.get(OwnershipService).getWorkflowOwnerCached(workflowId);
let { staticData } = fullExecutionData.workflowData;
if (loadStaticData) {
const workflowData = await Container.get(WorkflowRepository).findOne({
@@ -160,7 +157,7 @@ export class Worker extends BaseCommand {
});
const additionalData = await WorkflowExecuteAdditionalData.getBase(
workflowOwner.id,
undefined,
undefined,
executionTimeoutTimestamp,
);
+6 -1
View File
@@ -48,7 +48,8 @@ export const RESPONSE_ERROR_MESSAGES = {
USERS_QUOTA_REACHED: 'Maximum number of users reached',
OAUTH2_CREDENTIAL_TEST_SUCCEEDED: 'Connection Successful!',
OAUTH2_CREDENTIAL_TEST_FAILED: 'This OAuth2 credential was not connected to an account.',
};
MISSING_SCOPE: 'User is missing a scope required to perform this action',
} as const;
export const AUTH_COOKIE_NAME = 'n8n-auth';
@@ -86,6 +87,9 @@ export const LICENSE_FEATURES = {
MULTIPLE_MAIN_INSTANCES: 'feat:multipleMainInstances',
WORKER_VIEW: 'feat:workerView',
ADVANCED_PERMISSIONS: 'feat:advancedPermissions',
PROJECT_ROLE_ADMIN: 'feat:projectRole:admin',
PROJECT_ROLE_EDITOR: 'feat:projectRole:editor',
PROJECT_ROLE_VIEWER: 'feat:projectRole:viewer',
} as const;
export const LICENSE_QUOTAS = {
@@ -93,6 +97,7 @@ export const LICENSE_QUOTAS = {
VARIABLES_LIMIT: 'quota:maxVariables',
USERS_LIMIT: 'quota:users',
WORKFLOW_HISTORY_PRUNE_LIMIT: 'quota:workflowHistoryPrune',
TEAM_PROJECT_LIMIT: 'quota:maxTeamProjects',
} as const;
export const UNLIMITED_LICENSE_QUOTA = -1;
@@ -21,7 +21,7 @@ import { MfaService } from '@/Mfa/mfa.service';
import { Logger } from '@/Logger';
import { AuthError } from '@/errors/response-errors/auth.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { ApplicationError } from 'n8n-workflow';
import { UserRepository } from '@/databases/repositories/user.repository';
@@ -130,7 +130,7 @@ export class AuthController {
inviterId,
inviteeId,
});
throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
}
if (!inviterId || !inviteeId) {
+56 -34
View File
@@ -6,10 +6,10 @@ import { UserRepository } from '@db/repositories/user.repository';
import { ActiveWorkflowManager } from '@/ActiveWorkflowManager';
import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus';
import { License } from '@/License';
import { LICENSE_FEATURES, inE2ETests } from '@/constants';
import { LICENSE_FEATURES, LICENSE_QUOTAS, UNLIMITED_LICENSE_QUOTA, inE2ETests } from '@/constants';
import { Patch, Post, RestController } from '@/decorators';
import type { UserSetupPayload } from '@/requests';
import type { BooleanLicenseFeature, IPushDataType } from '@/Interfaces';
import type { BooleanLicenseFeature, IPushDataType, NumericLicenseFeature } from '@/Interfaces';
import { MfaService } from '@/Mfa/mfa.service';
import { Push } from '@/push';
import { CacheService } from '@/services/cache/cache.service';
@@ -25,21 +25,23 @@ if (!inE2ETests) {
const tablesToTruncate = [
'auth_identity',
'auth_provider_sync_history',
'event_destinations',
'shared_workflow',
'shared_credentials',
'webhook_entity',
'workflows_tags',
'credentials_entity',
'tag_entity',
'workflow_statistics',
'workflow_entity',
'event_destinations',
'execution_entity',
'settings',
'installed_packages',
'installed_nodes',
'installed_packages',
'project',
'project_relation',
'settings',
'shared_credentials',
'shared_workflow',
'tag_entity',
'user',
'variables',
'webhook_entity',
'workflow_entity',
'workflow_statistics',
'workflows_tags',
];
type ResetRequest = Request<
@@ -81,21 +83,35 @@ export class E2EController {
[LICENSE_FEATURES.MULTIPLE_MAIN_INSTANCES]: false,
[LICENSE_FEATURES.WORKER_VIEW]: false,
[LICENSE_FEATURES.ADVANCED_PERMISSIONS]: false,
[LICENSE_FEATURES.PROJECT_ROLE_ADMIN]: false,
[LICENSE_FEATURES.PROJECT_ROLE_EDITOR]: false,
[LICENSE_FEATURES.PROJECT_ROLE_VIEWER]: false,
};
private numericFeatures: Record<NumericLicenseFeature, number> = {
[LICENSE_QUOTAS.TRIGGER_LIMIT]: -1,
[LICENSE_QUOTAS.VARIABLES_LIMIT]: -1,
[LICENSE_QUOTAS.USERS_LIMIT]: -1,
[LICENSE_QUOTAS.WORKFLOW_HISTORY_PRUNE_LIMIT]: -1,
[LICENSE_QUOTAS.TEAM_PROJECT_LIMIT]: 0,
};
constructor(
license: License,
private readonly settingsRepo: SettingsRepository,
private readonly userRepo: UserRepository,
private readonly workflowRunner: ActiveWorkflowManager,
private readonly mfaService: MfaService,
private readonly cacheService: CacheService,
private readonly push: Push,
private readonly passwordUtility: PasswordUtility,
private readonly eventBus: MessageEventBus,
private readonly userRepository: UserRepository,
) {
license.isFeatureEnabled = (feature: BooleanLicenseFeature) =>
this.enabledFeatures[feature] ?? false;
// eslint-disable-next-line @typescript-eslint/unbound-method
license.getFeatureValue<NumericLicenseFeature> = (feature: NumericLicenseFeature) =>
this.numericFeatures[feature] ?? UNLIMITED_LICENSE_QUOTA;
}
@Post('/reset', { skipAuth: true })
@@ -119,6 +135,12 @@ export class E2EController {
this.enabledFeatures[feature] = enabled;
}
@Patch('/quota', { skipAuth: true })
setQuota(req: Request<{}, {}, { feature: NumericLicenseFeature; value: number }>) {
const { value, feature } = req.body;
this.numericFeatures[feature] = value;
}
@Patch('/queue-mode', { skipAuth: true })
async setQueueMode(req: Request<{}, {}, { enabled: boolean }>) {
const { enabled } = req.body;
@@ -163,34 +185,34 @@ export class E2EController {
members: UserSetupPayload[],
admin: UserSetupPayload,
) {
const instanceOwner = this.userRepo.create({
id: uuid(),
...owner,
password: await this.passwordUtility.hash(owner.password),
role: 'global:owner',
});
if (owner?.mfaSecret && owner.mfaRecoveryCodes?.length) {
const { encryptedRecoveryCodes, encryptedSecret } =
this.mfaService.encryptSecretAndRecoveryCodes(owner.mfaSecret, owner.mfaRecoveryCodes);
instanceOwner.mfaSecret = encryptedSecret;
instanceOwner.mfaRecoveryCodes = encryptedRecoveryCodes;
owner.mfaSecret = encryptedSecret;
owner.mfaRecoveryCodes = encryptedRecoveryCodes;
}
const adminUser = this.userRepo.create({
id: uuid(),
...admin,
password: await this.passwordUtility.hash(admin.password),
role: 'global:admin',
});
const userCreatePromises = [
this.userRepository.createUserWithProject({
id: uuid(),
...owner,
password: await this.passwordUtility.hash(owner.password),
role: 'global:owner',
}),
];
const users = [];
users.push(instanceOwner, adminUser);
userCreatePromises.push(
this.userRepository.createUserWithProject({
id: uuid(),
...admin,
password: await this.passwordUtility.hash(admin.password),
role: 'global:admin',
}),
);
for (const { password, ...payload } of members) {
users.push(
this.userRepo.create({
userCreatePromises.push(
this.userRepository.createUserWithProject({
id: uuid(),
...payload,
password: await this.passwordUtility.hash(password),
@@ -199,7 +221,7 @@ export class E2EController {
);
}
await this.userRepo.insert(users);
await Promise.all(userCreatePromises);
await this.settingsRepo.update(
{ key: 'userManagement.isInstanceOwnerSetUp' },
@@ -15,7 +15,7 @@ import { PostHogClient } from '@/posthog';
import type { User } from '@/databases/entities/User';
import { UserRepository } from '@db/repositories/user.repository';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { InternalHooks } from '@/InternalHooks';
import { ExternalHooks } from '@/ExternalHooks';
@@ -55,7 +55,7 @@ export class InvitationController {
this.logger.debug(
'Request to send email invite(s) to user(s) failed because the user limit quota has been reached',
);
throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
}
if (!config.getEnv('userManagement.isInstanceOwnerSetUp')) {
@@ -98,7 +98,7 @@ export class InvitationController {
}
if (invite.role === 'global:admin' && !this.license.isAdvancedPermissionsLicensed()) {
throw new UnauthorizedError(
throw new ForbiddenError(
'Cannot invite admin user without advanced permissions. Please upgrade to a license that includes this feature.',
);
}
@@ -47,6 +47,7 @@ export abstract class AbstractOAuthController {
const credential = await this.sharedCredentialsRepository.findCredentialForUser(
credentialId,
req.user,
['credential:read'],
);
if (!credential) {
@@ -17,7 +17,7 @@ import { InternalHooks } from '@/InternalHooks';
import { UrlService } from '@/services/url.service';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
import { UserRepository } from '@/databases/repositories/user.repository';
@@ -76,7 +76,7 @@ export class PasswordResetController {
this.logger.debug(
'Request to send password reset email failed because the user limit was reached',
);
throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
}
if (
isSamlCurrentAuthenticationMethod() &&
@@ -88,7 +88,7 @@ export class PasswordResetController {
this.logger.debug(
'Request to send password reset email failed because login is handled by SAML',
);
throw new UnauthorizedError(
throw new ForbiddenError(
'Login is handled by SAML. Please contact your Identity Provider to reset your password.',
);
}
@@ -163,7 +163,7 @@ export class PasswordResetController {
'Request to resolve password token failed because the user limit was reached',
{ userId: user.id },
);
throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
}
this.logger.info('Reset-password token resolved successfully', { userId: user.id });
@@ -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,
});
}
}
@@ -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),
})),
]),
);
}
}
+78 -104
View File
@@ -2,8 +2,6 @@ import { plainToInstance } from 'class-transformer';
import { AuthService } from '@/auth/auth.service';
import { User } from '@db/entities/User';
import { SharedCredentials } from '@db/entities/SharedCredentials';
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
import { GlobalScope, Delete, Get, RestController, Patch, Licensed } from '@/decorators';
import {
ListQuery,
@@ -11,7 +9,6 @@ import {
UserRoleChangePayload,
UserSettingsUpdatePayload,
} from '@/requests';
import { ActiveWorkflowManager } from '@/ActiveWorkflowManager';
import type { PublicUser, ITelemetryUserDeletionData } from '@/Interfaces';
import { AuthIdentity } from '@db/entities/AuthIdentity';
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
@@ -20,12 +17,17 @@ import { UserRepository } from '@db/repositories/user.repository';
import { UserService } from '@/services/user.service';
import { listQueryMiddleware } from '@/middlewares';
import { Logger } from '@/Logger';
import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ExternalHooks } from '@/ExternalHooks';
import { InternalHooks } from '@/InternalHooks';
import { validateEntity } from '@/GenericHelpers';
import { ProjectRepository } from '@/databases/repositories/project.repository';
import { Project } from '@/databases/entities/Project';
import { WorkflowService } from '@/workflows/workflow.service';
import { CredentialsService } from '@/credentials/credentials.service';
import { ProjectService } from '@/services/project.service';
@RestController('/users')
export class UsersController {
@@ -36,9 +38,12 @@ export class UsersController {
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly userRepository: UserRepository,
private readonly activeWorkflowManager: ActiveWorkflowManager,
private readonly authService: AuthService,
private readonly userService: UserService,
private readonly projectRepository: ProjectRepository,
private readonly workflowService: WorkflowService,
private readonly credentialsService: CredentialsService,
private readonly projectService: ProjectService,
) {}
static ERROR_MESSAGES = {
@@ -151,131 +156,92 @@ export class UsersController {
const { transferId } = req.query;
if (transferId === idToDelete) {
const userToDelete = await this.userRepository.findOneBy({ id: idToDelete });
if (!userToDelete) {
throw new NotFoundError(
'Request to delete a user failed because the user to delete was not found in DB',
);
}
const personalProjectToDelete = await this.projectRepository.getPersonalProjectForUserOrFail(
userToDelete.id,
);
if (transferId === personalProjectToDelete.id) {
throw new BadRequestError(
'Request to delete a user failed because the user to delete and the transferee are the same user',
);
}
const userIds = transferId ? [transferId, idToDelete] : [idToDelete];
const users = await this.userRepository.findManyByIds(userIds);
if (!users.length || (transferId && users.length !== 2)) {
throw new NotFoundError(
'Request to delete a user failed because the ID of the user to delete and/or the ID of the transferee were not found in DB',
);
}
const userToDelete = users.find((user) => user.id === req.params.id) as User;
const telemetryData: ITelemetryUserDeletionData = {
user_id: req.user.id,
target_user_old_status: userToDelete.isPending ? 'invited' : 'active',
target_user_id: idToDelete,
migration_strategy: transferId ? 'transfer_data' : 'delete_data',
};
telemetryData.migration_strategy = transferId ? 'transfer_data' : 'delete_data';
if (transferId) {
telemetryData.migration_user_id = transferId;
}
const transfereePersonalProject = await this.projectRepository.findOneBy({ id: transferId });
if (transferId) {
const transferee = users.find((user) => user.id === transferId);
await this.userService.getManager().transaction(async (transactionManager) => {
// Get all workflow ids belonging to user to delete
const sharedWorkflowIds = await transactionManager
.getRepository(SharedWorkflow)
.find({
select: ['workflowId'],
where: { userId: userToDelete.id, role: 'workflow:owner' },
})
.then((sharedWorkflows) => sharedWorkflows.map(({ workflowId }) => workflowId));
// Prevents issues with unique key constraints since user being assigned
// workflows and credentials might be a sharee
await this.sharedWorkflowRepository.deleteByIds(
transactionManager,
sharedWorkflowIds,
transferee,
if (!transfereePersonalProject) {
throw new NotFoundError(
'Request to delete a user failed because the transferee project was not found in DB',
);
}
// Transfer ownership of owned workflows
await transactionManager.update(
SharedWorkflow,
{ user: userToDelete, role: 'workflow:owner' },
{ user: transferee },
);
// Now do the same for creds
// Get all workflow ids belonging to user to delete
const sharedCredentialIds = await transactionManager
.getRepository(SharedCredentials)
.find({
select: ['credentialsId'],
where: { userId: userToDelete.id, role: 'credential:owner' },
})
.then((sharedCredentials) => sharedCredentials.map(({ credentialsId }) => credentialsId));
// Prevents issues with unique key constraints since user being assigned
// workflows and credentials might be a sharee
await this.sharedCredentialsRepository.deleteByIds(
transactionManager,
sharedCredentialIds,
transferee,
);
// Transfer ownership of owned credentials
await transactionManager.update(
SharedCredentials,
{ user: userToDelete, role: 'credential:owner' },
{ user: transferee },
);
await transactionManager.delete(AuthIdentity, { userId: userToDelete.id });
// This will remove all shared workflows and credentials not owned
await transactionManager.delete(User, { id: userToDelete.id });
const transferee = await this.userRepository.findOneByOrFail({
projectRelations: {
projectId: transfereePersonalProject.id,
role: 'project:personalOwner',
},
});
void this.internalHooks.onUserDeletion({
user: req.user,
telemetryData,
publicApi: false,
telemetryData.migration_user_id = transferee.id;
await this.userService.getManager().transaction(async (trx) => {
await this.workflowService.transferAll(
personalProjectToDelete.id,
transfereePersonalProject.id,
trx,
);
await this.credentialsService.transferAll(
personalProjectToDelete.id,
transfereePersonalProject.id,
trx,
);
});
await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]);
return { success: true };
await this.projectService.clearCredentialCanUseExternalSecretsCache(
transfereePersonalProject.id,
);
}
const [ownedSharedWorkflows, ownedSharedCredentials] = await Promise.all([
this.sharedWorkflowRepository.find({
relations: ['workflow'],
where: { userId: userToDelete.id, role: 'workflow:owner' },
select: { workflowId: true },
where: { projectId: personalProjectToDelete.id, role: 'workflow:owner' },
}),
this.sharedCredentialsRepository.find({
relations: ['credentials'],
where: { userId: userToDelete.id, role: 'credential:owner' },
relations: { credentials: true },
where: { projectId: personalProjectToDelete.id, role: 'credential:owner' },
}),
]);
await this.userService.getManager().transaction(async (transactionManager) => {
const ownedWorkflows = await Promise.all(
ownedSharedWorkflows.map(async ({ workflow }) => {
if (workflow.active) {
// deactivate before deleting
await this.activeWorkflowManager.remove(workflow.id);
}
return workflow;
}),
);
await transactionManager.remove(ownedWorkflows);
await transactionManager.remove(ownedSharedCredentials.map(({ credentials }) => credentials));
const ownedCredentials = ownedSharedCredentials.map(({ credentials }) => credentials);
await transactionManager.delete(AuthIdentity, { userId: userToDelete.id });
await transactionManager.delete(User, { id: userToDelete.id });
for (const { workflowId } of ownedSharedWorkflows) {
await this.workflowService.delete(userToDelete, workflowId);
}
for (const credential of ownedCredentials) {
await this.credentialsService.delete(credential);
}
await this.userService.getManager().transaction(async (trx) => {
await trx.delete(AuthIdentity, { userId: userToDelete.id });
await trx.delete(Project, { id: personalProjectToDelete.id });
await trx.delete(User, { id: userToDelete.id });
});
void this.internalHooks.onUserDeletion({
@@ -285,6 +251,7 @@ export class UsersController {
});
await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]);
return { success: true };
}
@@ -308,11 +275,11 @@ export class UsersController {
}
if (req.user.role === 'global:admin' && targetUser.role === 'global:owner') {
throw new UnauthorizedError(NO_ADMIN_ON_OWNER);
throw new ForbiddenError(NO_ADMIN_ON_OWNER);
}
if (req.user.role === 'global:owner' && targetUser.role === 'global:owner') {
throw new UnauthorizedError(NO_OWNER_ON_OWNER);
throw new ForbiddenError(NO_OWNER_ON_OWNER);
}
await this.userService.update(targetUser.id, { role: payload.newRoleName });
@@ -324,6 +291,13 @@ export class UsersController {
public_api: false,
});
const projects = await this.projectService.getUserOwnedOrAdminProjects(targetUser.id);
await Promise.all(
projects.map(
async (p) => await this.projectService.clearCredentialCanUseExternalSecretsCache(p.id),
),
);
return { success: true };
}
}
@@ -29,13 +29,15 @@ export class WorkflowStatisticsController {
*/
// TODO: move this into a new decorator `@ValidateWorkflowPermission`
@Middleware()
async hasWorkflowAccess(req: StatisticsRequest.GetOne, res: Response, next: NextFunction) {
async hasWorkflowAccess(req: StatisticsRequest.GetOne, _res: Response, next: NextFunction) {
const { user } = req;
const workflowId = req.params.id;
const hasAccess = await this.sharedWorkflowRepository.hasAccess(workflowId, user);
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [
'workflow:read',
]);
if (hasAccess) {
if (workflow) {
next();
} else {
this.logger.verbose('User attempted to read a workflow without permissions', {
@@ -1,41 +1,53 @@
import { deepCopy } from 'n8n-workflow';
import config from '@/config';
import { CredentialsService } from './credentials.service';
import { CredentialRequest, ListQuery } from '@/requests';
import { CredentialRequest } from '@/requests';
import { InternalHooks } from '@/InternalHooks';
import { Logger } from '@/Logger';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NamingService } from '@/services/naming.service';
import { License } from '@/License';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
import { OwnershipService } from '@/services/ownership.service';
import { EnterpriseCredentialsService } from './credentials.service.ee';
import { Delete, Get, Licensed, Patch, Post, Put, RestController } from '@/decorators';
import {
Delete,
Get,
Licensed,
Patch,
Post,
Put,
RestController,
ProjectScope,
} from '@/decorators';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { UserManagementMailer } from '@/UserManagement/email';
import * as Db from '@/Db';
import * as utils from '@/utils';
import { listQueryMiddleware } from '@/middlewares';
import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository';
import { In } from '@n8n/typeorm';
import { SharedCredentials } from '@/databases/entities/SharedCredentials';
import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository';
@RestController('/credentials')
export class CredentialsController {
constructor(
private readonly credentialsService: CredentialsService,
private readonly enterpriseCredentialsService: EnterpriseCredentialsService,
private readonly credentialsRepository: CredentialsRepository,
private readonly namingService: NamingService,
private readonly license: License,
private readonly logger: Logger,
private readonly ownershipService: OwnershipService,
private readonly internalHooks: InternalHooks,
private readonly userManagementMailer: UserManagementMailer,
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly projectRelationRepository: ProjectRelationRepository,
) {}
@Get('/', { middlewares: listQueryMiddleware })
async getMany(req: ListQuery.Request) {
async getMany(req: CredentialRequest.GetMany) {
return await this.credentialsService.getMany(req.user, {
listQueryOptions: req.listQueryOptions,
includeScopes: req.query.includeScopes,
});
}
@@ -48,128 +60,73 @@ export class CredentialsController {
};
}
@Get('/:id')
@Get('/:credentialId')
@ProjectScope('credential:read')
async getOne(req: CredentialRequest.Get) {
if (this.license.isSharingEnabled()) {
const { id: credentialId } = req.params;
const includeDecryptedData = req.query.includeData === 'true';
let credential = await this.credentialsRepository.findOne({
where: { id: credentialId },
relations: ['shared', 'shared.user'],
});
if (!credential) {
throw new NotFoundError(
'Could not load the credential. If you think this is an error, ask the owner to share it with you again',
);
}
const userSharing = credential.shared?.find((shared) => shared.user.id === req.user.id);
if (!userSharing && !req.user.hasGlobalScope('credential:read')) {
throw new UnauthorizedError('Forbidden.');
}
credential = this.ownershipService.addOwnedByAndSharedWith(credential);
// Below, if `userSharing` does not exist, it means this credential is being
// fetched by the instance owner or an admin. In this case, they get the full data
if (!includeDecryptedData || userSharing?.role === 'credential:user') {
const { data: _, ...rest } = credential;
return { ...rest };
}
const { data: _, ...rest } = credential;
const decryptedData = this.credentialsService.redact(
this.credentialsService.decrypt(credential),
credential,
const credentials = await this.enterpriseCredentialsService.getOne(
req.user,
req.params.credentialId,
// TODO: editor-ui is always sending this, maybe we can just rely on the
// the scopes and always decrypt the data if the user has the permissions
// to do so.
req.query.includeData === 'true',
);
return { data: decryptedData, ...rest };
const scopes = await this.credentialsService.getCredentialScopes(
req.user,
req.params.credentialId,
);
return { ...credentials, scopes };
}
// non-enterprise
const { id: credentialId } = req.params;
const includeDecryptedData = req.query.includeData === 'true';
const sharing = await this.credentialsService.getSharing(
const credentials = await this.credentialsService.getOne(
req.user,
credentialId,
{ allowGlobalScope: true, globalScope: 'credential:read' },
['credentials'],
req.params.credentialId,
req.query.includeData === 'true',
);
if (!sharing) {
throw new NotFoundError(`Credential with ID "${credentialId}" could not be found.`);
}
const { credentials: credential } = sharing;
const { data: _, ...rest } = credential;
if (!includeDecryptedData) {
return { ...rest };
}
const decryptedData = this.credentialsService.redact(
this.credentialsService.decrypt(credential),
credential,
const scopes = await this.credentialsService.getCredentialScopes(
req.user,
req.params.credentialId,
);
return { data: decryptedData, ...rest };
return { ...credentials, scopes };
}
// TODO: Write at least test cases for the failure paths.
@Post('/test')
async testCredentials(req: CredentialRequest.Test) {
if (this.license.isSharingEnabled()) {
const { credentials } = req.body;
const credentialId = credentials.id;
const { ownsCredential } = await this.enterpriseCredentialsService.isOwned(
req.user,
credentialId,
);
const sharing = await this.enterpriseCredentialsService.getSharing(req.user, credentialId, {
allowGlobalScope: true,
globalScope: 'credential:read',
});
if (!ownsCredential) {
if (!sharing) {
throw new UnauthorizedError('Forbidden');
}
const decryptedData = this.credentialsService.decrypt(sharing.credentials);
Object.assign(credentials, { data: decryptedData });
}
const mergedCredentials = deepCopy(credentials);
if (mergedCredentials.data && sharing?.credentials) {
const decryptedData = this.credentialsService.decrypt(sharing.credentials);
mergedCredentials.data = this.credentialsService.unredact(
mergedCredentials.data,
decryptedData,
);
}
return await this.credentialsService.test(req.user, mergedCredentials);
}
// non-enterprise
const { credentials } = req.body;
const sharing = await this.credentialsService.getSharing(req.user, credentials.id, {
allowGlobalScope: true,
globalScope: 'credential:read',
});
const storedCredential = await this.sharedCredentialsRepository.findCredentialForUser(
credentials.id,
req.user,
['credential:read'],
);
if (!storedCredential) {
throw new ForbiddenError();
}
const mergedCredentials = deepCopy(credentials);
if (mergedCredentials.data && sharing?.credentials) {
const decryptedData = this.credentialsService.decrypt(sharing.credentials);
const decryptedData = this.credentialsService.decrypt(storedCredential);
// When a sharee opens a credential, the fields and the credential data are missing
// so the payload will be empty
// We need to replace the credential contents with the db version if that's the case
// So the credential can be tested properly
this.credentialsService.replaceCredentialContentsForSharee(
req.user,
storedCredential,
decryptedData,
mergedCredentials,
);
if (mergedCredentials.data && storedCredential) {
mergedCredentials.data = this.credentialsService.unredact(
mergedCredentials.data,
decryptedData,
@@ -184,7 +141,12 @@ export class CredentialsController {
const newCredential = await this.credentialsService.prepareCreateData(req.body);
const encryptedData = this.credentialsService.createEncryptedData(null, newCredential);
const credential = await this.credentialsService.save(newCredential, encryptedData, req.user);
const credential = await this.credentialsService.save(
newCredential,
encryptedData,
req.user,
req.body.projectId,
);
void this.internalHooks.onUserCreatedCredentials({
user: req.user,
@@ -194,24 +156,23 @@ export class CredentialsController {
public_api: false,
});
return credential;
const scopes = await this.credentialsService.getCredentialScopes(req.user, credential.id);
return { ...credential, scopes };
}
@Patch('/:id')
@Patch('/:credentialId')
@ProjectScope('credential:update')
async updateCredentials(req: CredentialRequest.Update) {
const { id: credentialId } = req.params;
const { credentialId } = req.params;
const sharing = await this.credentialsService.getSharing(
req.user,
const credential = await this.sharedCredentialsRepository.findCredentialForUser(
credentialId,
{
allowGlobalScope: true,
globalScope: 'credential:update',
},
['credentials'],
req.user,
['credential:update'],
);
if (!sharing) {
if (!credential) {
this.logger.info('Attempt to update credential blocked due to lack of permissions', {
credentialId,
userId: req.user.id,
@@ -221,16 +182,6 @@ export class CredentialsController {
);
}
if (sharing.role !== 'credential:owner' && !req.user.hasGlobalScope('credential:update')) {
this.logger.info('Attempt to update credential blocked due to lack of permissions', {
credentialId,
userId: req.user.id,
});
throw new UnauthorizedError('You can only update credentials owned by you');
}
const { credentials: credential } = sharing;
const decryptedData = this.credentialsService.decrypt(credential);
const preparedCredentialData = await this.credentialsService.prepareUpdateData(
req.body,
@@ -259,24 +210,23 @@ export class CredentialsController {
credential_id: credential.id,
});
return { ...rest };
const scopes = await this.credentialsService.getCredentialScopes(req.user, credential.id);
return { ...rest, scopes };
}
@Delete('/:id')
@Delete('/:credentialId')
@ProjectScope('credential:delete')
async deleteCredentials(req: CredentialRequest.Delete) {
const { id: credentialId } = req.params;
const { credentialId } = req.params;
const sharing = await this.credentialsService.getSharing(
req.user,
const credential = await this.sharedCredentialsRepository.findCredentialForUser(
credentialId,
{
allowGlobalScope: true,
globalScope: 'credential:delete',
},
['credentials'],
req.user,
['credential:delete'],
);
if (!sharing) {
if (!credential) {
this.logger.info('Attempt to delete credential blocked due to lack of permissions', {
credentialId,
userId: req.user.id,
@@ -286,16 +236,6 @@ export class CredentialsController {
);
}
if (sharing.role !== 'credential:owner' && !req.user.hasGlobalScope('credential:delete')) {
this.logger.info('Attempt to delete credential blocked due to lack of permissions', {
credentialId,
userId: req.user.id,
});
throw new UnauthorizedError('You can only remove credentials owned by you');
}
const { credentials: credential } = sharing;
await this.credentialsService.delete(credential);
void this.internalHooks.onUserDeletedCredentials({
@@ -309,9 +249,10 @@ export class CredentialsController {
}
@Licensed('feat:sharing')
@Put('/:id/share')
@Put('/:credentialId/share')
@ProjectScope('credential:share')
async shareCredentials(req: CredentialRequest.Share) {
const { id: credentialId } = req.params;
const { credentialId } = req.params;
const { shareWithIds } = req.body;
if (
@@ -321,59 +262,45 @@ export class CredentialsController {
throw new BadRequestError('Bad request');
}
const isOwnedRes = await this.enterpriseCredentialsService.isOwned(req.user, credentialId);
const { ownsCredential } = isOwnedRes;
let { credential } = isOwnedRes;
if (!ownsCredential || !credential) {
credential = undefined;
// Allow owners/admins to share
if (req.user.hasGlobalScope('credential:share')) {
const sharedRes = await this.enterpriseCredentialsService.getSharing(
req.user,
credentialId,
{
allowGlobalScope: true,
globalScope: 'credential:share',
},
);
credential = sharedRes?.credentials;
}
if (!credential) {
throw new UnauthorizedError('Forbidden');
}
}
const credential = await this.sharedCredentialsRepository.findCredentialForUser(
credentialId,
req.user,
['credential:share'],
);
const ownerIds = (
await this.enterpriseCredentialsService.getSharings(
Db.getConnection().createEntityManager(),
credentialId,
['shared'],
)
)
.filter((e) => e.role === 'credential:owner')
.map((e) => e.userId);
if (!credential) {
throw new ForbiddenError();
}
let amountRemoved: number | null = null;
let newShareeIds: string[] = [];
await Db.transaction(async (trx) => {
// remove all sharings that are not supposed to exist anymore
const { affected } = await this.credentialsRepository.pruneSharings(trx, credentialId, [
...ownerIds,
...shareWithIds,
]);
if (affected) amountRemoved = affected;
const currentPersonalProjectIDs = credential.shared
.filter((sc) => sc.role === 'credential:user')
.map((sc) => sc.projectId);
const newPersonalProjectIds = shareWithIds;
const sharings = await this.enterpriseCredentialsService.getSharings(trx, credentialId);
// extract the new sharings that need to be added
newShareeIds = utils.rightDiff(
[sharings, (sharing) => sharing.userId],
[shareWithIds, (shareeId) => shareeId],
const toShare = utils.rightDiff(
[currentPersonalProjectIDs, (id) => id],
[newPersonalProjectIds, (id) => id],
);
const toUnshare = utils.rightDiff(
[newPersonalProjectIds, (id) => id],
[currentPersonalProjectIDs, (id) => id],
);
if (newShareeIds.length) {
await this.enterpriseCredentialsService.share(trx, credential, newShareeIds);
const deleteResult = await trx.delete(SharedCredentials, {
credentialsId: credentialId,
projectId: In(toUnshare),
});
await this.enterpriseCredentialsService.shareWithProjects(credential, toShare, trx);
if (deleteResult.affected) {
amountRemoved = deleteResult.affected;
}
newShareeIds = toShare;
});
void this.internalHooks.onUserSharedCredentials({
@@ -386,9 +313,14 @@ export class CredentialsController {
sharees_removed: amountRemoved,
});
const projectsRelations = await this.projectRelationRepository.findBy({
projectId: In(newShareeIds),
role: 'project:personalOwner',
});
await this.userManagementMailer.notifyCredentialsShared({
sharer: req.user,
newShareeIds,
newShareeIds: projectsRelations.map((pr) => pr.userId),
credentialsName: credential.name,
});
}
@@ -1,77 +1,94 @@
import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm';
import type { SharedCredentials } from '@db/entities/SharedCredentials';
import { In, type EntityManager } from '@n8n/typeorm';
import type { User } from '@db/entities/User';
import { type CredentialsGetSharedOptions } from './credentials.service';
import { CredentialsService } from './credentials.service';
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
import { UserRepository } from '@/databases/repositories/user.repository';
import { CredentialsEntity } from '@/databases/entities/CredentialsEntity';
import type { CredentialsEntity } from '@/databases/entities/CredentialsEntity';
import { Service } from 'typedi';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { OwnershipService } from '@/services/ownership.service';
import { Project } from '@/databases/entities/Project';
@Service()
export class EnterpriseCredentialsService {
constructor(
private readonly userRepository: UserRepository,
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly ownershipService: OwnershipService,
private readonly credentialsService: CredentialsService,
) {}
async isOwned(user: User, credentialId: string) {
const sharing = await this.getSharing(user, credentialId, { allowGlobalScope: false }, [
'credentials',
]);
if (!sharing || sharing.role !== 'credential:owner') return { ownsCredential: false };
const { credentials: credential } = sharing;
return { ownsCredential: true, credential };
}
/**
* Retrieve the sharing that matches a user and a credential.
*/
async getSharing(
user: User,
credentialId: string,
options: CredentialsGetSharedOptions,
relations: string[] = ['credentials'],
async shareWithProjects(
credential: CredentialsEntity,
shareWithIds: string[],
entityManager?: EntityManager,
) {
const where: FindOptionsWhere<SharedCredentials> = { credentialsId: credentialId };
const em = entityManager ?? this.sharedCredentialsRepository.manager;
// Omit user from where if the requesting user has relevant
// global credential permissions. This allows the user to
// access credentials they don't own.
if (!options.allowGlobalScope || !user.hasGlobalScope(options.globalScope)) {
where.userId = user.id;
}
return await this.sharedCredentialsRepository.findOne({
where,
relations,
});
}
async getSharings(transaction: EntityManager, credentialId: string, relations = ['shared']) {
const credential = await transaction.findOne(CredentialsEntity, {
where: { id: credentialId },
relations,
const projects = await em.find(Project, {
where: { id: In(shareWithIds), type: 'personal' },
});
return credential?.shared ?? [];
}
async share(transaction: EntityManager, credential: CredentialsEntity, shareWithIds: string[]) {
const users = await this.userRepository.getByIds(transaction, shareWithIds);
const newSharedCredentials = users
.filter((user) => !user.isPending)
.map((user) =>
const newSharedCredentials = projects
// We filter by role === 'project:personalOwner' above and there should
// always only be one owner.
.map((project) =>
this.sharedCredentialsRepository.create({
credentialsId: credential.id,
userId: user.id,
role: 'credential:user',
projectId: project.id,
}),
);
return await transaction.save(newSharedCredentials);
return await em.save(newSharedCredentials);
}
async getOne(user: User, credentialId: string, includeDecryptedData: boolean) {
let credential: CredentialsEntity | null = null;
let decryptedData: ICredentialDataDecryptedObject | null = null;
credential = includeDecryptedData
? // Try to get the credential with `credential:update` scope, which
// are required for decrypting the data.
await this.sharedCredentialsRepository.findCredentialForUser(
credentialId,
user,
// TODO: replace credential:update with credential:decrypt once it lands
// see: https://n8nio.slack.com/archives/C062YRE7EG4/p1708531433206069?thread_ts=1708525972.054149&cid=C062YRE7EG4
['credential:read', 'credential:update'],
)
: null;
if (credential) {
// Decrypt the data if we found the credential with the `credential:update`
// scope.
decryptedData = this.credentialsService.redact(
this.credentialsService.decrypt(credential),
credential,
);
} else {
// Otherwise try to find them with only the `credential:read` scope. In
// that case we return them without the decrypted data.
credential = await this.sharedCredentialsRepository.findCredentialForUser(
credentialId,
user,
['credential:read'],
);
}
if (!credential) {
throw new NotFoundError(
'Could not load the credential. If you think this is an error, ask the owner to share it with you again',
);
}
credential = this.ownershipService.addOwnedByAndSharedWith(credential);
const { data: _, ...rest } = credential;
if (decryptedData) {
return { data: decryptedData, ...rest };
}
return { ...rest };
}
}
@@ -5,8 +5,13 @@ import type {
ICredentialType,
INodeProperties,
} from 'n8n-workflow';
import { CREDENTIAL_EMPTY_VALUE, deepCopy, NodeHelpers } from 'n8n-workflow';
import type { FindOptionsWhere } from '@n8n/typeorm';
import { ApplicationError, CREDENTIAL_EMPTY_VALUE, deepCopy, NodeHelpers } from 'n8n-workflow';
import {
In,
type EntityManager,
type FindOptionsRelations,
type FindOptionsWhere,
} from '@n8n/typeorm';
import type { Scope } from '@n8n/permissions';
import * as Db from '@/Db';
import type { ICredentialsDb } from '@/Interfaces';
@@ -25,6 +30,12 @@ import { CredentialsRepository } from '@db/repositories/credentials.repository';
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
import { Service } from 'typedi';
import { CredentialsTester } from '@/services/credentials-tester.service';
import { ProjectRepository } from '@/databases/repositories/project.repository';
import { ProjectService } from '@/services/project.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { ProjectRelation } from '@/databases/entities/ProjectRelation';
import { RoleService } from '@/services/role.service';
export type CredentialsGetSharedOptions =
| { allowGlobalScope: true; globalScope: Scope }
@@ -40,62 +51,129 @@ export class CredentialsService {
private readonly credentialsTester: CredentialsTester,
private readonly externalHooks: ExternalHooks,
private readonly credentialTypes: CredentialTypes,
private readonly projectRepository: ProjectRepository,
private readonly projectService: ProjectService,
private readonly roleService: RoleService,
) {}
async get(where: FindOptionsWhere<ICredentialsDb>, options?: { relations: string[] }) {
return await this.credentialsRepository.findOne({
relations: options?.relations,
where,
});
}
async getMany(
user: User,
options: { listQueryOptions?: ListQuery.Options; onlyOwn?: boolean } = {},
options: {
listQueryOptions?: ListQuery.Options;
onlyOwn?: boolean;
includeScopes?: string;
} = {},
) {
const returnAll = user.hasGlobalScope('credential:list') && !options.onlyOwn;
const isDefaultSelect = !options.listQueryOptions?.select;
if (returnAll) {
const credentials = await this.credentialsRepository.findMany(options.listQueryOptions);
return isDefaultSelect
? credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c))
: credentials;
let projectRelations: ProjectRelation[] | undefined = undefined;
if (options.includeScopes) {
projectRelations = await this.projectService.getProjectRelationsForUser(user);
if (options.listQueryOptions?.filter?.projectId && user.hasGlobalScope('credential:list')) {
// Only instance owners and admins have the credential:list scope
// Those users should be able to use _all_ credentials within their workflows.
// TODO: Change this so we filter by `workflowId` in this case. Require a slight FE change
const projectRelation = projectRelations.find(
(relation) => relation.projectId === options.listQueryOptions?.filter?.projectId,
);
if (projectRelation?.role === 'project:personalOwner') {
// Will not affect team projects as these have admins, not owners.
delete options.listQueryOptions?.filter?.projectId;
}
}
}
const ids = await this.sharedCredentialsRepository.getAccessibleCredentialIds([user.id]);
if (returnAll) {
let credentials = await this.credentialsRepository.findMany(options.listQueryOptions);
const credentials = await this.credentialsRepository.findMany(
if (isDefaultSelect) {
credentials = credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c));
}
if (options.includeScopes) {
credentials = credentials.map((c) =>
this.roleService.addScopes(c, user, projectRelations!),
);
}
credentials.forEach((c) => {
// @ts-expect-error: This is to emulate the old behaviour of removing the shared
// field as part of `addOwnedByAndSharedWith`. We need this field in `addScopes`
// though. So to avoid leaking the information we just delete it.
delete c.shared;
});
return credentials;
}
// If the workflow is part of a personal project we want to show the credentials the user making the request has access to, not the credentials the user owning the workflow has access to.
if (typeof options.listQueryOptions?.filter?.projectId === 'string') {
const project = await this.projectService.getProject(
options.listQueryOptions.filter.projectId,
);
if (project?.type === 'personal') {
const currentUsersPersonalProject = await this.projectService.getPersonalProject(user);
options.listQueryOptions.filter.projectId = currentUsersPersonalProject?.id;
}
}
const ids = await this.sharedCredentialsRepository.getCredentialIdsByUserAndRole([user.id], {
scopes: ['credential:read'],
});
let credentials = await this.credentialsRepository.findMany(
options.listQueryOptions,
ids, // only accessible credentials
);
return isDefaultSelect
? credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c))
: credentials;
if (isDefaultSelect) {
credentials = credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c));
}
if (options.includeScopes) {
credentials = credentials.map((c) => this.roleService.addScopes(c, user, projectRelations!));
}
credentials.forEach((c) => {
// @ts-expect-error: This is to emulate the old behaviour of removing the shared
// field as part of `addOwnedByAndSharedWith`. We need this field in `addScopes`
// though. So to avoid leaking the information we just delete it.
delete c.shared;
});
return credentials;
}
/**
* Retrieve the sharing that matches a user and a credential.
*/
// TODO: move to SharedCredentialsService
async getSharing(
user: User,
credentialId: string,
options: CredentialsGetSharedOptions,
relations: string[] = ['credentials'],
globalScopes: Scope[],
relations: FindOptionsRelations<SharedCredentials> = { credentials: true },
): Promise<SharedCredentials | null> {
const where: FindOptionsWhere<SharedCredentials> = { credentialsId: credentialId };
let where: FindOptionsWhere<SharedCredentials> = { credentialsId: credentialId };
// Omit user from where if the requesting user has relevant
// global credential permissions. This allows the user to
// access credentials they don't own.
if (!options.allowGlobalScope || !user.hasGlobalScope(options.globalScope)) {
where.userId = user.id;
where.role = 'credential:owner';
if (!user.hasGlobalScope(globalScopes, { mode: 'allOf' })) {
where = {
...where,
role: 'credential:owner',
project: {
projectRelations: {
role: 'project:personalOwner',
userId: user.id,
},
},
};
}
return await this.sharedCredentialsRepository.findOne({ where, relations });
return await this.sharedCredentialsRepository.findOne({
where,
relations,
});
}
async prepareCreateData(
@@ -128,7 +206,7 @@ export class CredentialsService {
await validateEntity(updateData);
// Do not overwrite the oauth data else data like the access or refresh token would get lost
// everytime anybody changes anything on the credentials even if it is just the name.
// every time anybody changes anything on the credentials even if it is just the name.
if (decryptedData.oauthTokenData) {
// @ts-ignore
updateData.data.oauthTokenData = decryptedData.oauthTokenData;
@@ -165,7 +243,12 @@ export class CredentialsService {
return await this.credentialsRepository.findOneBy({ id: credentialId });
}
async save(credential: CredentialsEntity, encryptedData: ICredentialsDb, user: User) {
async save(
credential: CredentialsEntity,
encryptedData: ICredentialsDb,
user: User,
projectId?: string,
) {
// To avoid side effects
const newCredential = new CredentialsEntity();
Object.assign(newCredential, credential, encryptedData);
@@ -177,12 +260,31 @@ export class CredentialsService {
savedCredential.data = newCredential.data;
const newSharedCredential = new SharedCredentials();
const project =
projectId === undefined
? await this.projectRepository.getPersonalProjectForUserOrFail(user.id)
: await this.projectService.getProjectWithScope(
user,
projectId,
['credential:create'],
transactionManager,
);
Object.assign(newSharedCredential, {
if (typeof projectId === 'string' && project === null) {
throw new BadRequestError(
"You don't have the permissions to save the workflow in this project.",
);
}
// Safe guard in case the personal project does not exist for whatever reason.
if (project === null) {
throw new ApplicationError('No personal project found');
}
const newSharedCredential = this.sharedCredentialsRepository.create({
role: 'credential:owner',
user,
credentials: savedCredential,
projectId: project.id,
});
await transactionManager.save<SharedCredentials>(newSharedCredential);
@@ -295,4 +397,134 @@ export class CredentialsService {
this.unredactRestoreValues(mergedData, savedData);
return mergedData;
}
async getOne(user: User, credentialId: string, includeDecryptedData: boolean) {
let sharing: SharedCredentials | null = null;
let decryptedData: ICredentialDataDecryptedObject | null = null;
sharing = includeDecryptedData
? // Try to get the credential with `credential:update` scope, which
// are required for decrypting the data.
await this.getSharing(user, credentialId, [
'credential:read',
// TODO: Enable this once the scope exists and has been added to the
// global:owner role.
// 'credential:decrypt',
])
: null;
if (sharing) {
// Decrypt the data if we found the credential with the `credential:update`
// scope.
decryptedData = this.redact(this.decrypt(sharing.credentials), sharing.credentials);
} else {
// Otherwise try to find them with only the `credential:read` scope. In
// that case we return them without the decrypted data.
sharing = await this.getSharing(user, credentialId, ['credential:read']);
}
if (!sharing) {
throw new NotFoundError(`Credential with ID "${credentialId}" could not be found.`);
}
const { credentials: credential } = sharing;
const { data: _, ...rest } = credential;
if (decryptedData) {
return { data: decryptedData, ...rest };
}
return { ...rest };
}
async getCredentialScopes(user: User, credentialId: string): Promise<Scope[]> {
const userProjectRelations = await this.projectService.getProjectRelationsForUser(user);
const shared = await this.sharedCredentialsRepository.find({
where: {
projectId: In([...new Set(userProjectRelations.map((pr) => pr.projectId))]),
credentialsId: credentialId,
},
});
return this.roleService.combineResourceScopes('credential', user, shared, userProjectRelations);
}
/**
* Transfers all credentials owned by a project to another one.
* This has only been tested for personal projects. It may need to be amended
* for team projects.
**/
async transferAll(fromProjectId: string, toProjectId: string, trx?: EntityManager) {
trx = trx ?? this.credentialsRepository.manager;
// Get all shared credentials for both projects.
const allSharedCredentials = await trx.findBy(SharedCredentials, {
projectId: In([fromProjectId, toProjectId]),
});
const sharedCredentialsOfFromProject = allSharedCredentials.filter(
(sc) => sc.projectId === fromProjectId,
);
// For all credentials that the from-project owns transfer the ownership
// to the to-project.
// This will override whatever relationship the to-project already has to
// the resources at the moment.
const ownedCredentialIds = sharedCredentialsOfFromProject
.filter((sc) => sc.role === 'credential:owner')
.map((sc) => sc.credentialsId);
await this.sharedCredentialsRepository.makeOwner(ownedCredentialIds, toProjectId, trx);
// Delete the relationship to the from-project.
await this.sharedCredentialsRepository.deleteByIds(ownedCredentialIds, fromProjectId, trx);
// Transfer relationships that are not `credential:owner`.
// This will NOT override whatever relationship the to-project already has
// to the resource at the moment.
const sharedCredentialIdsOfTransferee = allSharedCredentials
.filter((sc) => sc.projectId === toProjectId)
.map((sc) => sc.credentialsId);
// All resources that are shared with the from-project, but not with the
// to-project.
const sharedCredentialsToTransfer = sharedCredentialsOfFromProject.filter(
(sc) =>
sc.role !== 'credential:owner' &&
!sharedCredentialIdsOfTransferee.includes(sc.credentialsId),
);
await trx.insert(
SharedCredentials,
sharedCredentialsToTransfer.map((sc) => ({
credentialsId: sc.credentialsId,
projectId: toProjectId,
role: sc.role,
})),
);
}
replaceCredentialContentsForSharee(
user: User,
credential: CredentialsEntity,
decryptedData: ICredentialDataDecryptedObject,
mergedCredentials: ICredentialsDecrypted,
) {
credential.shared.forEach((sharedCredentials) => {
if (sharedCredentials.role === 'credential:owner') {
if (sharedCredentials.project.type === 'personal') {
// Find the owner of this personal project
sharedCredentials.project.projectRelations.forEach((projectRelation) => {
if (
projectRelation.role === 'project:personalOwner' &&
projectRelation.user.id !== user.id
) {
// If we realize that the current user does not own this credential
// We replace the payload with the stored decrypted data
mergedCredentials.data = decryptedData;
}
});
}
}
});
}
}
+2
View File
@@ -11,6 +11,7 @@ import { ApplicationError } from 'n8n-workflow';
import config from '@/config';
import { entities } from './entities';
import { subscribers } from './subscribers';
import { mysqlMigrations } from './migrations/mysqldb';
import { postgresMigrations } from './migrations/postgresdb';
import { sqliteMigrations } from './migrations/sqlite';
@@ -32,6 +33,7 @@ const getCommonOptions = () => {
return {
entityPrefix,
entities: Object.values(entities),
subscribers: Object.values(subscribers),
migrationsTableName: `${entityPrefix}migrations`,
migrationsRun: false,
synchronize: false,
+4 -2
View File
@@ -94,9 +94,11 @@ export class Column {
options.type = isPostgres ? 'timestamptz' : 'datetime';
} else if (type === 'json' && isSqlite) {
options.type = 'text';
} else if (type === 'uuid' && isMysql) {
} else if (type === 'uuid') {
// mysql does not support uuid type
options.type = 'varchar(36)';
if (isMysql) options.type = 'varchar(36)';
// we haven't been defining length on "uuid" varchar on sqlite
if (isSqlite) options.type = 'varchar';
}
if ((type === 'varchar' || type === 'timestamp') && length !== 'auto') {
+8 -1
View File
@@ -46,7 +46,13 @@ export class CreateTable extends TableOperation {
withForeignKey(
columnName: string,
ref: { tableName: string; columnName: string; onDelete?: 'CASCADE'; onUpdate?: 'CASCADE' },
ref: {
tableName: string;
columnName: string;
onDelete?: 'CASCADE';
onUpdate?: 'CASCADE';
name?: string;
},
) {
const foreignKey: TableForeignKeyOptions = {
columnNames: [columnName],
@@ -55,6 +61,7 @@ export class CreateTable extends TableOperation {
};
if (ref.onDelete) foreignKey.onDelete = ref.onDelete;
if (ref.onUpdate) foreignKey.onUpdate = ref.onUpdate;
if (ref.name) foreignKey.name = ref.name;
this.foreignKeys.add(foreignKey);
return this;
}
@@ -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[];
}
@@ -0,0 +1,25 @@
import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import { User } from './User';
import { WithTimestamps } from './AbstractEntity';
import { Project } from './Project';
// personalOwner is only used for personal projects
export type ProjectRole = 'project:personalOwner' | 'project:admin' | 'project:editor';
@Entity()
export class ProjectRelation extends WithTimestamps {
@Column()
role: ProjectRole;
@ManyToOne('User', 'projectRelations')
user: User;
@PrimaryColumn('uuid')
userId: string;
@ManyToOne('Project', 'projectRelations')
project: Project;
@PrimaryColumn()
projectId: string;
}
@@ -1,7 +1,7 @@
import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import { CredentialsEntity } from './CredentialsEntity';
import { User } from './User';
import { WithTimestamps } from './AbstractEntity';
import { Project } from './Project';
export type CredentialSharingRole = 'credential:owner' | 'credential:user';
@@ -10,15 +10,15 @@ export class SharedCredentials extends WithTimestamps {
@Column()
role: CredentialSharingRole;
@ManyToOne('User', 'sharedCredentials')
user: User;
@PrimaryColumn()
userId: string;
@ManyToOne('CredentialsEntity', 'shared')
credentials: CredentialsEntity;
@PrimaryColumn()
credentialsId: string;
@ManyToOne('Project', 'sharedCredentials')
project: Project;
@PrimaryColumn()
projectId: string;
}
@@ -1,24 +1,24 @@
import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import { WorkflowEntity } from './WorkflowEntity';
import { User } from './User';
import { WithTimestamps } from './AbstractEntity';
import { Project } from './Project';
export type WorkflowSharingRole = 'workflow:owner' | 'workflow:editor' | 'workflow:user';
export type WorkflowSharingRole = 'workflow:owner' | 'workflow:editor';
@Entity()
export class SharedWorkflow extends WithTimestamps {
@Column()
role: WorkflowSharingRole;
@ManyToOne('User', 'sharedWorkflows')
user: User;
@PrimaryColumn()
userId: string;
@ManyToOne('WorkflowEntity', 'shared')
workflow: WorkflowEntity;
@PrimaryColumn()
workflowId: string;
@ManyToOne('Project', 'sharedWorkflows')
project: Project;
@PrimaryColumn()
projectId: string;
}
+23 -4
View File
@@ -18,16 +18,21 @@ import { objectRetriever, lowerCaser } from '../utils/transformers';
import { WithTimestamps, jsonColumnType } from './AbstractEntity';
import type { IPersonalizationSurveyAnswers } from '@/Interfaces';
import type { AuthIdentity } from './AuthIdentity';
import { ownerPermissions, memberPermissions, adminPermissions } from '@/permissions/roles';
import {
GLOBAL_OWNER_SCOPES,
GLOBAL_MEMBER_SCOPES,
GLOBAL_ADMIN_SCOPES,
} from '@/permissions/global-roles';
import { hasScope, type ScopeOptions, type Scope } from '@n8n/permissions';
import type { ProjectRelation } from './ProjectRelation';
export type GlobalRole = 'global:owner' | 'global:admin' | 'global:member';
export type AssignableRole = Exclude<GlobalRole, 'global:owner'>;
const STATIC_SCOPE_MAP: Record<GlobalRole, Scope[]> = {
'global:owner': ownerPermissions,
'global:member': memberPermissions,
'global:admin': adminPermissions,
'global:owner': GLOBAL_OWNER_SCOPES,
'global:member': GLOBAL_MEMBER_SCOPES,
'global:admin': GLOBAL_ADMIN_SCOPES,
};
@Entity()
@@ -85,6 +90,9 @@ export class User extends WithTimestamps implements IUser {
@OneToMany('SharedCredentials', 'user')
sharedCredentials: SharedCredentials[];
@OneToMany('ProjectRelation', 'user')
projectRelations: ProjectRelation[];
@Column({ type: Boolean, default: false })
disabled: boolean;
@@ -138,6 +146,7 @@ export class User extends WithTimestamps implements IUser {
{
global: this.globalScopes,
},
undefined,
scopeOptions,
);
}
@@ -146,4 +155,14 @@ export class User extends WithTimestamps implements IUser {
const { password, apiKey, mfaSecret, mfaRecoveryCodes, ...rest } = this;
return rest;
}
createPersonalProjectName() {
if (this.firstName && this.lastName && this.email) {
return `${this.firstName} ${this.lastName} <${this.email}>`;
} else if (this.email) {
return `<${this.email}>`;
} else {
return 'Unnamed Project';
}
}
}
@@ -19,6 +19,8 @@ import { WorkflowStatistics } from './WorkflowStatistics';
import { ExecutionMetadata } from './ExecutionMetadata';
import { ExecutionData } from './ExecutionData';
import { WorkflowHistory } from './WorkflowHistory';
import { Project } from './Project';
import { ProjectRelation } from './ProjectRelation';
export const entities = {
AuthIdentity,
@@ -41,4 +43,6 @@ export const entities = {
ExecutionMetadata,
ExecutionData,
WorkflowHistory,
Project,
ProjectRelation,
};
@@ -35,6 +35,11 @@ export class MoveSshKeysToDatabase1711390882123 implements ReversibleMigration {
return;
}
if (!privateKey && !publicKey) {
logger.info(`[${migrationName}] No SSH keys in filesystem, skipping`);
return;
}
const settings = escape.tableName('settings');
const key = escape.columnName('key');
const value = escape.columnName('value');
@@ -0,0 +1,328 @@
import type { MigrationContext, ReversibleMigration } from '@db/types';
import type { ProjectRole } from '@/databases/entities/ProjectRelation';
import type { User } from '@/databases/entities/User';
import { generateNanoId } from '@/databases/utils/generators';
import { ApplicationError } from 'n8n-workflow';
import { nanoid } from 'nanoid';
const projectAdminRole: ProjectRole = 'project:personalOwner';
type RelationTable = 'shared_workflow' | 'shared_credentials';
const table = {
sharedCredentials: 'shared_credentials',
sharedCredentialsTemp: 'shared_credentials_2',
sharedWorkflow: 'shared_workflow',
sharedWorkflowTemp: 'shared_workflow_2',
project: 'project',
user: 'user',
projectRelation: 'project_relation',
} as const;
function escapeNames(escape: MigrationContext['escape']) {
const t = {
project: escape.tableName(table.project),
projectRelation: escape.tableName(table.projectRelation),
sharedCredentials: escape.tableName(table.sharedCredentials),
sharedCredentialsTemp: escape.tableName(table.sharedCredentialsTemp),
sharedWorkflow: escape.tableName(table.sharedWorkflow),
sharedWorkflowTemp: escape.tableName(table.sharedWorkflowTemp),
user: escape.tableName(table.user),
};
const c = {
createdAt: escape.columnName('createdAt'),
updatedAt: escape.columnName('updatedAt'),
workflowId: escape.columnName('workflowId'),
credentialsId: escape.columnName('credentialsId'),
userId: escape.columnName('userId'),
projectId: escape.columnName('projectId'),
firstName: escape.columnName('firstName'),
lastName: escape.columnName('lastName'),
};
return { t, c };
}
export class CreateProject1714133768519 implements ReversibleMigration {
async setupTables({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(table.project).withColumns(
column('id').varchar(36).primary.notNull,
column('name').varchar(255).notNull,
column('type').varchar(36).notNull,
).withTimestamps;
await createTable(table.projectRelation)
.withColumns(
column('projectId').varchar(36).primary.notNull,
column('userId').uuid.primary.notNull,
column('role').varchar().notNull,
)
.withIndexOn('projectId')
.withIndexOn('userId')
.withForeignKey('projectId', {
tableName: table.project,
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
}
async alterSharedTable(
relationTableName: RelationTable,
{
escape,
isMysql,
runQuery,
schemaBuilder: { addForeignKey, addColumns, addNotNull, createIndex, column },
}: MigrationContext,
) {
const projectIdColumn = column('projectId').varchar(36).default('NULL');
await addColumns(relationTableName, [projectIdColumn]);
const relationTable = escape.tableName(relationTableName);
const { t, c } = escapeNames(escape);
// Populate projectId
const subQuery = `
SELECT P.id as ${c.projectId}, T.${c.userId}
FROM ${t.projectRelation} T
LEFT JOIN ${t.project} P
ON T.${c.projectId} = P.id AND P.type = 'personal'
LEFT JOIN ${relationTable} S
ON T.${c.userId} = S.${c.userId}
WHERE P.id IS NOT NULL
`;
const swQuery = isMysql
? `UPDATE ${relationTable}, (${subQuery}) as mapping
SET ${relationTable}.${c.projectId} = mapping.${c.projectId}
WHERE ${relationTable}.${c.userId} = mapping.${c.userId}`
: `UPDATE ${relationTable}
SET ${c.projectId} = mapping.${c.projectId}
FROM (${subQuery}) as mapping
WHERE ${relationTable}.${c.userId} = mapping.${c.userId}`;
await runQuery(swQuery);
await addForeignKey(relationTableName, 'projectId', ['project', 'id']);
await addNotNull(relationTableName, 'projectId');
// Index the new projectId column
await createIndex(relationTableName, ['projectId']);
}
async alterSharedCredentials({
escape,
runQuery,
schemaBuilder: { column, createTable, dropTable },
}: MigrationContext) {
await createTable(table.sharedCredentialsTemp)
.withColumns(
column('credentialsId').varchar(36).notNull.primary,
column('projectId').varchar(36).notNull.primary,
column('role').text.notNull,
)
.withForeignKey('credentialsId', {
tableName: 'credentials_entity',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('projectId', {
tableName: table.project,
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
const { c, t } = escapeNames(escape);
await runQuery(`
INSERT INTO ${t.sharedCredentialsTemp} (${c.createdAt}, ${c.updatedAt}, ${c.credentialsId}, ${c.projectId}, role)
SELECT ${c.createdAt}, ${c.updatedAt}, ${c.credentialsId}, ${c.projectId}, role FROM ${t.sharedCredentials};
`);
await dropTable(table.sharedCredentials);
await runQuery(`ALTER TABLE ${t.sharedCredentialsTemp} RENAME TO ${t.sharedCredentials};`);
}
async alterSharedWorkflow({
escape,
runQuery,
schemaBuilder: { column, createTable, dropTable },
}: MigrationContext) {
await createTable(table.sharedWorkflowTemp)
.withColumns(
column('workflowId').varchar(36).notNull.primary,
column('projectId').varchar(36).notNull.primary,
column('role').text.notNull,
)
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('projectId', {
tableName: table.project,
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
const { c, t } = escapeNames(escape);
await runQuery(`
INSERT INTO ${t.sharedWorkflowTemp} (${c.createdAt}, ${c.updatedAt}, ${c.workflowId}, ${c.projectId}, role)
SELECT ${c.createdAt}, ${c.updatedAt}, ${c.workflowId}, ${c.projectId}, role FROM ${t.sharedWorkflow};
`);
await dropTable(table.sharedWorkflow);
await runQuery(`ALTER TABLE ${t.sharedWorkflowTemp} RENAME TO ${t.sharedWorkflow};`);
}
async createUserPersonalProjects({ runQuery, runInBatches, escape }: MigrationContext) {
const { c, t } = escapeNames(escape);
const getUserQuery = `SELECT id, ${c.firstName}, ${c.lastName}, email FROM ${t.user}`;
await runInBatches<Pick<User, 'id' | 'firstName' | 'lastName' | 'email'>>(
getUserQuery,
async (users) => {
await Promise.all(
users.map(async (user) => {
const projectId = generateNanoId();
const name = this.createPersonalProjectName(user.firstName, user.lastName, user.email);
await runQuery(
`INSERT INTO ${t.project} (id, type, name) VALUES (:projectId, 'personal', :name)`,
{
projectId,
name,
},
);
await runQuery(
`INSERT INTO ${t.projectRelation} (${c.projectId}, ${c.userId}, role) VALUES (:projectId, :userId, :projectRole)`,
{
projectId,
userId: user.id,
projectRole: projectAdminRole,
},
);
}),
);
},
);
}
// Duplicated from packages/cli/src/databases/entities/User.ts
// Reason:
// This migration should work the same even if we refactor the function in
// `User.ts`.
createPersonalProjectName(firstName?: string, lastName?: string, email?: string) {
if (firstName && lastName && email) {
return `${firstName} ${lastName} <${email}>`;
} else if (email) {
return `<${email}>`;
} else {
return 'Unnamed Project';
}
}
async up(context: MigrationContext) {
await this.setupTables(context);
await this.createUserPersonalProjects(context);
await this.alterSharedTable(table.sharedCredentials, context);
await this.alterSharedCredentials(context);
await this.alterSharedTable(table.sharedWorkflow, context);
await this.alterSharedWorkflow(context);
}
async down({ isMysql, logger, escape, runQuery, schemaBuilder: sb }: MigrationContext) {
const { t, c } = escapeNames(escape);
// 0. check if all projects are personal projects
const [{ count: nonPersonalProjects }] = await runQuery<[{ count: number }]>(
`SELECT COUNT(*) FROM ${t.project} WHERE type <> 'personal';`,
);
if (nonPersonalProjects > 0) {
const message =
'Down migration only possible when there are no projects. Please delete all projects that were created via the UI first.';
logger.error(message);
throw new ApplicationError(message);
}
// 1. create temp table for shared workflows
await sb
.createTable(table.sharedWorkflowTemp)
.withColumns(
sb.column('workflowId').varchar(36).notNull.primary,
sb.column('userId').uuid.notNull.primary,
sb.column('role').text.notNull,
)
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE',
// In MySQL foreignKey names must be unique across all tables and
// TypeORM creates predictable names based on the columnName.
// So the current shared_workflow table's foreignKey for workflowId would
// clash with this one if we don't create a random name.
name: isMysql ? nanoid() : undefined,
})
.withForeignKey('userId', {
tableName: table.user,
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
// 2. migrate data into temp table
await runQuery(`
INSERT INTO ${t.sharedWorkflowTemp} (${c.createdAt}, ${c.updatedAt}, ${c.workflowId}, role, ${c.userId})
SELECT SW.${c.createdAt}, SW.${c.updatedAt}, SW.${c.workflowId}, SW.role, PR.${c.userId}
FROM ${t.sharedWorkflow} SW
LEFT JOIN project_relation PR on SW.${c.projectId} = PR.${c.projectId} AND PR.role = 'project:personalOwner'
`);
// 3. drop shared workflow table
await sb.dropTable(table.sharedWorkflow);
// 4. rename temp table
await runQuery(`ALTER TABLE ${t.sharedWorkflowTemp} RENAME TO ${t.sharedWorkflow};`);
// 5. same for shared creds
await sb
.createTable(table.sharedCredentialsTemp)
.withColumns(
sb.column('credentialsId').varchar(36).notNull.primary,
sb.column('userId').uuid.notNull.primary,
sb.column('role').text.notNull,
)
.withForeignKey('credentialsId', {
tableName: 'credentials_entity',
columnName: 'id',
onDelete: 'CASCADE',
// In MySQL foreignKey names must be unique across all tables and
// TypeORM creates predictable names based on the columnName.
// So the current shared_credentials table's foreignKey for credentialsId would
// clash with this one if we don't create a random name.
name: isMysql ? nanoid() : undefined,
})
.withForeignKey('userId', {
tableName: table.user,
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
await runQuery(`
INSERT INTO ${t.sharedCredentialsTemp} (${c.createdAt}, ${c.updatedAt}, ${c.credentialsId}, role, ${c.userId})
SELECT SC.${c.createdAt}, SC.${c.updatedAt}, SC.${c.credentialsId}, SC.role, PR.${c.userId}
FROM ${t.sharedCredentials} SC
LEFT JOIN project_relation PR on SC.${c.projectId} = PR.${c.projectId} AND PR.role = 'project:personalOwner'
`);
await sb.dropTable(table.sharedCredentials);
await runQuery(`ALTER TABLE ${t.sharedCredentialsTemp} RENAME TO ${t.sharedCredentials};`);
// 6. drop project and project relation table
await sb.dropTable(table.projectRelation);
await sb.dropTable(table.project);
}
}
@@ -51,6 +51,7 @@ import { ExecutionSoftDelete1693491613982 } from '../common/1693491613982-Execut
import { AddWorkflowMetadata1695128658538 } from '../common/1695128658538-AddWorkflowMetadata';
import { ModifyWorkflowHistoryNodesAndConnections1695829275184 } from '../common/1695829275184-ModifyWorkflowHistoryNodesAndConnections';
import { AddGlobalAdminRole1700571993961 } from '../common/1700571993961-AddGlobalAdminRole';
import { CreateProject1714133768519 } from '../common/1714133768519-CreateProject';
import { DropRoleMapping1705429061930 } from '../common/1705429061930-DropRoleMapping';
import { RemoveFailedExecutionStatus1711018413374 } from '../common/1711018413374-RemoveFailedExecutionStatus';
import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase';
@@ -113,4 +114,5 @@ export const mysqlMigrations: Migration[] = [
RemoveFailedExecutionStatus1711018413374,
MoveSshKeysToDatabase1711390882123,
RemoveNodesAccess1712044305787,
CreateProject1714133768519,
];
@@ -50,6 +50,7 @@ import { AddWorkflowMetadata1695128658538 } from '../common/1695128658538-AddWor
import { MigrateToTimestampTz1694091729095 } from './1694091729095-MigrateToTimestampTz';
import { ModifyWorkflowHistoryNodesAndConnections1695829275184 } from '../common/1695829275184-ModifyWorkflowHistoryNodesAndConnections';
import { AddGlobalAdminRole1700571993961 } from '../common/1700571993961-AddGlobalAdminRole';
import { CreateProject1714133768519 } from '../common/1714133768519-CreateProject';
import { DropRoleMapping1705429061930 } from '../common/1705429061930-DropRoleMapping';
import { RemoveFailedExecutionStatus1711018413374 } from '../common/1711018413374-RemoveFailedExecutionStatus';
import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase';
@@ -111,4 +112,5 @@ export const postgresMigrations: Migration[] = [
RemoveFailedExecutionStatus1711018413374,
MoveSshKeysToDatabase1711390882123,
RemoveNodesAccess1712044305787,
CreateProject1714133768519,
];
@@ -48,6 +48,7 @@ import { ExecutionSoftDelete1693491613982 } from './1693491613982-ExecutionSoftD
import { AddWorkflowMetadata1695128658538 } from '../common/1695128658538-AddWorkflowMetadata';
import { ModifyWorkflowHistoryNodesAndConnections1695829275184 } from '../common/1695829275184-ModifyWorkflowHistoryNodesAndConnections';
import { AddGlobalAdminRole1700571993961 } from '../common/1700571993961-AddGlobalAdminRole';
import { CreateProject1714133768519 } from '../common/1714133768519-CreateProject';
import { DropRoleMapping1705429061930 } from './1705429061930-DropRoleMapping';
import { RemoveFailedExecutionStatus1711018413374 } from '../common/1711018413374-RemoveFailedExecutionStatus';
import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase';
@@ -107,6 +108,7 @@ const sqliteMigrations: Migration[] = [
RemoveFailedExecutionStatus1711018413374,
MoveSshKeysToDatabase1711390882123,
RemoveNodesAccess1712044305787,
CreateProject1714133768519,
];
export { sqliteMigrations };
@@ -1,8 +1,7 @@
import { Service } from 'typedi';
import { DataSource, In, Not, Repository, Like } from '@n8n/typeorm';
import type { FindManyOptions, DeleteResult, EntityManager, FindOptionsWhere } from '@n8n/typeorm';
import { DataSource, In, Repository, Like } from '@n8n/typeorm';
import type { FindManyOptions } from '@n8n/typeorm';
import { CredentialsEntity } from '../entities/CredentialsEntity';
import { SharedCredentials } from '../entities/SharedCredentials';
import type { ListQuery } from '@/requests';
@Service()
@@ -11,18 +10,6 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
super(CredentialsEntity, dataSource.manager);
}
async pruneSharings(
transaction: EntityManager,
credentialId: string,
userIds: string[],
): Promise<DeleteResult> {
const conditions: FindOptionsWhere<SharedCredentials> = {
credentialsId: credentialId,
userId: Not(In(userIds)),
};
return await transaction.delete(SharedCredentials, conditions);
}
async findStartingWith(credentialName: string) {
return await this.find({
select: ['name'],
@@ -45,7 +32,7 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
type Select = Array<keyof CredentialsEntity>;
const defaultRelations = ['shared', 'shared.user'];
const defaultRelations = ['shared', 'shared.project'];
const defaultSelect: Select = ['id', 'name', 'type', 'createdAt', 'updatedAt'];
if (!listQueryOptions) return { select: defaultSelect, relations: defaultRelations };
@@ -60,6 +47,11 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
filter.type = Like(`%${filter.type}%`);
}
if (typeof filter?.projectId === 'string' && filter.projectId !== '') {
filter.shared = { projectId: filter.projectId };
delete filter.projectId;
}
if (filter) findManyOptions.where = filter;
if (select) findManyOptions.select = select;
if (take) findManyOptions.take = take;
@@ -81,7 +73,11 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
const findManyOptions: FindManyOptions<CredentialsEntity> = { where: { id: In(ids) } };
if (withSharings) {
findManyOptions.relations = ['shared', 'shared.user'];
findManyOptions.relations = {
shared: {
project: true,
},
};
}
return await this.find(findManyOptions);
@@ -0,0 +1,45 @@
import { Service } from 'typedi';
import type { EntityManager } from '@n8n/typeorm';
import { DataSource, Repository } from '@n8n/typeorm';
import { Project } from '../entities/Project';
@Service()
export class ProjectRepository extends Repository<Project> {
constructor(dataSource: DataSource) {
super(Project, dataSource.manager);
}
async getPersonalProjectForUser(userId: string, entityManager?: EntityManager) {
const em = entityManager ?? this.manager;
return await em.findOne(Project, {
where: { type: 'personal', projectRelations: { userId, role: 'project:personalOwner' } },
});
}
async getPersonalProjectForUserOrFail(userId: string) {
return await this.findOneOrFail({
where: { type: 'personal', projectRelations: { userId, role: 'project:personalOwner' } },
});
}
async getAccessibleProjects(userId: string) {
return await this.find({
where: [
{ type: 'personal' },
{
projectRelations: {
userId,
},
},
],
});
}
async getProjectCounts() {
return {
personal: await this.count({ where: { type: 'personal' } }),
team: await this.count({ where: { type: 'team' } }),
};
}
}
@@ -0,0 +1,55 @@
import { Service } from 'typedi';
import { DataSource, In, Repository } from '@n8n/typeorm';
import { ProjectRelation, type ProjectRole } from '../entities/ProjectRelation';
@Service()
export class ProjectRelationRepository extends Repository<ProjectRelation> {
constructor(dataSource: DataSource) {
super(ProjectRelation, dataSource.manager);
}
async getPersonalProjectOwners(projectIds: string[]) {
return await this.find({
where: {
projectId: In(projectIds),
role: 'project:personalOwner',
},
relations: { user: true },
});
}
async getPersonalProjectsForUsers(userIds: string[]) {
const projectRelations = await this.find({
where: {
userId: In(userIds),
role: 'project:personalOwner',
},
});
return projectRelations.map((pr) => pr.projectId);
}
/**
* Find the role of a user in a project.
*/
async findProjectRole({ userId, projectId }: { userId: string; projectId: string }) {
const relation = await this.findOneBy({ projectId, userId });
return relation?.role ?? null;
}
/** Counts the number of users in each role, e.g. `{ admin: 2, member: 6, owner: 1 }` */
async countUsersByRole() {
const rows = (await this.createQueryBuilder()
.select(['role', 'COUNT(role) as count'])
.groupBy('role')
.execute()) as Array<{ role: ProjectRole; count: string }>;
return rows.reduce(
(acc, row) => {
acc[row.role] = parseInt(row.count, 10);
return acc;
},
{} as Record<ProjectRole, number>,
);
}
}
@@ -1,22 +1,53 @@
import { Service } from 'typedi';
import type { EntityManager } from '@n8n/typeorm';
import type { EntityManager, FindOptionsRelations, FindOptionsWhere } from '@n8n/typeorm';
import { DataSource, In, Not, Repository } from '@n8n/typeorm';
import { type CredentialSharingRole, SharedCredentials } from '../entities/SharedCredentials';
import type { User } from '../entities/User';
import { RoleService } from '@/services/role.service';
import type { Scope } from '@n8n/permissions';
import type { Project } from '../entities/Project';
import type { ProjectRole } from '../entities/ProjectRelation';
@Service()
export class SharedCredentialsRepository extends Repository<SharedCredentials> {
constructor(dataSource: DataSource) {
constructor(
dataSource: DataSource,
private readonly roleService: RoleService,
) {
super(SharedCredentials, dataSource.manager);
}
/** Get a credential if it has been shared with a user */
async findCredentialForUser(credentialsId: string, user: User) {
async findCredentialForUser(
credentialsId: string,
user: User,
scopes: Scope[],
_relations?: FindOptionsRelations<SharedCredentials>,
) {
let where: FindOptionsWhere<SharedCredentials> = { credentialsId };
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes);
const credentialRoles = this.roleService.rolesWithScope('credential', scopes);
where = {
...where,
role: In(credentialRoles),
project: {
projectRelations: {
role: In(projectRoles),
userId: user.id,
},
},
};
}
const sharedCredential = await this.findOne({
relations: ['credentials'],
where: {
credentialsId,
...(!user.hasGlobalScope('credential:read') ? { userId: user.id } : {}),
where,
// TODO: write a small relations merger and use that one here
relations: {
credentials: {
shared: { project: { projectRelations: { user: true } } },
},
},
});
if (!sharedCredential) return null;
@@ -25,7 +56,7 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {
async findByCredentialIds(credentialIds: string[], role: CredentialSharingRole) {
return await this.find({
relations: ['credentials', 'user'],
relations: { credentials: true, project: { projectRelations: { user: true } } },
where: {
credentialsId: In(credentialIds),
role,
@@ -33,37 +64,91 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {
});
}
async makeOwnerOfAllCredentials(user: User) {
return await this.update({ userId: Not(user.id), role: 'credential:owner' }, { user });
async makeOwnerOfAllCredentials(project: Project) {
return await this.update(
{
projectId: Not(project.id),
role: 'credential:owner',
},
{ project },
);
}
/** Get the IDs of all credentials owned by a user */
async getOwnedCredentialIds(userIds: string[]) {
return await this.getCredentialIdsByUserAndRole(userIds, ['credential:owner']);
async makeOwner(credentialIds: string[], projectId: string, trx?: EntityManager) {
trx = trx ?? this.manager;
return await trx.upsert(
SharedCredentials,
credentialIds.map(
(credentialsId) =>
({
projectId,
credentialsId,
role: 'credential:owner',
}) as const,
),
['projectId', 'credentialsId'],
);
}
/** Get the IDs of all credentials owned by or shared with a user */
async getAccessibleCredentialIds(userIds: string[]) {
return await this.getCredentialIdsByUserAndRole(userIds, [
'credential:owner',
'credential:user',
]);
}
async getCredentialIdsByUserAndRole(
userIds: string[],
options:
| { scopes: Scope[] }
| { projectRoles: ProjectRole[]; credentialRoles: CredentialSharingRole[] },
) {
const projectRoles =
'scopes' in options
? this.roleService.rolesWithScope('project', options.scopes)
: options.projectRoles;
const credentialRoles =
'scopes' in options
? this.roleService.rolesWithScope('credential', options.scopes)
: options.credentialRoles;
private async getCredentialIdsByUserAndRole(userIds: string[], roles: CredentialSharingRole[]) {
const sharings = await this.find({
where: {
userId: In(userIds),
role: In(roles),
role: In(credentialRoles),
project: {
projectRelations: {
userId: In(userIds),
role: In(projectRoles),
},
},
},
});
return sharings.map((s) => s.credentialsId);
}
async deleteByIds(transaction: EntityManager, sharedCredentialsIds: string[], user?: User) {
return await transaction.delete(SharedCredentials, {
user,
async deleteByIds(sharedCredentialsIds: string[], projectId: string, trx?: EntityManager) {
trx = trx ?? this.manager;
return await trx.delete(SharedCredentials, {
projectId,
credentialsId: In(sharedCredentialsIds),
});
}
async getFilteredAccessibleCredentials(
projectIds: string[],
credentialsIds: string[],
): Promise<string[]> {
return (
await this.find({
where: {
projectId: In(projectIds),
credentialsId: In(credentialsIds),
},
select: ['credentialsId'],
})
).map((s) => s.credentialsId);
}
async findCredentialOwningProject(credentialsId: string) {
return (
await this.findOne({
where: { credentialsId, role: 'credential:owner' },
relations: { project: true },
})
)?.project;
}
}
@@ -4,33 +4,18 @@ import type { EntityManager, FindManyOptions, FindOptionsWhere } from '@n8n/type
import { SharedWorkflow, type WorkflowSharingRole } from '../entities/SharedWorkflow';
import { type User } from '../entities/User';
import type { Scope } from '@n8n/permissions';
import type { WorkflowEntity } from '../entities/WorkflowEntity';
import { RoleService } from '@/services/role.service';
import type { Project } from '../entities/Project';
@Service()
export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
constructor(dataSource: DataSource) {
constructor(
dataSource: DataSource,
private roleService: RoleService,
) {
super(SharedWorkflow, dataSource.manager);
}
async hasAccess(workflowId: string, user: User) {
const where: FindOptionsWhere<SharedWorkflow> = {
workflowId,
};
if (!user.hasGlobalScope('workflow:read')) {
where.userId = user.id;
}
return await this.exist({ where });
}
/** Get the IDs of all users this workflow is shared with */
async getSharedUserIds(workflowId: string) {
const sharedWorkflows = await this.find({
select: ['userId'],
where: { workflowId },
});
return sharedWorkflows.map((sharing) => sharing.userId);
}
async getSharedWorkflowIds(workflowIds: string[]) {
const sharedWorkflows = await this.find({
select: ['workflowId'],
@@ -43,11 +28,11 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
async findByWorkflowIds(workflowIds: string[]) {
return await this.find({
relations: ['user'],
where: {
role: 'workflow:owner',
workflowId: In(workflowIds),
},
relations: { project: { projectRelations: { user: true } } },
});
}
@@ -55,90 +40,49 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
userId: string,
workflowId: string,
): Promise<WorkflowSharingRole | undefined> {
return await this.findOne({
select: ['role'],
where: { workflowId, userId },
}).then((shared) => shared?.role);
}
async findSharing(
workflowId: string,
user: User,
scope: Scope,
{ roles, extraRelations }: { roles?: WorkflowSharingRole[]; extraRelations?: string[] } = {},
) {
const where: FindOptionsWhere<SharedWorkflow> = {
workflow: { id: workflowId },
};
if (!user.hasGlobalScope(scope)) {
where.user = { id: user.id };
}
if (roles) {
where.role = In(roles);
}
const relations = ['workflow'];
if (extraRelations) relations.push(...extraRelations);
return await this.findOne({ relations, where });
}
async makeOwnerOfAllWorkflows(user: User) {
return await this.update({ userId: Not(user.id), role: 'workflow:owner' }, { user });
}
async getSharing(
user: User,
workflowId: string,
options: { allowGlobalScope: true; globalScope: Scope } | { allowGlobalScope: false },
relations: string[] = ['workflow'],
): Promise<SharedWorkflow | null> {
const where: FindOptionsWhere<SharedWorkflow> = { workflowId };
// Omit user from where if the requesting user has relevant
// global workflow permissions. This allows the user to
// access workflows they don't own.
if (!options.allowGlobalScope || !user.hasGlobalScope(options.globalScope)) {
where.userId = user.id;
}
return await this.findOne({ where, relations });
}
async getSharedWorkflows(
user: User,
options: {
relations?: string[];
workflowIds?: string[];
},
): Promise<SharedWorkflow[]> {
return await this.find({
const sharing = await this.findOne({
// NOTE: We have to select everything that is used in the `where` clause. Otherwise typeorm will create an invalid query and we get this error:
// QueryFailedError: SQLITE_ERROR: no such column: distinctAlias.SharedWorkflow_...
select: {
role: true,
workflowId: true,
projectId: true,
},
where: {
...(!['global:owner', 'global:admin'].includes(user.role) && { userId: user.id }),
...(options.workflowIds && { workflowId: In(options.workflowIds) }),
workflowId,
project: { projectRelations: { role: 'project:personalOwner', userId } },
},
...(options.relations && { relations: options.relations }),
});
return sharing?.role;
}
async share(transaction: EntityManager, workflow: WorkflowEntity, users: User[]) {
const newSharedWorkflows = users.reduce<SharedWorkflow[]>((acc, user) => {
if (user.isPending) {
return acc;
}
const entity: Partial<SharedWorkflow> = {
workflowId: workflow.id,
userId: user.id,
role: 'workflow:editor',
};
acc.push(this.create(entity));
return acc;
}, []);
async makeOwnerOfAllWorkflows(project: Project) {
return await this.update(
{
projectId: Not(project.id),
role: 'workflow:owner',
},
{ project },
);
}
return await transaction.save(newSharedWorkflows);
async makeOwner(workflowIds: string[], projectId: string, trx?: EntityManager) {
trx = trx ?? this.manager;
return await trx.upsert(
SharedWorkflow,
workflowIds.map(
(workflowId) =>
({
workflowId,
projectId,
role: 'workflow:owner',
}) as const,
),
['projectId', 'workflowId'],
);
}
async findWithFields(
@@ -153,10 +97,107 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
});
}
async deleteByIds(transaction: EntityManager, sharedWorkflowIds: string[], user?: User) {
return await transaction.delete(SharedWorkflow, {
user,
async deleteByIds(sharedWorkflowIds: string[], projectId: string, trx?: EntityManager) {
trx = trx ?? this.manager;
return await trx.delete(SharedWorkflow, {
projectId,
workflowId: In(sharedWorkflowIds),
});
}
async findWorkflowForUser(
workflowId: string,
user: User,
scopes: Scope[],
{ includeTags = false, em = this.manager } = {},
) {
let where: FindOptionsWhere<SharedWorkflow> = { workflowId };
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes);
const workflowRoles = this.roleService.rolesWithScope('workflow', scopes);
where = {
...where,
role: In(workflowRoles),
project: {
projectRelations: {
role: In(projectRoles),
userId: user.id,
},
},
};
}
const sharedWorkflow = await em.findOne(SharedWorkflow, {
where,
relations: {
workflow: {
shared: { project: { projectRelations: { user: true } } },
tags: includeTags,
},
},
});
if (!sharedWorkflow) {
return null;
}
return sharedWorkflow.workflow;
}
async findAllWorkflowsForUser(user: User, scopes: Scope[]) {
let where: FindOptionsWhere<SharedWorkflow> = {};
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes);
const workflowRoles = this.roleService.rolesWithScope('workflow', scopes);
where = {
...where,
role: In(workflowRoles),
project: {
projectRelations: {
role: In(projectRoles),
userId: user.id,
},
},
};
}
const sharedWorkflows = await this.find({
where,
relations: {
workflow: {
shared: { project: { projectRelations: { user: true } } },
},
},
});
return sharedWorkflows.map((sw) => sw.workflow);
}
/**
* Find the IDs of all the projects where a workflow is accessible.
*/
async findProjectIds(workflowId: string) {
const rows = await this.find({ where: { workflowId }, select: ['projectId'] });
const projectIds = rows.reduce<string[]>((acc, row) => {
if (row.projectId) acc.push(row.projectId);
return acc;
}, []);
return [...new Set(projectIds)];
}
async getWorkflowOwningProject(workflowId: string) {
return (
await this.findOne({
where: { workflowId, role: 'workflow:owner' },
relations: { project: true },
})
)?.project;
}
}
@@ -1,9 +1,11 @@
import { Service } from 'typedi';
import type { EntityManager, FindManyOptions } from '@n8n/typeorm';
import type { DeepPartial, EntityManager, FindManyOptions } from '@n8n/typeorm';
import { DataSource, In, IsNull, Not, Repository } from '@n8n/typeorm';
import type { ListQuery } from '@/requests';
import { type GlobalRole, User } from '../entities/User';
import { Project } from '../entities/Project';
import { ProjectRelation } from '../entities/ProjectRelation';
@Service()
export class UserRepository extends Repository<User> {
constructor(dataSource: DataSource) {
@@ -16,6 +18,19 @@ export class UserRepository extends Repository<User> {
});
}
/**
* @deprecated Use `UserRepository.save` instead if you can.
*
* We need to use `save` so that that the subscriber in
* packages/cli/src/databases/entities/Project.ts receives the full user.
* With `update` it would only receive the updated fields, e.g. the `id`
* would be missing. test('does not use `Repository.update`, but
* `Repository.save` instead'.
*/
async update(...args: Parameters<Repository<User>['update']>) {
return await super.update(...args);
}
async deleteAllExcept(user: User) {
await this.delete({ id: Not(user.id) });
}
@@ -104,4 +119,34 @@ export class UserRepository extends Repository<User> {
where: { id: In(userIds), password: Not(IsNull()) },
});
}
async createUserWithProject(
user: DeepPartial<User>,
transactionManager?: EntityManager,
): Promise<{ user: User; project: Project }> {
const createInner = async (entityManager: EntityManager) => {
const newUser = entityManager.create(User, user);
const savedUser = await entityManager.save<User>(newUser);
const savedProject = await entityManager.save<Project>(
entityManager.create(Project, {
type: 'personal',
name: savedUser.createPersonalProjectName(),
}),
);
await entityManager.save<ProjectRelation>(
entityManager.create(ProjectRelation, {
projectId: savedProject.id,
userId: savedUser.id,
role: 'project:personalOwner',
}),
);
return { user: savedUser, project: savedProject };
};
if (transactionManager) {
return await createInner(transactionManager);
}
// TODO: use a transactions
// This is blocked by TypeORM having concurrency issues with transactions
return await createInner(this.manager);
}
}
@@ -8,15 +8,12 @@ import {
type FindOptionsWhere,
type FindOptionsSelect,
type FindManyOptions,
type EntityManager,
type DeleteResult,
Not,
type FindOptionsRelations,
} from '@n8n/typeorm';
import type { ListQuery } from '@/requests';
import { isStringArray } from '@/utils';
import config from '@/config';
import { WorkflowEntity } from '../entities/WorkflowEntity';
import { SharedWorkflow } from '../entities/SharedWorkflow';
import { WebhookEntity } from '../entities/WebhookEntity';
@Service()
@@ -25,7 +22,10 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
super(WorkflowEntity, dataSource.manager);
}
async get(where: FindOptionsWhere<WorkflowEntity>, options?: { relations: string[] }) {
async get(
where: FindOptionsWhere<WorkflowEntity>,
options?: { relations: string[] | FindOptionsRelations<WorkflowEntity> },
) {
return await this.findOne({
where,
relations: options?.relations,
@@ -35,7 +35,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
async getAllActive() {
return await this.find({
where: { active: true },
relations: ['shared', 'shared.user'],
relations: { shared: { project: { projectRelations: true } } },
});
}
@@ -50,7 +50,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
async findById(workflowId: string) {
return await this.findOne({
where: { id: workflowId },
relations: ['shared', 'shared.user'],
relations: { shared: { project: { projectRelations: true } } },
});
}
@@ -71,29 +71,6 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
return totalTriggerCount ?? 0;
}
async getSharings(
transaction: EntityManager,
workflowId: string,
relations = ['shared'],
): Promise<SharedWorkflow[]> {
const workflow = await transaction.findOne(WorkflowEntity, {
where: { id: workflowId },
relations,
});
return workflow?.shared ?? [];
}
async pruneSharings(
transaction: EntityManager,
workflowId: string,
userIds: string[],
): Promise<DeleteResult> {
return await transaction.delete(SharedWorkflow, {
workflowId,
userId: Not(In(userIds)),
});
}
async updateWorkflowTriggerCount(id: string, triggerCount: number): Promise<UpdateResult> {
const qb = this.createQueryBuilder('workflow');
return await qb
@@ -114,6 +91,11 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
async getMany(sharedWorkflowIds: string[], options?: ListQuery.Options) {
if (sharedWorkflowIds.length === 0) return { workflows: [], count: 0 };
if (typeof options?.filter?.projectId === 'string' && options.filter.projectId !== '') {
options.filter.shared = { projectId: options.filter.projectId };
delete options.filter.projectId;
}
const where: FindOptionsWhere<WorkflowEntity> = {
...options?.filter,
id: In(sharedWorkflowIds),
@@ -135,7 +117,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
createdAt: true,
updatedAt: true,
versionId: true,
shared: { userId: true, role: true },
shared: { role: true },
};
delete select?.ownedBy; // remove non-entity field, handled after query
@@ -152,7 +134,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
select.tags = { id: true, name: true };
}
if (isOwnedByIncluded) relations.push('shared', 'shared.user');
if (isOwnedByIncluded) relations.push('shared', 'shared.project');
if (typeof where.name === 'string' && where.name !== '') {
where.name = Like(`%${where.name}%`);
@@ -1,10 +1,8 @@
import { Service } from 'typedi';
import { DataSource, QueryFailedError, Repository } from '@n8n/typeorm';
import { DataSource, MoreThanOrEqual, QueryFailedError, Repository } from '@n8n/typeorm';
import config from '@/config';
import { StatisticsNames, WorkflowStatistics } from '../entities/WorkflowStatistics';
import type { User } from '@/databases/entities/User';
import { WorkflowEntity } from '@/databases/entities/WorkflowEntity';
import { SharedWorkflow } from '@/databases/entities/SharedWorkflow';
type StatisticsInsertResult = 'insert' | 'failed' | 'alreadyExists';
type StatisticsUpsertResult = StatisticsInsertResult | 'update';
@@ -102,18 +100,18 @@ export class WorkflowStatisticsRepository extends Repository<WorkflowStatistics>
}
async queryNumWorkflowsUserHasWithFiveOrMoreProdExecs(userId: User['id']): Promise<number> {
return await this.createQueryBuilder('workflow_statistics')
.innerJoin(WorkflowEntity, 'workflow', 'workflow.id = workflow_statistics.workflowId')
.innerJoin(
SharedWorkflow,
'shared_workflow',
'shared_workflow.workflowId = workflow_statistics.workflowId',
)
.where('shared_workflow.userId = :userId', { userId })
.andWhere('workflow.active = :isActive', { isActive: true })
.andWhere('workflow_statistics.name = :name', { name: StatisticsNames.productionSuccess })
.andWhere('workflow_statistics.count >= 5')
.andWhere('role = :roleName', { roleName: 'workflow:owner' })
.getCount();
return await this.count({
where: {
workflow: {
shared: {
role: 'workflow:owner',
project: { projectRelations: { userId, role: 'project:personalOwner' } },
},
active: true,
},
name: StatisticsNames.productionSuccess,
count: MoreThanOrEqual(5),
},
});
}
}
@@ -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);
}
}
}
}
}
@@ -0,0 +1,5 @@
import { UserSubscriber } from './UserSubscriber';
export const subscribers = {
UserSubscriber,
};
+60
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);
};
-14
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