feat: Introduce advanced permissions (#7844)

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 <68596159+valya@users.noreply.github.com>
Co-authored-by: Alex Grozav <alex@grozav.com>
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
This commit is contained in:
Csaba Tuncsik 2023-12-08 12:52:25 +01:00 committed by GitHub
parent e00577b1d3
commit dbd62a4992
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 364 additions and 71 deletions

View file

@ -1,4 +1,4 @@
import { INSTANCE_MEMBERS, INSTANCE_OWNER } from '../constants'; import { INSTANCE_MEMBERS, INSTANCE_OWNER, INSTANCE_ADMIN } from '../constants';
import { import {
CredentialsModal, CredentialsModal,
CredentialsPage, CredentialsPage,
@ -7,6 +7,7 @@ import {
WorkflowSharingModal, WorkflowSharingModal,
WorkflowsPage, WorkflowsPage,
} from '../pages'; } from '../pages';
import { getVisibleSelect } from '../utils';
/** /**
* User U1 - Instance owner * User U1 - Instance owner
@ -129,4 +130,38 @@ describe('Sharing', { disableAutoLogin: true }, () => {
credentialsPage.getters.credentialCard('Credential C2').click(); credentialsPage.getters.credentialCard('Credential C2').click();
credentialsModal.getters.testSuccessTag().should('be.visible'); 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();
});
}); });

View file

@ -1,6 +1,7 @@
import { INSTANCE_MEMBERS, INSTANCE_OWNER, INSTANCE_ADMIN } from '../constants'; import { INSTANCE_MEMBERS, INSTANCE_OWNER, INSTANCE_ADMIN } from '../constants';
import { MainSidebar, SettingsSidebar, SettingsUsersPage, WorkflowPage } from '../pages'; import { MainSidebar, SettingsSidebar, SettingsUsersPage, WorkflowPage } from '../pages';
import { PersonalSettingsPage } from '../pages/settings-personal'; import { PersonalSettingsPage } from '../pages/settings-personal';
import { getVisibleSelect } from '../utils';
/** /**
* User A - Instance owner * User A - Instance owner
@ -29,7 +30,9 @@ const settingsSidebar = new SettingsSidebar();
const mainSidebar = new MainSidebar(); const mainSidebar = new MainSidebar();
describe('User Management', { disableAutoLogin: true }, () => { describe('User Management', { disableAutoLogin: true }, () => {
before(() => cy.enableFeature('sharing')); before(() => {
cy.enableFeature('sharing');
});
it('should prevent non-owners to access UM settings', () => { it('should prevent non-owners to access UM settings', () => {
usersSettingsPage.actions.loginAndVisit( usersSettingsPage.actions.loginAndVisit(
@ -58,6 +61,67 @@ describe('User Management', { disableAutoLogin: true }, () => {
usersSettingsPage.getters.userActionsToggle(INSTANCE_ADMIN.email).should('exist'); 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', () => { it('should be able to change theme', () => {
personalSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password); personalSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password);

View file

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

View file

@ -1,4 +1,5 @@
import { BasePage } from '../base'; import { BasePage } from '../base';
import { getVisibleSelect } from '../../utils';
export class CredentialsModal extends BasePage { export class CredentialsModal extends BasePage {
getters = { getters = {
@ -30,11 +31,7 @@ export class CredentialsModal extends BasePage {
actions = { actions = {
addUser: (email: string) => { addUser: (email: string) => {
this.getters.usersSelect().click(); this.getters.usersSelect().click();
this.getters getVisibleSelect().contains(email.toLowerCase()).click();
.usersSelect()
.get('.el-select-dropdown__item')
.contains(email.toLowerCase())
.click();
}, },
setName: (name: string) => { setName: (name: string) => {
this.getters.name().click(); this.getters.name().click();
@ -48,6 +45,12 @@ export class CredentialsModal extends BasePage {
if (test) cy.wait('@testCredential'); if (test) cy.wait('@testCredential');
this.getters.saveButton().should('contain.text', 'Saved'); 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: () => { close: () => {
this.getters.closeButton().click(); this.getters.closeButton().click();
}, },

View file

@ -20,6 +20,8 @@ export class SettingsUsersPage extends BasePage {
userItem: (email: string) => cy.getByTestId(`user-list-item-${email.toLowerCase()}`), userItem: (email: string) => cy.getByTestId(`user-list-item-${email.toLowerCase()}`),
userActionsToggle: (email: string) => userActionsToggle: (email: string) =>
this.getters.userItem(email).find('[data-test-id="action-toggle"]'), 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: () => deleteUserAction: () =>
cy.getByTestId('action-toggle-dropdown').find('li:contains("Delete"):visible'), cy.getByTestId('action-toggle-dropdown').find('li:contains("Delete"):visible'),
confirmDeleteModal: () => cy.getByTestId('deleteUser-modal').last(), confirmDeleteModal: () => cy.getByTestId('deleteUser-modal').last(),

View file

@ -0,0 +1,9 @@
import { BasePage } from './base';
export class SettingsPage extends BasePage {
url = '/settings';
getters = {
menuItems: () => cy.getByTestId('menu-item'),
};
actions = {};
}

View file

@ -170,7 +170,6 @@ export class UsersController {
/** /**
* Delete a user. Optionally, designate a transferee for their workflows and credentials. * Delete a user. Optionally, designate a transferee for their workflows and credentials.
*/ */
@Authorized(['global', 'owner'])
@Delete('/:id') @Delete('/:id')
@RequireGlobalScope('user:delete') @RequireGlobalScope('user:delete')
async deleteUser(req: UserRequest.Delete) { async deleteUser(req: UserRequest.Delete) {

View file

@ -51,6 +51,7 @@
:key="option.value" :key="option.value"
:value="option.value" :value="option.value"
:label="option.label" :label="option.label"
:disabled="!!option.disabled"
size="small" size="small"
/> />
</n8n-select> </n8n-select>
@ -118,7 +119,7 @@ export interface Props {
validationRules?: Array<Rule | RuleGroup>; validationRules?: Array<Rule | RuleGroup>;
validators?: { [key: string]: IValidator | RuleGroup }; validators?: { [key: string]: IValidator | RuleGroup };
maxlength?: number; maxlength?: number;
options?: Array<{ value: string | number; label: string }>; options?: Array<{ value: string | number; label: string; disabled?: boolean }>;
autocomplete?: string; autocomplete?: string;
name?: string; name?: string;
focusInitially?: boolean; focusInitially?: boolean;

View file

@ -44,7 +44,7 @@ export type IFormInput = {
validateOnBlur?: boolean; validateOnBlur?: boolean;
infoText?: string; infoText?: string;
placeholder?: string; placeholder?: string;
options?: Array<{ label: string; value: string }>; options?: Array<{ label: string; value: string; disabled?: boolean }>;
autocomplete?: autocomplete?:
| 'off' | 'off'
| 'new-password' | 'new-password'

View file

@ -1790,7 +1790,8 @@ export type UTMCampaign =
| 'upgrade-users' | 'upgrade-users'
| 'upgrade-variables' | 'upgrade-variables'
| 'upgrade-community-nodes' | 'upgrade-community-nodes'
| 'upgrade-workflow-history'; | 'upgrade-workflow-history'
| 'upgrade-advanced-permissions';
export type N8nBanners = { export type N8nBanners = {
[key in BannerName]: { [key in BannerName]: {

View file

@ -4,6 +4,9 @@ import router from '@/router';
import { VIEWS } from '@/constants'; import { VIEWS } from '@/constants';
import { setupServer } from '@/__tests__/server'; import { setupServer } from '@/__tests__/server';
import { useSettingsStore } from '@/stores/settings.store'; 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 = { const App = {
template: '<div />', template: '<div />',
@ -64,7 +67,7 @@ describe('router', () => {
'should resolve %s to %s if user has permissions', 'should resolve %s to %s if user has permissions',
async (path, name) => { async (path, name) => {
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
await settingsStore.getSettings();
settingsStore.settings.enterprise.debugInEditor = true; settingsStore.settings.enterprise.debugInEditor = true;
settingsStore.settings.enterprise.workflowHistory = true; settingsStore.settings.enterprise.workflowHistory = true;
@ -73,4 +76,42 @@ describe('router', () => {
}, },
10000, 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,
);
}); });

View file

@ -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 type { IDataObject } from 'n8n-workflow';
import { makeRestApiRequest } from '@/utils/apiUtils'; import { makeRestApiRequest } from '@/utils/apiUtils';
@ -10,7 +10,10 @@ type AcceptInvitationParams = {
password: string; 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); return makeRestApiRequest<IInviteResponse[]>(context, 'POST', '/invitations', params);
} }

View file

@ -61,7 +61,7 @@
data-test-id="credentials-config-container-test-success" 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"> <n8n-notice v-if="documentationUrl && credentialProperties.length" theme="warning">
{{ $locale.baseText('credentialEdit.credentialConfig.needHelpFillingOutTheseFields') }} {{ $locale.baseText('credentialEdit.credentialConfig.needHelpFillingOutTheseFields') }}
<span class="ml-4xs"> <span class="ml-4xs">
@ -104,7 +104,7 @@
</enterprise-edition> </enterprise-edition>
<CredentialInputs <CredentialInputs
v-if="credentialType && credentialPermissions.updateConnection" v-if="credentialType && credentialPermissions.update"
:credentialData="credentialData" :credentialData="credentialData"
:credentialProperties="credentialProperties" :credentialProperties="credentialProperties"
:documentationUrl="documentationUrl" :documentationUrl="documentationUrl"

View file

@ -17,7 +17,7 @@
<InlineNameEdit <InlineNameEdit
:modelValue="credentialName" :modelValue="credentialName"
:subtitle="credentialType ? credentialType.displayName : ''" :subtitle="credentialType ? credentialType.displayName : ''"
:readonly="!credentialPermissions.updateName || !credentialType" :readonly="!credentialPermissions.update || !credentialType"
type="Credential" type="Credential"
@update:modelValue="onNameEdit" @update:modelValue="onNameEdit"
data-test-id="credential-name" data-test-id="credential-name"
@ -224,6 +224,7 @@ export default defineComponent({
selectedCredential: '', selectedCredential: '',
requiredCredentials: false, // Are credentials required or optional for the node requiredCredentials: false, // Are credentials required or optional for the node
hasUserSpecifiedName: false, hasUserSpecifiedName: false,
isSharedWithChanged: false,
}; };
}, },
async mounted() { async mounted() {
@ -683,6 +684,7 @@ export default defineComponent({
...this.credentialData, ...this.credentialData,
sharedWith: sharees, sharedWith: sharees,
}; };
this.isSharedWithChanged = true;
this.hasUnsavedChanges = true; this.hasUnsavedChanges = true;
}, },
@ -920,10 +922,23 @@ export default defineComponent({
): Promise<ICredentialsResponse | null> { ): Promise<ICredentialsResponse | null> {
let credential; let credential;
try { try {
if (this.credentialPermissions.update) {
credential = await this.credentialsStore.updateCredential({ credential = await this.credentialsStore.updateCredential({
id: this.credentialId, id: this.credentialId,
data: credentialDetails, data: credentialDetails,
}); });
}
if (
this.credentialPermissions.share &&
this.isSharedWithChanged &&
credentialDetails.sharedWith
) {
credential = await this.credentialsStore.setCredentialSharedWith({
credentialId: credentialDetails.id,
sharedWith: credentialDetails.sharedWith,
});
this.isSharedWithChanged = false;
}
this.hasUnsavedChanges = false; this.hasUnsavedChanges = false;
} catch (error) { } catch (error) {
this.showError( this.showError(

View file

@ -9,7 +9,7 @@
<el-col :span="16"> <el-col :span="16">
<div v-for="node in nodesWithAccess" :key="node.name" :class="$style.valueLabel"> <div v-for="node in nodesWithAccess" :key="node.name" :class="$style.valueLabel">
<el-checkbox <el-checkbox
v-if="credentialPermissions.updateNodeAccess" v-if="credentialPermissions.update"
:label=" :label="
$locale.headerText({ $locale.headerText({
key: `headers.${shortNodeType(node)}.displayName`, key: `headers.${shortNodeType(node)}.displayName`,

View file

@ -34,7 +34,7 @@
<n8n-info-tip v-if="credentialPermissions.isOwner" :bold="false" class="mb-s"> <n8n-info-tip v-if="credentialPermissions.isOwner" :bold="false" class="mb-s">
{{ $locale.baseText('credentialEdit.credentialSharing.info.owner') }} {{ $locale.baseText('credentialEdit.credentialSharing.info.owner') }}
</n8n-info-tip> </n8n-info-tip>
<n8n-info-tip v-if="!credentialPermissions.updateSharing" :bold="false" class="mb-s"> <n8n-info-tip v-if="!credentialPermissions.share" :bold="false" class="mb-s">
{{ {{
$locale.baseText('credentialEdit.credentialSharing.info.sharee', { $locale.baseText('credentialEdit.credentialSharing.info.sharee', {
interpolate: { credentialOwnerName }, interpolate: { credentialOwnerName },
@ -42,10 +42,14 @@
}} }}
</n8n-info-tip> </n8n-info-tip>
<n8n-info-tip v-if="credentialPermissions.read" class="mb-s" :bold="false"> <n8n-info-tip v-if="credentialPermissions.read" class="mb-s" :bold="false">
{{ $locale.baseText('credentialEdit.credentialSharing.info.reader') }} <i18n-t keypath="credentialEdit.credentialSharing.info.reader">
<template v-if="!isCredentialSharedWithCurrentUser" #notShared>
{{ $locale.baseText('credentialEdit.credentialSharing.info.notShared') }}
</template>
</i18n-t>
</n8n-info-tip> </n8n-info-tip>
<n8n-user-select <n8n-user-select
v-if="credentialPermissions.updateSharing" v-if="credentialPermissions.share"
class="mb-s" class="mb-s"
size="large" size="large"
:users="usersList" :users="usersList"
@ -62,7 +66,7 @@
:actions="usersListActions" :actions="usersListActions"
:users="sharedWithList" :users="sharedWithList"
:currentUserId="usersStore.currentUser.id" :currentUserId="usersStore.currentUser.id"
:readonly="!credentialPermissions.updateSharing" :readonly="!credentialPermissions.share"
@delete="onRemoveSharee" @delete="onRemoveSharee"
/> />
</div> </div>
@ -114,13 +118,12 @@ export default defineComponent({
}, },
usersList(): IUser[] { usersList(): IUser[] {
return this.usersStore.allUsers.filter((user: IUser) => { return this.usersStore.allUsers.filter((user: IUser) => {
const isCurrentUser = user.id === this.usersStore.currentUser?.id;
const isAlreadySharedWithUser = (this.credentialData.sharedWith || []).find( const isAlreadySharedWithUser = (this.credentialData.sharedWith || []).find(
(sharee: IUser) => sharee.id === user.id, (sharee: IUser) => sharee.id === user.id,
); );
const isOwner = this.credentialData.ownedBy.id === user.id; const isOwner = this.credentialData.ownedBy?.id === user.id;
return !isCurrentUser && !isAlreadySharedWithUser && !isOwner; return !isAlreadySharedWithUser && !isOwner;
}); });
}, },
sharedWithList(): IUser[] { sharedWithList(): IUser[] {
@ -134,6 +137,11 @@ export default defineComponent({
credentialOwnerName(): string { credentialOwnerName(): string {
return this.credentialsStore.getCredentialOwnerNameById(`${this.credentialId}`); return this.credentialsStore.getCredentialOwnerNameById(`${this.credentialId}`);
}, },
isCredentialSharedWithCurrentUser(): boolean {
return (this.credentialData.sharedWith || []).some((sharee: IUser) => {
return sharee.id === this.usersStore.currentUser?.id;
});
},
}, },
methods: { methods: {
async onAddSharee(userId: string) { async onAddSharee(userId: string) {

View file

@ -12,6 +12,15 @@
:eventBus="modalBus" :eventBus="modalBus"
> >
<template #content> <template #content>
<n8n-notice v-if="!isAdvancedPermissionsEnabled">
<i18n-t keypath="settings.users.advancedPermissions.warning">
<template #link>
<n8n-link size="small" @click="goToUpgradeAdvancedPermissions">
{{ $locale.baseText('settings.users.advancedPermissions.warning.link') }}
</n8n-link>
</template>
</i18n-t>
</n8n-notice>
<div v-if="showInviteUrls"> <div v-if="showInviteUrls">
<n8n-users-list :users="invitedUsers"> <n8n-users-list :users="invitedUsers">
<template #actions="{ user }"> <template #actions="{ user }">
@ -58,10 +67,11 @@ import { useToast } from '@/composables/useToast';
import { copyPaste } from '@/mixins/copyPaste'; import { copyPaste } from '@/mixins/copyPaste';
import Modal from './Modal.vue'; import Modal from './Modal.vue';
import type { IFormInputs, IInviteResponse, IUser } from '@/Interface'; import type { IFormInputs, IInviteResponse, IUser } from '@/Interface';
import { VALID_EMAIL_REGEX, INVITE_USER_MODAL_KEY } from '@/constants';
import { ROLE } from '@/utils/userUtils'; import { ROLE } from '@/utils/userUtils';
import { EnterpriseEditionFeature, VALID_EMAIL_REGEX, INVITE_USER_MODAL_KEY } from '@/constants';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store';
import { createEventBus } from 'n8n-design-system/utils'; import { createEventBus } from 'n8n-design-system/utils';
const NAME_EMAIL_FORMAT_REGEX = /^.* <(.*)>$/; const NAME_EMAIL_FORMAT_REGEX = /^.* <(.*)>$/;
@ -97,6 +107,7 @@ export default defineComponent({
formBus: createEventBus(), formBus: createEventBus(),
modalBus: createEventBus(), modalBus: createEventBus(),
emails: '', emails: '',
role: 'member',
showInviteUrls: null as IInviteResponse[] | null, showInviteUrls: null as IInviteResponse[] | null,
loading: false, loading: false,
INVITE_USER_MODAL_KEY, INVITE_USER_MODAL_KEY,
@ -132,6 +143,11 @@ export default defineComponent({
value: ROLE.Member, value: ROLE.Member,
label: this.$locale.baseText('auth.roles.member'), label: this.$locale.baseText('auth.roles.member'),
}, },
{
value: ROLE.Admin,
label: this.$locale.baseText('auth.roles.admin'),
disabled: !this.isAdvancedPermissionsEnabled,
},
], ],
capitalize: true, capitalize: true,
}, },
@ -139,7 +155,7 @@ export default defineComponent({
]; ];
}, },
computed: { computed: {
...mapStores(useUsersStore, useSettingsStore), ...mapStores(useUsersStore, useSettingsStore, useUIStore),
emailsCount(): number { emailsCount(): number {
return this.emails.split(',').filter((email: string) => !!email.trim()).length; return this.emails.split(',').filter((email: string) => !!email.trim()).length;
}, },
@ -167,6 +183,11 @@ export default defineComponent({
) )
: []; : [];
}, },
isAdvancedPermissionsEnabled(): boolean {
return this.settingsStore.isEnterpriseFeatureEnabled(
EnterpriseEditionFeature.AdvancedPermissions,
);
},
}, },
methods: { methods: {
validateEmails(value: string | number | boolean | null | undefined) { validateEmails(value: string | number | boolean | null | undefined) {
@ -193,6 +214,9 @@ export default defineComponent({
if (e.name === 'emails') { if (e.name === 'emails') {
this.emails = e.value; this.emails = e.value;
} }
if (e.name === 'role') {
this.role = e.value;
}
}, },
async onSubmit() { async onSubmit() {
try { try {
@ -200,7 +224,7 @@ export default defineComponent({
const emails = this.emails const emails = this.emails
.split(',') .split(',')
.map((email) => ({ email: getEmail(email) })) .map((email) => ({ email: getEmail(email), role: this.role }))
.filter((invite) => !!invite.email); .filter((invite) => !!invite.email);
if (emails.length === 0) { if (emails.length === 0) {
@ -308,6 +332,9 @@ export default defineComponent({
this.showCopyInviteLinkToast([]); this.showCopyInviteLinkToast([]);
} }
}, },
goToUpgradeAdvancedPermissions() {
void this.uiStore.goToUpgrade('settings-users', 'upgrade-advanced-permissions');
},
}, },
}); });
</script> </script>

View file

@ -210,13 +210,12 @@ export default defineComponent({
}, },
usersList(): IUser[] { usersList(): IUser[] {
return this.usersStore.allUsers.filter((user: IUser) => { return this.usersStore.allUsers.filter((user: IUser) => {
const isCurrentUser = user.id === this.usersStore.currentUser?.id;
const isAlreadySharedWithUser = (this.sharedWith || []).find( const isAlreadySharedWithUser = (this.sharedWith || []).find(
(sharee) => sharee.id === user.id, (sharee) => sharee.id === user.id,
); );
const isOwner = this.workflow?.ownedBy?.id === user.id; const isOwner = this.workflow?.ownedBy?.id === user.id;
return !isCurrentUser && !isAlreadySharedWithUser && !isOwner; return !isAlreadySharedWithUser && !isOwner;
}); });
}, },
sharedWithList(): Array<Partial<IUser>> { sharedWithList(): Array<Partial<IUser>> {

View file

@ -522,6 +522,7 @@ export const enum EnterpriseEditionFeature {
DebugInEditor = 'debugInEditor', DebugInEditor = 'debugInEditor',
WorkflowHistory = 'workflowHistory', WorkflowHistory = 'workflowHistory',
WorkerView = 'workerView', WorkerView = 'workerView',
AdvancedPermissions = 'advancedPermissions',
} }
export const MAIN_NODE_PANEL_WIDTH = 360; export const MAIN_NODE_PANEL_WIDTH = 360;

View file

@ -446,7 +446,7 @@ export const nodeHelpers = defineComponent({
.getCredentialsByType(credentialTypeDescription.name) .getCredentialsByType(credentialTypeDescription.name)
.filter((credential: ICredentialsResponse) => { .filter((credential: ICredentialsResponse) => {
const permissions = getCredentialPermissions(currentUser, credential); const permissions = getCredentialPermissions(currentUser, credential);
return permissions.use; return permissions.read;
}); });
if (userCredentials === null) { if (userCredentials === null) {

View file

@ -7,7 +7,7 @@
import type { IUser, ICredentialsResponse, IWorkflowDb } from '@/Interface'; import type { IUser, ICredentialsResponse, IWorkflowDb } from '@/Interface';
import { EnterpriseEditionFeature, PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants'; import { EnterpriseEditionFeature, PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useRBACStore } from '@/stores/rbac.store'; import { hasPermission } from './rbac/permissions';
/** /**
* Old permissions implementation * Old permissions implementation
@ -64,7 +64,6 @@ export const parsePermissionsTable = (
export const getCredentialPermissions = (user: IUser | null, credential: ICredentialsResponse) => { export const getCredentialPermissions = (user: IUser | null, credential: ICredentialsResponse) => {
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const rbacStore = useRBACStore();
const isSharingEnabled = settingsStore.isEnterpriseFeatureEnabled( const isSharingEnabled = settingsStore.isEnterpriseFeatureEnabled(
EnterpriseEditionFeature.Sharing, EnterpriseEditionFeature.Sharing,
); );
@ -78,17 +77,30 @@ export const getCredentialPermissions = (user: IUser | null, credential: ICreden
name: UserRole.ResourceSharee, name: UserRole.ResourceSharee,
test: () => !!credential?.sharedWith?.find((sharee) => sharee.id === user?.id), test: () => !!credential?.sharedWith?.find((sharee) => sharee.id === user?.id),
}, },
{ name: 'read', test: () => rbacStore.hasScope('credential:read') },
{ name: 'save', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
{ name: 'updateName', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
{ name: 'updateConnection', test: [UserRole.ResourceOwner] },
{ {
name: 'updateSharing', name: 'read',
test: (permissions) =>
hasPermission(['rbac'], { rbac: { scope: 'credential:read' } }) || !!permissions.isOwner,
},
{
name: 'save',
test: (permissions) =>
hasPermission(['rbac'], { rbac: { scope: 'credential:create' } }) || !!permissions.isOwner,
},
{
name: 'update',
test: (permissions) => !!permissions.isOwner, test: (permissions) => !!permissions.isOwner,
}, },
{ name: 'updateNodeAccess', test: [UserRole.ResourceOwner] }, {
{ name: 'delete', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] }, name: 'share',
{ name: 'use', test: [UserRole.ResourceOwner, UserRole.ResourceSharee] }, test: (permissions) =>
hasPermission(['rbac'], { rbac: { scope: 'credential:share' } }) || !!permissions.isOwner,
},
{
name: 'delete',
test: (permissions) =>
hasPermission(['rbac'], { rbac: { scope: 'credential:delete' } }) || !!permissions.isOwner,
},
]; ];
return parsePermissionsTable(user, table); return parsePermissionsTable(user, table);
@ -96,7 +108,6 @@ export const getCredentialPermissions = (user: IUser | null, credential: ICreden
export const getWorkflowPermissions = (user: IUser | null, workflow: IWorkflowDb) => { export const getWorkflowPermissions = (user: IUser | null, workflow: IWorkflowDb) => {
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const rbacStore = useRBACStore();
const isSharingEnabled = settingsStore.isEnterpriseFeatureEnabled( const isSharingEnabled = settingsStore.isEnterpriseFeatureEnabled(
EnterpriseEditionFeature.Sharing, EnterpriseEditionFeature.Sharing,
); );
@ -109,11 +120,13 @@ export const getWorkflowPermissions = (user: IUser | null, workflow: IWorkflowDb
}, },
{ {
name: 'updateSharing', name: 'updateSharing',
test: (permissions) => !!permissions.isOwner, test: (permissions) =>
hasPermission(['rbac'], { rbac: { scope: 'workflow:share' } }) || !!permissions.isOwner,
}, },
{ {
name: 'delete', name: 'delete',
test: (permissions) => rbacStore.hasScope('workflow:delete') || !!permissions.isOwner, test: (permissions) =>
hasPermission(['rbac'], { rbac: { scope: 'workflow:delete' } }) || !!permissions.isOwner,
}, },
]; ];
@ -121,12 +134,11 @@ export const getWorkflowPermissions = (user: IUser | null, workflow: IWorkflowDb
}; };
export const getVariablesPermissions = (user: IUser | null) => { export const getVariablesPermissions = (user: IUser | null) => {
const rbacStore = useRBACStore();
const table: IPermissionsTable = [ const table: IPermissionsTable = [
{ name: 'create', test: () => rbacStore.hasScope('variable:create') }, { name: 'create', test: () => hasPermission(['rbac'], { rbac: { scope: 'variable:create' } }) },
{ name: 'edit', test: () => rbacStore.hasScope('variable:update') }, { name: 'edit', test: () => hasPermission(['rbac'], { rbac: { scope: 'variable:update' } }) },
{ name: 'delete', test: () => rbacStore.hasScope('variable:delete') }, { name: 'delete', test: () => hasPermission(['rbac'], { rbac: { scope: 'variable:delete' } }) },
{ name: 'use', test: () => rbacStore.hasScope('variable:read') }, { name: 'use', test: () => hasPermission(['rbac'], { rbac: { scope: 'variable:read' } }) },
]; ];
return parsePermissionsTable(user, table); return parsePermissionsTable(user, table);

View file

@ -416,7 +416,8 @@
"credentialEdit.oAuthButton.connectMyAccount": "Connect my account", "credentialEdit.oAuthButton.connectMyAccount": "Connect my account",
"credentialEdit.oAuthButton.signInWithGoogle": "Sign in with Google", "credentialEdit.oAuthButton.signInWithGoogle": "Sign in with Google",
"credentialEdit.credentialSharing.info.owner": "Sharing a credential allows people to use it in their workflows. They cannot access credential details.", "credentialEdit.credentialSharing.info.owner": "Sharing a credential allows people to use it in their workflows. They cannot access credential details.",
"credentialEdit.credentialSharing.info.reader": "You can view this credential because you have permission to read and share (and rename or delete it too). To use it in a workflow, ask the credential owner to share it with you.", "credentialEdit.credentialSharing.info.reader": "You can view this credential because you have permission to read and share (and rename or delete it too).{notShared}",
"credentialEdit.credentialSharing.info.notShared": "To use it in a workflow, ask the credential owner to share it with you.",
"credentialEdit.credentialSharing.info.sharee": "Only {credentialOwnerName} can change who this credential is shared with", "credentialEdit.credentialSharing.info.sharee": "Only {credentialOwnerName} can change who this credential is shared with",
"credentialEdit.credentialSharing.info.sharee.fallback": "the owner", "credentialEdit.credentialSharing.info.sharee.fallback": "the owner",
"credentialEdit.credentialSharing.select.placeholder": "Add users...", "credentialEdit.credentialSharing.select.placeholder": "Add users...",
@ -1458,6 +1459,8 @@
"settings.users.usersEmailedError": "Couldn't send invite email", "settings.users.usersEmailedError": "Couldn't send invite email",
"settings.users.usersInvited": "Users invited", "settings.users.usersInvited": "Users invited",
"settings.users.usersInvitedError": "Could not invite users", "settings.users.usersInvitedError": "Could not invite users",
"settings.users.advancedPermissions.warning": "{link} to unlock the ability to create additional admin users",
"settings.users.advancedPermissions.warning.link": "Upgrade",
"settings.api": "API", "settings.api": "API",
"settings.n8napi": "n8n API", "settings.n8napi": "n8n API",
"settings.log-streaming": "Log Streaming", "settings.log-streaming": "Log Streaming",

View file

@ -303,14 +303,6 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
credentialId: credential.id, credentialId: credential.id,
ownedBy: data.ownedBy, ownedBy: data.ownedBy,
}); });
const usersStore = useUsersStore();
if (data.sharedWith && data.ownedBy.id === usersStore.currentUserId) {
await this.setCredentialSharedWith({
credentialId: credential.id,
sharedWith: data.sharedWith,
});
}
} }
} else { } else {
this.upsertCredential(credential); this.upsertCredential(credential);
@ -365,7 +357,10 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
ownedBy: payload.ownedBy, ownedBy: payload.ownedBy,
}; };
}, },
async setCredentialSharedWith(payload: { sharedWith: IUser[]; credentialId: string }) { async setCredentialSharedWith(payload: {
sharedWith: IUser[];
credentialId: string;
}): Promise<ICredentialsResponse> {
if (useSettingsStore().isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing)) { if (useSettingsStore().isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing)) {
await setCredentialSharedWith(useRootStore().getRestApiContext, payload.credentialId, { await setCredentialSharedWith(useRootStore().getRestApiContext, payload.credentialId, {
shareWithIds: payload.sharedWith.map((sharee) => sharee.id), shareWithIds: payload.sharedWith.map((sharee) => sharee.id),
@ -376,6 +371,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
sharedWith: payload.sharedWith, sharedWith: payload.sharedWith,
}; };
} }
return this.credentials[payload.credentialId];
}, },
addCredentialSharee(payload: { credentialId: string; sharee: Partial<IUser> }): void { addCredentialSharee(payload: { credentialId: string; sharee: Partial<IUser> }): void {
this.credentials[payload.credentialId] = { this.credentials[payload.credentialId] = {

View file

@ -71,6 +71,7 @@ import {
isValidTheme, isValidTheme,
updateTheme, updateTheme,
} from './ui.utils'; } from './ui.utils';
import { useUsersStore } from './users.store';
let savedTheme: ThemeOption = 'system'; let savedTheme: ThemeOption = 'system';
try { try {
@ -373,6 +374,7 @@ export const useUIStore = defineStore(STORES.UI, {
let linkUrl = ''; let linkUrl = '';
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
const { isInstanceOwner } = useUsersStore();
if (deploymentType === 'cloud' && hasPermission(['instanceOwner'])) { if (deploymentType === 'cloud' && hasPermission(['instanceOwner'])) {
const adminPanelHost = new URL(window.location.href).host.split('.').slice(1).join('.'); const adminPanelHost = new URL(window.location.href).host.split('.').slice(1).join('.');

View file

@ -95,7 +95,7 @@ export const useUsersStore = defineStore(STORES.USERS, {
return (resource: ICredentialsResponse): boolean => { return (resource: ICredentialsResponse): boolean => {
const permissions = getCredentialPermissions(this.currentUser, resource); const permissions = getCredentialPermissions(this.currentUser, resource);
return permissions.use; return permissions.read;
}; };
}, },
}, },
@ -301,10 +301,16 @@ export const useUsersStore = defineStore(STORES.USERS, {
const users = await getUsers(rootStore.getRestApiContext); const users = await getUsers(rootStore.getRestApiContext);
this.addUsers(users); this.addUsers(users);
}, },
async inviteUsers(params: Array<{ email: string }>): Promise<IInviteResponse[]> { async inviteUsers(params: Array<{ email: string; role: IRole }>): Promise<IInviteResponse[]> {
const rootStore = useRootStore(); const rootStore = useRootStore();
const users = await inviteUsers(rootStore.getRestApiContext, params); const users = await inviteUsers(rootStore.getRestApiContext, params);
this.addUsers(users.map(({ user }) => ({ isPending: true, ...user }))); this.addUsers(
users.map(({ user }, index) => ({
isPending: true,
globalRole: { name: params[index].role },
...user,
})),
);
return users; return users;
}, },
async reinviteUser(params: { email: string }): Promise<void> { async reinviteUser(params: { email: string }): Promise<void> {

View file

@ -35,6 +35,15 @@
@click:button="goToUpgrade" @click:button="goToUpgrade"
/> />
</div> </div>
<n8n-notice v-if="!isAdvancedPermissionsEnabled">
<i18n-t keypath="settings.users.advancedPermissions.warning">
<template #link>
<n8n-link size="small" @click="goToUpgradeAdvancedPermissions">
{{ $locale.baseText('settings.users.advancedPermissions.warning.link') }}
</n8n-link>
</template>
</i18n-t>
</n8n-notice>
<!-- If there's more than 1 user it means the account quota was more than 1 in the past. So we need to allow instance owner to be able to delete users and transfer workflows. <!-- If there's more than 1 user it means the account quota was more than 1 in the past. So we need to allow instance owner to be able to delete users and transfer workflows.
--> -->
<div <div
@ -52,7 +61,25 @@
@copyPasswordResetLink="onCopyPasswordResetLink" @copyPasswordResetLink="onCopyPasswordResetLink"
@allowSSOManualLogin="onAllowSSOManualLogin" @allowSSOManualLogin="onAllowSSOManualLogin"
@disallowSSOManualLogin="onDisallowSSOManualLogin" @disallowSSOManualLogin="onDisallowSSOManualLogin"
>
<template #actions="{ user }">
<n8n-select
v-if="user.id !== usersStore.currentUserId"
:modelValue="user?.globalRole?.name || 'member'"
@update:modelValue="onRoleChange(user, $event)"
:disabled="!canUpdateRole"
data-test-id="user-role-select"
>
<n8n-option
v-for="role in userRoles"
:key="role.value"
:value="role.value"
:label="role.label"
:disabled="role.disabled"
/> />
</n8n-select>
</template>
</n8n-users-list>
</div> </div>
</div> </div>
</template> </template>
@ -62,7 +89,7 @@ import { defineComponent } from 'vue';
import { mapStores } from 'pinia'; import { mapStores } from 'pinia';
import { EnterpriseEditionFeature, INVITE_USER_MODAL_KEY, VIEWS } from '@/constants'; import { EnterpriseEditionFeature, INVITE_USER_MODAL_KEY, VIEWS } from '@/constants';
import type { IRole, IUser, IUserListAction } from '@/Interface'; import type { IUser, IUserListAction } from '@/Interface';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { copyPaste } from '@/mixins/copyPaste'; import { copyPaste } from '@/mixins/copyPaste';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
@ -113,11 +140,18 @@ export default defineComponent({
{ {
label: this.$locale.baseText('settings.users.actions.delete'), label: this.$locale.baseText('settings.users.actions.delete'),
value: 'delete', value: 'delete',
guard: (user) =>
hasPermission(['rbac'], { rbac: { scope: 'user:delete' } }) &&
user.id !== this.usersStore.currentUserId,
}, },
{ {
label: this.$locale.baseText('settings.users.actions.copyPasswordResetLink'), label: this.$locale.baseText('settings.users.actions.copyPasswordResetLink'),
value: 'copyPasswordResetLink', value: 'copyPasswordResetLink',
guard: () => this.settingsStore.isBelowUserQuota, guard: (user) =>
hasPermission(['rbac'], { rbac: { scope: 'user:resetPassword' } }) &&
this.settingsStore.isBelowUserQuota &&
!user.isPendingUser &&
user.id !== this.usersStore.currentUserId,
}, },
{ {
label: this.$locale.baseText('settings.users.actions.allowSSOManualLogin'), label: this.$locale.baseText('settings.users.actions.allowSSOManualLogin'),
@ -133,7 +167,12 @@ export default defineComponent({
}, },
]; ];
}, },
userRoles(): Array<{ value: IRole; label: string }> { isAdvancedPermissionsEnabled(): boolean {
return this.settingsStore.isEnterpriseFeatureEnabled(
EnterpriseEditionFeature.AdvancedPermissions,
);
},
userRoles(): Array<{ value: IRole; label: string; disabled?: boolean }> {
return [ return [
{ {
value: ROLE.Member, value: ROLE.Member,
@ -142,11 +181,12 @@ export default defineComponent({
{ {
value: ROLE.Admin, value: ROLE.Admin,
label: this.$locale.baseText('auth.roles.admin'), label: this.$locale.baseText('auth.roles.admin'),
disabled: !this.isAdvancedPermissionsEnabled,
}, },
]; ];
}, },
canUpdateRole(): boolean { canUpdateRole(): boolean {
return hasPermission(['rbac'], { rbac: { scope: 'user:update' } }); return hasPermission(['rbac'], { rbac: { scope: ['user:update', 'user:changeRole'] } });
}, },
}, },
methods: { methods: {
@ -235,6 +275,9 @@ export default defineComponent({
goToUpgrade() { goToUpgrade() {
void this.uiStore.goToUpgrade('settings-users', 'upgrade-users'); void this.uiStore.goToUpgrade('settings-users', 'upgrade-users');
}, },
goToUpgradeAdvancedPermissions() {
void this.uiStore.goToUpgrade('settings-users', 'upgrade-advanced-permissions');
},
async onRoleChange(user: IUser, name: IRole) { async onRoleChange(user: IUser, name: IRole) {
await this.usersStore.updateRole({ id: user.id, role: { scope: 'global', name } }); await this.usersStore.updateRole({ id: user.id, role: { scope: 'global', name } });
}, },