diff --git a/packages/@n8n/permissions/src/roles/__tests__/custom-role-scopes.test.ts b/packages/@n8n/permissions/src/roles/__tests__/custom-role-scopes.test.ts index cba95866e75..6df63f5fbb4 100644 --- a/packages/@n8n/permissions/src/roles/__tests__/custom-role-scopes.test.ts +++ b/packages/@n8n/permissions/src/roles/__tests__/custom-role-scopes.test.ts @@ -1,5 +1,6 @@ import { CUSTOM_ROLE_SCOPE_WHITELIST, + GLOBAL_CUSTOM_ROLE_SCOPE_GROUPS, GLOBAL_CUSTOM_ROLE_SCOPES, PROJECT_CUSTOM_ROLE_SCOPES, } from '@/roles/custom-role-scopes.ee'; @@ -44,4 +45,28 @@ describe('custom role scope whitelists', () => { expect(CUSTOM_ROLE_SCOPE_WHITELIST.project).toBe(PROJECT_CUSTOM_ROLE_SCOPES); expect(CUSTOM_ROLE_SCOPE_WHITELIST.global).toBe(GLOBAL_CUSTOM_ROLE_SCOPES); }); + + it('includes Chat scopes in the settings.Manage bundle', () => { + const bundle = GLOBAL_CUSTOM_ROLE_SCOPE_GROUPS.settings.Manage; + + expect(bundle).toContain('chatHub:manage'); + expect(bundle).toContain('chatHub:message'); + }); + + it('includes AI Assistant and n8n Agent scopes in the settings.Manage bundle', () => { + const bundle = GLOBAL_CUSTOM_ROLE_SCOPE_GROUPS.settings.Manage; + + expect(bundle).toContain('aiAssistant:manage'); + expect(bundle).toContain('instanceAi:manage'); + expect(bundle).toContain('instanceAi:message'); + }); + + it('includes instance-level MCP scopes in the settings.Manage bundle', () => { + const bundle = GLOBAL_CUSTOM_ROLE_SCOPE_GROUPS.settings.Manage; + + expect(bundle).toContain('mcp:manage'); + expect(bundle).toContain('mcp:oauth'); + expect(bundle).toContain('mcpApiKey:create'); + expect(bundle).toContain('mcpApiKey:rotate'); + }); }); diff --git a/packages/@n8n/permissions/src/roles/custom-role-scopes.ee.ts b/packages/@n8n/permissions/src/roles/custom-role-scopes.ee.ts index 53858d05887..f0ec8d4eec1 100644 --- a/packages/@n8n/permissions/src/roles/custom-role-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/custom-role-scopes.ee.ts @@ -98,6 +98,15 @@ export const GLOBAL_CUSTOM_ROLE_SCOPE_GROUPS = { 'variable:list', 'variable:read', 'dataTable:list', + 'chatHub:manage', // Chat + 'chatHub:message', // needed for model listing on the Chat settings page + 'aiAssistant:manage', // AI Assistant + 'instanceAi:manage', + 'instanceAi:message', + 'mcp:manage', // Instance-level MCP + 'mcp:oauth', // MCP OAuth clients + 'mcpApiKey:create', // MCP personal API key + 'mcpApiKey:rotate', ], }, user: { diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/SettingsChatHubView.test.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/SettingsChatHubView.test.ts index 96a71693a85..b5d9dc82837 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/SettingsChatHubView.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/SettingsChatHubView.test.ts @@ -5,6 +5,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createChatHubModuleSettings } from './__test__/data'; import SettingsChatHubView from './SettingsChatHubView.vue'; +const { hasPermissionMock } = vi.hoisted(() => ({ + hasPermissionMock: vi.fn().mockReturnValue(true), +})); + +vi.mock('@/app/utils/rbac/permissions', () => ({ + hasPermission: hasPermissionMock, +})); + const { settingsState, setChatEnabledMock } = vi.hoisted(() => ({ settingsState: { enabled: true as boolean | undefined, @@ -34,10 +42,6 @@ vi.mock('./chat.store', () => ({ }), })); -vi.mock('@/features/settings/users/users.store', () => ({ - useUsersStore: () => ({ isInstanceOwner: true, isAdmin: true }), -})); - vi.mock('@/features/credentials/credentials.store', () => ({ useCredentialsStore: () => ({ fetchAllCredentials: vi.fn().mockResolvedValue(undefined), @@ -78,6 +82,7 @@ describe('SettingsChatHubView', () => { beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); + hasPermissionMock.mockReturnValue(true); settingsState.enabled = true; settingsState.isChatFeatureEnabled = true; }); @@ -120,4 +125,12 @@ describe('SettingsChatHubView', () => { expect(setChatEnabledMock).toHaveBeenCalledWith(true); }); + + it('disables the toggle for users without chatHub:manage scope', () => { + hasPermissionMock.mockReturnValue(false); + + const { getByTestId } = renderComponent(); + + expect(getByTestId('chat-hub-enabled-switch')).toBeDisabled(); + }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/SettingsChatHubView.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/SettingsChatHubView.vue index 51546f2b2b4..32d56055c37 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/SettingsChatHubView.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/SettingsChatHubView.vue @@ -5,8 +5,8 @@ import { useToast } from '@/app/composables/useToast'; import { useSettingsStore } from '@/app/stores/settings.store'; import { useUIStore } from '@/app/stores/ui.store'; import { useCredentialsStore } from '@/features/credentials/credentials.store'; -import { useUsersStore } from '@/features/settings/users/users.store'; import { usePostHog } from '@/app/stores/posthog.store'; +import { hasPermission } from '@/app/utils/rbac/permissions'; import { CHAT_HUB_SEMANTIC_SEARCH_EXPERIMENT } from '@/app/constants'; import { type ChatHubLLMProvider, @@ -33,15 +33,12 @@ const toast = useToast(); const documentTitle = useDocumentTitle(); const chatStore = useChatStore(); -const usersStore = useUsersStore(); const settingsStore = useSettingsStore(); const credentialsStore = useCredentialsStore(); const uiStore = useUIStore(); const telemetry = useTelemetry(); -const isOwner = computed(() => usersStore.isInstanceOwner); -const isAdmin = computed(() => usersStore.isAdmin); -const disabled = computed(() => !isOwner.value && !isAdmin.value); +const disabled = computed(() => !hasPermission(['rbac'], { rbac: { scope: 'chatHub:manage' } })); const isChatEnabled = computed(() => settingsStore.moduleSettings['chat-hub']?.enabled === true); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/module.descriptor.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/module.descriptor.ts index 40cdfc2f97e..57c0476bfd0 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/module.descriptor.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/module.descriptor.ts @@ -121,7 +121,7 @@ export const InstanceAiModule: FrontendModuleDescription = { middleware: ['authenticated', 'rbac', 'custom'], middlewareOptions: { rbac: { - scope: 'instanceAi:message', + scope: ['instanceAi:message', 'instanceAi:manage'], }, }, telemetry: { @@ -158,7 +158,9 @@ export const InstanceAiModule: FrontendModuleDescription = { route: { to: { name: INSTANCE_AI_SETTINGS_VIEW } }, preview: true, get available() { - return hasPermission(['rbac'], { rbac: { scope: 'instanceAi:message' } }); + return hasPermission(['rbac'], { + rbac: { scope: ['instanceAi:message', 'instanceAi:manage'] }, + }); }, }, ], diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts index ae68e957a3e..237eddb1d24 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.test.ts @@ -6,7 +6,6 @@ import { createComponentRenderer } from '@/__tests__/render'; import { mockedStore, type MockedStore } from '@/__tests__/utils'; import SettingsMCPView from '@/features/ai/mcpAccess/SettingsMCPView.vue'; import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store'; -import { useUsersStore } from '@/features/settings/users/users.store'; import { useSettingsStore } from '@/app/stores/settings.store'; import { useUIStore } from '@/app/stores/ui.store'; import type { FrontendSettings } from '@n8n/api-types'; @@ -16,6 +15,14 @@ import type { WorkflowListItem } from '@/Interface'; import { EXPOSE_ALL_WORKFLOWS_TO_MCP_MODAL_KEY } from '@/experiments/exposeAllWorkflowsToMcp/constants'; import { useExposeAllWorkflowsToMcpStore } from '@/experiments/exposeAllWorkflowsToMcp/stores/exposeAllWorkflowsToMcp.store'; +const { hasPermissionMock } = vi.hoisted(() => ({ + hasPermissionMock: vi.fn().mockReturnValue(true), +})); + +vi.mock('@/app/utils/rbac/permissions', () => ({ + hasPermission: hasPermissionMock, +})); + vi.mock('vue-router', async (importOriginal) => ({ ...(await importOriginal()), useRouter: vi.fn(), @@ -52,7 +59,6 @@ vi.mock('@/app/composables/useToast', () => ({ let pinia: ReturnType; let mcpStore: MockedStore; -let usersStore: MockedStore; let settingsStore: MockedStore; let uiStore: MockedStore; let exposeAllWorkflowsToMcpStore: MockedStore; @@ -100,11 +106,11 @@ describe('SettingsMCPView', () => { beforeEach(() => { pinia = createTestingPinia(); mcpStore = mockedStore(useMCPStore); - usersStore = mockedStore(useUsersStore); settingsStore = mockedStore(useSettingsStore); uiStore = mockedStore(useUIStore); exposeAllWorkflowsToMcpStore = mockedStore(useExposeAllWorkflowsToMcpStore); exposeAllWorkflowsToMcpStore.isEnabled = false; + hasPermissionMock.mockReturnValue(true); settingsStore.settings = { enterprise: {}, @@ -170,11 +176,6 @@ describe('SettingsMCPView', () => { }); describe('Toggle MCP on/off', () => { - beforeEach(() => { - // Set user as admin to allow toggling - usersStore.isAdmin = true; - }); - it('should call setMcpAccessEnabled when turning on MCP', async () => { mcpStore.setMcpAccessEnabled.mockResolvedValue(true); @@ -224,7 +225,6 @@ describe('SettingsMCPView', () => { describe('Expose all workflows experiment', () => { beforeEach(() => { - usersStore.isAdmin = true; mcpStore.setMcpAccessEnabled.mockResolvedValue(true); mcpStore.fetchWorkflowsAvailableForMCP.mockResolvedValue(workflowPage()); mcpStore.getAllOAuthClients.mockResolvedValue([]); @@ -370,9 +370,8 @@ describe('SettingsMCPView', () => { }); describe('Permissions', () => { - it('should disable toggle button for non-owner/non-admin users', async () => { - usersStore.isInstanceOwner = false; - usersStore.isAdmin = false; + it('should disable toggle button for users without mcp:manage scope', async () => { + hasPermissionMock.mockReturnValue(false); const { getByTestId } = createComponent({ pinia }); await nextTick(); @@ -381,9 +380,8 @@ describe('SettingsMCPView', () => { expect(enableButton).toBeDisabled(); }); - it('should enable toggle button for admin users', async () => { - usersStore.isInstanceOwner = false; - usersStore.isAdmin = true; + it('should enable toggle button for users with mcp:manage scope', async () => { + // hasPermissionMock defaults to true const { getByTestId } = createComponent({ pinia }); await nextTick(); @@ -392,20 +390,7 @@ describe('SettingsMCPView', () => { expect(enableButton).not.toBeDisabled(); }); - it('should enable toggle button for owner users', async () => { - usersStore.isInstanceOwner = true; - usersStore.isAdmin = false; - - const { getByTestId } = createComponent({ pinia }); - await nextTick(); - - const enableButton = getByTestId('enable-mcp-button'); - expect(enableButton).not.toBeDisabled(); - }); - - it('should disable toggle button for owner when MCP is managed by env', async () => { - usersStore.isInstanceOwner = true; - usersStore.isAdmin = false; + it('should disable toggle button when MCP is managed by env, even with mcp:manage scope', async () => { settingsStore.moduleSettings = { mcp: { mcpAccessEnabled: false, @@ -421,7 +406,6 @@ describe('SettingsMCPView', () => { }); it('should not call setMcpAccessEnabled when toggle is clicked under env management', async () => { - usersStore.isInstanceOwner = true; settingsStore.moduleSettings = { mcp: { mcpAccessEnabled: false, @@ -703,8 +687,7 @@ describe('SettingsMCPView', () => { mcpStore.getInstanceClientStats.mockResolvedValue(null); }); - it('should render the notice for an instance owner when atCapacity is true', async () => { - usersStore.isInstanceOwner = true; + it('should render the notice for users with mcp:manage scope when atCapacity is true', async () => { mcpStore.instanceClientStats = { count: 2, limit: 2, atCapacity: true }; const { findByTestId } = createComponent({ pinia }); @@ -714,21 +697,10 @@ describe('SettingsMCPView', () => { expect(notice.textContent).toContain('2/2'); }); - it('should render the notice for an admin when atCapacity is true', async () => { - usersStore.isAdmin = true; - mcpStore.instanceClientStats = { count: 5, limit: 5, atCapacity: true }; - - const { findByTestId } = createComponent({ pinia }); - - const notice = await findByTestId('mcp-instance-capacity-notice'); - expect(notice).toBeVisible(); - }); - - it('should NOT render the notice for a non-admin member', async () => { - usersStore.isInstanceOwner = false; - usersStore.isAdmin = false; + it('should NOT render the notice for users without mcp:manage scope', async () => { + hasPermissionMock.mockReturnValue(false); // Even if a stats payload sneaks in (shouldn't happen — store guards 403), - // the view should still hide the notice for non-admins. + // the view should still hide the notice for users without the manage scope. mcpStore.instanceClientStats = { count: 2, limit: 2, atCapacity: true }; const { queryByTestId } = createComponent({ pinia }); @@ -738,7 +710,6 @@ describe('SettingsMCPView', () => { }); it('should NOT render the notice when atCapacity is false', async () => { - usersStore.isInstanceOwner = true; mcpStore.instanceClientStats = { count: 1, limit: 5, atCapacity: false }; const { queryByTestId } = createComponent({ pinia }); @@ -748,7 +719,6 @@ describe('SettingsMCPView', () => { }); it('should NOT render the notice when stats have not been fetched', async () => { - usersStore.isInstanceOwner = true; mcpStore.instanceClientStats = null; const { queryByTestId } = createComponent({ pinia }); @@ -757,18 +727,15 @@ describe('SettingsMCPView', () => { expect(queryByTestId('mcp-instance-capacity-notice')).not.toBeInTheDocument(); }); - it('should fetch instance stats on mount for an admin/owner', async () => { - usersStore.isInstanceOwner = true; - + it('should fetch instance stats on mount for users with mcp:manage scope', async () => { createComponent({ pinia }); await nextTick(); expect(mcpStore.getInstanceClientStats).toHaveBeenCalled(); }); - it('should not fetch instance stats on mount for a regular member', async () => { - usersStore.isInstanceOwner = false; - usersStore.isAdmin = false; + it('should not fetch instance stats on mount for users without mcp:manage scope', async () => { + hasPermissionMock.mockReturnValue(false); createComponent({ pinia }); await nextTick(); @@ -779,8 +746,6 @@ describe('SettingsMCPView', () => { describe('Redirect URI controls decoupled from env-lock', () => { beforeEach(() => { - usersStore.isAdmin = true; - usersStore.isInstanceOwner = false; mcpStore.fetchAllowedRedirectUris.mockResolvedValue(['https://example.com/oauth']); }); @@ -835,9 +800,8 @@ describe('SettingsMCPView', () => { expect(saveButton).not.toBeDisabled(); }); - it('should disable both toggle and redirect-URI controls for a non-admin user', async () => { - usersStore.isAdmin = false; - usersStore.isInstanceOwner = false; + it('should disable both toggle and redirect-URI controls for users without mcp:manage scope', async () => { + hasPermissionMock.mockReturnValue(false); settingsStore.moduleSettings = { mcp: { mcpAccessEnabled: true, diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue index fd85c609177..893b8978ff1 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/SettingsMCPView.vue @@ -5,8 +5,8 @@ import type { WorkflowListItem } from '@/Interface'; import { useI18n } from '@n8n/i18n'; import { computed, onMounted, ref } from 'vue'; import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store'; -import { useUsersStore } from '@/features/settings/users/users.store'; import { useUIStore } from '@/app/stores/ui.store'; +import { hasPermission } from '@/app/utils/rbac/permissions'; import { LOADING_INDICATOR_TIMEOUT, MCP_CONNECT_WORKFLOWS_MODAL_KEY, @@ -44,16 +44,15 @@ const mcp = useMcp(); const telemetry = useTelemetry(); const mcpStore = useMCPStore(); -const usersStore = useUsersStore(); const uiStore = useUIStore(); const { offerToExposeAllWorkflows } = useExposeAllWorkflowsToMcpOffer(); const mcpStatusLoading = ref(false); const selectedTab = ref('workflows'); -const isOwner = computed(() => usersStore.isInstanceOwner); -const isAdmin = computed(() => usersStore.isAdmin); -const canManageMcpInstance = computed(() => isOwner.value || isAdmin.value); +const canManageMcpInstance = computed(() => + hasPermission(['rbac'], { rbac: { scope: 'mcp:manage' } }), +); const tabs = computed>>(() => { const base: Array> = [ diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/module.descriptor.ts b/packages/frontend/editor-ui/src/features/ai/mcpAccess/module.descriptor.ts index bd250cb4237..2d8773c1acd 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/module.descriptor.ts +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/module.descriptor.ts @@ -40,7 +40,7 @@ export const MCPModule: FrontendModuleDescription = { route: { to: { name: MCP_SETTINGS_VIEW } }, get available() { return hasPermission(['rbac'], { - rbac: { scope: ['mcp:oauth', 'mcpApiKey:create', 'mcpApiKey:rotate'] }, + rbac: { scope: ['mcp:manage', 'mcp:oauth', 'mcpApiKey:create', 'mcpApiKey:rotate'] }, }); }, },