From 5799cf712a4e20d122c2f0b0dbadf65cbdd1f6b0 Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Mon, 8 Dec 2025 12:52:24 +0100 Subject: [PATCH] feat(core): Dynamic credential resolvers API routes (#22862) --- .../create-credential-resolver.dto.test.ts | 199 +++++++++++++ .../update-credential-resolver.dto.test.ts | 161 +++++++++++ .../create-credential-resolver.dto.ts | 13 + .../update-credential-resolver.dto.ts | 11 + packages/@n8n/api-types/src/dto/index.ts | 3 + packages/@n8n/api-types/src/index.ts | 5 + .../credential-resolver.schema.test.ts | 261 ++++++++++++++++++ .../src/schemas/credential-resolver.schema.ts | 20 ++ .../scope-information.test.ts.snap | 6 + packages/@n8n/permissions/src/constants.ee.ts | 1 + .../src/roles/scopes/global-scopes.ee.ts | 5 + .../get-resource-permissions.test.ts | 2 + .../credential-resolvers.controller.ts | 143 ++++++++++ .../dynamic-credentials.module.ts | 1 + .../services/credential-resolver.service.ts | 2 +- .../credential-resolvers.api.test.ts | 195 +++++++++++++ packages/cli/test/integration/shared/types.ts | 8 +- .../editor-ui/src/app/stores/rbac.store.ts | 1 + 18 files changed, 1035 insertions(+), 2 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/credential-resolver/__tests__/create-credential-resolver.dto.test.ts create mode 100644 packages/@n8n/api-types/src/dto/credential-resolver/__tests__/update-credential-resolver.dto.test.ts create mode 100644 packages/@n8n/api-types/src/dto/credential-resolver/create-credential-resolver.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/credential-resolver/update-credential-resolver.dto.ts create mode 100644 packages/@n8n/api-types/src/schemas/__tests__/credential-resolver.schema.test.ts create mode 100644 packages/@n8n/api-types/src/schemas/credential-resolver.schema.ts create mode 100644 packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers.controller.ts create mode 100644 packages/cli/test/integration/dynamic-credentials.ee/credential-resolvers.api.test.ts diff --git a/packages/@n8n/api-types/src/dto/credential-resolver/__tests__/create-credential-resolver.dto.test.ts b/packages/@n8n/api-types/src/dto/credential-resolver/__tests__/create-credential-resolver.dto.test.ts new file mode 100644 index 00000000000..1f090af015f --- /dev/null +++ b/packages/@n8n/api-types/src/dto/credential-resolver/__tests__/create-credential-resolver.dto.test.ts @@ -0,0 +1,199 @@ +import { CreateCredentialResolverDto } from '../create-credential-resolver.dto'; + +describe('CreateCredentialResolverDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'basic valid resolver', + data: { + name: 'Test Resolver', + type: 'credential-resolver.stub-1.0', + config: { prefix: 'test-' }, + }, + }, + { + name: 'resolver with complex config', + data: { + name: 'AWS Secrets Manager', + type: 'credential-resolver.aws-secrets-1.0', + config: { + region: 'us-east-1', + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + }, + }, + }, + { + name: 'resolver with empty config', + data: { + name: 'Simple Resolver', + type: 'credential-resolver.simple-1.0', + config: {}, + }, + }, + { + name: 'resolver with minimum name length', + data: { + name: 'A', + type: 'type', + config: {}, + }, + }, + ])('should succeed validation for $name', ({ data }) => { + const result = CreateCredentialResolverDto.safeParse(data); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe(data.name); + expect(result.data.type).toBe(data.type); + expect(result.data.config).toEqual(data.config); + } + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'missing name', + data: { + type: 'credential-resolver.stub-1.0', + config: {}, + }, + expectedErrorPath: ['name'], + }, + { + name: 'missing type', + data: { + name: 'Test Resolver', + config: {}, + }, + expectedErrorPath: ['type'], + }, + { + name: 'missing config', + data: { + name: 'Test Resolver', + type: 'credential-resolver.stub-1.0', + }, + expectedErrorPath: ['config'], + }, + { + name: 'empty name', + data: { + name: '', + type: 'credential-resolver.stub-1.0', + config: {}, + }, + expectedErrorPath: ['name'], + }, + { + name: 'whitespace-only name', + data: { + name: ' ', + type: 'credential-resolver.stub-1.0', + config: {}, + }, + expectedErrorPath: ['name'], + }, + { + name: 'name too long (>255 chars)', + data: { + name: 'a'.repeat(256), + type: 'credential-resolver.stub-1.0', + config: {}, + }, + expectedErrorPath: ['name'], + }, + { + name: 'empty type', + data: { + name: 'Test Resolver', + type: '', + config: {}, + }, + expectedErrorPath: ['type'], + }, + { + name: 'whitespace-only type', + data: { + name: 'Test Resolver', + type: ' ', + config: {}, + }, + expectedErrorPath: ['type'], + }, + { + name: 'type too long (>255 chars)', + data: { + name: 'Test Resolver', + type: 'a'.repeat(256), + config: {}, + }, + expectedErrorPath: ['type'], + }, + { + name: 'config as string', + data: { + name: 'Test Resolver', + type: 'credential-resolver.stub-1.0', + config: 'invalid', + }, + expectedErrorPath: ['config'], + }, + { + name: 'config as array', + data: { + name: 'Test Resolver', + type: 'credential-resolver.stub-1.0', + config: [], + }, + expectedErrorPath: ['config'], + }, + { + name: 'config as null', + data: { + name: 'Test Resolver', + type: 'credential-resolver.stub-1.0', + config: null, + }, + expectedErrorPath: ['config'], + }, + ])('should fail validation for $name', ({ data, expectedErrorPath }) => { + const result = CreateCredentialResolverDto.safeParse(data); + + expect(result.success).toBe(false); + + if (expectedErrorPath && !result.success) { + expect(result.error.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); + + describe('Trimming', () => { + test('should trim name', () => { + const result = CreateCredentialResolverDto.safeParse({ + name: ' Test Resolver ', + type: 'credential-resolver.stub-1.0', + config: {}, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe('Test Resolver'); + } + }); + + test('should trim type', () => { + const result = CreateCredentialResolverDto.safeParse({ + name: 'Test Resolver', + type: ' credential-resolver.stub-1.0 ', + config: {}, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe('credential-resolver.stub-1.0'); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/credential-resolver/__tests__/update-credential-resolver.dto.test.ts b/packages/@n8n/api-types/src/dto/credential-resolver/__tests__/update-credential-resolver.dto.test.ts new file mode 100644 index 00000000000..a8d284eba03 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/credential-resolver/__tests__/update-credential-resolver.dto.test.ts @@ -0,0 +1,161 @@ +import { UpdateCredentialResolverDto } from '../update-credential-resolver.dto'; + +describe('UpdateCredentialResolverDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'update only name', + data: { + name: 'Updated Resolver', + }, + }, + { + name: 'update only config', + data: { + config: { prefix: 'updated-' }, + }, + }, + { + name: 'update both name and config', + data: { + name: 'Updated Resolver', + config: { prefix: 'updated-' }, + }, + }, + { + name: 'update with empty config', + data: { + name: 'Updated Resolver', + config: {}, + }, + }, + { + name: 'update with complex config', + data: { + config: { + region: 'eu-west-1', + timeout: 5000, + nested: { + value: true, + }, + }, + }, + }, + { + name: 'empty update (all optional)', + data: {}, + }, + { + name: 'minimum name length', + data: { + name: 'A', + }, + }, + ])('should succeed validation for $name', ({ data }) => { + const result = UpdateCredentialResolverDto.safeParse(data); + + expect(result.success).toBe(true); + if (result.success) { + if (data.name !== undefined) { + expect(result.data.name).toBe(data.name); + } + if (data.config !== undefined) { + expect(result.data.config).toEqual(data.config); + } + } + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'empty name', + data: { + name: '', + }, + expectedErrorPath: ['name'], + }, + { + name: 'whitespace-only name', + data: { + name: ' ', + }, + expectedErrorPath: ['name'], + }, + { + name: 'name too long (>255 chars)', + data: { + name: 'a'.repeat(256), + }, + expectedErrorPath: ['name'], + }, + { + name: 'config as string', + data: { + config: 'invalid', + }, + expectedErrorPath: ['config'], + }, + { + name: 'config as array', + data: { + config: [], + }, + expectedErrorPath: ['config'], + }, + { + name: 'config as null', + data: { + config: null, + }, + expectedErrorPath: ['config'], + }, + { + name: 'name as number', + data: { + name: 123, + }, + expectedErrorPath: ['name'], + }, + { + name: 'name as object', + data: { + name: { value: 'test' }, + }, + expectedErrorPath: ['name'], + }, + ])('should fail validation for $name', ({ data, expectedErrorPath }) => { + const result = UpdateCredentialResolverDto.safeParse(data); + + expect(result.success).toBe(false); + + if (expectedErrorPath && !result.success) { + expect(result.error.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); + + describe('Trimming', () => { + test('should trim name when provided', () => { + const result = UpdateCredentialResolverDto.safeParse({ + name: ' Updated Resolver ', + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe('Updated Resolver'); + } + }); + + test('should not affect config', () => { + const result = UpdateCredentialResolverDto.safeParse({ + config: { key: ' value with spaces ' }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.config).toEqual({ key: ' value with spaces ' }); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/credential-resolver/create-credential-resolver.dto.ts b/packages/@n8n/api-types/src/dto/credential-resolver/create-credential-resolver.dto.ts new file mode 100644 index 00000000000..a1fc58f83d0 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/credential-resolver/create-credential-resolver.dto.ts @@ -0,0 +1,13 @@ +import { Z } from 'zod-class'; + +import { + credentialResolverNameSchema, + credentialResolverTypeSchema, + credentialResolverConfigSchema, +} from '../../schemas/credential-resolver.schema'; + +export class CreateCredentialResolverDto extends Z.class({ + name: credentialResolverNameSchema, + type: credentialResolverTypeSchema, + config: credentialResolverConfigSchema, +}) {} diff --git a/packages/@n8n/api-types/src/dto/credential-resolver/update-credential-resolver.dto.ts b/packages/@n8n/api-types/src/dto/credential-resolver/update-credential-resolver.dto.ts new file mode 100644 index 00000000000..442b35ee89d --- /dev/null +++ b/packages/@n8n/api-types/src/dto/credential-resolver/update-credential-resolver.dto.ts @@ -0,0 +1,11 @@ +import { Z } from 'zod-class'; + +import { + credentialResolverNameSchema, + credentialResolverConfigSchema, +} from '../../schemas/credential-resolver.schema'; + +export class UpdateCredentialResolverDto extends Z.class({ + name: credentialResolverNameSchema.optional(), + config: credentialResolverConfigSchema.optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 5b0cfec8718..b4d8ac15d26 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -12,6 +12,9 @@ export { BinaryDataSignedQueryDto } from './binary-data/binary-data-signed-query export { LoginRequestDto } from './auth/login-request.dto'; export { ResolveSignupTokenQueryDto } from './auth/resolve-signup-token-query.dto'; +export { CreateCredentialResolverDto } from './credential-resolver/create-credential-resolver.dto'; +export { UpdateCredentialResolverDto } from './credential-resolver/update-credential-resolver.dto'; + export { OptionsRequestDto } from './dynamic-node-parameters/options-request.dto'; export { ResourceLocatorRequestDto } from './dynamic-node-parameters/resource-locator-request.dto'; export { ResourceMapperFieldsRequestDto } from './dynamic-node-parameters/resource-mapper-fields-request.dto'; diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 6a7b0c46aab..d686345c8ce 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -60,6 +60,11 @@ export type { SendWorkerStatusMessage } from './push/worker'; export type { BannerName } from './schemas/banner-name.schema'; export { ViewableMimeTypes } from './schemas/binary-data.schema'; export { passwordSchema } from './schemas/password.schema'; +export { + credentialResolverSchema, + credentialResolversSchema, + type CredentialResolver, +} from './schemas/credential-resolver.schema'; export type { ProjectType, diff --git a/packages/@n8n/api-types/src/schemas/__tests__/credential-resolver.schema.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/credential-resolver.schema.test.ts new file mode 100644 index 00000000000..7a046b72784 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/__tests__/credential-resolver.schema.test.ts @@ -0,0 +1,261 @@ +import { + credentialResolverIdSchema, + credentialResolverNameSchema, + credentialResolverTypeSchema, + credentialResolverConfigSchema, + credentialResolverSchema, + credentialResolversSchema, +} from '../credential-resolver.schema'; + +describe('credential-resolver.schema', () => { + describe('credentialResolverIdSchema', () => { + test.each([ + { name: 'valid UUID', value: '550e8400-e29b-41d4-a716-446655440000', expected: true }, + { name: 'short ID', value: 'abc123', expected: true }, + { name: 'nanoid', value: 'V1StGXR8_Z5jdHi6B-myT', expected: true }, + { name: 'ID at max length (36 chars)', value: 'a'.repeat(36), expected: true }, + { name: 'ID too long (37 chars)', value: 'a'.repeat(37), expected: false }, + { name: 'empty ID', value: '', expected: true }, // zod string allows empty by default + ])('should validate $name', ({ value, expected }) => { + const result = credentialResolverIdSchema.safeParse(value); + expect(result.success).toBe(expected); + }); + }); + + describe('credentialResolverNameSchema', () => { + test.each([ + { name: 'valid name', value: 'Test Resolver', expected: true }, + { name: 'minimum length (1 char)', value: 'A', expected: true }, + { name: 'maximum length (255 chars)', value: 'a'.repeat(255), expected: true }, + { name: 'name with special chars', value: 'Test-Resolver_123', expected: true }, + { name: 'name with unicode', value: 'Tëst Rësölvër 🚀', expected: true }, + { name: 'empty name', value: '', expected: false }, + { name: 'whitespace-only name', value: ' ', expected: false }, + { name: 'name too long (256 chars)', value: 'a'.repeat(256), expected: false }, + ])('should validate $name', ({ value, expected }) => { + const result = credentialResolverNameSchema.safeParse(value); + expect(result.success).toBe(expected); + }); + + test('should trim whitespace', () => { + const result = credentialResolverNameSchema.safeParse(' Test Resolver '); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe('Test Resolver'); + } + }); + }); + + describe('credentialResolverTypeSchema', () => { + test.each([ + { name: 'valid type', value: 'credential-resolver.stub-1.0', expected: true }, + { name: 'simple type', value: 'simple', expected: true }, + { name: 'type with dots', value: 'resolver.aws.v2', expected: true }, + { name: 'minimum length (1 char)', value: 'A', expected: true }, + { name: 'maximum length (255 chars)', value: 'a'.repeat(255), expected: true }, + { name: 'empty type', value: '', expected: false }, + { name: 'whitespace-only type', value: ' ', expected: false }, + { name: 'type too long (256 chars)', value: 'a'.repeat(256), expected: false }, + ])('should validate $name', ({ value, expected }) => { + const result = credentialResolverTypeSchema.safeParse(value); + expect(result.success).toBe(expected); + }); + + test('should trim whitespace', () => { + const result = credentialResolverTypeSchema.safeParse(' credential-resolver.stub-1.0 '); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe('credential-resolver.stub-1.0'); + } + }); + }); + + describe('credentialResolverConfigSchema', () => { + test.each([ + { name: 'empty object', value: {}, expected: true }, + { name: 'simple config', value: { prefix: 'test-' }, expected: true }, + { + name: 'complex config', + value: { + region: 'us-east-1', + timeout: 5000, + nested: { value: true }, + }, + expected: true, + }, + { + name: 'config with various types', + value: { + stringValue: 'value', + numberValue: 123, + booleanValue: true, + nullValue: null, + arrayValue: [1, 2, 3], + objectValue: { key: 'value' }, + }, + expected: true, + }, + { name: 'string instead of object', value: 'invalid', expected: false }, + { name: 'array instead of object', value: [], expected: false }, + { name: 'null instead of object', value: null, expected: false }, + { name: 'number instead of object', value: 123, expected: false }, + ])('should validate $name', ({ value, expected }) => { + const result = credentialResolverConfigSchema.safeParse(value); + expect(result.success).toBe(expected); + }); + }); + + describe('credentialResolverSchema', () => { + const validResolver = { + id: 'resolver-123', + name: 'Test Resolver', + type: 'credential-resolver.stub-1.0', + config: 'encrypted-config-string', + decryptedConfig: { prefix: 'test-' }, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-02T00:00:00.000Z'), + }; + + test('should validate complete resolver object', () => { + const result = credentialResolverSchema.safeParse(validResolver); + expect(result.success).toBe(true); + }); + + test('should allow missing optional decryptedConfig', () => { + const { decryptedConfig, ...resolverWithoutDecrypted } = validResolver; + const result = credentialResolverSchema.safeParse(resolverWithoutDecrypted); + expect(result.success).toBe(true); + }); + + test.each([ + { + name: 'missing id', + data: { ...validResolver, id: undefined }, + expectedErrorPath: ['id'], + }, + { + name: 'missing name', + data: { ...validResolver, name: undefined }, + expectedErrorPath: ['name'], + }, + { + name: 'missing type', + data: { ...validResolver, type: undefined }, + expectedErrorPath: ['type'], + }, + { + name: 'missing config', + data: { ...validResolver, config: undefined }, + expectedErrorPath: ['config'], + }, + { + name: 'missing createdAt', + data: { ...validResolver, createdAt: undefined }, + expectedErrorPath: ['createdAt'], + }, + { + name: 'missing updatedAt', + data: { ...validResolver, updatedAt: undefined }, + expectedErrorPath: ['updatedAt'], + }, + { + name: 'invalid createdAt format', + data: { ...validResolver, createdAt: 'invalid-date' }, + expectedErrorPath: ['createdAt'], + }, + { + name: 'config as object instead of string', + data: { ...validResolver, config: { key: 'value' } }, + expectedErrorPath: ['config'], + }, + ])('should fail validation for $name', ({ data, expectedErrorPath }) => { + const result = credentialResolverSchema.safeParse(data); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toEqual(expectedErrorPath); + } + }); + + test('should accept date strings and coerce to Date', () => { + const resolverWithStringDates = { + ...validResolver, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }; + const result = credentialResolverSchema.safeParse(resolverWithStringDates); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.createdAt).toBeInstanceOf(Date); + expect(result.data.updatedAt).toBeInstanceOf(Date); + } + }); + + test('should accept Date objects directly', () => { + const result = credentialResolverSchema.safeParse(validResolver); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.createdAt).toBeInstanceOf(Date); + expect(result.data.updatedAt).toBeInstanceOf(Date); + } + }); + }); + + describe('credentialResolversSchema', () => { + const validResolver1 = { + id: 'resolver-1', + name: 'Test Resolver 1', + type: 'credential-resolver.stub-1.0', + config: 'encrypted-config-1', + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-02T00:00:00.000Z'), + }; + + const validResolver2 = { + id: 'resolver-2', + name: 'Test Resolver 2', + type: 'credential-resolver.stub-2.0', + config: 'encrypted-config-2', + decryptedConfig: { prefix: 'test-' }, + createdAt: new Date('2024-01-03T00:00:00.000Z'), + updatedAt: new Date('2024-01-04T00:00:00.000Z'), + }; + + test('should validate empty array', () => { + const result = credentialResolversSchema.safeParse([]); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual([]); + } + }); + + test('should validate array with single resolver', () => { + const result = credentialResolversSchema.safeParse([validResolver1]); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toHaveLength(1); + } + }); + + test('should validate array with multiple resolvers', () => { + const result = credentialResolversSchema.safeParse([validResolver1, validResolver2]); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toHaveLength(2); + } + }); + + test('should fail if any resolver is invalid', () => { + const invalidResolver = { ...validResolver1, name: '' }; + const result = credentialResolversSchema.safeParse([validResolver1, invalidResolver]); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toEqual([1, 'name']); + } + }); + + test('should fail for non-array input', () => { + const result = credentialResolversSchema.safeParse(validResolver1); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/packages/@n8n/api-types/src/schemas/credential-resolver.schema.ts b/packages/@n8n/api-types/src/schemas/credential-resolver.schema.ts new file mode 100644 index 00000000000..8e9bad6d6d6 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/credential-resolver.schema.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +export const credentialResolverIdSchema = z.string().max(36); +export const credentialResolverNameSchema = z.string().trim().min(1).max(255); +export const credentialResolverTypeSchema = z.string().trim().min(1).max(255); +export const credentialResolverConfigSchema = z.record(z.unknown()); + +export const credentialResolverSchema = z.object({ + id: credentialResolverIdSchema, + name: credentialResolverNameSchema, + type: credentialResolverTypeSchema, + config: z.string(), // Encrypted config + decryptedConfig: credentialResolverConfigSchema.optional(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}); + +export const credentialResolversSchema = z.array(credentialResolverSchema); + +export type CredentialResolver = z.infer; diff --git a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap index cea5aff1737..feafe00b87a 100644 --- a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap +++ b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap @@ -162,6 +162,12 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = ` "breakingChanges:*", "apiKey:manage", "apiKey:*", + "credentialResolver:create", + "credentialResolver:read", + "credentialResolver:update", + "credentialResolver:delete", + "credentialResolver:list", + "credentialResolver:*", "*", ] `; diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 8f9d5f83f7e..1c7b9bed059 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -38,6 +38,7 @@ export const RESOURCES = { chatHubAgent: [...DEFAULT_OPERATIONS] as const, breakingChanges: ['list'] as const, apiKey: ['manage'] as const, + credentialResolver: [...DEFAULT_OPERATIONS] as const, } as const; export const API_KEY_RESOURCES = { 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 6c6910504f4..ad5905f64d7 100644 --- a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts @@ -113,6 +113,11 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'chatHubAgent:list', 'breakingChanges:list', 'apiKey:manage', + 'credentialResolver:create', + 'credentialResolver:read', + 'credentialResolver:update', + 'credentialResolver:delete', + 'credentialResolver:list', ]; export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat(); diff --git a/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts index f37b702507b..fe1370706b5 100644 --- a/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts +++ b/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts @@ -43,6 +43,7 @@ describe('permissions', () => { chatHubAgent: {}, breakingChanges: {}, apiKey: {}, + credentialResolver: {}, }); }); it('getResourcePermissions', () => { @@ -163,6 +164,7 @@ describe('permissions', () => { apiKey: { manage: true, }, + credentialResolver: {}, }; expect(getResourcePermissions(scopes)).toEqual(permissionRecord); 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 new file mode 100644 index 00000000000..b519d063e79 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers.controller.ts @@ -0,0 +1,143 @@ +import { + CreateCredentialResolverDto, + CredentialResolver, + credentialResolverSchema, + credentialResolversSchema, + UpdateCredentialResolverDto, +} from '@n8n/api-types'; +import { AuthenticatedRequest } from '@n8n/db'; +import { + Body, + Delete, + Get, + GlobalScope, + Param, + Patch, + Post, + RestController, + CredentialResolverValidationError, +} from '@n8n/decorators'; +import { Response } from 'express'; + +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { InternalServerError } from '@/errors/response-errors/internal-server.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; + +import { DynamicCredentialResolverNotFoundError } from './errors/credential-resolver-not-found.error'; +import { DynamicCredentialResolverService } from './services/credential-resolver.service'; + +@RestController('/credential-resolvers') +export class CredentialResolversController { + constructor(private readonly service: DynamicCredentialResolverService) {} + + @Get('/') + @GlobalScope('credentialResolver:list') + async listResolvers( + _req: AuthenticatedRequest, + _res: Response, + ): Promise> { + try { + return credentialResolversSchema.parse(await this.service.findAll()); + } catch (e: unknown) { + if (e instanceof Error) { + throw new InternalServerError(e.message, e); + } + throw e; + } + } + + @Post('/') + @GlobalScope('credentialResolver:create') + async createResolver( + _req: AuthenticatedRequest, + _res: Response, + @Body dto: CreateCredentialResolverDto, + ): Promise { + try { + const createdResolver = await this.service.create({ + name: dto.name, + type: dto.type, + config: dto.config, + }); + return credentialResolverSchema.parse(createdResolver); + } catch (e: unknown) { + if (e instanceof CredentialResolverValidationError) { + throw new BadRequestError(e.message); + } + if (e instanceof Error) { + throw new InternalServerError(e.message, e); + } + throw e; + } + } + + @Get('/:id') + @GlobalScope('credentialResolver:read') + async getResolver( + _req: AuthenticatedRequest, + _res: Response, + @Param('id') id: string, + ): Promise { + try { + return credentialResolverSchema.parse(await this.service.findById(id)); + } catch (e: unknown) { + if (e instanceof DynamicCredentialResolverNotFoundError) { + throw new NotFoundError(e.message); + } + if (e instanceof Error) { + throw new InternalServerError(e.message, e); + } + throw e; + } + } + + @Patch('/:id') + @GlobalScope('credentialResolver:update') + async updateResolver( + _req: AuthenticatedRequest, + _res: Response, + @Param('id') id: string, + @Body dto: UpdateCredentialResolverDto, + ): Promise { + try { + return credentialResolverSchema.parse( + await this.service.update(id, { + name: dto.name, + config: dto.config, + }), + ); + } catch (e: unknown) { + if (e instanceof DynamicCredentialResolverNotFoundError) { + throw new NotFoundError(e.message); + } + if (e instanceof CredentialResolverValidationError) { + throw new BadRequestError(e.message); + } + if (e instanceof Error) { + throw new InternalServerError(e.message, e); + } + throw e; + } + } + + @Delete('/:id') + @GlobalScope('credentialResolver:delete') + async deleteResolver( + _req: AuthenticatedRequest, + _res: Response, + @Param('id') id: string, + ): Promise<{ success: true }> { + try { + await this.service.delete(id); + return { success: true }; + } catch (e: unknown) { + if (e instanceof DynamicCredentialResolverNotFoundError) { + throw new NotFoundError(e.message); + } + if (e instanceof Error) { + throw new InternalServerError(e.message, e); + } + throw e; + } + } +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.module.ts b/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.module.ts index 613d469ebaa..cf371bafefd 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.module.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.module.ts @@ -5,6 +5,7 @@ import { Container } from '@n8n/di'; @BackendModule({ name: 'dynamic-credentials', licenseFlag: 'feat:externalSecrets' }) export class DynamicCredentialsModule implements ModuleInterface { async init() { + await import('./credential-resolvers.controller'); await import('./context-establishment-hooks'); await import('./credential-resolvers'); const { DynamicCredentialResolverRegistry } = await import('./services'); diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/credential-resolver.service.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/credential-resolver.service.ts index a72305b7965..72d9238cf96 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/services/credential-resolver.service.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/credential-resolver.service.ts @@ -58,7 +58,7 @@ export class DynamicCredentialResolverService { const saved = await this.repository.save(resolver); this.logger.debug(`Created credential resolver "${saved.name}" (${saved.id})`); - return saved; + return this.withDecryptedConfig(saved); } /** 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 new file mode 100644 index 00000000000..0b07e657763 --- /dev/null +++ b/packages/cli/test/integration/dynamic-credentials.ee/credential-resolvers.api.test.ts @@ -0,0 +1,195 @@ +import { LicenseState } from '@n8n/backend-common'; +import { mockInstance } from '@n8n/backend-test-utils'; +import type { User } from '@n8n/db'; +import { GLOBAL_OWNER_ROLE, GLOBAL_MEMBER_ROLE } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { mock } from 'jest-mock-extended'; + +import { Telemetry } from '@/telemetry'; +import { DynamicCredentialResolverService } from '@/modules/dynamic-credentials.ee/services/credential-resolver.service'; +import { DynamicCredentialResolverRepository } from '@/modules/dynamic-credentials.ee/database/repositories/credential-resolver.repository'; + +import { createUser } from '../shared/db/users'; +import type { SuperAgentTest } from '../shared/types'; +import * as utils from '../shared/utils/'; + +mockInstance(Telemetry); + +const licenseMock = mock(); +licenseMock.isLicensed.mockReturnValue(true); +Container.set(LicenseState, licenseMock); + +const testServer = utils.setupTestServer({ + endpointGroups: ['credentials'], + enabledFeatures: ['feat:externalSecrets'], + modules: ['dynamic-credentials'], +}); + +describe('Credential Resolvers API', () => { + let owner: User; + let member: User; + let ownerAgent: SuperAgentTest; + let memberAgent: SuperAgentTest; + let service: DynamicCredentialResolverService; + let repository: DynamicCredentialResolverRepository; + + beforeAll(async () => { + owner = await createUser({ role: GLOBAL_OWNER_ROLE }); + member = await createUser({ role: GLOBAL_MEMBER_ROLE }); + + ownerAgent = testServer.authAgentFor(owner); + memberAgent = testServer.authAgentFor(member); + + service = Container.get(DynamicCredentialResolverService); + repository = Container.get(DynamicCredentialResolverRepository); + }); + + beforeEach(async () => { + await repository.delete({}); + }); + + describe('GET /credential-resolvers', () => { + it('should return empty list when no resolvers exist', async () => { + const response = await ownerAgent.get('/credential-resolvers').expect(200); + expect(response.body.data).toEqual([]); + }); + + it('should list all resolvers', async () => { + // Create resolvers using service + await service.create({ + name: 'Resolver 1', + type: 'credential-resolver.stub-1.0', + config: { prefix: 'test1-' }, + }); + await service.create({ + name: 'Resolver 2', + type: 'credential-resolver.stub-1.0', + config: { prefix: 'test2-' }, + }); + + const response = await ownerAgent.get('/credential-resolvers').expect(200); + + expect(response.body.data).toHaveLength(2); + expect(response.body.data[0]).toMatchObject({ + id: expect.any(String), + name: 'Resolver 1', + type: 'credential-resolver.stub-1.0', + }); + }); + + it('should reject access for members', async () => { + await memberAgent.get('/credential-resolvers').expect(403); + }); + }); + + describe('POST /credential-resolvers', () => { + it('should create a resolver', async () => { + const payload = { + name: 'Test Resolver', + type: 'credential-resolver.stub-1.0', + config: { prefix: 'test-' }, + }; + + const response = await ownerAgent.post('/credential-resolvers').send(payload).expect(200); + + expect(response.body.data).toMatchObject({ + id: expect.any(String), + name: 'Test Resolver', + type: 'credential-resolver.stub-1.0', + }); + expect(response.body.data.decryptedConfig).toEqual({ prefix: 'test-' }); + + // Verify it was actually created + const resolvers = await repository.find(); + expect(resolvers).toHaveLength(1); + }); + + it('should reject unknown resolver type', async () => { + const payload = { + name: 'Test Resolver', + type: 'unknown-type', + config: {}, + }; + + const response = await ownerAgent.post('/credential-resolvers').send(payload).expect(400); + expect(response.body.message).toContain('Unknown resolver type'); + }); + + it('should reject access for members', async () => { + const payload = { + name: 'Test Resolver', + type: 'credential-resolver.stub-1.0', + config: { prefix: 'test-' }, + }; + + await memberAgent.post('/credential-resolvers').send(payload).expect(403); + }); + }); + + describe('GET /resolvers/:id', () => { + it('should return a specific resolver', async () => { + const resolver = await service.create({ + name: 'Test Resolver', + type: 'credential-resolver.stub-1.0', + config: { prefix: 'test-' }, + }); + + const response = await ownerAgent.get(`/credential-resolvers/${resolver.id}`).expect(200); + + expect(response.body.data).toMatchObject({ + id: resolver.id, + name: 'Test Resolver', + type: 'credential-resolver.stub-1.0', + }); + }); + + it('should return 404 for non-existent resolver', async () => { + await ownerAgent.get('/credential-resolvers/non-existent-id').expect(404); + }); + }); + + describe('PATCH /resolvers/:id', () => { + it('should update resolver name', async () => { + const resolver = await service.create({ + name: 'Original Name', + type: 'credential-resolver.stub-1.0', + config: { prefix: 'test-' }, + }); + + const response = await ownerAgent + .patch(`/credential-resolvers/${resolver.id}`) + .send({ name: 'Updated Name' }) + .expect(200); + + expect(response.body.data.name).toBe('Updated Name'); + }); + + it('should return 404 for non-existent resolver', async () => { + await ownerAgent + .patch('/credential-resolvers/non-existent-id') + .send({ name: 'New Name' }) + .expect(404); + }); + }); + + describe('DELETE /resolvers/:id', () => { + it('should delete a resolver', async () => { + const resolver = await service.create({ + name: 'Test Resolver', + type: 'credential-resolver.stub-1.0', + config: { prefix: 'test-' }, + }); + + const response = await ownerAgent.delete(`/credential-resolvers/${resolver.id}`).expect(200); + expect(response.body.data).toEqual({ success: true }); + + // Verify it was actually deleted + const remaining = await repository.find(); + expect(remaining).toHaveLength(0); + }); + + it('should return 404 for non-existent resolver', async () => { + await ownerAgent.delete('/credential-resolvers/non-existent-id').expect(404); + }); + }); +}); diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 9b4e1d495be..d5afbe47696 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -48,7 +48,13 @@ type EndpointGroup = | 'third-party-licenses' | 'mcp'; -type ModuleName = 'insights' | 'external-secrets' | 'community-packages' | 'data-table' | 'mcp'; +type ModuleName = + | 'insights' + | 'external-secrets' + | 'community-packages' + | 'data-table' + | 'mcp' + | 'dynamic-credentials'; export interface SetupProps { endpointGroups?: EndpointGroup[]; diff --git a/packages/frontend/editor-ui/src/app/stores/rbac.store.ts b/packages/frontend/editor-ui/src/app/stores/rbac.store.ts index 93abc502349..3c0847fbfeb 100644 --- a/packages/frontend/editor-ui/src/app/stores/rbac.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/rbac.store.ts @@ -49,6 +49,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => { chatHubAgent: {}, breakingChanges: {}, apiKey: {}, + credentialResolver: {}, }); function addGlobalRole(role: Role) {