feat: RBAC (#8922)

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
Co-authored-by: Val <68596159+valya@users.noreply.github.com>
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
Co-authored-by: Valya Bullions <valya@n8n.io>
Co-authored-by: Danny Martini <danny@n8n.io>
Co-authored-by: Danny Martini <despair.blue@gmail.com>
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
Co-authored-by: Omar Ajoue <krynble@gmail.com>
Co-authored-by: oleg <me@olegivaniv.com>
Co-authored-by: Michael Kret <michael.k@radency.com>
Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com>
Co-authored-by: Elias Meire <elias@meire.dev>
Co-authored-by: Giulio Andreini <andreini@netseven.it>
Co-authored-by: Giulio Andreini <g.andreini@gmail.com>
Co-authored-by: Ayato Hayashi <go12limchangyong@gmail.com>
This commit is contained in:
Csaba Tuncsik 2024-05-17 10:53:15 +02:00 committed by GitHub
parent b1f977ebd0
commit 596c472ecc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
292 changed files with 14129 additions and 3989 deletions

View file

@ -0,0 +1,18 @@
export const getHomeButton = () => cy.getByTestId('project-home-menu-item');
export const getMenuItems = () => cy.getByTestId('project-menu-item');
export const getAddProjectButton = () => cy.getByTestId('add-project-menu-item');
export const getProjectTabs = () => cy.getByTestId('project-tabs').find('a');
export const getProjectTabWorkflows = () => getProjectTabs().filter('a[href$="/workflows"]');
export const getProjectTabCredentials = () => getProjectTabs().filter('a[href$="/credentials"]');
export const getProjectTabSettings = () => getProjectTabs().filter('a[href$="/settings"]');
export const getProjectSettingsSaveButton = () => cy.getByTestId('project-settings-save-button');
export const getProjectSettingsCancelButton = () =>
cy.getByTestId('project-settings-cancel-button');
export const getProjectSettingsDeleteButton = () =>
cy.getByTestId('project-settings-delete-button');
export const getProjectMembersSelect = () => cy.getByTestId('project-members-select');
export const addProjectMember = (email: string) => {
getProjectMembersSelect().click();
getProjectMembersSelect().get('.el-select-dropdown__item').contains(email.toLowerCase()).click();
};

View file

@ -30,7 +30,7 @@ const workflowSharingModal = new WorkflowSharingModal();
const ndv = new NDV();
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)

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

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', () => {

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

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

View file

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

View file

