fix(editor): Include Chat, AI Assistant, and MCP settings under 'Instance settings: Manage' custom role permission (#34455)

This commit is contained in:
Yuliia Pominchuk
2026-07-20 10:04:54 +00:00
committed by GitHub
parent e845882c5c
commit fa5476c0cb
8 changed files with 85 additions and 76 deletions
@@ -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');
});
});
@@ -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: {
@@ -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();
});
});
@@ -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);
@@ -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'] },
});
},
},
],
@@ -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<typeof createTestingPinia>;
let mcpStore: MockedStore<typeof useMCPStore>;
let usersStore: MockedStore<typeof useUsersStore>;
let settingsStore: MockedStore<typeof useSettingsStore>;
let uiStore: MockedStore<typeof useUIStore>;
let exposeAllWorkflowsToMcpStore: MockedStore<typeof useExposeAllWorkflowsToMcpStore>;
@@ -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,
@@ -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<MCPTabs>('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<Array<TabOptions<MCPTabs>>>(() => {
const base: Array<TabOptions<MCPTabs>> = [
@@ -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'] },
});
},
},