mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
fix(editor): Include Chat, AI Assistant, and MCP settings under 'Instance settings: Manage' custom role permission (#34455)
This commit is contained in:
@@ -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'] },
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user