@ -0,0 +1,151 @@
import { INSTANCE_ADMIN, INSTANCE_MEMBERS } from '../constants';
import { WorkflowsPage, WorkflowPage, CredentialsModal, CredentialsPage } from '../pages';
import * as projects from '../composables/projects';
const workflowsPage = new WorkflowsPage();
const workflowPage = new WorkflowPage();
const credentialsPage = new CredentialsPage();
const credentialsModal = new CredentialsModal();
describe('Projects', () => {
beforeEach(() => {
cy.resetDatabase();
cy.enableFeature('advancedPermissions');
cy.enableFeature('projectRole:admin');
cy.enableFeature('projectRole:editor');
cy.changeQuota('maxTeamProjects', -1);
});
it('should handle workflows and credentials', () => {
cy.signin(INSTANCE_ADMIN);
cy.visit(workflowsPage.url);
workflowsPage.getters.workflowCards().should('not.have.length');
workflowsPage.getters.newWorkflowButtonCard().click();
cy.intercept('POST', '/rest/workflows').as('workflowSave');
workflowPage.actions.saveWorkflowOnButtonClick();
cy.wait('@workflowSave').then((interception) => {
expect(interception.request.body).not.to.have.property('projectId');
});
projects.getHomeButton().click();
projects.getProjectTabs().should('have.length', 2);
projects.getProjectTabCredentials().click();
credentialsPage.getters.credentialCards().should('not.have.length');
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.getters.newCredentialModal().should('be.visible');
credentialsModal.getters.newCredentialTypeSelect().should('be.visible');
credentialsModal.getters.newCredentialTypeOption('Notion API').click();
credentialsModal.getters.newCredentialTypeButton().click();
credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890');
credentialsModal.actions.setName('My awesome Notion account');
cy.intercept('POST', '/rest/credentials').as('credentialSave');
credentialsModal.actions.save();
cy.wait('@credentialSave').then((interception) => {
expect(interception.request.body).not.to.have.property('projectId');
});
credentialsModal.actions.close();
credentialsPage.getters.credentialCards().should('have.length', 1);
projects.getProjectTabWorkflows().click();
workflowsPage.getters.workflowCards().should('have.length', 1);
projects.getMenuItems().should('not.have.length');
cy.intercept('POST', '/rest/projects').as('projectCreate');
projects.getAddProjectButton().click();
cy.wait('@projectCreate');
projects.getMenuItems().should('have.length', 1);
projects.getProjectTabs().should('have.length', 3);
cy.get('input[name="name"]').type('Development');
projects.addProjectMember(INSTANCE_MEMBERS[0].email);
cy.intercept('PATCH', '/rest/projects/*').as('projectSettingsSave');
projects.getProjectSettingsSaveButton().click();
cy.wait('@projectSettingsSave').then((interception) => {
expect(interception.request.body).to.have.property('name').and.to.equal('Development');
expect(interception.request.body).to.have.property('relations').to.have.lengthOf(2);
});
projects.getMenuItems().first().click();
workflowsPage.getters.workflowCards().should('not.have.length');
projects.getProjectTabs().should('have.length', 3);
workflowsPage.getters.newWorkflowButtonCard().click();
cy.intercept('POST', '/rest/workflows').as('workflowSave');
workflowPage.actions.saveWorkflowOnButtonClick();
cy.wait('@workflowSave').then((interception) => {
expect(interception.request.body).to.have.property('projectId');
});
projects.getMenuItems().first().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters.credentialCards().should('not.have.length');
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.getters.newCredentialModal().should('be.visible');
credentialsModal.getters.newCredentialTypeSelect().should('be.visible');
credentialsModal.getters.newCredentialTypeOption('Notion API').click();
credentialsModal.getters.newCredentialTypeButton().click();
credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890');
credentialsModal.actions.setName('My awesome Notion account');
cy.intercept('POST', '/rest/credentials').as('credentialSave');
credentialsModal.actions.save();
cy.wait('@credentialSave').then((interception) => {
expect(interception.request.body).to.have.property('projectId');
});
credentialsModal.actions.close();
projects.getAddProjectButton().click();
projects.getMenuItems().should('have.length', 2);
let projectId: string;
projects.getMenuItems().first().click();
cy.intercept('GET', '/rest/credentials*').as('credentialsList');
projects.getProjectTabCredentials().click();
cy.wait('@credentialsList').then((interception) => {
const url = new URL(interception.request.url);
const queryParams = new URLSearchParams(url.search);
const filter = queryParams.get('filter');
expect(filter).to.be.a('string').and.to.contain('projectId');
if (filter) {
projectId = JSON.parse(filter).projectId;
}
});
projects.getMenuItems().last().click();
cy.intercept('GET', '/rest/credentials*').as('credentialsList');
projects.getProjectTabCredentials().click();
cy.wait('@credentialsList').then((interception) => {
const url = new URL(interception.request.url);
const queryParams = new URLSearchParams(url.search);
const filter = queryParams.get('filter');
expect(filter).to.be.a('string').and.to.contain('projectId');
if (filter) {
expect(JSON.parse(filter).projectId).not.to.equal(projectId);
}
});
projects.getHomeButton().click();
workflowsPage.getters.workflowCards().should('have.length', 2);
cy.intercept('GET', '/rest/credentials*').as('credentialsList');
projects.getProjectTabCredentials().click();
cy.wait('@credentialsList').then((interception) => {
expect(interception.request.url).not.to.contain('filter');
});
});
});

View file

