From 6b171fc025991890e565d141b84c3f2f8a8968e7 Mon Sep 17 00:00:00 2001 From: phyllis-noester <102315132+phyllis-noester@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:42:13 +0100 Subject: [PATCH] fix: Fix credential resolver RBAC for member users (#26552) --- .../src/roles/scopes/global-scopes.ee.ts | 1 + .../credential-resolvers.controller.ts | 3 +- .../credential-resolvers.api.test.ts | 10 +- .../app/components/WorkflowSettings.test.ts | 95 +++++++++++++++++++ .../src/app/components/WorkflowSettings.vue | 20 +++- packages/frontend/editor-ui/src/app/router.ts | 11 ++- 6 files changed, 130 insertions(+), 10 deletions(-) diff --git a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts index 8e4d2cf77cc..ba21fe89fd9 100644 --- a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts @@ -156,6 +156,7 @@ export const GLOBAL_MEMBER_SCOPES: Scope[] = [ 'chatHubAgent:delete', 'chatHubAgent:list', 'apiKey:manage', + 'credentialResolver:list', ]; export const GLOBAL_CHAT_USER_SCOPES: Scope[] = [ diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers.controller.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers.controller.ts index de9865f939d..11eb2d9bccb 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers.controller.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers.controller.ts @@ -36,7 +36,8 @@ export class CredentialResolversController { @GlobalScope('credentialResolver:list') async listResolvers(_req: AuthenticatedRequest, _res: Response): Promise { try { - return credentialResolversSchema.parse(await this.service.findAll()); + const resolvers = credentialResolversSchema.parse(await this.service.findAll()); + return resolvers.map(({ decryptedConfig: _, ...rest }) => ({ ...rest, config: '' })); } catch (e: unknown) { if (e instanceof Error) { throw new InternalServerError(e.message, e); diff --git a/packages/cli/test/integration/dynamic-credentials.ee/credential-resolvers.api.test.ts b/packages/cli/test/integration/dynamic-credentials.ee/credential-resolvers.api.test.ts index d547654e017..068f1e7ea21 100644 --- a/packages/cli/test/integration/dynamic-credentials.ee/credential-resolvers.api.test.ts +++ b/packages/cli/test/integration/dynamic-credentials.ee/credential-resolvers.api.test.ts @@ -109,8 +109,9 @@ describe('Credential Resolvers API', () => { }); }); - it('should reject access for members', async () => { - await memberAgent.get('/credential-resolvers').expect(403); + it('should allow access for members', async () => { + const response = await memberAgent.get('/credential-resolvers').expect(200); + expect(response.body.data).toBeInstanceOf(Array); }); }); @@ -131,8 +132,9 @@ describe('Credential Resolvers API', () => { }); }); - it('should reject access for members', async () => { - await memberAgent.get('/credential-resolvers/types').expect(403); + it('should allow access for members', async () => { + const response = await memberAgent.get('/credential-resolvers/types').expect(200); + expect(response.body.data).toBeInstanceOf(Array); }); }); diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.test.ts b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.test.ts index af2249e389a..50523248a83 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.test.ts +++ b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.test.ts @@ -9,6 +9,7 @@ import { createComponentRenderer } from '@/__tests__/render'; import { createTestWorkflow } from '@/__tests__/mocks'; import { getDropdownItems, mockedStore, type MockedStore } from '@/__tests__/utils'; import { EnterpriseEditionFeature } from '@/app/constants'; +import { useRBACStore } from '@/app/stores/rbac.store'; import WorkflowSettingsVue from '@/app/components/WorkflowSettings.vue'; import { useWorkflowsStore } from '@/app/stores/workflows.store'; import { useWorkflowsListStore } from '@/app/stores/workflowsList.store'; @@ -619,6 +620,10 @@ describe('WorkflowSettingsVue', () => { beforeEach(() => { vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue(mockResolvers); vi.mocked(restApiClient.getCredentialResolverTypes).mockResolvedValue(mockResolverTypes); + const rbacStore = useRBACStore(); + rbacStore.addGlobalScope('credentialResolver:list'); + rbacStore.addGlobalScope('credentialResolver:create'); + rbacStore.addGlobalScope('credentialResolver:update'); }); it('should render credential resolver dropdown', async () => { @@ -789,6 +794,96 @@ describe('WorkflowSettingsVue', () => { }); }); + describe('Credential Resolver RBAC', () => { + const mockResolvers = [ + { + id: 'resolver-1', + name: 'Test Resolver 1', + type: 'editable-type', + config: '{}', + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + const mockResolverTypes = [ + { + name: 'editable-type', + displayName: 'Editable Resolver', + options: [{ name: 'url', type: 'string', displayName: 'URL', default: '' }], + }, + ]; + + beforeEach(() => { + vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue(mockResolvers); + vi.mocked(restApiClient.getCredentialResolverTypes).mockResolvedValue(mockResolverTypes); + }); + + it('should not show "Create new" button when user lacks credentialResolver:create scope', async () => { + const rbacStore = useRBACStore(); + rbacStore.addGlobalScope('credentialResolver:list'); + rbacStore.addGlobalScope('credentialResolver:update'); + + const { queryByTestId } = createComponent({ pinia }); + await flushPromises(); + + expect( + queryByTestId('workflow-settings-credential-resolver-create-new'), + ).not.toBeInTheDocument(); + }); + + it('should show "Create new" button when user has credentialResolver:create scope', async () => { + const rbacStore = useRBACStore(); + rbacStore.addGlobalScope('credentialResolver:list'); + rbacStore.addGlobalScope('credentialResolver:create'); + + const { getByTestId } = createComponent({ pinia }); + await flushPromises(); + + expect(getByTestId('workflow-settings-credential-resolver-create-new')).toBeInTheDocument(); + }); + + it('should not show "Edit" button when user lacks credentialResolver:update scope', async () => { + workflowDocumentStore.setSettings({ credentialResolverId: 'resolver-1' }); + const rbacStore = useRBACStore(); + rbacStore.addGlobalScope('credentialResolver:list'); + rbacStore.addGlobalScope('credentialResolver:create'); + + const { queryByTestId } = createComponent({ pinia }); + await flushPromises(); + + expect(queryByTestId('workflow-settings-credential-resolver-edit')).not.toBeInTheDocument(); + }); + + it('should show "Edit" button when user has credentialResolver:update scope and editable resolver is selected', async () => { + workflowDocumentStore.setSettings({ credentialResolverId: 'resolver-1' }); + const rbacStore = useRBACStore(); + rbacStore.addGlobalScope('credentialResolver:list'); + rbacStore.addGlobalScope('credentialResolver:update'); + + const { getByTestId } = createComponent({ pinia }); + await flushPromises(); + + await waitFor(() => { + expect(getByTestId('workflow-settings-credential-resolver-edit')).toBeInTheDocument(); + }); + }); + + it('should not fetch resolvers and should disable dropdown when user lacks credentialResolver:list scope', async () => { + // No scopes added — user has no credentialResolver:list + + const { getByTestId } = createComponent({ pinia }); + await flushPromises(); + + expect(restApiClient.getCredentialResolvers).not.toHaveBeenCalled(); + expect(restApiClient.getCredentialResolverTypes).not.toHaveBeenCalled(); + + const dropdownContainer = getByTestId('workflow-settings-credential-resolver'); + const input = dropdownContainer.querySelector('input'); + expect(input).toBeDisabled(); + }); + }); + describe('Redaction Policy', () => { it('should not render redaction policy when redaction module is inactive', async () => { vi.spyOn(settingsStore, 'isModuleActive').mockImplementation( diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue index b41d20745bd..6d4036ddd1e 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue +++ b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue @@ -50,6 +50,7 @@ import { useGlobalLinkActions } from '@/app/composables/useGlobalLinkActions'; import { useNodeCreatorStore } from '@/features/shared/nodeCreator/nodeCreator.store'; import { useCredentialResolvers } from '@/features/resolvers/composables/useCredentialResolvers'; import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials'; +import { hasPermission } from '@/app/utils/rbac/permissions'; import { ElCol, ElRow, ElSwitch } from 'element-plus'; @@ -62,6 +63,15 @@ const telemetry = useTelemetry(); const { isEligibleForMcpAccess, trackMcpAccessEnabledForWorkflow, mcpTriggerMap } = useMcp(); const { registerCustomAction, unregisterCustomAction } = useGlobalLinkActions(); const { isEnabled: isCredentialResolverEnabled } = useDynamicCredentials(); +const canListCredentialResolvers = hasPermission(['rbac'], { + rbac: { scope: 'credentialResolver:list' }, +}); +const canCreateCredentialResolver = hasPermission(['rbac'], { + rbac: { scope: 'credentialResolver:create' }, +}); +const canUpdateCredentialResolver = hasPermission(['rbac'], { + rbac: { scope: 'credentialResolver:update' }, +}); const rootStore = useRootStore(); const settingsStore = useSettingsStore(); @@ -665,7 +675,7 @@ onMounted(async () => { loadWorkflowCallerPolicyOptions(), ]; - if (isCredentialResolverEnabled.value) { + if (isCredentialResolverEnabled.value && canListCredentialResolvers) { promises.push(loadCredentialResolvers(), loadCredentialResolverTypes()); } @@ -864,7 +874,9 @@ onBeforeUnmount(() => { :placeholder="i18n.baseText('workflowSettings.credentialResolver.placeholder')" filterable clearable - :disabled="readOnlyEnv || !workflowPermissions.update" + :disabled=" + readOnlyEnv || !workflowPermissions.update || !canListCredentialResolvers + " :limit-popper-width="true" data-test-id="workflow-settings-credential-resolver" > @@ -875,7 +887,7 @@ onBeforeUnmount(() => { :value="resolver.id" > -