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 {
CredentialsModal,
CredentialsPage,
@ -7,6 +7,7 @@ import {
WorkflowSharingModal,
WorkflowsPage,
} from '../pages';
import { getVisibleSelect } from '../utils';
/**
* User U1 - Instance owner
@ -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();
});
});

View file

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

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 { getVisibleSelect } from '../../utils';
export class CredentialsModal extends BasePage {
getters = {
@ -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();
@ -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();
},

View file

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

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.
*/
@Authorized(['global', 'owner'])
@Delete('/:id')
@RequireGlobalScope('user:delete')
async deleteUser(req: UserRequest.Delete) {

View file

@ -51,6 +51,7 @@
:key="option.value"
:value="option.value"
:label="option.label"
:disabled="!!option.disabled"
size="small"
/>
</n8n-select>
@ -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;

View file

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

View file

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

View file

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

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 { makeRestApiRequest } from '@/utils/apiUtils';
@ -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);
}

View file

@ -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">
@ -104,7 +104,7 @@
</enterprise-edition>
<CredentialInputs
v-if="credentialType && credentialPermissions.updateConnection"
v-if="credentialType && credentialPermissions.update"
:credentialData="credentialData"
:credentialProperties="credentialProperties"
:documentationUrl="documentationUrl"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -416,7 +416,8 @@
"credentialEdit.oAuthButton.connectMyAccount": "Connect my account",
"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.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.fallback": "the owner",
"credentialEdit.credentialSharing.select.placeholder": "Add users...",
@ -1458,6 +1459,8 @@
"settings.users.usersEmailedError": "Couldn't send invite email",
"settings.users.usersInvited": "Users invited",
"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.n8napi": "n8n API",
"settings.log-streaming": "Log Streaming",

View file

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

View file

@ -71,6 +71,7 @@ import {
isValidTheme,
updateTheme,
} from './ui.utils';
import { useUsersStore } from './users.store';
let savedTheme: ThemeOption = 'system';
try {
@ -373,6 +374,7 @@ export const useUIStore = defineStore(STORES.UI, {
let linkUrl = '';
const searchParams = new URLSearchParams();
const { isInstanceOwner } = useUsersStore();
if (deploymentType === 'cloud' && hasPermission(['instanceOwner'])) {
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 => {
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);
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 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;
},
async reinviteUser(params: { email: string }): Promise<void> {

View file

@ -35,6 +35,15 @@
@click:button="goToUpgrade"
/>
</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.
-->
<div
@ -52,7 +61,25 @@
@copyPasswordResetLink="onCopyPasswordResetLink"
@allowSSOManualLogin="onAllowSSOManualLogin"
@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>
</template>
@ -62,7 +89,7 @@ import { defineComponent } from 'vue';
import { mapStores } from 'pinia';
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 { copyPaste } from '@/mixins/copyPaste';
import { useUIStore } from '@/stores/ui.store';
@ -113,11 +140,18 @@ export default defineComponent({
{
label: this.$locale.baseText('settings.users.actions.delete'),
value: 'delete',
guard: (user) =>
hasPermission(['rbac'], { rbac: { scope: 'user:delete' } }) &&
user.id !== this.usersStore.currentUserId,
},
{
label: this.$locale.baseText('settings.users.actions.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'),
@ -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 [
{
value: ROLE.Member,
@ -142,11 +181,12 @@ export default defineComponent({
{
value: ROLE.Admin,
label: this.$locale.baseText('auth.roles.admin'),
disabled: !this.isAdvancedPermissionsEnabled,
},
];
},
canUpdateRole(): boolean {
return hasPermission(['rbac'], { rbac: { scope: 'user:update' } });
return hasPermission(['rbac'], { rbac: { scope: ['user:update', 'user:changeRole'] } });
},
},
methods: {
@ -235,6 +275,9 @@ export default defineComponent({
goToUpgrade() {
void this.uiStore.goToUpgrade('settings-users', 'upgrade-users');
},
goToUpgradeAdvancedPermissions() {
void this.uiStore.goToUpgrade('settings-users', 'upgrade-advanced-permissions');
},
async onRoleChange(user: IUser, name: IRole) {
await this.usersStore.updateRole({ id: user.id, role: { scope: 'global', name } });
},