mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 05:38:33 +08:00
fix: Fix credential resolver RBAC for member users (#26552)
This commit is contained in:
@@ -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);
|
||||
|
||||
+6
-4
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user