fix: Fix credential resolver RBAC for member users (#26552)

This commit is contained in:
phyllis-noester
2026-03-05 14:42:13 +01:00
committed by GitHub
parent f1a074f946
commit 6b171fc025
6 changed files with 130 additions and 10 deletions
@@ -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[] = [
@@ -36,7 +36,8 @@ export class CredentialResolversController {
@GlobalScope('credentialResolver:list')
async listResolvers(_req: AuthenticatedRequest, _res: Response): Promise<CredentialResolver[]> {
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);
@@ -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);
});
});
@@ -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(
@@ -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"
>
</N8nOption>
<template #footer>
<template v-if="canCreateCredentialResolver" #footer>
<button
type="button"
:class="$style['create-new-button']"
@@ -889,7 +901,7 @@ onBeforeUnmount(() => {
</template>
</N8nSelect>
<N8nIconButton
v-if="isSelectedResolverEditable"
v-if="isSelectedResolverEditable && canUpdateCredentialResolver"
variant="ghost"
icon="pen"
size="small"
+10 -1
View File
@@ -681,8 +681,17 @@ export const routes: RouteRecordRaw[] = [
name: VIEWS.RESOLVERS,
component: SettingsResolversView,
meta: {
middleware: ['authenticated', 'custom'],
middleware: ['authenticated', 'rbac', 'custom'],
middlewareOptions: {
rbac: {
scope: [
'credentialResolver:read',
'credentialResolver:list',
'credentialResolver:create',
'credentialResolver:update',
'credentialResolver:delete',
],
},
custom: () => {
const { isEnabled } = useDynamicCredentials();
return isEnabled.value;