From 67a88914f2f2d11c413e7f627d659333d8419af8 Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Thu, 23 Nov 2023 13:22:47 +0200 Subject: [PATCH] feat(editor): Add routing middleware, permission checks, RBAC store, RBAC component (#7702) Github issue / Community forum post (link here to close automatically): --------- Co-authored-by: Csaba Tuncsik --- packages/@n8n/permissions/src/hasScope.ts | 4 +- packages/@n8n/permissions/src/types.ts | 5 +- packages/design-system/src/plugin.ts | 4 +- packages/editor-ui/package.json | 1 + packages/editor-ui/src/App.vue | 55 +-- packages/editor-ui/src/Interface.ts | 3 + .../src/__tests__/permissions.spec.ts | 10 + .../editor-ui/src/components/MainSidebar.vue | 8 +- packages/editor-ui/src/components/RBAC.vue | 61 +++ .../TagsManager/TagsView/TagsView.vue | 5 +- .../src/components/__tests__/RBAC.test.ts | 50 ++ packages/editor-ui/src/constants.ts | 1 + packages/editor-ui/src/hooks/register.ts | 12 + packages/editor-ui/src/init.ts | 67 +++ packages/editor-ui/src/mixins/userHelpers.ts | 23 +- packages/editor-ui/src/permissions.ts | 75 ++- packages/editor-ui/src/plugins/components.ts | 2 + .../src/rbac/__tests__/permissions.test.ts | 63 +++ .../src/rbac/checks/__tests__/hasRole.test.ts | 51 ++ .../rbac/checks/__tests__/hasScope.test.ts | 54 +++ .../checks/__tests__/isAuthenticated.test.ts | 27 ++ .../isEnterpriseFeatureEnabled.test.ts | 87 ++++ .../src/rbac/checks/__tests__/isGuest.test.ts | 27 ++ .../src/rbac/checks/__tests__/isValid.test.ts | 19 + packages/editor-ui/src/rbac/checks/hasRole.ts | 16 + .../editor-ui/src/rbac/checks/hasScope.ts | 20 + packages/editor-ui/src/rbac/checks/index.ts | 6 + .../src/rbac/checks/isAuthenticated.ts | 7 + .../rbac/checks/isEnterpriseFeatureEnabled.ts | 19 + packages/editor-ui/src/rbac/checks/isGuest.ts | 7 + packages/editor-ui/src/rbac/checks/isValid.ts | 5 + packages/editor-ui/src/rbac/middleware.ts | 20 + .../__tests__/authenticated.test.ts | 60 +++ .../rbac/middleware/__tests__/custom.test.ts | 35 ++ .../middleware/__tests__/enterprise.test.ts | 98 ++++ .../rbac/middleware/__tests__/guest.test.ts | 54 +++ .../rbac/middleware/__tests__/rbac.test.ts | 60 +++ .../rbac/middleware/__tests__/role.test.ts | 82 ++++ .../src/rbac/middleware/authenticated.ts | 18 + .../editor-ui/src/rbac/middleware/custom.ts | 14 + .../src/rbac/middleware/enterprise.ts | 16 + .../editor-ui/src/rbac/middleware/guest.ts | 16 + .../editor-ui/src/rbac/middleware/rbac.ts | 25 + .../editor-ui/src/rbac/middleware/role.ts | 16 + packages/editor-ui/src/rbac/permissions.ts | 38 ++ packages/editor-ui/src/router.ts | 454 ++++++------------ .../src/stores/__tests__/rbac.store.test.ts | 152 ++++++ .../editor-ui/src/stores/cloudPlan.store.ts | 13 + packages/editor-ui/src/stores/index.ts | 1 + packages/editor-ui/src/stores/rbac.store.ts | 113 +++++ packages/editor-ui/src/stores/usage.store.ts | 1 - packages/editor-ui/src/stores/users.store.ts | 59 +-- packages/editor-ui/src/types/rbac.ts | 33 ++ packages/editor-ui/src/types/router.ts | 59 +++ .../src/utils/__tests__/rbacUtils.test.ts | 65 +++ .../src/utils/__tests__/userUtils.test.ts | 67 --- packages/editor-ui/src/utils/rbacUtils.ts | 32 ++ packages/editor-ui/src/utils/userUtils.ts | 85 ---- .../src/views/SettingsUsageAndPlan.vue | 12 +- .../editor-ui/src/views/SettingsUsersView.vue | 9 +- .../src/views/__tests__/VariablesView.spec.ts | 77 +-- pnpm-lock.yaml | 3 + 62 files changed, 1935 insertions(+), 646 deletions(-) create mode 100644 packages/editor-ui/src/components/RBAC.vue create mode 100644 packages/editor-ui/src/components/__tests__/RBAC.test.ts create mode 100644 packages/editor-ui/src/hooks/register.ts create mode 100644 packages/editor-ui/src/init.ts create mode 100644 packages/editor-ui/src/rbac/__tests__/permissions.test.ts create mode 100644 packages/editor-ui/src/rbac/checks/__tests__/hasRole.test.ts create mode 100644 packages/editor-ui/src/rbac/checks/__tests__/hasScope.test.ts create mode 100644 packages/editor-ui/src/rbac/checks/__tests__/isAuthenticated.test.ts create mode 100644 packages/editor-ui/src/rbac/checks/__tests__/isEnterpriseFeatureEnabled.test.ts create mode 100644 packages/editor-ui/src/rbac/checks/__tests__/isGuest.test.ts create mode 100644 packages/editor-ui/src/rbac/checks/__tests__/isValid.test.ts create mode 100644 packages/editor-ui/src/rbac/checks/hasRole.ts create mode 100644 packages/editor-ui/src/rbac/checks/hasScope.ts create mode 100644 packages/editor-ui/src/rbac/checks/index.ts create mode 100644 packages/editor-ui/src/rbac/checks/isAuthenticated.ts create mode 100644 packages/editor-ui/src/rbac/checks/isEnterpriseFeatureEnabled.ts create mode 100644 packages/editor-ui/src/rbac/checks/isGuest.ts create mode 100644 packages/editor-ui/src/rbac/checks/isValid.ts create mode 100644 packages/editor-ui/src/rbac/middleware.ts create mode 100644 packages/editor-ui/src/rbac/middleware/__tests__/authenticated.test.ts create mode 100644 packages/editor-ui/src/rbac/middleware/__tests__/custom.test.ts create mode 100644 packages/editor-ui/src/rbac/middleware/__tests__/enterprise.test.ts create mode 100644 packages/editor-ui/src/rbac/middleware/__tests__/guest.test.ts create mode 100644 packages/editor-ui/src/rbac/middleware/__tests__/rbac.test.ts create mode 100644 packages/editor-ui/src/rbac/middleware/__tests__/role.test.ts create mode 100644 packages/editor-ui/src/rbac/middleware/authenticated.ts create mode 100644 packages/editor-ui/src/rbac/middleware/custom.ts create mode 100644 packages/editor-ui/src/rbac/middleware/enterprise.ts create mode 100644 packages/editor-ui/src/rbac/middleware/guest.ts create mode 100644 packages/editor-ui/src/rbac/middleware/rbac.ts create mode 100644 packages/editor-ui/src/rbac/middleware/role.ts create mode 100644 packages/editor-ui/src/rbac/permissions.ts create mode 100644 packages/editor-ui/src/stores/__tests__/rbac.store.test.ts create mode 100644 packages/editor-ui/src/stores/rbac.store.ts create mode 100644 packages/editor-ui/src/types/rbac.ts create mode 100644 packages/editor-ui/src/types/router.ts create mode 100644 packages/editor-ui/src/utils/__tests__/rbacUtils.test.ts delete mode 100644 packages/editor-ui/src/utils/__tests__/userUtils.test.ts create mode 100644 packages/editor-ui/src/utils/rbacUtils.ts diff --git a/packages/@n8n/permissions/src/hasScope.ts b/packages/@n8n/permissions/src/hasScope.ts index 80cb9e8982..76c22f7b19 100644 --- a/packages/@n8n/permissions/src/hasScope.ts +++ b/packages/@n8n/permissions/src/hasScope.ts @@ -12,14 +12,14 @@ export function hasScope( ): boolean; export function hasScope( scope: Scope | Scope[], - userScopes: unknown, + userScopes: GlobalScopes | ScopeLevels, options: ScopeOptions = { mode: 'oneOf' }, ): boolean { if (!Array.isArray(scope)) { scope = [scope]; } - const userScopeSet = new Set(Object.values(userScopes ?? {}).flat()); + const userScopeSet = new Set(Object.values(userScopes).flat()); if (options.mode === 'allOf') { return !!scope.length && scope.every((s) => userScopeSet.has(s)); diff --git a/packages/@n8n/permissions/src/types.ts b/packages/@n8n/permissions/src/types.ts index 1ef19459ea..0b1feb355e 100644 --- a/packages/@n8n/permissions/src/types.ts +++ b/packages/@n8n/permissions/src/types.ts @@ -10,8 +10,9 @@ export type Resource = export type ResourceScope< R extends Resource, - Operations extends string = DefaultOperations, -> = `${R}:${Operations}`; + Operation extends string = DefaultOperations, +> = `${R}:${Operation}`; + export type WildcardScope = `${Resource}:*` | '*'; export type WorkflowScope = ResourceScope<'workflow', DefaultOperations | 'share'>; diff --git a/packages/design-system/src/plugin.ts b/packages/design-system/src/plugin.ts index 9729284b40..6a5ec45bb8 100644 --- a/packages/design-system/src/plugin.ts +++ b/packages/design-system/src/plugin.ts @@ -55,7 +55,9 @@ import { N8nUserStack, } from './components'; -export const N8nPlugin: Plugin<{}> = { +export interface N8nPluginOptions {} + +export const N8nPlugin: Plugin = { install: (app) => { app.component('n8n-action-box', N8nActionBox); app.component('n8n-action-dropdown', N8nActionDropdown); diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 55cbd86ba0..45e47d8009 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -47,6 +47,7 @@ "@jsplumb/util": "^5.13.2", "@lezer/common": "^1.0.4", "@n8n/codemirror-lang-sql": "^1.0.2", + "@n8n/permissions": "workspace:*", "@vueuse/components": "^10.5.0", "@vueuse/core": "^10.5.0", "axios": "^0.21.1", diff --git a/packages/editor-ui/src/App.vue b/packages/editor-ui/src/App.vue index 9cb629ca88..aba2c0e8d9 100644 --- a/packages/editor-ui/src/App.vue +++ b/packages/editor-ui/src/App.vue @@ -35,7 +35,6 @@ diff --git a/packages/editor-ui/src/components/TagsManager/TagsView/TagsView.vue b/packages/editor-ui/src/components/TagsManager/TagsView/TagsView.vue index f045041812..481b550052 100644 --- a/packages/editor-ui/src/components/TagsManager/TagsView/TagsView.vue +++ b/packages/editor-ui/src/components/TagsManager/TagsView/TagsView.vue @@ -30,6 +30,7 @@ import TagsTableHeader from '@/components/TagsManager/TagsView/TagsTableHeader.v import TagsTable from '@/components/TagsManager/TagsView/TagsTable.vue'; import { mapStores } from 'pinia'; import { useUsersStore } from '@/stores/users.store'; +import { useRBACStore } from '@/stores/rbac.store'; const matches = (name: string, filter: string) => name.toLowerCase().trim().includes(filter.toLowerCase().trim()); @@ -50,7 +51,7 @@ export default defineComponent({ }; }, computed: { - ...mapStores(useUsersStore), + ...mapStores(useUsersStore, useRBACStore), isCreateEnabled(): boolean { return (this.tags || []).length === 0 || this.createEnabled; }, @@ -70,7 +71,7 @@ export default defineComponent({ disable: disabled && tag.id !== this.deleteId && tag.id !== this.updateId, update: disabled && tag.id === this.updateId, delete: disabled && tag.id === this.deleteId, - canDelete: this.usersStore.canUserDeleteTags, + canDelete: this.rbacStore.hasScope('tag:delete'), }), ); diff --git a/packages/editor-ui/src/components/__tests__/RBAC.test.ts b/packages/editor-ui/src/components/__tests__/RBAC.test.ts new file mode 100644 index 0000000000..566fe71294 --- /dev/null +++ b/packages/editor-ui/src/components/__tests__/RBAC.test.ts @@ -0,0 +1,50 @@ +import RBAC from '@/components/RBAC.vue'; +import { createComponentRenderer } from '@/__tests__/render'; +import { useRBACStore } from '@/stores/rbac.store'; + +const renderComponent = createComponentRenderer(RBAC); + +vi.mock('vue-router', () => ({ + useRoute: vi.fn(() => ({ + path: '/workflows', + params: {}, + })), +})); + +vi.mock('@/stores/rbac.store', () => ({ + useRBACStore: vi.fn(), +})); + +describe('RBAC', () => { + it('renders default slot when hasScope is true', async () => { + vi.mocked(useRBACStore).mockImplementation(() => ({ + hasScope: () => true, + })); + + const wrapper = renderComponent({ + props: { scope: 'worfklow:list' }, + slots: { + default: 'Default Content', + fallback: 'Fallback Content', + }, + }); + + expect(wrapper.getByText('Default Content')).toBeInTheDocument(); + }); + + it('renders fallback slot when hasScope is false', async () => { + vi.mocked(useRBACStore).mockImplementation(() => ({ + hasScope: () => false, + })); + + const wrapper = renderComponent({ + props: { scope: 'worfklow:list' }, + slots: { + default: 'Default Content', + fallback: 'Fallback Content', + }, + }); + + expect(wrapper.getByText('Fallback Content')).toBeInTheDocument(); + }); +}); diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index b817229487..c17c56cecc 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -567,6 +567,7 @@ export const enum STORES { WEBHOOKS = 'webhooks', HISTORY = 'history', CLOUD_PLAN = 'cloudPlan', + RBAC = 'rbac', COLLABORATION = 'collaboration', PUSH = 'push', } diff --git a/packages/editor-ui/src/hooks/register.ts b/packages/editor-ui/src/hooks/register.ts new file mode 100644 index 0000000000..fb937c61a9 --- /dev/null +++ b/packages/editor-ui/src/hooks/register.ts @@ -0,0 +1,12 @@ +import { extendExternalHooks } from '@/mixins/externalHooks'; + +let cloudHooksInitialized = false; +export async function initializeCloudHooks() { + if (cloudHooksInitialized) { + return; + } + + const { n8nCloudHooks } = await import('@/hooks/cloud'); + extendExternalHooks(n8nCloudHooks); + cloudHooksInitialized = true; +} diff --git a/packages/editor-ui/src/init.ts b/packages/editor-ui/src/init.ts new file mode 100644 index 0000000000..bc30972e4f --- /dev/null +++ b/packages/editor-ui/src/init.ts @@ -0,0 +1,67 @@ +import { useCloudPlanStore } from '@/stores/cloudPlan.store'; +import { useNodeTypesStore } from '@/stores/nodeTypes.store'; +import { useRootStore } from '@/stores/n8nRoot.store'; +import { useSettingsStore } from '@/stores/settings.store'; +import { useSourceControlStore } from '@/stores/sourceControl.store'; +import { useUsersStore } from '@/stores/users.store'; +import { initializeCloudHooks } from '@/hooks/register'; + +let coreInitialized = false; +let authenticatedFeaturesInitialized = false; + +/** + * Initializes the core application stores and hooks + * This is called once, when the first route is loaded. + */ +export async function initializeCore() { + if (coreInitialized) { + return; + } + + const settingsStore = useSettingsStore(); + const cloudPlanStore = useCloudPlanStore(); + const usersStore = useUsersStore(); + + await settingsStore.initialize(); + await usersStore.initialize(); + if (settingsStore.isCloudDeployment) { + await Promise.all([cloudPlanStore.initialize(), initializeCloudHooks()]); + } + + coreInitialized = true; +} + +/** + * Initializes the features of the application that require an authenticated user + */ +export async function initializeAuthenticatedFeatures() { + if (authenticatedFeaturesInitialized) { + return; + } + + const usersStore = useUsersStore(); + if (!usersStore.currentUser) { + return; + } + + const sourceControlStore = useSourceControlStore(); + const settingsStore = useSettingsStore(); + const rootStore = useRootStore(); + const nodeTypesStore = useNodeTypesStore(); + + if (sourceControlStore.isEnterpriseSourceControlEnabled) { + await sourceControlStore.getPreferences(); + } + + if (settingsStore.isTemplatesEnabled) { + try { + await settingsStore.testTemplatesEndpoint(); + } catch (e) {} + } + + if (rootStore.defaultLocale !== 'en') { + await nodeTypesStore.getNodeTranslationHeaders(); + } + + authenticatedFeaturesInitialized = true; +} diff --git a/packages/editor-ui/src/mixins/userHelpers.ts b/packages/editor-ui/src/mixins/userHelpers.ts index b07248974a..b45bd676b1 100644 --- a/packages/editor-ui/src/mixins/userHelpers.ts +++ b/packages/editor-ui/src/mixins/userHelpers.ts @@ -1,27 +1,30 @@ -import type { IPermissions } from '@/Interface'; -import { isAuthorized } from '@/utils'; -import { useUsersStore } from '@/stores/users.store'; import { defineComponent } from 'vue'; import type { RouteLocation } from 'vue-router'; +import { hasPermission } from '@/rbac/permissions'; +import type { RouteConfig } from '@/types/router'; +import type { PermissionTypeOptions } from '@/types/rbac'; export const userHelpers = defineComponent({ methods: { - canUserAccessRouteByName(name: string): boolean { + canUserAccessRouteByName(name: string) { const route = this.$router.resolve({ name }); return this.canUserAccessRoute(route); }, - canUserAccessCurrentRoute(): boolean { + canUserAccessCurrentRoute() { return this.canUserAccessRoute(this.$route); }, - canUserAccessRoute(route: RouteLocation): boolean { - const permissions: IPermissions = route.meta?.permissions; - const usersStore = useUsersStore(); - const currentUser = usersStore.currentUser; + canUserAccessRoute(route: RouteLocation & RouteConfig) { + const middleware = route.meta?.middleware; + const middlewareOptions = route.meta?.middlewareOptions; - return permissions && isAuthorized(permissions, currentUser); + if (!middleware) { + return true; + } + + return hasPermission(middleware, middlewareOptions as PermissionTypeOptions | undefined); }, }, }); diff --git a/packages/editor-ui/src/permissions.ts b/packages/editor-ui/src/permissions.ts index 790b999ecc..47b1dee9d3 100644 --- a/packages/editor-ui/src/permissions.ts +++ b/packages/editor-ui/src/permissions.ts @@ -6,7 +6,13 @@ import type { IUser, ICredentialsResponse, IWorkflowDb } from '@/Interface'; 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'; + +/** + * Old permissions implementation + * @deprecated + */ export const enum UserRole { InstanceOwner = 'isInstanceOwner', @@ -36,15 +42,20 @@ export const parsePermissionsTable = ( user: IUser | null, table: IPermissionsTable, ): IPermissions => { - const genericTable = [{ name: UserRole.InstanceOwner, test: () => user?.isOwner }]; + const genericTable: IPermissionsTable = [ + { name: UserRole.InstanceOwner, test: () => !!user?.isOwner }, + ]; - return [...genericTable, ...table].reduce((permissions: IPermissions, row) => { - permissions[row.name] = Array.isArray(row.test) - ? row.test.some((ability) => permissions[ability]) - : (row.test as IPermissionsTableRowTestFn)(permissions); + return [...genericTable, ...table].reduce( + (permissions: IPermissions, row: IPermissionsTableRow) => { + permissions[row.name] = Array.isArray(row.test) + ? row.test.some((ability) => permissions[ability]) + : row.test(permissions); - return permissions; - }, {}); + return permissions; + }, + {}, + ); }; /** @@ -60,17 +71,12 @@ export const getCredentialPermissions = (user: IUser | null, credential: ICreden const table: IPermissionsTable = [ { name: UserRole.ResourceOwner, - test: () => - !!(credential?.ownedBy && credential.ownedBy.id === user?.id) || !isSharingEnabled, + test: () => !!(credential?.ownedBy?.id === user?.id) || !isSharingEnabled, }, { name: UserRole.ResourceSharee, test: () => !!credential?.sharedWith?.find((sharee) => sharee.id === user?.id), }, - { - name: 'read', - test: [UserRole.ResourceOwner, UserRole.InstanceOwner, UserRole.ResourceSharee], - }, { name: 'save', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] }, { name: 'updateName', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] }, { name: 'updateConnection', test: [UserRole.ResourceOwner] }, @@ -85,6 +91,7 @@ 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, ); @@ -93,27 +100,15 @@ export const getWorkflowPermissions = (user: IUser | null, workflow: IWorkflowDb const table: IPermissionsTable = [ { name: UserRole.ResourceOwner, - test: () => - !!(isNewWorkflow || (workflow?.ownedBy && workflow.ownedBy.id === user?.id)) || - !isSharingEnabled, + test: () => !!(isNewWorkflow || workflow?.ownedBy?.id === user?.id) || !isSharingEnabled, }, { - name: UserRole.ResourceSharee, - test: () => !!workflow?.sharedWith?.find((sharee) => sharee.id === user?.id), + name: 'updateSharing', + test: (permissions) => rbacStore.hasScope('workflow:update') || !!permissions.isOwner, }, { - name: 'read', - test: [UserRole.ResourceOwner, UserRole.InstanceOwner, UserRole.ResourceSharee], - }, - { name: 'save', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] }, - { name: 'updateName', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] }, - { name: 'updateConnection', test: [UserRole.ResourceOwner] }, - { name: 'updateSharing', test: [UserRole.ResourceOwner] }, - { name: 'updateNodeAccess', test: [UserRole.ResourceOwner] }, - { name: 'delete', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] }, - { - name: 'use', - test: [UserRole.ResourceOwner, UserRole.InstanceOwner, UserRole.ResourceSharee], + name: 'delete', + test: (permissions) => rbacStore.hasScope('workflow:delete') || !!permissions.isOwner, }, ]; @@ -121,20 +116,12 @@ export const getWorkflowPermissions = (user: IUser | null, workflow: IWorkflowDb }; export const getVariablesPermissions = (user: IUser | null) => { + const rbacStore = useRBACStore(); const table: IPermissionsTable = [ - { - name: 'create', - test: [UserRole.InstanceOwner], - }, - { - name: 'edit', - test: [UserRole.InstanceOwner], - }, - { - name: 'delete', - test: [UserRole.InstanceOwner], - }, - { name: 'use', test: () => true }, + { 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') }, ]; return parsePermissionsTable(user, table); diff --git a/packages/editor-ui/src/plugins/components.ts b/packages/editor-ui/src/plugins/components.ts index d349c90d75..392ed5dd3d 100644 --- a/packages/editor-ui/src/plugins/components.ts +++ b/packages/editor-ui/src/plugins/components.ts @@ -6,12 +6,14 @@ import ElementPlus, { ElLoading, ElMessageBox } from 'element-plus'; import { N8nPlugin } from 'n8n-design-system'; import { useMessage } from '@/composables/useMessage'; import EnterpriseEdition from '@/components/EnterpriseEdition.ee.vue'; +import RBAC from '@/components/RBAC.vue'; export const GlobalComponentsPlugin: Plugin<{}> = { install(app) { const messageService = useMessage(); app.component('enterprise-edition', EnterpriseEdition); + app.component('RBAC', RBAC); app.use(ElementPlus); app.use(N8nPlugin); diff --git a/packages/editor-ui/src/rbac/__tests__/permissions.test.ts b/packages/editor-ui/src/rbac/__tests__/permissions.test.ts new file mode 100644 index 0000000000..7a7ea3fa12 --- /dev/null +++ b/packages/editor-ui/src/rbac/__tests__/permissions.test.ts @@ -0,0 +1,63 @@ +import { hasPermission } from '@/rbac/permissions'; +import * as checks from '@/rbac/checks'; + +vi.mock('@/rbac/checks', () => ({ + hasRole: vi.fn(), + hasScope: vi.fn(), + isGuest: vi.fn(), + isAuthenticated: vi.fn(), + isEnterpriseFeatureEnabled: vi.fn(), + isValid: vi.fn(), +})); + +describe('hasPermission()', () => { + it('should return true if all permissions are valid', () => { + vi.mocked(checks.hasRole).mockReturnValue(true); + vi.mocked(checks.hasScope).mockReturnValue(true); + vi.mocked(checks.isGuest).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, + ); + }); + + it('should return false if any permission is invalid', () => { + vi.mocked(checks.hasRole).mockReturnValue(true); + vi.mocked(checks.isGuest).mockReturnValue(true); + vi.mocked(checks.isAuthenticated).mockReturnValue(true); + vi.mocked(checks.isEnterpriseFeatureEnabled).mockReturnValue(true); + vi.mocked(checks.isValid).mockReturnValue(true); + + vi.mocked(checks.hasScope).mockReturnValue(false); + + expect(hasPermission(['authenticated', 'custom', 'enterprise', 'guest', 'rbac', 'role'])).toBe( + false, + ); + }); + + it('should return true for a specific valid permission', () => { + vi.mocked(checks.isAuthenticated).mockReturnValue(true); + + expect(hasPermission(['authenticated'])).toBe(true); + }); + + it('should return false for a specific invalid permission', () => { + vi.mocked(checks.isGuest).mockReturnValue(false); + + expect(hasPermission(['guest'])).toBe(false); + }); + + it('should call permission function with given permission options', () => { + const customFn = () => true; + vi.mocked(checks.isValid).mockReturnValue(true); + + hasPermission(['custom'], { + custom: customFn, + }); + + expect(checks.isValid).toHaveBeenCalledWith(customFn); + }); +}); diff --git a/packages/editor-ui/src/rbac/checks/__tests__/hasRole.test.ts b/packages/editor-ui/src/rbac/checks/__tests__/hasRole.test.ts new file mode 100644 index 0000000000..9261fae270 --- /dev/null +++ b/packages/editor-ui/src/rbac/checks/__tests__/hasRole.test.ts @@ -0,0 +1,51 @@ +import { useUsersStore } from '@/stores/users.store'; +import { hasRole } from '@/rbac/checks'; +import { ROLE } from '@/utils'; + +vi.mock('@/stores/users.store', () => ({ + useUsersStore: vi.fn(), +})); + +describe('Checks', () => { + describe('hasRole', () => { + it('should return true if the user has the specified role', () => { + vi.mocked(useUsersStore).mockReturnValue({ + currentUser: { + isDefaultUser: false, + globalRole: { name: ROLE.Owner }, + }, + } as ReturnType); + + expect(hasRole([ROLE.Owner])).toBe(true); + }); + + it('should return false if the user does not have the specified role', () => { + vi.mocked(useUsersStore).mockReturnValue({ + currentUser: { + isDefaultUser: false, + globalRole: { name: ROLE.Member }, + }, + } as ReturnType); + + expect(hasRole([ROLE.Owner])).toBe(false); + }); + + it('should return true for default user if checking for default role', () => { + vi.mocked(useUsersStore).mockReturnValue({ + currentUser: { + isDefaultUser: true, + }, + } as ReturnType); + + expect(hasRole([ROLE.Default])).toBe(true); + }); + + it('should return false if there is no current user', () => { + vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as ReturnType< + typeof useUsersStore + >); + + expect(hasRole([ROLE.Owner])).toBe(false); + }); + }); +}); diff --git a/packages/editor-ui/src/rbac/checks/__tests__/hasScope.test.ts b/packages/editor-ui/src/rbac/checks/__tests__/hasScope.test.ts new file mode 100644 index 0000000000..80feaaaf96 --- /dev/null +++ b/packages/editor-ui/src/rbac/checks/__tests__/hasScope.test.ts @@ -0,0 +1,54 @@ +import { useRBACStore } from '@/stores/rbac.store'; +import { hasScope } from '@/rbac/checks/hasScope'; +import type { HasScopeOptions } from '@n8n/permissions'; + +vi.mock('@/stores/rbac.store', () => ({ + useRBACStore: vi.fn(), +})); + +describe('Checks', () => { + describe('hasScope()', () => { + it('should return true if no scope is provided', () => { + expect(hasScope({})).toBe(true); + }); + + it('should call rbacStore.hasScope with the correct parameters', () => { + const mockHasScope = vi.fn().mockReturnValue(true); + vi.mocked(useRBACStore).mockReturnValue({ + hasScope: mockHasScope, + } as unknown as ReturnType); + + const scope = 'workflow:read'; + const options: HasScopeOptions = { mode: 'allOf' }; + const projectId = 'proj123'; + const resourceType = 'workflow'; + const resourceId = 'res123'; + + hasScope({ scope, options, projectId, resourceType, resourceId }); + + expect(mockHasScope).toHaveBeenCalledWith( + scope, + { projectId, resourceType, resourceId }, + options, + ); + }); + + it('should return true if rbacStore.hasScope returns true', () => { + const mockHasScope = vi.fn().mockReturnValue(true); + vi.mocked(useRBACStore).mockReturnValue({ hasScope: mockHasScope } as unknown as ReturnType< + typeof useRBACStore + >); + + expect(hasScope({ scope: 'workflow:read' })).toBe(true); + }); + + it('should return false if rbacStore.hasScope returns false', () => { + const mockHasScope = vi.fn().mockReturnValue(false); + vi.mocked(useRBACStore).mockReturnValue({ hasScope: mockHasScope } as unknown as ReturnType< + typeof useRBACStore + >); + + expect(hasScope({ scope: 'workflow:read' })).toBe(false); + }); + }); +}); diff --git a/packages/editor-ui/src/rbac/checks/__tests__/isAuthenticated.test.ts b/packages/editor-ui/src/rbac/checks/__tests__/isAuthenticated.test.ts new file mode 100644 index 0000000000..96fd17aaab --- /dev/null +++ b/packages/editor-ui/src/rbac/checks/__tests__/isAuthenticated.test.ts @@ -0,0 +1,27 @@ +import { useUsersStore } from '@/stores/users.store'; +import { isAuthenticated } from '@/rbac/checks/isAuthenticated'; + +vi.mock('@/stores/users.store', () => ({ + useUsersStore: vi.fn(), +})); + +describe('Checks', () => { + describe('isAuthenticated()', () => { + it('should return true if there is a current user', () => { + const mockUser = { id: 'user123', name: 'Test User' }; + vi.mocked(useUsersStore).mockReturnValue({ currentUser: mockUser } as unknown as ReturnType< + typeof useUsersStore + >); + + expect(isAuthenticated()).toBe(true); + }); + + it('should return false if there is no current user', () => { + vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as ReturnType< + typeof useUsersStore + >); + + expect(isAuthenticated()).toBe(false); + }); + }); +}); diff --git a/packages/editor-ui/src/rbac/checks/__tests__/isEnterpriseFeatureEnabled.test.ts b/packages/editor-ui/src/rbac/checks/__tests__/isEnterpriseFeatureEnabled.test.ts new file mode 100644 index 0000000000..9de45f6bd6 --- /dev/null +++ b/packages/editor-ui/src/rbac/checks/__tests__/isEnterpriseFeatureEnabled.test.ts @@ -0,0 +1,87 @@ +import { useSettingsStore } from '@/stores/settings.store'; +import { isEnterpriseFeatureEnabled } from '@/rbac/checks/isEnterpriseFeatureEnabled'; +import { EnterpriseEditionFeature } from '@/constants'; + +vi.mock('@/stores/settings.store', () => ({ + useSettingsStore: vi.fn(), +})); + +describe('Checks', () => { + describe('isEnterpriseFeatureEnabled()', () => { + it('should return true if no feature is provided', () => { + expect(isEnterpriseFeatureEnabled({})).toBe(true); + }); + + it('should return true if feature is enabled', () => { + vi.mocked(useSettingsStore).mockReturnValue({ + isEnterpriseFeatureEnabled: vi + .fn() + .mockImplementation((feature) => feature !== EnterpriseEditionFeature.Variables), + } as unknown as ReturnType); + + expect( + isEnterpriseFeatureEnabled({ + feature: EnterpriseEditionFeature.Saml, + }), + ).toBe(true); + }); + + it('should return true if all features are enabled in allOf mode', () => { + vi.mocked(useSettingsStore).mockReturnValue({ + isEnterpriseFeatureEnabled: vi + .fn() + .mockImplementation((feature) => feature !== EnterpriseEditionFeature.Variables), + } as unknown as ReturnType); + + expect( + isEnterpriseFeatureEnabled({ + feature: [EnterpriseEditionFeature.Ldap, EnterpriseEditionFeature.Saml], + mode: 'allOf', + }), + ).toBe(true); + }); + + it('should return false if any feature is not enabled in allOf mode', () => { + vi.mocked(useSettingsStore).mockReturnValue({ + isEnterpriseFeatureEnabled: vi + .fn() + .mockImplementation((feature) => feature !== EnterpriseEditionFeature.Saml), + } as unknown as ReturnType); + + expect( + isEnterpriseFeatureEnabled({ + feature: [EnterpriseEditionFeature.Ldap, EnterpriseEditionFeature.Saml], + mode: 'allOf', + }), + ).toBe(false); + }); + + it('should return true if any feature is enabled in oneOf mode', () => { + vi.mocked(useSettingsStore).mockReturnValue({ + isEnterpriseFeatureEnabled: vi + .fn() + .mockImplementation((feature) => feature === EnterpriseEditionFeature.Ldap), + } as unknown as ReturnType); + + expect( + isEnterpriseFeatureEnabled({ + feature: [EnterpriseEditionFeature.Ldap, EnterpriseEditionFeature.Saml], + mode: 'oneOf', + }), + ).toBe(true); + }); + + it('should return false if no features are enabled in anyOf mode', () => { + vi.mocked(useSettingsStore).mockReturnValue({ + isEnterpriseFeatureEnabled: vi.fn().mockReturnValue(false), + } as unknown as ReturnType); + + expect( + isEnterpriseFeatureEnabled({ + feature: [EnterpriseEditionFeature.Ldap, EnterpriseEditionFeature.Saml], + mode: 'oneOf', + }), + ).toBe(false); + }); + }); +}); diff --git a/packages/editor-ui/src/rbac/checks/__tests__/isGuest.test.ts b/packages/editor-ui/src/rbac/checks/__tests__/isGuest.test.ts new file mode 100644 index 0000000000..8f539ded62 --- /dev/null +++ b/packages/editor-ui/src/rbac/checks/__tests__/isGuest.test.ts @@ -0,0 +1,27 @@ +import { useUsersStore } from '@/stores/users.store'; +import { isGuest } from '@/rbac/checks/isGuest'; + +vi.mock('@/stores/users.store', () => ({ + useUsersStore: vi.fn(), +})); + +describe('Checks', () => { + describe('isGuest()', () => { + it('should return false if there is a current user', () => { + const mockUser = { id: 'user123', name: 'Test User' }; + vi.mocked(useUsersStore).mockReturnValue({ currentUser: mockUser } as unknown as ReturnType< + typeof useUsersStore + >); + + expect(isGuest()).toBe(false); + }); + + it('should return true if there is no current user', () => { + vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as ReturnType< + typeof useUsersStore + >); + + expect(isGuest()).toBe(true); + }); + }); +}); diff --git a/packages/editor-ui/src/rbac/checks/__tests__/isValid.test.ts b/packages/editor-ui/src/rbac/checks/__tests__/isValid.test.ts new file mode 100644 index 0000000000..644f816330 --- /dev/null +++ b/packages/editor-ui/src/rbac/checks/__tests__/isValid.test.ts @@ -0,0 +1,19 @@ +import { isValid } from '@/rbac/checks/isValid'; + +describe('Checks', () => { + describe('isValid()', () => { + it('should return true if the provided function returns true', () => { + const mockFn = () => true; + expect(isValid(mockFn)).toBe(true); + }); + + it('should return false if the provided function returns false', () => { + const mockFn = () => false; + expect(isValid(mockFn)).toBe(false); + }); + + it('should return false if no function is provided', () => { + expect(isValid(undefined)).toBe(false); + }); + }); +}); diff --git a/packages/editor-ui/src/rbac/checks/hasRole.ts b/packages/editor-ui/src/rbac/checks/hasRole.ts new file mode 100644 index 0000000000..e396b9edbf --- /dev/null +++ b/packages/editor-ui/src/rbac/checks/hasRole.ts @@ -0,0 +1,16 @@ +import { useUsersStore } from '@/stores/users.store'; +import type { RBACPermissionCheck, RolePermissionOptions } from '@/types/rbac'; +import { ROLE } from '@/utils'; +import type { IRole } from '@/Interface'; + +export const hasRole: RBACPermissionCheck = (checkRoles) => { + const usersStore = useUsersStore(); + const currentUser = usersStore.currentUser; + + if (currentUser && checkRoles) { + const userRole = currentUser.isDefaultUser ? ROLE.Default : currentUser.globalRole?.name; + return checkRoles.includes(userRole as IRole); + } + + return false; +}; diff --git a/packages/editor-ui/src/rbac/checks/hasScope.ts b/packages/editor-ui/src/rbac/checks/hasScope.ts new file mode 100644 index 0000000000..60ba995de0 --- /dev/null +++ b/packages/editor-ui/src/rbac/checks/hasScope.ts @@ -0,0 +1,20 @@ +import { useRBACStore } from '@/stores/rbac.store'; +import type { RBACPermissionCheck, RBACPermissionOptions } from '@/types/rbac'; + +export const hasScope: RBACPermissionCheck = (opts) => { + if (!opts?.scope) { + return true; + } + const { projectId, resourceType, resourceId, scope, options } = opts; + + const rbacStore = useRBACStore(); + return rbacStore.hasScope( + scope, + { + projectId, + resourceType, + resourceId, + }, + options, + ); +}; diff --git a/packages/editor-ui/src/rbac/checks/index.ts b/packages/editor-ui/src/rbac/checks/index.ts new file mode 100644 index 0000000000..cb8acc3533 --- /dev/null +++ b/packages/editor-ui/src/rbac/checks/index.ts @@ -0,0 +1,6 @@ +export * from './hasRole'; +export * from './hasScope'; +export * from './isAuthenticated'; +export * from './isEnterpriseFeatureEnabled'; +export * from './isGuest'; +export * from './isValid'; diff --git a/packages/editor-ui/src/rbac/checks/isAuthenticated.ts b/packages/editor-ui/src/rbac/checks/isAuthenticated.ts new file mode 100644 index 0000000000..a4d0c99436 --- /dev/null +++ b/packages/editor-ui/src/rbac/checks/isAuthenticated.ts @@ -0,0 +1,7 @@ +import { useUsersStore } from '@/stores'; +import type { RBACPermissionCheck, AuthenticatedPermissionOptions } from '@/types/rbac'; + +export const isAuthenticated: RBACPermissionCheck = () => { + const usersStore = useUsersStore(); + return !!usersStore.currentUser; +}; diff --git a/packages/editor-ui/src/rbac/checks/isEnterpriseFeatureEnabled.ts b/packages/editor-ui/src/rbac/checks/isEnterpriseFeatureEnabled.ts new file mode 100644 index 0000000000..ee73c9cc4f --- /dev/null +++ b/packages/editor-ui/src/rbac/checks/isEnterpriseFeatureEnabled.ts @@ -0,0 +1,19 @@ +import { useSettingsStore } from '@/stores'; +import type { RBACPermissionCheck, EnterprisePermissionOptions } from '@/types/rbac'; + +export const isEnterpriseFeatureEnabled: RBACPermissionCheck = ( + options, +) => { + if (!options?.feature) { + return true; + } + + const features = Array.isArray(options.feature) ? options.feature : [options.feature]; + const settingsStore = useSettingsStore(); + const mode = options.mode ?? 'allOf'; + if (mode === 'allOf') { + return features.every(settingsStore.isEnterpriseFeatureEnabled); + } else { + return features.some(settingsStore.isEnterpriseFeatureEnabled); + } +}; diff --git a/packages/editor-ui/src/rbac/checks/isGuest.ts b/packages/editor-ui/src/rbac/checks/isGuest.ts new file mode 100644 index 0000000000..c6a6cfadb1 --- /dev/null +++ b/packages/editor-ui/src/rbac/checks/isGuest.ts @@ -0,0 +1,7 @@ +import { useUsersStore } from '@/stores'; +import type { RBACPermissionCheck, GuestPermissionOptions } from '@/types/rbac'; + +export const isGuest: RBACPermissionCheck = () => { + const usersStore = useUsersStore(); + return !usersStore.currentUser; +}; diff --git a/packages/editor-ui/src/rbac/checks/isValid.ts b/packages/editor-ui/src/rbac/checks/isValid.ts new file mode 100644 index 0000000000..35b1244ab1 --- /dev/null +++ b/packages/editor-ui/src/rbac/checks/isValid.ts @@ -0,0 +1,5 @@ +import type { RBACPermissionCheck, CustomPermissionOptions } from '@/types/rbac'; + +export const isValid: RBACPermissionCheck = (fn) => { + return fn ? fn() : false; +}; diff --git a/packages/editor-ui/src/rbac/middleware.ts b/packages/editor-ui/src/rbac/middleware.ts new file mode 100644 index 0000000000..e8c0ed5ae3 --- /dev/null +++ b/packages/editor-ui/src/rbac/middleware.ts @@ -0,0 +1,20 @@ +import type { RouterMiddleware, RouterMiddlewareType, MiddlewareOptions } from '@/types/router'; +import { authenticatedMiddleware } from '@/rbac/middleware/authenticated'; +import { enterpriseMiddleware } from '@/rbac/middleware/enterprise'; +import { guestMiddleware } from '@/rbac/middleware/guest'; +import { rbacMiddleware } from '@/rbac/middleware/rbac'; +import { roleMiddleware } from '@/rbac/middleware/role'; +import { customMiddleware } from '@/rbac/middleware/custom'; + +type Middleware = { + [key in RouterMiddlewareType]: RouterMiddleware; +}; + +export const middleware: Middleware = { + authenticated: authenticatedMiddleware, + custom: customMiddleware, + enterprise: enterpriseMiddleware, + guest: guestMiddleware, + rbac: rbacMiddleware, + role: roleMiddleware, +}; diff --git a/packages/editor-ui/src/rbac/middleware/__tests__/authenticated.test.ts b/packages/editor-ui/src/rbac/middleware/__tests__/authenticated.test.ts new file mode 100644 index 0000000000..50a9320c1c --- /dev/null +++ b/packages/editor-ui/src/rbac/middleware/__tests__/authenticated.test.ts @@ -0,0 +1,60 @@ +import { authenticatedMiddleware } from '@/rbac/middleware/authenticated'; +import { useUsersStore } from '@/stores/users.store'; +import { VIEWS } from '@/constants'; +import type { RouteLocationNormalized } from 'vue-router'; + +vi.mock('@/stores/users.store', () => ({ + useUsersStore: vi.fn(), +})); + +describe('Middleware', () => { + describe('authenticated', () => { + it('should redirect to signin if no current user is present', 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 authenticatedMiddleware(toMock, fromMock, nextMock, {}); + + expect(nextMock).toHaveBeenCalledWith({ + name: VIEWS.SIGNIN, + query: { redirect: encodeURIComponent('/') }, + }); + }); + + it('should call next with the correct redirect query if present', async () => { + vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as ReturnType< + typeof useUsersStore + >); + + const nextMock = vi.fn(); + const toMock = { query: { redirect: '/' } } as unknown as RouteLocationNormalized; + const fromMock = {} as RouteLocationNormalized; + + await authenticatedMiddleware(toMock, fromMock, nextMock, {}); + + expect(nextMock).toHaveBeenCalledWith({ + name: VIEWS.SIGNIN, + query: { redirect: '/' }, + }); + }); + + it('should allow navigation if a current user is present', async () => { + vi.mocked(useUsersStore).mockReturnValue({ currentUser: { id: '123' } } as ReturnType< + typeof useUsersStore + >); + + const nextMock = vi.fn(); + const toMock = { query: {} } as RouteLocationNormalized; + const fromMock = {} as RouteLocationNormalized; + + await authenticatedMiddleware(toMock, fromMock, nextMock, {}); + + expect(nextMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/editor-ui/src/rbac/middleware/__tests__/custom.test.ts b/packages/editor-ui/src/rbac/middleware/__tests__/custom.test.ts new file mode 100644 index 0000000000..54960ba037 --- /dev/null +++ b/packages/editor-ui/src/rbac/middleware/__tests__/custom.test.ts @@ -0,0 +1,35 @@ +import { customMiddleware } from '@/rbac/middleware/custom'; +import type { RouteLocationNormalized } from 'vue-router'; +import { VIEWS } from '@/constants'; + +describe('Middleware', () => { + describe('custom', () => { + it('should redirect to homepage if validation function returns false', async () => { + const nextMock = vi.fn(); + const fn = () => false; + + await customMiddleware( + {} as RouteLocationNormalized, + {} as RouteLocationNormalized, + nextMock, + fn, + ); + + expect(nextMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE }); + }); + + it('should pass if validation function returns true', async () => { + const nextMock = vi.fn(); + const fn = () => true; + + await customMiddleware( + {} as RouteLocationNormalized, + {} as RouteLocationNormalized, + nextMock, + fn, + ); + + expect(nextMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/editor-ui/src/rbac/middleware/__tests__/enterprise.test.ts b/packages/editor-ui/src/rbac/middleware/__tests__/enterprise.test.ts new file mode 100644 index 0000000000..330e7aaa5e --- /dev/null +++ b/packages/editor-ui/src/rbac/middleware/__tests__/enterprise.test.ts @@ -0,0 +1,98 @@ +import { useSettingsStore } from '@/stores/settings.store'; +import { VIEWS, EnterpriseEditionFeature } from '@/constants'; +import { enterpriseMiddleware } from '@/rbac/middleware/enterprise'; +import { type RouteLocationNormalized } from 'vue-router'; +import type { EnterprisePermissionOptions } from '@/types/rbac'; + +vi.mock('@/stores/settings.store', () => ({ + useSettingsStore: vi.fn(), +})); + +describe('Middleware', () => { + describe('enterprise', () => { + it('should redirect to homepage if none of the required features are enabled in allOf mode', async () => { + vi.mocked(useSettingsStore).mockReturnValue({ + isEnterpriseFeatureEnabled: (_) => false, + } as ReturnType); + + const nextMock = vi.fn(); + const options: EnterprisePermissionOptions = { + feature: [EnterpriseEditionFeature.Saml, EnterpriseEditionFeature.Ldap], + mode: 'allOf', + }; + + await enterpriseMiddleware( + {} as RouteLocationNormalized, + {} as RouteLocationNormalized, + nextMock, + options, + ); + + expect(nextMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE }); + }); + + it('should allow navigation if all of the required features are enabled in allOf mode', async () => { + vi.mocked(useSettingsStore).mockReturnValue({ + isEnterpriseFeatureEnabled: (feature) => + [EnterpriseEditionFeature.Saml, EnterpriseEditionFeature.Ldap].includes(feature), + } as ReturnType); + + const nextMock = vi.fn(); + const options: EnterprisePermissionOptions = { + feature: [EnterpriseEditionFeature.Saml, EnterpriseEditionFeature.Ldap], + mode: 'allOf', + }; + + await enterpriseMiddleware( + {} as RouteLocationNormalized, + {} as RouteLocationNormalized, + nextMock, + options, + ); + + expect(nextMock).toHaveBeenCalledTimes(0); + }); + + it('should redirect to homepage if none of the required features are enabled in oneOf mode', async () => { + vi.mocked(useSettingsStore).mockReturnValue({ + isEnterpriseFeatureEnabled: (_) => false, + } as ReturnType); + + const nextMock = vi.fn(); + const options: EnterprisePermissionOptions = { + feature: [EnterpriseEditionFeature.Saml], + mode: 'oneOf', + }; + + await enterpriseMiddleware( + {} as RouteLocationNormalized, + {} as RouteLocationNormalized, + nextMock, + options, + ); + + expect(nextMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE }); + }); + + it('should allow navigation if at least one of the required features is enabled in oneOf mode', async () => { + vi.mocked(useSettingsStore).mockReturnValue({ + isEnterpriseFeatureEnabled: (feature) => feature === EnterpriseEditionFeature.Saml, + } as ReturnType); + + const nextMock = vi.fn(); + const options: EnterprisePermissionOptions = { + feature: [EnterpriseEditionFeature.Saml, EnterpriseEditionFeature.Ldap], + mode: 'oneOf', + }; + + await enterpriseMiddleware( + {} as RouteLocationNormalized, + {} as RouteLocationNormalized, + nextMock, + options, + ); + + expect(nextMock).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/packages/editor-ui/src/rbac/middleware/__tests__/guest.test.ts b/packages/editor-ui/src/rbac/middleware/__tests__/guest.test.ts new file mode 100644 index 0000000000..6b27a23283 --- /dev/null +++ b/packages/editor-ui/src/rbac/middleware/__tests__/guest.test.ts @@ -0,0 +1,54 @@ +import { useUsersStore } from '@/stores/users.store'; +import { VIEWS } from '@/constants'; +import { guestMiddleware } from '@/rbac/middleware/guest'; +import type { RouteLocationNormalized } from 'vue-router'; + +vi.mock('@/stores/users.store', () => ({ + useUsersStore: vi.fn(), +})); + +describe('Middleware', () => { + describe('guest', () => { + it('should redirect to given path if current user is present and valid redirect is provided', async () => { + vi.mocked(useUsersStore).mockReturnValue({ currentUser: { id: '123' } } as ReturnType< + typeof useUsersStore + >); + + const nextMock = vi.fn(); + const toMock = { query: { redirect: '/some-path' } } as unknown as RouteLocationNormalized; + const fromMock = {} as RouteLocationNormalized; + + await guestMiddleware(toMock, fromMock, nextMock, {}); + + expect(nextMock).toHaveBeenCalledWith('/some-path'); + }); + + it('should redirect to homepage if current user is present and no valid redirect', async () => { + vi.mocked(useUsersStore).mockReturnValue({ currentUser: { id: '123' } } as ReturnType< + typeof useUsersStore + >); + + const nextMock = vi.fn(); + const toMock = { query: {} } as RouteLocationNormalized; + const fromMock = {} as RouteLocationNormalized; + + await guestMiddleware(toMock, fromMock, nextMock, {}); + + expect(nextMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE }); + }); + + it('should allow navigation if no current user is present', 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 guestMiddleware(toMock, fromMock, nextMock, {}); + + expect(nextMock).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/packages/editor-ui/src/rbac/middleware/__tests__/rbac.test.ts b/packages/editor-ui/src/rbac/middleware/__tests__/rbac.test.ts new file mode 100644 index 0000000000..0743936330 --- /dev/null +++ b/packages/editor-ui/src/rbac/middleware/__tests__/rbac.test.ts @@ -0,0 +1,60 @@ +import { useRBACStore } from '@/stores/rbac.store'; +import { rbacMiddleware } from '@/rbac/middleware/rbac'; +import { VIEWS } from '@/constants'; +import { + inferProjectIdFromRoute, + inferResourceIdFromRoute, + inferResourceTypeFromRoute, +} from '@/utils/rbacUtils'; +import type { RouteLocationNormalized } from 'vue-router'; +import type { Scope } from '@n8n/permissions'; + +vi.mock('@/stores/rbac.store', () => ({ + useRBACStore: vi.fn(), +})); + +vi.mock('@/utils/rbacUtils', () => ({ + inferProjectIdFromRoute: vi.fn(), + inferResourceIdFromRoute: vi.fn(), + inferResourceTypeFromRoute: vi.fn(), +})); + +describe('Middleware', () => { + describe('rbac', () => { + it('should redirect to homepage if the user does not have the required scope', async () => { + vi.mocked(useRBACStore).mockReturnValue({ + hasScope: vi.fn().mockReturnValue(false), + } as unknown as ReturnType); + vi.mocked(inferProjectIdFromRoute).mockReturnValue('123'); + vi.mocked(inferResourceTypeFromRoute).mockReturnValue('workflow'); + vi.mocked(inferResourceIdFromRoute).mockReturnValue('456'); + + const nextMock = vi.fn(); + const scope: Scope = 'workflow:read'; + + await rbacMiddleware({} as RouteLocationNormalized, {} as RouteLocationNormalized, nextMock, { + scope, + }); + + expect(nextMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE }); + }); + + it('should allow navigation if the user has the required scope', async () => { + vi.mocked(useRBACStore).mockReturnValue({ + hasScope: vi.fn().mockReturnValue(true), + } as unknown as ReturnType); + vi.mocked(inferProjectIdFromRoute).mockReturnValue('123'); + vi.mocked(inferResourceTypeFromRoute).mockReturnValue(undefined); + vi.mocked(inferResourceIdFromRoute).mockReturnValue(undefined); + + const nextMock = vi.fn(); + const scope: Scope = 'workflow:read'; + + await rbacMiddleware({} as RouteLocationNormalized, {} as RouteLocationNormalized, nextMock, { + scope, + }); + + expect(nextMock).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/packages/editor-ui/src/rbac/middleware/__tests__/role.test.ts b/packages/editor-ui/src/rbac/middleware/__tests__/role.test.ts new file mode 100644 index 0000000000..5dc739eca5 --- /dev/null +++ b/packages/editor-ui/src/rbac/middleware/__tests__/role.test.ts @@ -0,0 +1,82 @@ +import { roleMiddleware } from '@/rbac/middleware/role'; +import { useUsersStore } from '@/stores/users.store'; +import { ROLE } from '@/utils'; +import type { IUser } from '@/Interface'; +import type { RouteLocationNormalized } from 'vue-router'; +import { VIEWS } from '@/constants'; + +vi.mock('@/stores/users.store', () => ({ + useUsersStore: vi.fn(), +})); + +describe('Middleware', () => { + describe('role', () => { + it('should redirect to homepage if the user does not have the required role', async () => { + vi.mocked(useUsersStore).mockReturnValue({ + currentUser: { + isDefaultUser: false, + globalRole: { + id: '123', + createdAt: new Date(), + name: ROLE.Owner, + }, + } as IUser, + } as ReturnType); + + const nextMock = vi.fn(); + const role = [ROLE.Default]; + + await roleMiddleware( + {} as RouteLocationNormalized, + {} as RouteLocationNormalized, + nextMock, + role, + ); + + expect(nextMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE }); + }); + + it('should redirect to homepage if the user is not logged in', async () => { + vi.mocked(useUsersStore).mockReturnValue({ + currentUser: null, + } as ReturnType); + + const nextMock = vi.fn(); + const role = [ROLE.Default]; + + await roleMiddleware( + {} as RouteLocationNormalized, + {} as RouteLocationNormalized, + nextMock, + role, + ); + + expect(nextMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE }); + }); + + it('should nor redirect if the user has the required role', async () => { + vi.mocked(useUsersStore).mockReturnValue({ + currentUser: { + isDefaultUser: false, + globalRole: { + id: '123', + createdAt: new Date(), + name: ROLE.Owner, + }, + } as IUser, + } as ReturnType); + + const nextMock = vi.fn(); + const role = [ROLE.Owner]; + + await roleMiddleware( + {} as RouteLocationNormalized, + {} as RouteLocationNormalized, + nextMock, + role, + ); + + expect(nextMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/editor-ui/src/rbac/middleware/authenticated.ts b/packages/editor-ui/src/rbac/middleware/authenticated.ts new file mode 100644 index 0000000000..20100f78f0 --- /dev/null +++ b/packages/editor-ui/src/rbac/middleware/authenticated.ts @@ -0,0 +1,18 @@ +import type { RouterMiddleware } from '@/types/router'; +import { VIEWS } from '@/constants'; +import type { AuthenticatedPermissionOptions } from '@/types/rbac'; +import { isAuthenticated } from '@/rbac/checks'; + +export const authenticatedMiddleware: RouterMiddleware = async ( + to, + from, + next, +) => { + const valid = isAuthenticated(); + if (!valid) { + const redirect = + to.query.redirect ?? + encodeURIComponent(`${window.location.pathname}${window.location.search}`); + return next({ name: VIEWS.SIGNIN, query: { redirect } }); + } +}; diff --git a/packages/editor-ui/src/rbac/middleware/custom.ts b/packages/editor-ui/src/rbac/middleware/custom.ts new file mode 100644 index 0000000000..debf9e8ba9 --- /dev/null +++ b/packages/editor-ui/src/rbac/middleware/custom.ts @@ -0,0 +1,14 @@ +import type { CustomMiddlewareOptions, RouterMiddleware } from '@/types/router'; +import { VIEWS } from '@/constants'; + +export const customMiddleware: RouterMiddleware = async ( + to, + from, + next, + isValid, +) => { + const valid = isValid({ to, from, next }); + if (!valid) { + return next({ name: VIEWS.HOMEPAGE }); + } +}; diff --git a/packages/editor-ui/src/rbac/middleware/enterprise.ts b/packages/editor-ui/src/rbac/middleware/enterprise.ts new file mode 100644 index 0000000000..88f09a8557 --- /dev/null +++ b/packages/editor-ui/src/rbac/middleware/enterprise.ts @@ -0,0 +1,16 @@ +import type { RouterMiddleware } from '@/types/router'; +import { VIEWS } from '@/constants'; +import type { EnterprisePermissionOptions } from '@/types/rbac'; +import { isEnterpriseFeatureEnabled } from '@/rbac/checks'; + +export const enterpriseMiddleware: RouterMiddleware = async ( + to, + from, + next, + options, +) => { + const valid = isEnterpriseFeatureEnabled(options); + if (!valid) { + return next({ name: VIEWS.HOMEPAGE }); + } +}; diff --git a/packages/editor-ui/src/rbac/middleware/guest.ts b/packages/editor-ui/src/rbac/middleware/guest.ts new file mode 100644 index 0000000000..72a56555a3 --- /dev/null +++ b/packages/editor-ui/src/rbac/middleware/guest.ts @@ -0,0 +1,16 @@ +import type { RouterMiddleware } from '@/types/router'; +import { VIEWS } from '@/constants'; +import type { GuestPermissionOptions } from '@/types/rbac'; +import { isGuest } from '@/rbac/checks'; + +export const guestMiddleware: RouterMiddleware = async (to, from, next) => { + const valid = isGuest(); + if (!valid) { + const redirect = to.query.redirect as string; + if (redirect && redirect.startsWith('/')) { + return next(redirect); + } + + return next({ name: VIEWS.HOMEPAGE }); + } +}; diff --git a/packages/editor-ui/src/rbac/middleware/rbac.ts b/packages/editor-ui/src/rbac/middleware/rbac.ts new file mode 100644 index 0000000000..e3c25ec433 --- /dev/null +++ b/packages/editor-ui/src/rbac/middleware/rbac.ts @@ -0,0 +1,25 @@ +import type { RouterMiddleware } from '@/types/router'; +import { VIEWS } from '@/constants'; +import { + inferProjectIdFromRoute, + inferResourceIdFromRoute, + inferResourceTypeFromRoute, +} from '@/utils/rbacUtils'; +import type { RBACPermissionOptions } from '@/types/rbac'; +import { hasScope } from '@/rbac/checks'; + +export const rbacMiddleware: RouterMiddleware = async ( + to, + from, + next, + { scope, options }, +) => { + const projectId = inferProjectIdFromRoute(to); + const resourceType = inferResourceTypeFromRoute(to); + const resourceId = resourceType ? inferResourceIdFromRoute(to) : undefined; + + const valid = hasScope({ scope, projectId, resourceType, resourceId, options }); + if (!valid) { + return next({ name: VIEWS.HOMEPAGE }); + } +}; diff --git a/packages/editor-ui/src/rbac/middleware/role.ts b/packages/editor-ui/src/rbac/middleware/role.ts new file mode 100644 index 0000000000..b4614aa547 --- /dev/null +++ b/packages/editor-ui/src/rbac/middleware/role.ts @@ -0,0 +1,16 @@ +import type { RouterMiddleware } from '@/types/router'; +import type { RolePermissionOptions } from '@/types/rbac'; +import { VIEWS } from '@/constants'; +import { hasRole } from '@/rbac/checks'; + +export const roleMiddleware: RouterMiddleware = async ( + to, + from, + next, + checkRoles, +) => { + const valid = hasRole(checkRoles); + if (!valid) { + return next({ name: VIEWS.HOMEPAGE }); + } +}; diff --git a/packages/editor-ui/src/rbac/permissions.ts b/packages/editor-ui/src/rbac/permissions.ts new file mode 100644 index 0000000000..13e9c2be00 --- /dev/null +++ b/packages/editor-ui/src/rbac/permissions.ts @@ -0,0 +1,38 @@ +import { + hasRole, + hasScope, + isAuthenticated, + isEnterpriseFeatureEnabled, + isGuest, + isValid, +} from '@/rbac/checks'; +import type { PermissionType, PermissionTypeOptions, RBACPermissionCheck } from '@/types/rbac'; + +type Permissions = { + [key in PermissionType]: RBACPermissionCheck; +}; + +export const permissions: Permissions = { + authenticated: isAuthenticated, + custom: isValid, + enterprise: isEnterpriseFeatureEnabled, + guest: isGuest, + rbac: hasScope, + role: hasRole, +}; + +export function hasPermission( + permissionNames: PermissionType[], + options?: Partial, +) { + let valid = true; + + for (const permissionName of permissionNames) { + const permissionOptions = options?.[permissionName] ?? {}; + const permissionFn = permissions[permissionName] as RBACPermissionCheck; + + valid = valid && permissionFn(permissionOptions); + } + + return valid; +} diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index e394d60b09..13d6109b1e 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -1,16 +1,23 @@ import { useStorage } from '@/composables/useStorage'; -import type { RouteLocation, RouteRecordRaw } from 'vue-router'; +import type { + NavigationGuardNext, + RouteLocation, + RouteRecordRaw, + RouteLocationRaw, + RouteLocationNormalized, +} from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router'; -import type { IPermissions } from './Interface'; -import { isAuthorized, LOGIN_STATUS, ROLE, runExternalHook } from '@/utils'; +import { ROLE, runExternalHook } from '@/utils'; import { useSettingsStore } from './stores/settings.store'; -import { useUsersStore } from './stores/users.store'; import { useTemplatesStore } from './stores/templates.store'; import { useUIStore } from '@/stores/ui.store'; import { useSSOStore } from './stores/sso.store'; import { EnterpriseEditionFeature, VIEWS } from '@/constants'; import { useTelemetry } from '@/composables'; +import { middleware } from '@/rbac/middleware'; +import type { RouteConfig, RouterMiddleware } from '@/types/router'; +import { initializeCore } from '@/init'; const ChangePasswordView = async () => import('./views/ChangePasswordView.vue'); const ErrorView = async () => import('./views/ErrorView.vue'); @@ -51,20 +58,6 @@ const WorkerView = async () => import('./views/WorkerView.vue'); const WorkflowHistory = async () => import('@/views/WorkflowHistory.vue'); const WorkflowOnboardingView = async () => import('@/views/WorkflowOnboardingView.vue'); -interface IRouteConfig { - meta: { - nodeView?: boolean; - templatesEnabled?: boolean; - getRedirect?: () => { name: string } | false; - permissions: IPermissions; - telemetry?: { - disabled?: true; - getProperties: (route: RouteLocation) => object; - }; - scrollOffset?: number; - }; -} - function getTemplatesRedirect(defaultRedirect: VIEWS[keyof VIEWS]) { const settingsStore = useSettingsStore(); const isTemplatesEnabled: boolean = settingsStore.isTemplatesEnabled; @@ -83,11 +76,7 @@ export const routes = [ return { name: VIEWS.WORKFLOWS }; }, meta: { - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - }, + middleware: ['authenticated'], }, }, { @@ -109,11 +98,7 @@ export const routes = [ }, }, getRedirect: getTemplatesRedirect, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - }, + middleware: ['authenticated'], }, }, { @@ -135,11 +120,7 @@ export const routes = [ }; }, }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - }, + middleware: ['authenticated'], }, }, { @@ -165,11 +146,7 @@ export const routes = [ setScrollPosition(pos: number) { this.scrollOffset = pos; }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - }, + middleware: ['authenticated'], }, }, { @@ -180,11 +157,7 @@ export const routes = [ sidebar: MainSidebar, }, meta: { - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - }, + middleware: ['authenticated'], }, }, { @@ -194,13 +167,7 @@ export const routes = [ default: VariablesView, sidebar: MainSidebar, }, - meta: { - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - }, - }, + meta: { middleware: ['authenticated'] }, }, { path: '/executions', @@ -210,11 +177,7 @@ export const routes = [ sidebar: MainSidebar, }, meta: { - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - }, + middleware: ['authenticated'], }, }, { @@ -225,11 +188,7 @@ export const routes = [ sidebar: MainSidebar, }, meta: { - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - }, + middleware: ['authenticated'], }, }, { @@ -243,13 +202,10 @@ export const routes = [ meta: { nodeView: true, keepWorkflowAlive: true, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - deny: { - shouldDeny: () => - !useSettingsStore().isEnterpriseFeatureEnabled(EnterpriseEditionFeature.DebugInEditor), + middleware: ['authenticated', 'enterprise'], + middlewareOptions: { + enterprise: { + feature: [EnterpriseEditionFeature.DebugInEditor], }, }, }, @@ -264,11 +220,7 @@ export const routes = [ }, meta: { keepWorkflowAlive: true, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - }, + middleware: ['authenticated'], }, children: [ { @@ -279,11 +231,7 @@ export const routes = [ }, meta: { keepWorkflowAlive: true, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - }, + middleware: ['authenticated'], }, }, { @@ -294,11 +242,7 @@ export const routes = [ }, meta: { keepWorkflowAlive: true, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - }, + middleware: ['authenticated'], }, }, ], @@ -311,15 +255,10 @@ export const routes = [ sidebar: MainSidebar, }, meta: { - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - deny: { - shouldDeny: () => - !useSettingsStore().isEnterpriseFeatureEnabled( - EnterpriseEditionFeature.WorkflowHistory, - ), + middleware: ['authenticated', 'enterprise'], + middlewareOptions: { + enterprise: { + feature: [EnterpriseEditionFeature.WorkflowHistory], }, }, }, @@ -336,11 +275,7 @@ export const routes = [ templatesEnabled: true, keepWorkflowAlive: true, getRedirect: getTemplatesRedirect, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - }, + middleware: ['authenticated'], }, }, { @@ -355,11 +290,7 @@ export const routes = [ templatesEnabled: true, keepWorkflowAlive: true, getRedirect: () => getTemplatesRedirect(VIEWS.NEW_WORKFLOW), - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - }, + middleware: ['authenticated'], }, }, { @@ -373,11 +304,7 @@ export const routes = [ meta: { nodeView: true, keepWorkflowAlive: true, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - }, + middleware: ['authenticated'], }, }, { @@ -387,11 +314,7 @@ export const routes = [ default: NodeView, }, meta: { - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - }, + middleware: ['authenticated'], }, }, { @@ -405,11 +328,7 @@ export const routes = [ meta: { nodeView: true, keepWorkflowAlive: true, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - }, + middleware: ['authenticated'], }, }, { @@ -426,11 +345,7 @@ export const routes = [ telemetry: { pageCategory: 'auth', }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedOut], - }, - }, + middleware: ['guest'], }, }, { @@ -443,11 +358,7 @@ export const routes = [ telemetry: { pageCategory: 'auth', }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedOut], - }, - }, + middleware: ['guest'], }, }, { @@ -460,11 +371,7 @@ export const routes = [ telemetry: { pageCategory: 'auth', }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - }, + middleware: ['authenticated'], }, }, { @@ -474,14 +381,13 @@ export const routes = [ default: SetupView, }, meta: { + middleware: ['role'], + middlewareOptions: { + role: [ROLE.Default], + }, telemetry: { pageCategory: 'auth', }, - permissions: { - allow: { - role: [ROLE.Default], - }, - }, }, }, { @@ -491,14 +397,10 @@ export const routes = [ default: ForgotMyPasswordView, }, meta: { + middleware: ['guest'], telemetry: { pageCategory: 'auth', }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedOut], - }, - }, }, }, { @@ -508,14 +410,10 @@ export const routes = [ default: ChangePasswordView, }, meta: { + middleware: ['guest'], telemetry: { pageCategory: 'auth', }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedOut], - }, - }, }, }, { @@ -530,6 +428,16 @@ export const routes = [ settingsView: SettingsUsageAndPlan, }, meta: { + middleware: ['authenticated', 'custom'], + middlewareOptions: { + custom: () => { + const settingsStore = useSettingsStore(); + return !( + settingsStore.settings.hideUsagePage || + settingsStore.settings.deployment?.type === 'cloud' + ); + }, + }, telemetry: { pageCategory: 'settings', getProperties(route: RouteLocation) { @@ -538,20 +446,6 @@ export const routes = [ }; }, }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - deny: { - shouldDeny: () => { - const settingsStore = useSettingsStore(); - return ( - settingsStore.settings.hideUsagePage || - settingsStore.settings.deployment?.type === 'cloud' - ); - }, - }, - }, }, }, { @@ -561,6 +455,10 @@ export const routes = [ settingsView: SettingsPersonalView, }, meta: { + middleware: ['authenticated', 'role'], + middlewareOptions: { + role: [ROLE.Owner, ROLE.Member], + }, telemetry: { pageCategory: 'settings', getProperties(route: RouteLocation) { @@ -569,14 +467,6 @@ export const routes = [ }; }, }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - deny: { - role: [ROLE.Default], - }, - }, }, }, { @@ -586,6 +476,10 @@ export const routes = [ settingsView: SettingsUsersView, }, meta: { + middleware: ['authenticated', 'role'], + middlewareOptions: { + role: [ROLE.Owner], + }, telemetry: { pageCategory: 'settings', getProperties(route: RouteLocation) { @@ -594,11 +488,6 @@ export const routes = [ }; }, }, - permissions: { - allow: { - role: [ROLE.Owner], - }, - }, }, }, { @@ -608,6 +497,13 @@ export const routes = [ settingsView: SettingsApiView, }, meta: { + middleware: ['authenticated', 'custom'], + middlewareOptions: { + custom: () => { + const settingsStore = useSettingsStore(); + return settingsStore.isPublicApiEnabled; + }, + }, telemetry: { pageCategory: 'settings', getProperties(route: RouteLocation) { @@ -616,17 +512,6 @@ export const routes = [ }; }, }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - deny: { - shouldDeny: () => { - const settingsStore = useSettingsStore(); - return !settingsStore.isPublicApiEnabled; - }, - }, - }, }, }, { @@ -636,6 +521,10 @@ export const routes = [ settingsView: SettingsSourceControl, }, meta: { + middleware: ['authenticated', 'role'], + middlewareOptions: { + role: [ROLE.Owner], + }, telemetry: { pageCategory: 'settings', getProperties(route: RouteLocation) { @@ -644,11 +533,6 @@ export const routes = [ }; }, }, - permissions: { - allow: { - role: [ROLE.Owner], - }, - }, }, }, { @@ -658,19 +542,18 @@ export const routes = [ settingsView: SettingsExternalSecrets, }, meta: { + middleware: ['authenticated', 'role'], + middlewareOptions: { + role: [ROLE.Owner], + }, telemetry: { pageCategory: 'settings', - getProperties(route: Route) { + getProperties(route: RouteLocation) { return { feature: 'external-secrets', }; }, }, - permissions: { - allow: { - role: [ROLE.Owner], - }, - }, }, }, { @@ -680,6 +563,14 @@ export const routes = [ settingsView: SettingsSso, }, meta: { + middleware: ['authenticated', 'role', 'custom'], + middlewareOptions: { + custom: () => { + const settingsStore = useSettingsStore(); + return !settingsStore.isDesktopDeployment; + }, + role: [ROLE.Owner], + }, telemetry: { pageCategory: 'settings', getProperties(route: RouteLocation) { @@ -688,17 +579,6 @@ export const routes = [ }; }, }, - permissions: { - allow: { - role: [ROLE.Owner], - }, - deny: { - shouldDeny: () => { - const settingsStore = useSettingsStore(); - return settingsStore.isDesktopDeployment; - }, - }, - }, }, }, { @@ -708,17 +588,13 @@ export const routes = [ settingsView: SettingsLogStreamingView, }, meta: { + middleware: ['authenticated', 'role'], + middlewareOptions: { + role: [ROLE.Owner], + }, telemetry: { pageCategory: 'settings', }, - permissions: { - allow: { - role: [ROLE.Owner], - }, - deny: { - role: [ROLE.Member], - }, - }, }, }, { @@ -728,11 +604,7 @@ export const routes = [ settingsView: WorkerView, }, meta: { - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - }, + middleware: ['authenticated'], }, }, { @@ -742,20 +614,17 @@ export const routes = [ settingsView: SettingsCommunityNodesView, }, meta: { + middleware: ['authenticated', 'role', 'custom'], + middlewareOptions: { + role: [ROLE.Owner], + custom: () => { + const settingsStore = useSettingsStore(); + return settingsStore.isCommunityNodesFeatureEnabled; + }, + }, telemetry: { pageCategory: 'settings', }, - permissions: { - allow: { - role: [ROLE.Owner], - }, - deny: { - shouldDeny: () => { - const settingsStore = useSettingsStore(); - return !settingsStore.isCommunityNodesFeatureEnabled; - }, - }, - }, }, }, { @@ -765,6 +634,7 @@ export const routes = [ settingsView: SettingsFakeDoorView, }, meta: { + middleware: ['authenticated'], telemetry: { pageCategory: 'settings', getProperties(route: RouteLocation) { @@ -773,11 +643,6 @@ export const routes = [ }; }, }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - }, }, }, { @@ -787,13 +652,9 @@ export const routes = [ settingsView: SettingsLdapView, }, meta: { - permissions: { - allow: { - role: [ROLE.Owner], - }, - deny: { - role: [ROLE.Member], - }, + middleware: ['authenticated', 'role'], + middlewareOptions: { + role: [ROLE.Owner], }, }, }, @@ -804,6 +665,13 @@ export const routes = [ settingsView: SettingsAuditLogs, }, meta: { + middleware: ['authenticated', 'role', 'custom'], + middlewareOptions: { + custom: () => { + return !!useStorage('audit-logs').value; + }, + role: [ROLE.Owner], + }, telemetry: { pageCategory: 'settings', getProperties(route: RouteLocation) { @@ -812,14 +680,6 @@ export const routes = [ }; }, }, - permissions: { - allow: { - role: [ROLE.Owner], - }, - deny: { - shouldDeny: () => !useStorage('audit-logs').value, - }, - }, }, }, ], @@ -831,25 +691,21 @@ export const routes = [ default: SamlOnboarding, }, meta: { + middleware: ['authenticated', 'custom'], + middlewareOptions: { + custom: () => { + const settingsStore = useSettingsStore(); + const ssoStore = useSSOStore(); + return ( + ssoStore.isEnterpriseSamlEnabled && + !settingsStore.isCloudDeployment && + !settingsStore.isDesktopDeployment + ); + }, + }, telemetry: { pageCategory: 'auth', }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - deny: { - shouldDeny: () => { - const settingsStore = useSettingsStore(); - const ssoStore = useSSOStore(); - return ( - !ssoStore.isEnterpriseSamlEnabled || - settingsStore.isCloudDeployment || - settingsStore.isDesktopDeployment - ); - }, - }, - }, }, }, { @@ -867,21 +723,15 @@ export const routes = [ telemetry: { disabled: true, }, - permissions: { - allow: { - // TODO: Once custom permissions are merged, this needs to be updated with index validation - loginStatus: [LOGIN_STATUS.LoggedIn, LOGIN_STATUS.LoggedOut], - }, - }, }, }, -] as Array; +] as Array; const router = createRouter({ history: createWebHistory(import.meta.env.DEV ? '/' : window.BASE_PATH ?? '/'), - scrollBehavior(to, from, savedPosition) { + scrollBehavior(to: RouteLocationNormalized & RouteConfig, from, savedPosition) { // saved position == null means the page is NOT visited from history (back button) - if (savedPosition === null && to.name === VIEWS.TEMPLATES && to.meta) { + if (savedPosition === null && to.name === VIEWS.TEMPLATES && to.meta?.setScrollPosition) { // for templates view, reset scroll position in this case to.meta.setScrollPosition(0); } @@ -889,19 +739,19 @@ const router = createRouter({ routes, }); -router.beforeEach(async (to, from, next) => { +router.beforeEach(async (to: RouteLocationNormalized & RouteConfig, from, next) => { /** - * Initialize stores before routing + * Initialize application core + * This step executes before first route is loaded and is required for permission checks */ - const settingsStore = useSettingsStore(); - const usersStore = useUsersStore(); - await usersStore.initialize(); + await initializeCore(); /** * Redirect to setup page. User should be redirected to this only once */ + const settingsStore = useSettingsStore(); if (settingsStore.showSetupPage) { if (to.name === VIEWS.SETUP) { return next(); @@ -914,41 +764,25 @@ router.beforeEach(async (to, from, next) => { * Verify user permissions for current route */ - const currentUser = usersStore.currentUser; - const permissions = to.meta?.permissions as IPermissions; - const canUserAccessCurrentRoute = permissions && isAuthorized(permissions, currentUser); - if (canUserAccessCurrentRoute) { - return next(); - } + const routeMiddleware = to.meta?.middleware ?? []; + const routeMiddlewareOptions = to.meta?.middlewareOptions ?? {}; + for (const middlewareName of routeMiddleware) { + let nextCalled = false; + const middlewareNext = ((location: RouteLocationRaw): void => { + next(location); + nextCalled = true; + }) as NavigationGuardNext; - /** - * If user cannot access the page and is not logged in, redirect to sign in - */ + const middlewareOptions = routeMiddlewareOptions[middlewareName]; + const middlewareFn = middleware[middlewareName] as RouterMiddleware; + await middlewareFn(to, from, middlewareNext, middlewareOptions); - if (!currentUser) { - const redirect = - to.query.redirect || - encodeURIComponent(`${window.location.pathname}${window.location.search}`); - return next({ name: VIEWS.SIGNIN, query: { redirect } }); - } - - /** - * If user cannot access page but is logged in, respect sign in redirect - */ - - if (to.name === VIEWS.SIGNIN && typeof to.query.redirect === 'string') { - const redirect = decodeURIComponent(to.query.redirect); - if (redirect.startsWith('/')) { - // protect against phishing - return next(redirect); + if (nextCalled) { + return; } } - /** - * Otherwise, redirect to home page - */ - - return next({ name: VIEWS.HOMEPAGE }); + return next(); }); router.afterEach((to, from) => { diff --git a/packages/editor-ui/src/stores/__tests__/rbac.store.test.ts b/packages/editor-ui/src/stores/__tests__/rbac.store.test.ts new file mode 100644 index 0000000000..71972f4a51 --- /dev/null +++ b/packages/editor-ui/src/stores/__tests__/rbac.store.test.ts @@ -0,0 +1,152 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { useRBACStore } from '@/stores/rbac.store'; +import type { Scope } from '@n8n/permissions'; +import { hasScope } from '@n8n/permissions'; + +vi.mock('@n8n/permissions', async () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const { hasScope } = await vi.importActual('@n8n/permissions'); + return { + hasScope: vi.fn().mockImplementation(hasScope), + }; +}); + +describe('RBAC store', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + describe('addGlobalScope()', () => { + it('should add global scope', () => { + const newScope = 'example:list' as Scope; + const rbacStore = useRBACStore(); + rbacStore.addGlobalScope(newScope); + expect(rbacStore.globalScopes).toContain(newScope); + }); + + it('should not add global scope if it already exists', () => { + const newScope = 'example:list' as Scope; + const rbacStore = useRBACStore(); + rbacStore.addGlobalScope(newScope); + rbacStore.addGlobalScope(newScope); + expect(rbacStore.globalScopes.filter((scope) => scope === newScope)).toHaveLength(1); + }); + }); + + describe('addProjectScope()', () => { + it('should add project scope', () => { + const newScope = 'example:list' as Scope; + const rbacStore = useRBACStore(); + rbacStore.addProjectScope(newScope, { projectId: '1' }); + expect(rbacStore.scopesByProjectId['1']).toContain(newScope); + }); + + it('should not add project scope if it already exists', () => { + const newScope = 'example:list' as Scope; + const rbacStore = useRBACStore(); + rbacStore.addProjectScope(newScope, { projectId: '1' }); + rbacStore.addProjectScope(newScope, { projectId: '1' }); + expect(rbacStore.scopesByProjectId['1'].filter((scope) => scope === newScope)).toHaveLength( + 1, + ); + }); + }); + + describe('addResourceScope()', () => { + it('should add resource scope', () => { + const newScope = 'example:list' as Scope; + const rbacStore = useRBACStore(); + rbacStore.addResourceScope(newScope, { resourceId: '1', resourceType: 'variable' }); + expect(rbacStore.scopesByResourceId['variable']['1']).toContain(newScope); + }); + + it('should not add resource scope if it already exists', () => { + const newScope = 'example:list' as Scope; + const rbacStore = useRBACStore(); + rbacStore.addResourceScope(newScope, { resourceId: '1', resourceType: 'variable' }); + rbacStore.addResourceScope(newScope, { resourceId: '1', resourceType: 'variable' }); + expect( + rbacStore.scopesByResourceId['variable']['1'].filter((scope) => scope === newScope), + ).toHaveLength(1); + }); + }); + + describe('hasScope()', () => { + it('evaluates global scope correctly', () => { + const newScope = 'example:list' as Scope; + const store = useRBACStore(); + store.addGlobalScope(newScope); + + const result = store.hasScope(newScope, {}); + expect(result).toBe(true); + expect(vi.mocked(hasScope)).toHaveBeenCalledWith( + newScope, + { + global: expect.arrayContaining([newScope]), + project: [], + resource: [], + }, + undefined, + ); + }); + + it('evaluates project scope correctly', () => { + const newScope = 'example:list' as Scope; + const store = useRBACStore(); + store.addProjectScope(newScope, { projectId: '1' }); + + const result = store.hasScope(newScope, { projectId: '1' }); + expect(result).toBe(true); + expect(vi.mocked(hasScope)).toHaveBeenCalledWith( + newScope, + { + global: expect.any(Array), + project: expect.arrayContaining([newScope]), + resource: [], + }, + undefined, + ); + }); + + it('evaluates resource scope correctly', () => { + const newScope = 'example:list' as Scope; + const store = useRBACStore(); + store.addResourceScope(newScope, { resourceId: '1', resourceType: 'variable' }); + + const result = store.hasScope(newScope, { resourceId: '1', resourceType: 'variable' }); + expect(result).toBe(true); + expect(vi.mocked(hasScope)).toHaveBeenCalledWith( + newScope, + { + global: expect.any(Array), + project: [], + resource: expect.arrayContaining([newScope]), + }, + undefined, + ); + }); + + it('evaluates project and resource scope correctly', () => { + const newScope = 'example:list' as Scope; + const store = useRBACStore(); + store.addProjectScope(newScope, { projectId: '1' }); + store.addResourceScope(newScope, { resourceId: '1', resourceType: 'variable' }); + + const result = store.hasScope(newScope, { + projectId: '1', + resourceId: '1', + resourceType: 'variable', + }); + expect(result).toBe(true); + expect(vi.mocked(hasScope)).toHaveBeenCalledWith( + newScope, + { + global: expect.any(Array), + project: expect.arrayContaining([newScope]), + resource: expect.arrayContaining([newScope]), + }, + undefined, + ); + }); + }); +}); diff --git a/packages/editor-ui/src/stores/cloudPlan.store.ts b/packages/editor-ui/src/stores/cloudPlan.store.ts index 1c2893d1da..1d89ed6556 100644 --- a/packages/editor-ui/src/stores/cloudPlan.store.ts +++ b/packages/editor-ui/src/stores/cloudPlan.store.ts @@ -10,6 +10,7 @@ import { DateTime } from 'luxon'; import { CLOUD_TRIAL_CHECK_INTERVAL, STORES } from '@/constants'; const DEFAULT_STATE: CloudPlanState = { + initialized: false, data: null, usage: null, loadingPlan: false, @@ -157,8 +158,20 @@ export const useCloudPlanStore = defineStore(STORES.CLOUD_PLAN, () => { window.location.href = `https://${adminPanelHost}/login?code=${code}`; }; + const initialize = async () => { + if (state.initialized) { + return; + } + + await checkForCloudPlanData(); + await fetchUserCloudAccount(); + + state.initialized = true; + }; + return { state, + initialize, getOwnerCurrentPlan, getInstanceCurrentUsage, usageLeft, diff --git a/packages/editor-ui/src/stores/index.ts b/packages/editor-ui/src/stores/index.ts index 86581c132f..82a04766f0 100644 --- a/packages/editor-ui/src/stores/index.ts +++ b/packages/editor-ui/src/stores/index.ts @@ -26,5 +26,6 @@ export * from './cloudPlan.store'; export * from './sourceControl.store'; export * from './sso.store'; export * from './auditLogs.store'; +export * from './rbac.store'; export * from './collaboration.store'; export * from './pushConnection.store'; diff --git a/packages/editor-ui/src/stores/rbac.store.ts b/packages/editor-ui/src/stores/rbac.store.ts new file mode 100644 index 0000000000..3667b519ac --- /dev/null +++ b/packages/editor-ui/src/stores/rbac.store.ts @@ -0,0 +1,113 @@ +import { defineStore } from 'pinia'; +import { hasScope as genericHasScope } from '@n8n/permissions'; +import type { HasScopeOptions, Scope, Resource } from '@n8n/permissions'; +import { ref } from 'vue'; +import { STORES } from '@/constants'; +import type { IRole } from '@/Interface'; + +export const useRBACStore = defineStore(STORES.RBAC, () => { + const globalRoles = ref([]); + const rolesByProjectId = ref>({}); + + const globalScopes = ref([]); + const scopesByProjectId = ref>({}); + const scopesByResourceId = ref>>({ + workflow: {}, + tag: {}, + user: {}, + credential: {}, + variable: {}, + sourceControl: {}, + externalSecretsStore: {}, + }); + + function addGlobalRole(role: IRole) { + if (!globalRoles.value.includes(role)) { + globalRoles.value.push(role); + } + } + + function hasRole(role: IRole) { + return globalRoles.value.includes(role); + } + + function addGlobalScope(scope: Scope) { + if (!globalScopes.value.includes(scope)) { + globalScopes.value.push(scope); + } + } + + function setGlobalScopes(scopes: Scope[]) { + globalScopes.value = scopes; + } + + function addProjectScope( + scope: Scope, + context: { + projectId: string; + }, + ) { + if (!scopesByProjectId.value[context.projectId]) { + scopesByProjectId.value[context.projectId] = []; + } + + if (!scopesByProjectId.value[context.projectId].includes(scope)) { + scopesByProjectId.value[context.projectId].push(scope); + } + } + + function addResourceScope( + scope: Scope, + context: { + resourceType: Resource; + resourceId: string; + }, + ) { + const scopesByResourceType = scopesByResourceId.value[context.resourceType]; + if (!scopesByResourceType[context.resourceId]) { + scopesByResourceType[context.resourceId] = []; + } + + if (!scopesByResourceType[context.resourceId].includes(scope)) { + scopesByResourceType[context.resourceId].push(scope); + } + } + + function hasScope( + scope: Scope | Scope[], + context?: { + resourceType?: Resource; + resourceId?: string; + projectId?: string; + }, + options?: HasScopeOptions, + ): boolean { + return genericHasScope( + scope, + { + global: globalScopes.value, + project: context?.projectId ? scopesByProjectId.value[context.projectId] : [], + resource: + context?.resourceType && context?.resourceId + ? scopesByResourceId.value[context.resourceType][context.resourceId] + : [], + }, + options, + ); + } + + return { + globalRoles, + rolesByProjectId, + globalScopes, + scopesByProjectId, + scopesByResourceId, + addGlobalRole, + hasRole, + addGlobalScope, + setGlobalScopes, + addProjectScope, + addResourceScope, + hasScope, + }; +}); diff --git a/packages/editor-ui/src/stores/usage.store.ts b/packages/editor-ui/src/stores/usage.store.ts index 602e624f1f..34b90600c2 100644 --- a/packages/editor-ui/src/stores/usage.store.ts +++ b/packages/editor-ui/src/stores/usage.store.ts @@ -111,7 +111,6 @@ export const useUsageStore = defineStore('usage', () => { () => `${subscriptionAppUrl.value}/manage?token=${managementToken.value}&${commonSubscriptionAppUrlQueryParams.value}`, ), - canUserActivateLicense: computed(() => usersStore.canUserActivateLicense), isLoading: computed(() => state.loading), telemetryPayload: computed(() => ({ instance_id: instanceId.value, diff --git a/packages/editor-ui/src/stores/users.store.ts b/packages/editor-ui/src/stores/users.store.ts index dc37492b83..dd87089aa9 100644 --- a/packages/editor-ui/src/stores/users.store.ts +++ b/packages/editor-ui/src/stores/users.store.ts @@ -26,9 +26,10 @@ import type { IUser, IUserResponse, IUsersState, + CurrentUserResponse, } from '@/Interface'; import { getCredentialPermissions } from '@/permissions'; -import { getPersonalizedNodeTypes, isAuthorized, PERMISSIONS, ROLE } from '@/utils'; +import { getPersonalizedNodeTypes, ROLE } from '@/utils'; import { defineStore } from 'pinia'; import { useRootStore } from './n8nRoot.store'; import { usePostHog } from './posthog.store'; @@ -37,6 +38,8 @@ import { useUIStore } from './ui.store'; import { useCloudPlanStore } from './cloudPlan.store'; import { disableMfa, enableMfa, getMfaQR, verifyMfaToken } from '@/api/mfa'; import { confirmEmail, getCloudUserInfo } from '@/api/cloudPlans'; +import { useRBACStore } from '@/stores/rbac.store'; +import type { Scope } from '@n8n/permissions'; import { inviteUsers, acceptInvitation } from '@/api/invitation'; const isDefaultUser = (user: IUserResponse | null) => @@ -79,26 +82,6 @@ export const useUsersStore = defineStore(STORES.USERS, { globalRoleName(): IRole { return this.currentUser?.globalRole?.name ?? 'default'; }, - canUserDeleteTags(): boolean { - return isAuthorized(PERMISSIONS.TAGS.CAN_DELETE_TAGS, this.currentUser); - }, - canUserActivateLicense(): boolean { - return isAuthorized(PERMISSIONS.USAGE.CAN_ACTIVATE_LICENSE, this.currentUser); - }, - canUserAccessSidebarUserInfo() { - if (this.currentUser) { - const currentUser: IUser = this.currentUser; - return isAuthorized(PERMISSIONS.PRIMARY_MENU.CAN_ACCESS_USER_INFO, currentUser); - } - return false; - }, - showUMSetupWarning() { - if (this.currentUser) { - const currentUser: IUser = this.currentUser; - return isAuthorized(PERMISSIONS.USER_SETTINGS.VIEW_UM_SETUP_WARNING, currentUser); - } - return false; - }, personalizedNodeTypes(): string[] { const user = this.currentUser; if (!user) { @@ -130,6 +113,19 @@ export const useUsersStore = defineStore(STORES.USERS, { this.initialized = true; } catch (e) {} }, + setCurrentUser(user: CurrentUserResponse) { + this.addUsers([user]); + this.currentUserId = user.id; + + const defaultScopes: Scope[] = []; + useRBACStore().setGlobalScopes(user.globalScopes || defaultScopes); + usePostHog().init(user.featureFlags); + }, + unsetCurrentUser() { + this.currentUserId = null; + this.currentUserCloudInfo = null; + useRBACStore().setGlobalScopes([]); + }, addUsers(users: IUserResponse[]) { users.forEach((userResponse: IUserResponse) => { const prevUser = this.users[userResponse.id] || {}; @@ -177,10 +173,7 @@ export const useUsersStore = defineStore(STORES.USERS, { return; } - this.addUsers([user]); - this.currentUserId = user.id; - - usePostHog().init(user.featureFlags); + this.setCurrentUser(user); }, async loginWithCreds(params: { email: string; @@ -194,18 +187,14 @@ export const useUsersStore = defineStore(STORES.USERS, { return; } - this.addUsers([user]); - this.currentUserId = user.id; - - usePostHog().init(user.featureFlags); + this.setCurrentUser(user); }, async logout(): Promise { const rootStore = useRootStore(); await logout(rootStore.getRestApiContext); - this.currentUserId = null; + this.unsetCurrentUser(); useCloudPlanStore().reset(); usePostHog().reset(); - this.currentUserCloudInfo = null; useUIStore().clearBannerStack(); }, async createOwner(params: { @@ -218,10 +207,8 @@ export const useUsersStore = defineStore(STORES.USERS, { const user = await setupOwner(rootStore.getRestApiContext, params); const settingsStore = useSettingsStore(); if (user) { - this.addUsers([user]); - this.currentUserId = user.id; + this.setCurrentUser(user); settingsStore.stopShowingSetupPage(); - usePostHog().init(user.featureFlags); } }, async validateSignupToken(params: { @@ -241,9 +228,7 @@ export const useUsersStore = defineStore(STORES.USERS, { const rootStore = useRootStore(); const user = await acceptInvitation(rootStore.getRestApiContext, params); if (user) { - this.addUsers([user]); - this.currentUserId = user.id; - usePostHog().init(user.featureFlags); + this.setCurrentUser(user); } }, async sendForgotPasswordEmail(params: { email: string }): Promise { diff --git a/packages/editor-ui/src/types/rbac.ts b/packages/editor-ui/src/types/rbac.ts new file mode 100644 index 0000000000..b6603b2e1b --- /dev/null +++ b/packages/editor-ui/src/types/rbac.ts @@ -0,0 +1,33 @@ +import type { EnterpriseEditionFeature } from '@/constants'; +import type { Resource, HasScopeOptions, Scope } from '@n8n/permissions'; +import type { IRole } from '@/Interface'; + +export type AuthenticatedPermissionOptions = {}; +export type CustomPermissionOptions = RBACPermissionCheck; +export type EnterprisePermissionOptions = { + feature?: EnterpriseEditionFeature | EnterpriseEditionFeature[]; + mode?: 'oneOf' | 'allOf'; +}; +export type GuestPermissionOptions = {}; +export type RBACPermissionOptions = { + scope?: Scope | Scope[]; + projectId?: string; + resourceType?: Resource; + resourceId?: string; + options?: HasScopeOptions; +}; +export type RolePermissionOptions = IRole[]; + +export type PermissionType = 'authenticated' | 'custom' | 'enterprise' | 'guest' | 'rbac' | 'role'; +export type PermissionTypeOptions = { + authenticated: AuthenticatedPermissionOptions; + custom: CustomPermissionOptions; + enterprise: EnterprisePermissionOptions; + guest: GuestPermissionOptions; + rbac: RBACPermissionOptions; + role: RolePermissionOptions; +}; + +export interface RBACPermissionCheck { + (options?: Options): boolean; +} diff --git a/packages/editor-ui/src/types/router.ts b/packages/editor-ui/src/types/router.ts new file mode 100644 index 0000000000..6ddf027e55 --- /dev/null +++ b/packages/editor-ui/src/types/router.ts @@ -0,0 +1,59 @@ +import type { + NavigationGuardNext, + NavigationGuardWithThis, + RouteLocationNormalized, + RouteLocation, +} from 'vue-router'; +import type { IPermissions } from '@/Interface'; +import type { + AuthenticatedPermissionOptions, + CustomPermissionOptions, + EnterprisePermissionOptions, + GuestPermissionOptions, + RBACPermissionOptions, + RolePermissionOptions, + PermissionType, +} from '@/types/rbac'; + +export type RouterMiddlewareType = PermissionType; +export type CustomMiddlewareOptions = CustomPermissionOptions<{ + to: RouteLocationNormalized; + from: RouteLocationNormalized; + next: NavigationGuardNext; +}>; +export type MiddlewareOptions = { + authenticated: AuthenticatedPermissionOptions; + custom: CustomMiddlewareOptions; + enterprise: EnterprisePermissionOptions; + guest: GuestPermissionOptions; + rbac: RBACPermissionOptions; + role: RolePermissionOptions; +}; + +export interface RouteConfig { + meta: { + nodeView?: boolean; + templatesEnabled?: boolean; + getRedirect?: () => { name: string } | false; + permissions?: IPermissions; + middleware?: RouterMiddlewareType[]; + middlewareOptions?: Partial; + telemetry?: { + disabled?: true; + getProperties: (route: RouteLocation) => object; + }; + scrollOffset?: number; + setScrollPosition?: (position: number) => void; + }; +} + +export type RouterMiddlewareReturnType = ReturnType>; + +export interface RouterMiddleware { + ( + to: RouteLocationNormalized, + from: RouteLocationNormalized, + next: NavigationGuardNext, + options: RouterMiddlewareOptions, + ): RouterMiddlewareReturnType; +} diff --git a/packages/editor-ui/src/utils/__tests__/rbacUtils.test.ts b/packages/editor-ui/src/utils/__tests__/rbacUtils.test.ts new file mode 100644 index 0000000000..a6612e8b50 --- /dev/null +++ b/packages/editor-ui/src/utils/__tests__/rbacUtils.test.ts @@ -0,0 +1,65 @@ +import type { RouteLocationNormalized } from 'vue-router'; +import { + inferProjectIdFromRoute, + inferResourceTypeFromRoute, + inferResourceIdFromRoute, +} from '../rbacUtils'; + +describe('rbacUtils', () => { + describe('inferProjectIdFromRoute()', () => { + it('should infer project ID from route correctly', () => { + const route = { path: '/dashboard/projects/123/settings' } as RouteLocationNormalized; + const projectId = inferProjectIdFromRoute(route); + expect(projectId).toBe('123'); + }); + + it('should return undefined for project ID if not found', () => { + const route = { path: '/dashboard/settings' } as RouteLocationNormalized; + const projectId = inferProjectIdFromRoute(route); + expect(projectId).toBeUndefined(); + }); + }); + + describe('inferResourceTypeFromRoute()', () => { + it.each([ + ['/workflows', 'workflow'], + ['/workflows/123', 'workflow'], + ['/workflows/123/settings', 'workflow'], + ['/credentials', 'credential'], + ['/variables', 'variable'], + ['/users', 'user'], + ['/source-control', 'sourceControl'], + ['/external-secrets', 'externalSecretsStore'], + ])('should infer resource type from %s correctly to %s', (path, type) => { + const route = { path } as RouteLocationNormalized; + const resourceType = inferResourceTypeFromRoute(route); + expect(resourceType).toBe(type); + }); + + it('should return undefined for resource type if not found', () => { + const route = { path: '/dashboard/settings' } as RouteLocationNormalized; + const resourceType = inferResourceTypeFromRoute(route); + expect(resourceType).toBeUndefined(); + }); + }); + + describe('inferResourceIdFromRoute()', () => { + it('should infer resource ID from params.id', () => { + const route = { params: { id: 'abc123' } } as RouteLocationNormalized; + const resourceId = inferResourceIdFromRoute(route); + expect(resourceId).toBe('abc123'); + }); + + it('should infer resource ID from params.name if id is not present', () => { + const route = { params: { name: 'my-resource' } } as RouteLocationNormalized; + const resourceId = inferResourceIdFromRoute(route); + expect(resourceId).toBe('my-resource'); + }); + + it('should return undefined for resource ID if neither id nor name is present', () => { + const route = { params: {} } as RouteLocationNormalized; + const resourceId = inferResourceIdFromRoute(route); + expect(resourceId).toBeUndefined(); + }); + }); +}); diff --git a/packages/editor-ui/src/utils/__tests__/userUtils.test.ts b/packages/editor-ui/src/utils/__tests__/userUtils.test.ts deleted file mode 100644 index 8ed6ed606a..0000000000 --- a/packages/editor-ui/src/utils/__tests__/userUtils.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { beforeAll } from 'vitest'; -import { setActivePinia } from 'pinia'; -import { merge } from 'lodash-es'; -import { isAuthorized, ROLE } from '@/utils'; -import { useSettingsStore } from '@/stores/settings.store'; -import { useSSOStore } from '@/stores/sso.store'; -import type { IUser } from '@/Interface'; -import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; -import type { IN8nUISettings } from 'n8n-workflow'; -import { createTestingPinia } from '@pinia/testing'; - -const DEFAULT_SETTINGS: IN8nUISettings = SETTINGS_STORE_DEFAULT_STATE.settings; - -const DEFAULT_USER: IUser = { - id: '1', - isPending: false, - isDefaultUser: true, - isOwner: false, - isPendingUser: false, - globalRole: { - name: 'default', - id: '1', - createdAt: new Date(), - }, -}; - -describe('userUtils', () => { - let settingsStore: ReturnType; - let ssoStore: ReturnType; - - describe('isAuthorized', () => { - beforeAll(() => { - setActivePinia(createTestingPinia()); - settingsStore = useSettingsStore(); - ssoStore = useSSOStore(); - }); - - // @TODO Move to routes tests in the future - it('should check SSO settings route permissions', () => { - const ssoSettingsPermissions = { - allow: { - role: [ROLE.Owner], - }, - deny: { - shouldDeny: () => { - const settingsStore = useSettingsStore(); - return settingsStore.isDesktopDeployment; - }, - }, - }; - - const user: IUser = merge({}, DEFAULT_USER, { - isDefaultUser: false, - isOwner: true, - globalRole: { - id: '1', - name: 'owner', - createdAt: new Date(), - }, - }); - - settingsStore.setSettings(merge({}, DEFAULT_SETTINGS, { enterprise: { saml: true } })); - - expect(isAuthorized(ssoSettingsPermissions, user)).toBe(true); - }); - }); -}); diff --git a/packages/editor-ui/src/utils/rbacUtils.ts b/packages/editor-ui/src/utils/rbacUtils.ts new file mode 100644 index 0000000000..2608127992 --- /dev/null +++ b/packages/editor-ui/src/utils/rbacUtils.ts @@ -0,0 +1,32 @@ +import type { RouteLocationNormalized } from 'vue-router'; +import type { Resource } from '@n8n/permissions'; + +export function inferProjectIdFromRoute(to: RouteLocationNormalized): string { + const routeParts = to.path.split('/'); + const projectsIndex = routeParts.indexOf('projects'); + const projectIdIndex = projectsIndex !== -1 ? projectsIndex + 1 : -1; + + return routeParts[projectIdIndex]; +} + +export function inferResourceTypeFromRoute(to: RouteLocationNormalized): Resource | undefined { + const routeParts = to.path.split('/'); + const routeMap = { + workflow: 'workflows', + credential: 'credentials', + user: 'users', + variable: 'variables', + sourceControl: 'source-control', + externalSecretsStore: 'external-secrets', + }; + + for (const resource of Object.keys(routeMap) as Array) { + if (routeParts.includes(routeMap[resource])) { + return resource; + } + } +} + +export function inferResourceIdFromRoute(to: RouteLocationNormalized): string | undefined { + return (to.params.id as string | undefined) ?? (to.params.name as string | undefined); +} diff --git a/packages/editor-ui/src/utils/userUtils.ts b/packages/editor-ui/src/utils/userUtils.ts index 8bba6fda51..316d3e9602 100644 --- a/packages/editor-ui/src/utils/userUtils.ts +++ b/packages/editor-ui/src/utils/userUtils.ts @@ -61,7 +61,6 @@ import { CODE_NODE_TYPE, } from '@/constants'; import type { - IPermissions, IPersonalizationSurveyAnswersV1, IPersonalizationSurveyAnswersV2, IPersonalizationSurveyAnswersV3, @@ -70,7 +69,6 @@ import type { IUser, ILogInStatus, IRole, - IUserPermissions, } from '@/Interface'; /* @@ -97,89 +95,6 @@ export const LOGIN_STATUS: { LoggedIn: ILogInStatus; LoggedOut: ILogInStatus } = LoggedOut: 'LoggedOut', // Can only be logged out if UM has been setup }; -export const PERMISSIONS: IUserPermissions = { - TAGS: { - CAN_DELETE_TAGS: { - allow: { - role: [ROLE.Owner], - }, - }, - }, - PRIMARY_MENU: { - CAN_ACCESS_USER_INFO: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - deny: { - role: [ROLE.Default], - }, - }, - }, - USER_SETTINGS: { - VIEW_UM_SETUP_WARNING: { - allow: { - role: [ROLE.Default], - }, - }, - }, - USAGE: { - CAN_ACTIVATE_LICENSE: { - allow: { - role: [ROLE.Owner], - }, - }, - }, -}; - -/** - * To be authorized, user must pass all deny rules and pass any of the allow rules. - * - */ -export const isAuthorized = (permissions: IPermissions, currentUser: IUser | null): boolean => { - const loginStatus = currentUser ? LOGIN_STATUS.LoggedIn : LOGIN_STATUS.LoggedOut; - // big AND block - // if any of these are false, block user - if (permissions.deny) { - if (permissions.deny.shouldDeny && permissions.deny.shouldDeny()) { - return false; - } - - if (permissions.deny.loginStatus && permissions.deny.loginStatus.includes(loginStatus)) { - return false; - } - - if (currentUser?.globalRole?.name) { - const role = currentUser.isDefaultUser ? ROLE.Default : currentUser.globalRole.name; - if (permissions.deny.role && permissions.deny.role.includes(role)) { - return false; - } - } else if (permissions.deny.role) { - return false; - } - } - - // big OR block - // if any of these are true, allow user - if (permissions.allow) { - if (permissions.allow.shouldAllow && permissions.allow.shouldAllow()) { - return true; - } - - if (permissions.allow.loginStatus && permissions.allow.loginStatus.includes(loginStatus)) { - return true; - } - - if (currentUser?.globalRole?.name) { - const role = currentUser.isDefaultUser ? ROLE.Default : currentUser.globalRole.name; - if (permissions.allow.role && permissions.allow.role.includes(role)) { - return true; - } - } - } - - return false; -}; - export function getPersonalizedNodeTypes( answers: | IPersonalizationSurveyAnswersV1 diff --git a/packages/editor-ui/src/views/SettingsUsageAndPlan.vue b/packages/editor-ui/src/views/SettingsUsageAndPlan.vue index 276692e0f9..e113f8b60b 100644 --- a/packages/editor-ui/src/views/SettingsUsageAndPlan.vue +++ b/packages/editor-ui/src/views/SettingsUsageAndPlan.vue @@ -8,6 +8,8 @@ import { i18n as locale } from '@/plugins/i18n'; import { useUIStore } from '@/stores'; import { N8N_PRICING_PAGE_URL } from '@/constants'; import { useToast } from '@/composables'; +import { ROLE } from '@/utils'; +import { hasPermission } from '@/rbac/permissions'; const usageStore = useUsageStore(); const route = useRoute(); @@ -26,6 +28,12 @@ const activationKeyModal = ref(false); const activationKey = ref(''); const activationKeyInput = ref(null); +const canUserActivateLicense = computed(() => + hasPermission(['role'], { + role: [ROLE.Owner], + }), +); + const showActivationSuccess = () => { toast.showMessage({ type: 'success', @@ -77,7 +85,7 @@ onMounted(async () => { } } try { - if (!route.query.key && usageStore.canUserActivateLicense) { + if (!route.query.key && canUserActivateLicense.value) { await usageStore.refreshLicenseManagementToken(); } else { await usageStore.getLicenseInfo(); @@ -184,7 +192,7 @@ const openPricingPage = () => { diff --git a/packages/editor-ui/src/views/SettingsUsersView.vue b/packages/editor-ui/src/views/SettingsUsersView.vue index 9f392a3ee9..15e0104320 100644 --- a/packages/editor-ui/src/views/SettingsUsersView.vue +++ b/packages/editor-ui/src/views/SettingsUsersView.vue @@ -2,7 +2,7 @@
{{ $locale.baseText('settings.users') }} -
+