feat(editor): Replace middleware for Role checks with Scope checks (#7847)

This commit is contained in:
Alex Grozav 2023-11-29 10:35:40 +02:00 committed by GitHub
parent d4970410e1
commit 72852a60eb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 267 additions and 111 deletions

View file

@ -63,13 +63,23 @@ describe('User Management', { disableAutoLogin: true }, () => {
personalSettingsPage.actions.changeTheme('Dark');
cy.get('body').should('have.attr', 'data-theme', 'dark');
settingsSidebar.actions.back();
mainSidebar.getters.logo().should('have.attr', 'src', '/n8n-dev-logo-dark-mode.svg');
mainSidebar.getters
.logo()
.should('have.attr', 'src')
.then((src) => {
expect(src).to.include('/n8n-dev-logo-dark-mode.svg');
});
cy.visit(personalSettingsPage.url);
personalSettingsPage.actions.changeTheme('Light');
cy.get('body').should('have.attr', 'data-theme', 'light');
settingsSidebar.actions.back();
mainSidebar.getters.logo().should('have.attr', 'src', '/n8n-dev-logo.svg');
mainSidebar.getters
.logo()
.should('have.attr', 'src')
.then((src) => {
expect(src).to.include('/n8n-dev-logo.svg');
});
});
it('should delete user and their data', () => {

View file

@ -1,19 +1,21 @@
export type DefaultOperations = 'create' | 'read' | 'update' | 'delete' | 'list';
export type Resource =
| 'workflow'
| 'tag'
| 'user'
| 'auditLogs'
| 'communityPackage'
| 'credential'
| 'variable'
| 'sourceControl'
| 'externalSecretsProvider'
| 'externalSecret'
| 'eventBusEvent'
| 'eventBusDestination'
| 'orchestration'
| 'communityPackage'
| 'ldap'
| 'saml';
| 'logStreaming'
| 'orchestration'
| 'sourceControl'
| 'saml'
| 'tag'
| 'user'
| 'variable'
| 'workflow';
export type ResourceScope<
R extends Resource,
@ -22,45 +24,49 @@ export type ResourceScope<
export type WildcardScope = `${Resource}:*` | '*';
export type WorkflowScope = ResourceScope<'workflow', DefaultOperations | 'share'>;
export type TagScope = ResourceScope<'tag'>;
export type UserScope = ResourceScope<'user', DefaultOperations | 'resetPassword'>;
export type AuditLogsScope = ResourceScope<'auditLogs', 'manage'>;
export type CommunityPackageScope = ResourceScope<
'communityPackage',
'install' | 'uninstall' | 'update' | 'list' | 'manage'
>;
export type CredentialScope = ResourceScope<'credential', DefaultOperations | 'share'>;
export type VariableScope = ResourceScope<'variable'>;
export type SourceControlScope = ResourceScope<'sourceControl', 'pull' | 'push' | 'manage'>;
export type ExternalSecretScope = ResourceScope<'externalSecret', 'list'>;
export type ExternalSecretProviderScope = ResourceScope<
'externalSecretsProvider',
DefaultOperations | 'sync'
>;
export type ExternalSecretScope = ResourceScope<'externalSecret', 'list'>;
export type EventBusEventScope = ResourceScope<'eventBusEvent', DefaultOperations | 'query'>;
export type EventBusDestinationScope = ResourceScope<
'eventBusDestination',
DefaultOperations | 'test'
>;
export type OrchestrationScope = ResourceScope<'orchestration', 'read' | 'list'>;
export type CommunityPackageScope = ResourceScope<
'communityPackage',
'install' | 'uninstall' | 'update' | 'list'
>;
export type EventBusEventScope = ResourceScope<'eventBusEvent', DefaultOperations | 'query'>;
export type LdapScope = ResourceScope<'ldap', 'manage' | 'sync'>;
export type LogStreamingScope = ResourceScope<'logStreaming', 'manage'>;
export type OrchestrationScope = ResourceScope<'orchestration', 'read' | 'list'>;
export type SamlScope = ResourceScope<'saml', 'manage'>;
export type SourceControlScope = ResourceScope<'sourceControl', 'pull' | 'push' | 'manage'>;
export type TagScope = ResourceScope<'tag'>;
export type UserScope = ResourceScope<'user', DefaultOperations | 'resetPassword'>;
export type VariableScope = ResourceScope<'variable'>;
export type WorkflowScope = ResourceScope<'workflow', DefaultOperations | 'share'>;
export type Scope =
| WorkflowScope
| TagScope
| UserScope
| AuditLogsScope
| CommunityPackageScope
| CredentialScope
| VariableScope
| SourceControlScope
| ExternalSecretProviderScope
| ExternalSecretScope
| EventBusEventScope
| EventBusDestinationScope
| OrchestrationScope
| CommunityPackageScope
| LdapScope
| SamlScope;
| LogStreamingScope
| OrchestrationScope
| SamlScope
| SourceControlScope
| TagScope
| UserScope
| VariableScope
| WorkflowScope;
export type ScopeLevel = 'global' | 'project' | 'resource';
export type GetScopeLevel<T extends ScopeLevel> = Record<T, Scope[]>;

View file

@ -1,53 +1,17 @@
import type { Scope } from '@n8n/permissions';
export const ownerPermissions: Scope[] = [
'workflow:create',
'workflow:read',
'workflow:update',
'workflow:delete',
'workflow:list',
'workflow:share',
'tag:create',
'tag:read',
'tag:update',
'tag:delete',
'tag:list',
'user:create',
'user:read',
'user:update',
'user:delete',
'user:list',
'user:resetPassword',
'auditLogs:manage',
'credential:create',
'credential:read',
'credential:update',
'credential:delete',
'credential:list',
'credential:share',
'variable:create',
'variable:read',
'variable:update',
'variable:delete',
'variable:list',
'sourceControl:pull',
'sourceControl:push',
'sourceControl:manage',
'externalSecretsProvider:create',
'externalSecretsProvider:read',
'externalSecretsProvider:update',
'externalSecretsProvider:delete',
'externalSecretsProvider:list',
'externalSecretsProvider:sync',
'externalSecret:list',
'orchestration:read',
'orchestration:list',
'communityPackage:install',
'communityPackage:uninstall',
'communityPackage:update',
'communityPackage:list',
'ldap:manage',
'ldap:sync',
'saml:manage',
'eventBusEvent:create',
'eventBusEvent:read',
'eventBusEvent:update',
@ -61,18 +25,56 @@ export const ownerPermissions: Scope[] = [
'eventBusDestination:delete',
'eventBusDestination:list',
'eventBusDestination:test',
];
export const adminPermissions: Scope[] = ownerPermissions.concat();
export const memberPermissions: Scope[] = [
'user:list',
'variable:list',
'variable:read',
'externalSecretsProvider:create',
'externalSecretsProvider:read',
'externalSecretsProvider:update',
'externalSecretsProvider:delete',
'externalSecretsProvider:list',
'externalSecretsProvider:sync',
'externalSecret:list',
'ldap:manage',
'ldap:sync',
'logStreaming:manage',
'orchestration:read',
'orchestration:list',
'saml:manage',
'sourceControl:pull',
'sourceControl:push',
'sourceControl:manage',
'tag:create',
'tag:read',
'tag:update',
'tag:delete',
'tag:list',
'user:create',
'user:read',
'user:update',
'user:delete',
'user:list',
'user:resetPassword',
'variable:create',
'variable:read',
'variable:update',
'variable:delete',
'variable:list',
'workflow:create',
'workflow:read',
'workflow:update',
'workflow:delete',
'workflow:list',
'workflow:share',
];
export const adminPermissions: Scope[] = ownerPermissions.concat();
export const memberPermissions: Scope[] = [
'eventBusEvent:list',
'eventBusEvent:read',
'eventBusDestination:list',
'eventBusDestination:test',
'tag:create',
'tag:read',
'tag:update',
'tag:list',
'user:list',
'variable:list',
'variable:read',
];

View file

@ -120,7 +120,6 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { isNavigationFailure } from 'vue-router';
import ExecutionsUsage from '@/components/ExecutionsUsage.vue';
import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue';
import { ROLE } from '@/utils/userUtils';
import { hasPermission } from '@/rbac/permissions';
export default defineComponent({
@ -177,9 +176,7 @@ export default defineComponent({
return accessibleRoute !== null;
},
showUserArea(): boolean {
return hasPermission(['role'], {
role: [ROLE.Member, ROLE.Owner],
});
return hasPermission(['authenticated']);
},
workflowExecution(): IExecutionResponse | null {
return this.workflowsStore.getWorkflowExecution;

View file

@ -5,6 +5,7 @@ vi.mock('@/rbac/checks', () => ({
hasRole: vi.fn(),
hasScope: vi.fn(),
isGuest: vi.fn(),
isDefaultUser: vi.fn(),
isAuthenticated: vi.fn(),
isEnterpriseFeatureEnabled: vi.fn(),
isValid: vi.fn(),
@ -15,13 +16,22 @@ describe('hasPermission()', () => {
vi.mocked(checks.hasRole).mockReturnValue(true);
vi.mocked(checks.hasScope).mockReturnValue(true);
vi.mocked(checks.isGuest).mockReturnValue(true);
vi.mocked(checks.isDefaultUser).mockReturnValue(true);
vi.mocked(checks.isAuthenticated).mockReturnValue(true);
vi.mocked(checks.isEnterpriseFeatureEnabled).mockReturnValue(true);
vi.mocked(checks.isValid).mockReturnValue(true);
expect(hasPermission(['authenticated', 'custom', 'enterprise', 'guest', 'rbac', 'role'])).toBe(
true,
);
expect(
hasPermission([
'authenticated',
'custom',
'enterprise',
'guest',
'rbac',
'role',
'defaultUser',
]),
).toBe(true);
});
it('should return false if any permission is invalid', () => {

View file

@ -1,6 +1,6 @@
import { useRBACStore } from '@/stores/rbac.store';
import { hasScope } from '@/rbac/checks/hasScope';
import type { HasScopeOptions } from '@n8n/permissions';
import type { ScopeOptions } from '@n8n/permissions';
vi.mock('@/stores/rbac.store', () => ({
useRBACStore: vi.fn(),
@ -19,7 +19,7 @@ describe('Checks', () => {
} as unknown as ReturnType<typeof useRBACStore>);
const scope = 'workflow:read';
const options: HasScopeOptions = { mode: 'allOf' };
const options: ScopeOptions = { mode: 'allOf' };
const projectId = 'proj123';
const resourceType = 'workflow';
const resourceId = 'res123';

View file

@ -0,0 +1,27 @@
import { useUsersStore } from '@/stores/users.store';
import { isDefaultUser } from '@/rbac/checks/isDefaultUser';
vi.mock('@/stores/users.store', () => ({
useUsersStore: vi.fn(),
}));
describe('Checks', () => {
describe('isDefaultUser()', () => {
it('should return false if user not logged in', () => {
vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as ReturnType<
typeof useUsersStore
>);
expect(isDefaultUser()).toBe(false);
});
it('should return true if user is default user', () => {
const mockUser = { id: 'user123', name: 'Test User', isDefaultUser: true };
vi.mocked(useUsersStore).mockReturnValue({ currentUser: mockUser } as unknown as ReturnType<
typeof useUsersStore
>);
expect(isDefaultUser()).toBe(mockUser.isDefaultUser);
});
});
});

View file

@ -1,6 +1,7 @@
export * from './hasRole';
export * from './hasScope';
export * from './isAuthenticated';
export * from './isDefaultUser';
export * from './isEnterpriseFeatureEnabled';
export * from './isGuest';
export * from './isValid';

View file

@ -0,0 +1,12 @@
import { useUsersStore } from '@/stores/users.store';
import type { DefaultUserMiddlewareOptions, RBACPermissionCheck } from '@/types/rbac';
export const isDefaultUser: RBACPermissionCheck<DefaultUserMiddlewareOptions> = () => {
const usersStore = useUsersStore();
const currentUser = usersStore.currentUser;
if (currentUser) {
return currentUser.isDefaultUser;
}
return false;
};

View file

@ -5,6 +5,7 @@ import { guestMiddleware } from '@/rbac/middleware/guest';
import { rbacMiddleware } from '@/rbac/middleware/rbac';
import { roleMiddleware } from '@/rbac/middleware/role';
import { customMiddleware } from '@/rbac/middleware/custom';
import { defaultUserMiddleware } from '@/rbac/middleware/defaultUser';
type Middleware = {
[key in RouterMiddlewareType]: RouterMiddleware<MiddlewareOptions[key]>;
@ -13,6 +14,7 @@ type Middleware = {
export const middleware: Middleware = {
authenticated: authenticatedMiddleware,
custom: customMiddleware,
defaultUser: defaultUserMiddleware,
enterprise: enterpriseMiddleware,
guest: guestMiddleware,
rbac: rbacMiddleware,

View file

@ -0,0 +1,54 @@
import { useUsersStore } from '@/stores/users.store';
import { VIEWS } from '@/constants';
import { defaultUserMiddleware } from '@/rbac/middleware/defaultUser';
import type { RouteLocationNormalized } from 'vue-router';
vi.mock('@/stores/users.store', () => ({
useUsersStore: vi.fn(),
}));
describe('Middleware', () => {
describe('defaultUser', () => {
it('should redirect to homepage if user not logged in', async () => {
vi.mocked(useUsersStore).mockReturnValue({
currentUser: null,
} as ReturnType<typeof useUsersStore>);
const nextMock = vi.fn();
const toMock = { query: {} } as RouteLocationNormalized;
const fromMock = {} as RouteLocationNormalized;
await defaultUserMiddleware(toMock, fromMock, nextMock, {});
expect(nextMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
});
it('should redirect to homepage if user is not default user', async () => {
vi.mocked(useUsersStore).mockReturnValue({
currentUser: { id: '123', isDefaultUser: false },
} as ReturnType<typeof useUsersStore>);
const nextMock = vi.fn();
const toMock = { query: {} } as RouteLocationNormalized;
const fromMock = {} as RouteLocationNormalized;
await defaultUserMiddleware(toMock, fromMock, nextMock, {});
expect(nextMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
});
it('should allow navigation if a current user is present', async () => {
vi.mocked(useUsersStore).mockReturnValue({
currentUser: { id: '123', isDefaultUser: true },
} as ReturnType<typeof useUsersStore>);
const nextMock = vi.fn();
const toMock = { query: {} } as RouteLocationNormalized;
const fromMock = {} as RouteLocationNormalized;
await defaultUserMiddleware(toMock, fromMock, nextMock, {});
expect(nextMock).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,15 @@
import type { RouterMiddleware } from '@/types/router';
import { VIEWS } from '@/constants';
import type { DefaultUserMiddlewareOptions } from '@/types/rbac';
import { isDefaultUser } from '@/rbac/checks';
export const defaultUserMiddleware: RouterMiddleware<DefaultUserMiddlewareOptions> = async (
to,
from,
next,
) => {
const valid = isDefaultUser();
if (!valid) {
return next({ name: VIEWS.HOMEPAGE });
}
};

View file

@ -2,6 +2,7 @@ import {
hasRole,
hasScope,
isAuthenticated,
isDefaultUser,
isEnterpriseFeatureEnabled,
isGuest,
isValid,
@ -15,6 +16,7 @@ type Permissions = {
export const permissions: Permissions = {
authenticated: isAuthenticated,
custom: isValid,
defaultUser: isDefaultUser,
enterprise: isEnterpriseFeatureEnabled,
guest: isGuest,
rbac: hasScope,

View file

@ -9,7 +9,6 @@ import type {
} from 'vue-router';
import { createRouter, createWebHistory } from 'vue-router';
import { runExternalHook } from '@/utils/externalHooks';
import { ROLE } from '@/utils/userUtils';
import { useSettingsStore } from '@/stores/settings.store';
import { useTemplatesStore } from '@/stores/templates.store';
import { useUIStore } from '@/stores/ui.store';
@ -406,10 +405,7 @@ export const routes = [
default: SetupView,
},
meta: {
middleware: ['role'],
middlewareOptions: {
role: [ROLE.Default],
},
middleware: ['defaultUser'],
telemetry: {
pageCategory: 'auth',
},
@ -480,10 +476,7 @@ export const routes = [
settingsView: SettingsPersonalView,
},
meta: {
middleware: ['authenticated', 'role'],
middlewareOptions: {
role: [ROLE.Owner, ROLE.Member],
},
middleware: ['authenticated'],
telemetry: {
pageCategory: 'settings',
getProperties(route: RouteLocation) {
@ -548,9 +541,11 @@ export const routes = [
settingsView: SettingsSourceControl,
},
meta: {
middleware: ['authenticated', 'role'],
middleware: ['authenticated', 'rbac'],
middlewareOptions: {
role: [ROLE.Owner],
rbac: {
scope: 'sourceControl:manage',
},
},
telemetry: {
pageCategory: 'settings',
@ -569,9 +564,11 @@ export const routes = [
settingsView: SettingsExternalSecrets,
},
meta: {
middleware: ['authenticated', 'role'],
middleware: ['authenticated', 'rbac'],
middlewareOptions: {
role: [ROLE.Owner],
rbac: {
scope: ['externalSecretsProvider:list', 'externalSecretsProvider:update'],
},
},
telemetry: {
pageCategory: 'settings',
@ -590,13 +587,15 @@ export const routes = [
settingsView: SettingsSso,
},
meta: {
middleware: ['authenticated', 'role', 'custom'],
middleware: ['authenticated', 'rbac', 'custom'],
middlewareOptions: {
custom: () => {
const settingsStore = useSettingsStore();
return !settingsStore.isDesktopDeployment;
},
role: [ROLE.Owner],
rbac: {
scope: 'saml:manage',
},
},
telemetry: {
pageCategory: 'settings',
@ -615,9 +614,11 @@ export const routes = [
settingsView: SettingsLogStreamingView,
},
meta: {
middleware: ['authenticated', 'role'],
middleware: ['authenticated', 'rbac'],
middlewareOptions: {
role: [ROLE.Owner],
rbac: {
scope: 'logStreaming:manage',
},
},
telemetry: {
pageCategory: 'settings',
@ -641,9 +642,11 @@ export const routes = [
settingsView: SettingsCommunityNodesView,
},
meta: {
middleware: ['authenticated', 'role', 'custom'],
middleware: ['authenticated', 'rbac', 'custom'],
middlewareOptions: {
role: [ROLE.Owner],
rbac: {
scope: ['communityPackage:list', 'communityPackage:update'],
},
custom: () => {
const settingsStore = useSettingsStore();
return settingsStore.isCommunityNodesFeatureEnabled;
@ -679,9 +682,11 @@ export const routes = [
settingsView: SettingsLdapView,
},
meta: {
middleware: ['authenticated', 'role'],
middleware: ['authenticated', 'rbac'],
middlewareOptions: {
role: [ROLE.Owner],
rbac: {
scope: 'ldap:manage',
},
},
},
},
@ -692,12 +697,14 @@ export const routes = [
settingsView: SettingsAuditLogs,
},
meta: {
middleware: ['authenticated', 'role', 'custom'],
middleware: ['authenticated', 'rbac', 'custom'],
middlewareOptions: {
custom: () => {
return !!useStorage('audit-logs').value;
},
role: [ROLE.Owner],
rbac: {
scope: 'auditLogs:manage',
},
},
telemetry: {
pageCategory: 'settings',

View file

@ -1,6 +1,6 @@
import { defineStore } from 'pinia';
import { hasScope as genericHasScope } from '@n8n/permissions';
import type { HasScopeOptions, Scope, Resource } from '@n8n/permissions';
import type { ScopeOptions, Scope, Resource } from '@n8n/permissions';
import { ref } from 'vue';
import { STORES } from '@/constants';
import type { IRole } from '@/Interface';
@ -80,7 +80,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
resourceId?: string;
projectId?: string;
},
options?: HasScopeOptions,
options?: ScopeOptions,
): boolean {
return genericHasScope(
scope,

View file

@ -1,9 +1,10 @@
import type { EnterpriseEditionFeature } from '@/constants';
import type { Resource, HasScopeOptions, Scope } from '@n8n/permissions';
import type { Resource, ScopeOptions, Scope } from '@n8n/permissions';
import type { IRole } from '@/Interface';
export type AuthenticatedPermissionOptions = {};
export type CustomPermissionOptions<C = {}> = RBACPermissionCheck<C>;
export type DefaultUserMiddlewareOptions = {};
export type EnterprisePermissionOptions = {
feature?: EnterpriseEditionFeature | EnterpriseEditionFeature[];
mode?: 'oneOf' | 'allOf';
@ -14,14 +15,22 @@ export type RBACPermissionOptions = {
projectId?: string;
resourceType?: Resource;
resourceId?: string;
options?: HasScopeOptions;
options?: ScopeOptions;
};
export type RolePermissionOptions = IRole[];
export type PermissionType = 'authenticated' | 'custom' | 'enterprise' | 'guest' | 'rbac' | 'role';
export type PermissionType =
| 'authenticated'
| 'custom'
| 'defaultUser'
| 'enterprise'
| 'guest'
| 'rbac'
| 'role';
export type PermissionTypeOptions = {
authenticated: AuthenticatedPermissionOptions;
custom: CustomPermissionOptions;
defaultUser: DefaultUserMiddlewareOptions;
enterprise: EnterprisePermissionOptions;
guest: GuestPermissionOptions;
rbac: RBACPermissionOptions;

View file

@ -13,6 +13,7 @@ import type {
RBACPermissionOptions,
RolePermissionOptions,
PermissionType,
DefaultUserMiddlewareOptions,
} from '@/types/rbac';
export type RouterMiddlewareType = PermissionType;
@ -24,6 +25,7 @@ export type CustomMiddlewareOptions = CustomPermissionOptions<{
export type MiddlewareOptions = {
authenticated: AuthenticatedPermissionOptions;
custom: CustomMiddlewareOptions;
defaultUser: DefaultUserMiddlewareOptions;
enterprise: EnterprisePermissionOptions;
guest: GuestPermissionOptions;
rbac: RBACPermissionOptions;

View file

@ -92,7 +92,7 @@ export default defineComponent({
return this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing);
},
showUMSetupWarning() {
return hasPermission(['role'], { role: [ROLE.Default] });
return hasPermission(['defaultUser']);
},
usersListActions(): IUserListAction[] {
return [