@ -697,7 +697,7 @@ describe('NDV', () => {
});
it('Stop listening for trigger event from NDV', () => {
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',

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'),

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 = {

View file

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

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

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

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 = {

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) => {

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

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'),

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

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

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: {

View file

@ -0,0 +1,23 @@
import type { Scope, ScopeLevels, GlobalScopes, MaskLevels } from './types';
export function combineScopes(userScopes: GlobalScopes, masks?: MaskLevels): Set<Scope>;
export function combineScopes(userScopes: ScopeLevels, masks?: MaskLevels): Set<Scope>;
export function combineScopes(
userScopes: GlobalScopes | ScopeLevels,
masks?: MaskLevels,
): Set<Scope> {
const maskedScopes: GlobalScopes | ScopeLevels = Object.fromEntries(
Object.entries(userScopes).map((e) => [e[0], [...e[1]]]),
) as GlobalScopes | ScopeLevels;
if (masks?.sharing) {
if ('project' in maskedScopes) {
maskedScopes.project = maskedScopes.project.filter((v) => masks.sharing.includes(v));
}
if ('resource' in maskedScopes) {
maskedScopes.resource = maskedScopes.resource.filter((v) => masks.sharing.includes(v));
}
}
return new Set(Object.values(maskedScopes).flat());
}

View file

@ -1,25 +1,29 @@
import type { Scope, ScopeLevels, GlobalScopes, ScopeOptions } from './types';
import { combineScopes } from './combineScopes';
import type { Scope, ScopeLevels, GlobalScopes, ScopeOptions, MaskLevels } from './types';
export function hasScope(
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));

View file

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

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

View file

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

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

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,

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 {

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

View file

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

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

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

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,

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

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

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';

View file

@ -1,11 +1,11 @@
import { authorize } from '@/PublicApi/v1/shared/middlewares/global.middleware';
import { globalScope } from '@/PublicApi/v1/shared/middlewares/global.middleware';
import type { Response } from 'express';
import type { 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');

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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 (

View file

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

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

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,

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;

View file

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

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

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

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

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

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

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

View file

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

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

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;

View file

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

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' },

View file

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

View file

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

View file

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

View file

@ -0,0 +1,221 @@
import type { Project } from '@db/entities/Project';
import {
Get,
Post,
GlobalScope,
RestController,
Licensed,
Patch,
ProjectScope,
Delete,
} from '@/decorators';
import { ProjectRequest } from '@/requests';
import {
ProjectService,
TeamProjectOverQuotaError,
UnlicensedProjectRoleError,
} from '@/services/project.service';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { combineScopes } from '@n8n/permissions';
import type { Scope } from '@n8n/permissions';
import { RoleService } from '@/services/role.service';
import { ProjectRepository } from '@/databases/repositories/project.repository';
import { In, Not } from '@n8n/typeorm';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { InternalHooks } from '@/InternalHooks';
@RestController('/projects')
export class ProjectController {
constructor(
private readonly projectsService: ProjectService,
private readonly roleService: RoleService,
private readonly projectRepository: ProjectRepository,
private readonly internalHooks: InternalHooks,
) {}
@Get('/')
async getAllProjects(req: ProjectRequest.GetAll): Promise<Project[]> {
return await this.projectsService.getAccessibleProjects(req.user);
}
@Get('/count')
async getProjectCounts() {
return await this.projectsService.getProjectCounts();
}
@Post('/')
@GlobalScope('project:create')
// Using admin as all plans that contain projects should allow admins at the very least
@Licensed('feat:projectRole:admin')
async createProject(req: ProjectRequest.Create): Promise<Project> {
try {
const project = await this.projectsService.createTeamProject(req.body.name, req.user);
void this.internalHooks.onTeamProjectCreated({
user_id: req.user.id,
role: req.user.role,
});
return project;
} catch (e) {
if (e instanceof TeamProjectOverQuotaError) {
throw new BadRequestError(e.message);
}
throw e;
}
}
@Get('/my-projects')
async getMyProjects(
req: ProjectRequest.GetMyProjects,
): Promise<ProjectRequest.GetMyProjectsResponse> {
const relations = await this.projectsService.getProjectRelationsForUser(req.user);
const otherTeamProject = req.user.hasGlobalScope('project:read')
? await this.projectRepository.findBy({
type: 'team',
id: Not(In(relations.map((pr) => pr.projectId))),
})
: [];
const results: ProjectRequest.GetMyProjectsResponse = [];
for (const pr of relations) {
const result: ProjectRequest.GetMyProjectsResponse[number] = Object.assign(
this.projectRepository.create(pr.project),
{
role: pr.role,
scopes: req.query.includeScopes ? ([] as Scope[]) : undefined,
},
);
if (result.scopes) {
result.scopes.push(
...combineScopes({
global: this.roleService.getRoleScopes(req.user.role),
project: this.roleService.getRoleScopes(pr.role),
}),
);
}
results.push(result);
}
for (const project of otherTeamProject) {
const result: ProjectRequest.GetMyProjectsResponse[number] = Object.assign(
this.projectRepository.create(project),
{
// If the user has the global `project:read` scope then they may not
// own this relationship in that case we use the global user role
// instead of the relation role, which is for another user.
role: req.user.role,
scopes: req.query.includeScopes ? [] : undefined,
},
);
if (result.scopes) {
result.scopes.push(
...combineScopes({ global: this.roleService.getRoleScopes(req.user.role) }),
);
}
results.push(result);
}
// Deduplicate and sort scopes
for (const result of results) {
if (result.scopes) {
result.scopes = [...new Set(result.scopes)].sort();
}
}
return results;
}
@Get('/personal')
async getPersonalProject(req: ProjectRequest.GetPersonalProject) {
const project = await this.projectsService.getPersonalProject(req.user);
if (!project) {
throw new NotFoundError('Could not find a personal project for this user');
}
const scopes: Scope[] = [
...combineScopes({
global: this.roleService.getRoleScopes(req.user.role),
project: this.roleService.getRoleScopes('project:personalOwner'),
}),
];
return {
...project,
scopes,
};
}
@Get('/:projectId')
@ProjectScope('project:read')
async getProject(req: ProjectRequest.Get): Promise<ProjectRequest.ProjectWithRelations> {
const [{ id, name, type }, relations] = await Promise.all([
this.projectsService.getProject(req.params.projectId),
this.projectsService.getProjectRelations(req.params.projectId),
]);
const myRelation = relations.find((r) => r.userId === req.user.id);
return {
id,
name,
type,
relations: relations.map((r) => ({
id: r.user.id,
email: r.user.email,
firstName: r.user.firstName,
lastName: r.user.lastName,
role: r.role,
})),
scopes: [
...combineScopes({
global: this.roleService.getRoleScopes(req.user.role),
...(myRelation ? { project: this.roleService.getRoleScopes(myRelation.role) } : {}),
}),
],
};
}
@Patch('/:projectId')
@ProjectScope('project:update')
async updateProject(req: ProjectRequest.Update) {
if (req.body.name) {
await this.projectsService.updateProject(req.body.name, req.params.projectId);
}
if (req.body.relations) {
try {
await this.projectsService.syncProjectRelations(req.params.projectId, req.body.relations);
} catch (e) {
if (e instanceof UnlicensedProjectRoleError) {
throw new BadRequestError(e.message);
}
throw e;
}
void this.internalHooks.onTeamProjectUpdated({
user_id: req.user.id,
role: req.user.role,
members: req.body.relations.map(({ userId, role }) => ({ user_id: userId, role })),
project_id: req.params.projectId,
});
}
}
@Delete('/:projectId')
@ProjectScope('project:delete')
async deleteProject(req: ProjectRequest.Delete) {
await this.projectsService.deleteProject(req.user, req.params.projectId, {
migrateToProject: req.query.transferId,
});
void this.internalHooks.onTeamProjectDeleted({
user_id: req.user.id,
role: req.user.role,
project_id: req.params.projectId,
removal_type: req.query.transferId !== undefined ? 'transfer' : 'delete',
target_project_id: req.query.transferId,
});
}
}

View file

@ -0,0 +1,22 @@
import { Get, RestController } from '@/decorators';
import { type AllRoleTypes, RoleService } from '@/services/role.service';
@RestController('/roles')
export class RoleController {
constructor(private readonly roleService: RoleService) {}
@Get('/')
async getAllRoles() {
return Object.fromEntries(
Object.entries(this.roleService.getRoles()).map((e) => [
e[0],
(e[1] as AllRoleTypes[]).map((r) => ({
name: this.roleService.getRoleName(r),
role: r,
scopes: this.roleService.getRoleScopes(r),
licensed: this.roleService.isRoleLicensed(r),
})),
]),
);
}
}

View file

@ -2,8 +2,6 @@ import { plainToInstance } from 'class-transformer';
import { AuthService } from '@/auth/auth.service';
import { 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 };
}
}

View file

@ -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', {

View file

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

View file

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

View file

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

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,

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') {

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

View file

@ -0,0 +1,25 @@
import { Column, Entity, OneToMany } from '@n8n/typeorm';
import { WithTimestampsAndStringId } from './AbstractEntity';
import type { ProjectRelation } from './ProjectRelation';
import type { SharedCredentials } from './SharedCredentials';
import type { SharedWorkflow } from './SharedWorkflow';
export type ProjectType = 'personal' | 'team';
@Entity()
export class Project extends WithTimestampsAndStringId {
@Column({ length: 255, nullable: true })
name: string;
@Column({ length: 36 })
type: ProjectType;
@OneToMany('ProjectRelation', 'project')
projectRelations: ProjectRelation[];
@OneToMany('SharedCredentials', 'project')
sharedCredentials: SharedCredentials[];
@OneToMany('SharedWorkflow', 'project')
sharedWorkflows: SharedWorkflow[];
}

View file

@ -0,0 +1,25 @@
import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import { User } from './User';
import { WithTimestamps } from './AbstractEntity';
import { Project } from './Project';
// personalOwner is only used for personal projects
export type ProjectRole = 'project:personalOwner' | 'project:admin' | 'project:editor';
@Entity()
export class ProjectRelation extends WithTimestamps {
@Column()
role: ProjectRole;
@ManyToOne('User', 'projectRelations')
user: User;
@PrimaryColumn('uuid')
userId: string;
@ManyToOne('Project', 'projectRelations')
project: Project;
@PrimaryColumn()
projectId: string;
}

View file

@ -1,7 +1,7 @@
import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import { 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;
}

View file

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

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

View file

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

View file

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

View file

@ -0,0 +1,328 @@
import type { MigrationContext, ReversibleMigration } from '@db/types';
import type { ProjectRole } from '@/databases/entities/ProjectRelation';
import type { User } from '@/databases/entities/User';
import { generateNanoId } from '@/databases/utils/generators';
import { ApplicationError } from 'n8n-workflow';
import { nanoid } from 'nanoid';
const projectAdminRole: ProjectRole = 'project:personalOwner';
type RelationTable = 'shared_workflow' | 'shared_credentials';
const table = {
sharedCredentials: 'shared_credentials',
sharedCredentialsTemp: 'shared_credentials_2',
sharedWorkflow: 'shared_workflow',
sharedWorkflowTemp: 'shared_workflow_2',
project: 'project',
user: 'user',
projectRelation: 'project_relation',
} as const;
function escapeNames(escape: MigrationContext['escape']) {
const t = {
project: escape.tableName(table.project),
projectRelation: escape.tableName(table.projectRelation),
sharedCredentials: escape.tableName(table.sharedCredentials),
sharedCredentialsTemp: escape.tableName(table.sharedCredentialsTemp),
sharedWorkflow: escape.tableName(table.sharedWorkflow),
sharedWorkflowTemp: escape.tableName(table.sharedWorkflowTemp),
user: escape.tableName(table.user),
};
const c = {
createdAt: escape.columnName('createdAt'),
updatedAt: escape.columnName('updatedAt'),
workflowId: escape.columnName('workflowId'),
credentialsId: escape.columnName('credentialsId'),
userId: escape.columnName('userId'),
projectId: escape.columnName('projectId'),
firstName: escape.columnName('firstName'),
lastName: escape.columnName('lastName'),
};
return { t, c };
}
export class CreateProject1714133768519 implements ReversibleMigration {
async setupTables({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(table.project).withColumns(
column('id').varchar(36).primary.notNull,
column('name').varchar(255).notNull,
column('type').varchar(36).notNull,
).withTimestamps;
await createTable(table.projectRelation)
.withColumns(
column('projectId').varchar(36).primary.notNull,
column('userId').uuid.primary.notNull,
column('role').varchar().notNull,
)
.withIndexOn('projectId')
.withIndexOn('userId')
.withForeignKey('projectId', {
tableName: table.project,
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
}
async alterSharedTable(
relationTableName: RelationTable,
{
escape,
isMysql,
runQuery,
schemaBuilder: { addForeignKey, addColumns, addNotNull, createIndex, column },
}: MigrationContext,
) {
const projectIdColumn = column('projectId').varchar(36).default('NULL');
await addColumns(relationTableName, [projectIdColumn]);
const relationTable = escape.tableName(relationTableName);
const { t, c } = escapeNames(escape);
// Populate projectId
const subQuery = `
SELECT P.id as ${c.projectId}, T.${c.userId}
FROM ${t.projectRelation} T
LEFT JOIN ${t.project} P
ON T.${c.projectId} = P.id AND P.type = 'personal'
LEFT JOIN ${relationTable} S
ON T.${c.userId} = S.${c.userId}
WHERE P.id IS NOT NULL
`;
const swQuery = isMysql
? `UPDATE ${relationTable}, (${subQuery}) as mapping
SET ${relationTable}.${c.projectId} = mapping.${c.projectId}
WHERE ${relationTable}.${c.userId} = mapping.${c.userId}`
: `UPDATE ${relationTable}
SET ${c.projectId} = mapping.${c.projectId}
FROM (${subQuery}) as mapping
WHERE ${relationTable}.${c.userId} = mapping.${c.userId}`;
await runQuery(swQuery);
await addForeignKey(relationTableName, 'projectId', ['project', 'id']);
await addNotNull(relationTableName, 'projectId');
// Index the new projectId column
await createIndex(relationTableName, ['projectId']);
}
async alterSharedCredentials({
escape,
runQuery,
schemaBuilder: { column, createTable, dropTable },
}: MigrationContext) {
await createTable(table.sharedCredentialsTemp)
.withColumns(
column('credentialsId').varchar(36).notNull.primary,
column('projectId').varchar(36).notNull.primary,
column('role').text.notNull,
)
.withForeignKey('credentialsId', {
tableName: 'credentials_entity',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('projectId', {
tableName: table.project,
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
const { c, t } = escapeNames(escape);
await runQuery(`
INSERT INTO ${t.sharedCredentialsTemp} (${c.createdAt}, ${c.updatedAt}, ${c.credentialsId}, ${c.projectId}, role)
SELECT ${c.createdAt}, ${c.updatedAt}, ${c.credentialsId}, ${c.projectId}, role FROM ${t.sharedCredentials};
`);
await dropTable(table.sharedCredentials);
await runQuery(`ALTER TABLE ${t.sharedCredentialsTemp} RENAME TO ${t.sharedCredentials};`);
}
async alterSharedWorkflow({
escape,
runQuery,
schemaBuilder: { column, createTable, dropTable },
}: MigrationContext) {
await createTable(table.sharedWorkflowTemp)
.withColumns(
column('workflowId').varchar(36).notNull.primary,
column('projectId').varchar(36).notNull.primary,
column('role').text.notNull,
)
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('projectId', {
tableName: table.project,
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
const { c, t } = escapeNames(escape);
await runQuery(`
INSERT INTO ${t.sharedWorkflowTemp} (${c.createdAt}, ${c.updatedAt}, ${c.workflowId}, ${c.projectId}, role)
SELECT ${c.createdAt}, ${c.updatedAt}, ${c.workflowId}, ${c.projectId}, role FROM ${t.sharedWorkflow};
`);
await dropTable(table.sharedWorkflow);
await runQuery(`ALTER TABLE ${t.sharedWorkflowTemp} RENAME TO ${t.sharedWorkflow};`);
}
async createUserPersonalProjects({ runQuery, runInBatches, escape }: MigrationContext) {
const { c, t } = escapeNames(escape);
const getUserQuery = `SELECT id, ${c.firstName}, ${c.lastName}, email FROM ${t.user}`;
await runInBatches<Pick<User, 'id' | 'firstName' | 'lastName' | 'email'>>(
getUserQuery,
async (users) => {
await Promise.all(
users.map(async (user) => {
const projectId = generateNanoId();
const name = this.createPersonalProjectName(user.firstName, user.lastName, user.email);
await runQuery(
`INSERT INTO ${t.project} (id, type, name) VALUES (:projectId, 'personal', :name)`,
{
projectId,
name,
},
);
await runQuery(
`INSERT INTO ${t.projectRelation} (${c.projectId}, ${c.userId}, role) VALUES (:projectId, :userId, :projectRole)`,
{
projectId,
userId: user.id,
projectRole: projectAdminRole,
},
);
}),
);
},
);
}
// Duplicated from packages/cli/src/databases/entities/User.ts
// Reason:
// This migration should work the same even if we refactor the function in
// `User.ts`.
createPersonalProjectName(firstName?: string, lastName?: string, email?: string) {
if (firstName && lastName && email) {
return `${firstName} ${lastName} <${email}>`;
} else if (email) {
return `<${email}>`;
} else {
return 'Unnamed Project';
}
}
async up(context: MigrationContext) {
await this.setupTables(context);
await this.createUserPersonalProjects(context);
await this.alterSharedTable(table.sharedCredentials, context);
await this.alterSharedCredentials(context);
await this.alterSharedTable(table.sharedWorkflow, context);
await this.alterSharedWorkflow(context);
}
async down({ isMysql, logger, escape, runQuery, schemaBuilder: sb }: MigrationContext) {
const { t, c } = escapeNames(escape);
// 0. check if all projects are personal projects
const [{ count: nonPersonalProjects }] = await runQuery<[{ count: number }]>(
`SELECT COUNT(*) FROM ${t.project} WHERE type <> 'personal';`,
);
if (nonPersonalProjects > 0) {
const message =
'Down migration only possible when there are no projects. Please delete all projects that were created via the UI first.';
logger.error(message);
throw new ApplicationError(message);
}
// 1. create temp table for shared workflows
await sb
.createTable(table.sharedWorkflowTemp)
.withColumns(
sb.column('workflowId').varchar(36).notNull.primary,
sb.column('userId').uuid.notNull.primary,
sb.column('role').text.notNull,
)
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE',
// In MySQL foreignKey names must be unique across all tables and
// TypeORM creates predictable names based on the columnName.
// So the current shared_workflow table's foreignKey for workflowId would
// clash with this one if we don't create a random name.
name: isMysql ? nanoid() : undefined,
})
.withForeignKey('userId', {
tableName: table.user,
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
// 2. migrate data into temp table
await runQuery(`
INSERT INTO ${t.sharedWorkflowTemp} (${c.createdAt}, ${c.updatedAt}, ${c.workflowId}, role, ${c.userId})
SELECT SW.${c.createdAt}, SW.${c.updatedAt}, SW.${c.workflowId}, SW.role, PR.${c.userId}
FROM ${t.sharedWorkflow} SW
LEFT JOIN project_relation PR on SW.${c.projectId} = PR.${c.projectId} AND PR.role = 'project:personalOwner'
`);
// 3. drop shared workflow table
await sb.dropTable(table.sharedWorkflow);
// 4. rename temp table
await runQuery(`ALTER TABLE ${t.sharedWorkflowTemp} RENAME TO ${t.sharedWorkflow};`);
// 5. same for shared creds
await sb
.createTable(table.sharedCredentialsTemp)
.withColumns(
sb.column('credentialsId').varchar(36).notNull.primary,
sb.column('userId').uuid.notNull.primary,
sb.column('role').text.notNull,
)
.withForeignKey('credentialsId', {
tableName: 'credentials_entity',
columnName: 'id',
onDelete: 'CASCADE',
// In MySQL foreignKey names must be unique across all tables and
// TypeORM creates predictable names based on the columnName.
// So the current shared_credentials table's foreignKey for credentialsId would
// clash with this one if we don't create a random name.
name: isMysql ? nanoid() : undefined,
})
.withForeignKey('userId', {
tableName: table.user,
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
await runQuery(`
INSERT INTO ${t.sharedCredentialsTemp} (${c.createdAt}, ${c.updatedAt}, ${c.credentialsId}, role, ${c.userId})
SELECT SC.${c.createdAt}, SC.${c.updatedAt}, SC.${c.credentialsId}, SC.role, PR.${c.userId}
FROM ${t.sharedCredentials} SC
LEFT JOIN project_relation PR on SC.${c.projectId} = PR.${c.projectId} AND PR.role = 'project:personalOwner'
`);
await sb.dropTable(table.sharedCredentials);
await runQuery(`ALTER TABLE ${t.sharedCredentialsTemp} RENAME TO ${t.sharedCredentials};`);
// 6. drop project and project relation table
await sb.dropTable(table.projectRelation);
await sb.dropTable(table.project);
}
}

View file

@ -51,6 +51,7 @@ import { ExecutionSoftDelete1693491613982 } from '../common/1693491613982-Execut
import { AddWorkflowMetadata1695128658538 } from '../common/1695128658538-AddWorkflowMetadata';
import { 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,
];

View file

@ -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,
];

View file

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

View file

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

View file

@ -0,0 +1,45 @@
import { Service } from 'typedi';
import type { EntityManager } from '@n8n/typeorm';
import { DataSource, Repository } from '@n8n/typeorm';
import { Project } from '../entities/Project';
@Service()
export class ProjectRepository extends Repository<Project> {
constructor(dataSource: DataSource) {
super(Project, dataSource.manager);
}
async getPersonalProjectForUser(userId: string, entityManager?: EntityManager) {
const em = entityManager ?? this.manager;
return await em.findOne(Project, {
where: { type: 'personal', projectRelations: { userId, role: 'project:personalOwner' } },
});
}
async getPersonalProjectForUserOrFail(userId: string) {
return await this.findOneOrFail({
where: { type: 'personal', projectRelations: { userId, role: 'project:personalOwner' } },
});
}
async getAccessibleProjects(userId: string) {
return await this.find({
where: [
{ type: 'personal' },
{
projectRelations: {
userId,
},
},
],
});
}
async getProjectCounts() {
return {
personal: await this.count({ where: { type: 'personal' } }),
team: await this.count({ where: { type: 'team' } }),
};
}
}

View file

@ -0,0 +1,55 @@
import { Service } from 'typedi';
import { DataSource, In, Repository } from '@n8n/typeorm';
import { ProjectRelation, type ProjectRole } from '../entities/ProjectRelation';
@Service()
export class ProjectRelationRepository extends Repository<ProjectRelation> {
constructor(dataSource: DataSource) {
super(ProjectRelation, dataSource.manager);
}
async getPersonalProjectOwners(projectIds: string[]) {
return await this.find({
where: {
projectId: In(projectIds),
role: 'project:personalOwner',
},
relations: { user: true },
});
}
async getPersonalProjectsForUsers(userIds: string[]) {
const projectRelations = await this.find({
where: {
userId: In(userIds),
role: 'project:personalOwner',
},
});
return projectRelations.map((pr) => pr.projectId);
}
/**
* Find the role of a user in a project.
*/
async findProjectRole({ userId, projectId }: { userId: string; projectId: string }) {
const relation = await this.findOneBy({ projectId, userId });
return relation?.role ?? null;
}
/** Counts the number of users in each role, e.g. `{ admin: 2, member: 6, owner: 1 }` */
async countUsersByRole() {
const rows = (await this.createQueryBuilder()
.select(['role', 'COUNT(role) as count'])
.groupBy('role')
.execute()) as Array<{ role: ProjectRole; count: string }>;
return rows.reduce(
(acc, row) => {
acc[row.role] = parseInt(row.count, 10);
return acc;
},
{} as Record<ProjectRole, number>,
);
}
}

View file

@ -1,22 +1,53 @@
import { Service } from 'typedi';
import 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;
}
}

View file

@ -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({
where: {
...(!['global:owner', 'global:admin'].includes(user.role) && { userId: user.id }),
...(options.workflowIds && { workflowId: In(options.workflowIds) }),
const sharing = await this.findOne({
// NOTE: We have to select everything that is used in the `where` clause. Otherwise typeorm will create an invalid query and we get this error:
// QueryFailedError: SQLITE_ERROR: no such column: distinctAlias.SharedWorkflow_...
select: {
role: true,
workflowId: true,
projectId: true,
},
where: {
workflowId,
project: { projectRelations: { role: 'project:personalOwner', userId } },
},
...(options.relations && { relations: options.relations }),
});
return sharing?.role;
}
async share(transaction: EntityManager, workflow: WorkflowEntity, users: User[]) {
const newSharedWorkflows = users.reduce<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;
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,73 @@
import type { EntitySubscriberInterface, UpdateEvent } from '@n8n/typeorm';
import { EventSubscriber } from '@n8n/typeorm';
import { User } from '../entities/User';
import Container from 'typedi';
import { ProjectRepository } from '../repositories/project.repository';
import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow';
import { Logger } from '@/Logger';
import { UserRepository } from '../repositories/user.repository';
import { Project } from '../entities/Project';
@EventSubscriber()
export class UserSubscriber implements EntitySubscriberInterface<User> {
listenTo() {
return User;
}
async afterUpdate(event: UpdateEvent<User>): Promise<void> {
if (event.entity) {
const newUserData = event.entity;
if (event.databaseEntity) {
const fields = event.updatedColumns.map((c) => c.propertyName);
if (
fields.includes('firstName') ||
fields.includes('lastName') ||
fields.includes('email')
) {
const oldUser = event.databaseEntity;
const name =
newUserData instanceof User
? newUserData.createPersonalProjectName()
: Container.get(UserRepository).create(newUserData).createPersonalProjectName();
const project = await Container.get(ProjectRepository).getPersonalProjectForUser(
oldUser.id,
);
if (!project) {
// Since this is benign we're not throwing the exception. We don't
// know if we're running inside a transaction and thus there is a risk
// that this could cause further data inconsistencies.
const message = "Could not update the personal project's name";
Container.get(Logger).warn(message, event.entity);
const exception = new ApplicationError(message);
ErrorReporterProxy.warn(exception, event.entity);
return;
}
project.name = name;
await event.manager.save(Project, project);
}
} else {
// This means the user was updated using `Repository.update`. In this
// case we're missing the user's id and cannot update their project.
//
// When updating the user's firstName, lastName or email we must use
// `Repository.save`, so this is a bug and we should report it to sentry.
//
if (event.entity.firstName || event.entity.lastName || event.entity.email) {
// Since this is benign we're not throwing the exception. We don't
// know if we're running inside a transaction and thus there is a risk
// that this could cause further data inconsistencies.
const message = "Could not update the personal project's name";
Container.get(Logger).warn(message, event.entity);
const exception = new ApplicationError(message);
ErrorReporterProxy.warn(exception, event.entity);
}
}
}
}
}

View file

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

View file

@ -0,0 +1,60 @@
import type { Scope } from '@n8n/permissions';
import type { RouteScopeMetadata } from './types';
import { CONTROLLER_ROUTE_SCOPES } from './constants';
const Scoped = (scope: Scope | Scope[], { globalOnly } = { globalOnly: false }) => {
return (target: Function | object, handlerName?: string) => {
const controllerClass = handlerName ? target.constructor : target;
const scopes = (Reflect.getMetadata(CONTROLLER_ROUTE_SCOPES, controllerClass) ??
{}) as RouteScopeMetadata;
const metadata = {
scopes: Array.isArray(scope) ? scope : [scope],
globalOnly,
};
scopes[handlerName ?? '*'] = metadata;
Reflect.defineMetadata(CONTROLLER_ROUTE_SCOPES, scopes, controllerClass);
};
};
/**
* Decorator for a controller method to ensure the user has a scope,
* checking only at the global level.
*
* To check only at project level as well, use the `@ProjectScope` decorator.
*
* @example
* ```ts
* @RestController()
* export class UsersController {
* @Delete('/:id')
* @GlobalScope('user:delete')
* async deleteUser(req, res) { ... }
* }
* ```
*/
export const GlobalScope = (scope: Scope | Scope[]) => {
return Scoped(scope, { globalOnly: true });
};
/**
* Decorator for a controller method to ensure the user has a scope,
* checking first at project level and then at global level.
*
* To check only at global level, use the `@GlobalScope` decorator.
*
* @example
* ```ts
* @RestController()
* export class WorkflowController {
* @Get('/:workflowId')
* @GlobalScope('workflow:read')
* async getWorkflow(req, res) { ... }
* }
* ```
*/
export const ProjectScope = (scope: Scope | Scope[]) => {
return Scoped(scope);
};

View file

@ -1,14 +0,0 @@
import type { Scope } from '@n8n/permissions';
import type { ScopeMetadata } from './types';
import { CONTROLLER_REQUIRED_SCOPES } from './constants';
export const GlobalScope = (scope: Scope | Scope[]) => {
// eslint-disable-next-line @typescript-eslint/ban-types
return (target: Function | object, handlerName?: string) => {
const controllerClass = handlerName ? target.constructor : target;
const scopes = (Reflect.getMetadata(CONTROLLER_REQUIRED_SCOPES, controllerClass) ??
[]) as ScopeMetadata;
scopes[handlerName ?? '*'] = Array.isArray(scope) ? scope : [scope];
Reflect.defineMetadata(CONTROLLER_REQUIRED_SCOPES, scopes, controllerClass);
};
};

Some files were not shown because too many files have changed in this diff Show more