Skip to content

Commit

Permalink
feat: Introduce advanced permissions (#7844)
Browse files Browse the repository at this point in the history
This PR introduces the possibility of inviting new users with an `admin`
role and changing the role of already invited users.
Also using scoped permission checks where applicable instead of using
user role checks.

---------

Co-authored-by: Val <[email protected]>
Co-authored-by: Alex Grozav <[email protected]>
Co-authored-by: Iván Ovejero <[email protected]>
  • Loading branch information
4 people authored Dec 8, 2023
1 parent e00577b commit dbd62a4
Show file tree
Hide file tree
Showing 26 changed files with 364 additions and 71 deletions.
37 changes: 36 additions & 1 deletion cypress/e2e/17-sharing.cy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { INSTANCE_MEMBERS, INSTANCE_OWNER } from '../constants';
import { INSTANCE_MEMBERS, INSTANCE_OWNER, INSTANCE_ADMIN } from '../constants';
import {
CredentialsModal,
CredentialsPage,
Expand All @@ -7,6 +7,7 @@ import {
WorkflowSharingModal,
WorkflowsPage,
} from '../pages';
import { getVisibleSelect } from '../utils';

/**
* User U1 - Instance owner
Expand Down Expand Up @@ -129,4 +130,38 @@ describe('Sharing', { disableAutoLogin: true }, () => {
credentialsPage.getters.credentialCard('Credential C2').click();
credentialsModal.getters.testSuccessTag().should('be.visible');
});

it.only('should work for admin role on credentials created by others (also can share it with themselves)', () => {
cy.signin(INSTANCE_MEMBERS[0]);

cy.visit(credentialsPage.url);
credentialsPage.getters.createCredentialButton().click();
credentialsModal.getters.newCredentialTypeOption('Notion API').click();
credentialsModal.getters.newCredentialTypeButton().click({ force: true });
credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890');
credentialsModal.actions.setName('Credential C3');
credentialsModal.actions.save();
credentialsModal.actions.close();

cy.signout();
cy.signin(INSTANCE_ADMIN);
cy.visit(credentialsPage.url);
credentialsPage.getters.credentialCard('Credential C3').click();
credentialsModal.getters.testSuccessTag().should('be.visible');
cy.get('input').should('not.have.length');
credentialsModal.actions.changeTab('Sharing');

credentialsModal.getters.usersSelect().click();
cy.getByTestId('user-email')
.filter(':visible')
.should('have.length', 3)
.contains(INSTANCE_ADMIN.email)
.should('have.length', 1);
getVisibleSelect().contains(INSTANCE_OWNER.email.toLowerCase()).click();

credentialsModal.actions.addUser(INSTANCE_MEMBERS[1].email);
credentialsModal.actions.addUser(INSTANCE_ADMIN.email);
credentialsModal.actions.saveSharing();
credentialsModal.actions.close();
});
});
66 changes: 65 additions & 1 deletion cypress/e2e/18-user-management.cy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { INSTANCE_MEMBERS, INSTANCE_OWNER, INSTANCE_ADMIN } from '../constants';
import { MainSidebar, SettingsSidebar, SettingsUsersPage, WorkflowPage } from '../pages';
import { PersonalSettingsPage } from '../pages/settings-personal';
import { getVisibleSelect } from '../utils';

/**
* User A - Instance owner
Expand Down Expand Up @@ -29,7 +30,9 @@ const settingsSidebar = new SettingsSidebar();
const mainSidebar = new MainSidebar();

describe('User Management', { disableAutoLogin: true }, () => {
before(() => cy.enableFeature('sharing'));
before(() => {
cy.enableFeature('sharing');
});

it('should prevent non-owners to access UM settings', () => {
usersSettingsPage.actions.loginAndVisit(
Expand Down Expand Up @@ -58,6 +61,67 @@ describe('User Management', { disableAutoLogin: true }, () => {
usersSettingsPage.getters.userActionsToggle(INSTANCE_ADMIN.email).should('exist');
});

it('should be able to change user role to Admin and back', () => {
cy.enableFeature('advancedPermissions');

usersSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password, true);

// Change role from Member to Admin
usersSettingsPage.getters
.userRoleSelect(INSTANCE_MEMBERS[0].email)
.find('input')
.should('contain.value', 'Member');
usersSettingsPage.getters.userRoleSelect(INSTANCE_MEMBERS[0].email).click();
getVisibleSelect().find('li').contains('Admin').click();
usersSettingsPage.getters
.userRoleSelect(INSTANCE_MEMBERS[0].email)
.find('input')
.should('contain.value', 'Admin');

usersSettingsPage.actions.loginAndVisit(
INSTANCE_MEMBERS[0].email,
INSTANCE_MEMBERS[0].password,
true,
);

// Change role from Admin to Member, then back to Admin
usersSettingsPage.getters
.userRoleSelect(INSTANCE_ADMIN.email)
.find('input')
.should('contain.value', 'Admin');

usersSettingsPage.getters.userRoleSelect(INSTANCE_ADMIN.email).click();
getVisibleSelect().find('li').contains('Member').click();
usersSettingsPage.getters
.userRoleSelect(INSTANCE_ADMIN.email)
.find('input')
.should('contain.value', 'Member');

usersSettingsPage.actions.loginAndVisit(INSTANCE_ADMIN.email, INSTANCE_ADMIN.password, false);
usersSettingsPage.actions.loginAndVisit(
INSTANCE_MEMBERS[0].email,
INSTANCE_MEMBERS[0].password,
true,
);

usersSettingsPage.getters.userRoleSelect(INSTANCE_ADMIN.email).click();
getVisibleSelect().find('li').contains('Admin').click();
usersSettingsPage.getters
.userRoleSelect(INSTANCE_ADMIN.email)
.find('input')
.should('contain.value', 'Admin');

usersSettingsPage.actions.loginAndVisit(INSTANCE_ADMIN.email, INSTANCE_ADMIN.password, true);
usersSettingsPage.getters.userRoleSelect(INSTANCE_MEMBERS[0].email).click();
getVisibleSelect().find('li').contains('Member').click();
usersSettingsPage.getters
.userRoleSelect(INSTANCE_MEMBERS[0].email)
.find('input')
.should('contain.value', 'Member');

cy.disableFeature('advancedPermissions');
});

it('should be able to change theme', () => {
personalSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password);

Expand Down
23 changes: 23 additions & 0 deletions cypress/e2e/35-admin-user-smoke-test.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { INSTANCE_ADMIN, INSTANCE_OWNER } from '../constants';
import { SettingsPage } from '../pages/settings';

const settingsPage = new SettingsPage();

describe('Admin user', { disableAutoLogin: true }, () => {
it('should see same Settings sub menu items as instance owner', () => {
cy.signin(INSTANCE_OWNER);
cy.visit(settingsPage.url);

let ownerMenuItems = 0;

settingsPage.getters.menuItems().then(($el) => {
ownerMenuItems = $el.length;
});

cy.signout();
cy.signin(INSTANCE_ADMIN);
cy.visit(settingsPage.url);

settingsPage.getters.menuItems().should('have.length', ownerMenuItems);
});
});
13 changes: 8 additions & 5 deletions cypress/pages/modals/credentials-modal.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BasePage } from '../base';
import { getVisibleSelect } from '../../utils';

export class CredentialsModal extends BasePage {
getters = {
Expand Down Expand Up @@ -30,11 +31,7 @@ export class CredentialsModal extends BasePage {
actions = {
addUser: (email: string) => {
this.getters.usersSelect().click();
this.getters
.usersSelect()
.get('.el-select-dropdown__item')
.contains(email.toLowerCase())
.click();
getVisibleSelect().contains(email.toLowerCase()).click();
},
setName: (name: string) => {
this.getters.name().click();
Expand All @@ -48,6 +45,12 @@ export class CredentialsModal extends BasePage {
if (test) cy.wait('@testCredential');
this.getters.saveButton().should('contain.text', 'Saved');
},
saveSharing: (test = false) => {
cy.intercept('PUT', '/rest/credentials/*/share').as('shareCredential');
this.getters.saveButton().click({ force: true });
cy.wait('@shareCredential');
this.getters.saveButton().should('contain.text', 'Saved');
},
close: () => {
this.getters.closeButton().click();
},
Expand Down
2 changes: 2 additions & 0 deletions cypress/pages/settings-users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export class SettingsUsersPage extends BasePage {
userItem: (email: string) => cy.getByTestId(`user-list-item-${email.toLowerCase()}`),
userActionsToggle: (email: string) =>
this.getters.userItem(email).find('[data-test-id="action-toggle"]'),
userRoleSelect: (email: string) =>
this.getters.userItem(email).find('[data-test-id="user-role-select"]'),
deleteUserAction: () =>
cy.getByTestId('action-toggle-dropdown').find('li:contains("Delete"):visible'),
confirmDeleteModal: () => cy.getByTestId('deleteUser-modal').last(),
Expand Down
9 changes: 9 additions & 0 deletions cypress/pages/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { BasePage } from './base';

export class SettingsPage extends BasePage {
url = '/settings';
getters = {
menuItems: () => cy.getByTestId('menu-item'),
};
actions = {};
}
1 change: 0 additions & 1 deletion packages/cli/src/controllers/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,6 @@ export class UsersController {
/**
* Delete a user. Optionally, designate a transferee for their workflows and credentials.
*/
@Authorized(['global', 'owner'])
@Delete('/:id')
@RequireGlobalScope('user:delete')
async deleteUser(req: UserRequest.Delete) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
:key="option.value"
:value="option.value"
:label="option.label"
:disabled="!!option.disabled"
size="small"
/>
</n8n-select>
Expand Down Expand Up @@ -118,7 +119,7 @@ export interface Props {
validationRules?: Array<Rule | RuleGroup>;
validators?: { [key: string]: IValidator | RuleGroup };
maxlength?: number;
options?: Array<{ value: string | number; label: string }>;
options?: Array<{ value: string | number; label: string; disabled?: boolean }>;
autocomplete?: string;
name?: string;
focusInitially?: boolean;
Expand Down
2 changes: 1 addition & 1 deletion packages/design-system/src/types/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export type IFormInput = {
validateOnBlur?: boolean;
infoText?: string;
placeholder?: string;
options?: Array<{ label: string; value: string }>;
options?: Array<{ label: string; value: string; disabled?: boolean }>;
autocomplete?:
| 'off'
| 'new-password'
Expand Down
3 changes: 2 additions & 1 deletion packages/editor-ui/src/Interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1790,7 +1790,8 @@ export type UTMCampaign =
| 'upgrade-users'
| 'upgrade-variables'
| 'upgrade-community-nodes'
| 'upgrade-workflow-history';
| 'upgrade-workflow-history'
| 'upgrade-advanced-permissions';

export type N8nBanners = {
[key in BannerName]: {
Expand Down
43 changes: 42 additions & 1 deletion packages/editor-ui/src/__tests__/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import router from '@/router';
import { VIEWS } from '@/constants';
import { setupServer } from '@/__tests__/server';
import { useSettingsStore } from '@/stores/settings.store';
import { useRBACStore } from '@/stores/rbac.store';
import type { Scope } from '@n8n/permissions';
import type { RouteRecordName } from 'vue-router';

const App = {
template: '<div />',
Expand Down Expand Up @@ -64,7 +67,7 @@ describe('router', () => {
'should resolve %s to %s if user has permissions',
async (path, name) => {
const settingsStore = useSettingsStore();
await settingsStore.getSettings();

settingsStore.settings.enterprise.debugInEditor = true;
settingsStore.settings.enterprise.workflowHistory = true;

Expand All @@ -73,4 +76,42 @@ describe('router', () => {
},
10000,
);

test.each<[string, RouteRecordName, Scope[]]>([
['/settings/users', VIEWS.WORKFLOWS, []],
['/settings/users', VIEWS.USERS_SETTINGS, ['user:create', 'user:update']],
['/settings/environments', VIEWS.WORKFLOWS, []],
['/settings/environments', VIEWS.SOURCE_CONTROL, ['sourceControl:manage']],
['/settings/external-secrets', VIEWS.WORKFLOWS, []],
[
'/settings/external-secrets',
VIEWS.EXTERNAL_SECRETS_SETTINGS,
['externalSecretsProvider:list', 'externalSecretsProvider:update'],
],
['/settings/sso', VIEWS.WORKFLOWS, []],
['/settings/sso', VIEWS.SSO_SETTINGS, ['saml:manage']],
['/settings/log-streaming', VIEWS.WORKFLOWS, []],
['/settings/log-streaming', VIEWS.LOG_STREAMING_SETTINGS, ['logStreaming:manage']],
['/settings/community-nodes', VIEWS.WORKFLOWS, []],
[
'/settings/community-nodes',
VIEWS.COMMUNITY_NODES,
['communityPackage:list', 'communityPackage:update'],
],
['/settings/ldap', VIEWS.WORKFLOWS, []],
['/settings/ldap', VIEWS.LDAP_SETTINGS, ['ldap:manage']],
])(
'should resolve %s to %s with %s user permissions',
async (path, name, scopes) => {
const settingsStore = useSettingsStore();
const rbacStore = useRBACStore();

settingsStore.settings.communityNodesEnabled = true;
rbacStore.setGlobalScopes(scopes);

await router.push(path);
expect(router.currentRoute.value.name).toBe(name);
},
10000,
);
});
7 changes: 5 additions & 2 deletions packages/editor-ui/src/api/invitation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CurrentUserResponse, IInviteResponse, IRestApiContext } from '@/Interface';
import type { CurrentUserResponse, IInviteResponse, IRestApiContext, IRole } from '@/Interface';
import type { IDataObject } from 'n8n-workflow';
import { makeRestApiRequest } from '@/utils/apiUtils';

Expand All @@ -10,7 +10,10 @@ type AcceptInvitationParams = {
password: string;
};

export async function inviteUsers(context: IRestApiContext, params: Array<{ email: string }>) {
export async function inviteUsers(
context: IRestApiContext,
params: Array<{ email: string; role: IRole }>,
) {
return makeRestApiRequest<IInviteResponse[]>(context, 'POST', '/invitations', params);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
data-test-id="credentials-config-container-test-success"
/>

<template v-if="credentialPermissions.updateConnection">
<template v-if="credentialPermissions.update">
<n8n-notice v-if="documentationUrl && credentialProperties.length" theme="warning">
{{ $locale.baseText('credentialEdit.credentialConfig.needHelpFillingOutTheseFields') }}
<span class="ml-4xs">
Expand Down Expand Up @@ -104,7 +104,7 @@
</enterprise-edition>

<CredentialInputs
v-if="credentialType && credentialPermissions.updateConnection"
v-if="credentialType && credentialPermissions.update"
:credentialData="credentialData"
:credentialProperties="credentialProperties"
:documentationUrl="documentationUrl"
Expand Down
Loading

0 comments on commit dbd62a4

Please sign in to comment.