chore(core): Allow marking fields in credentials as resolvable (#23074)

This commit is contained in:
Andreas Fitzek 2025-12-11 16:17:58 +01:00 committed by GitHub
parent 90c2d2ea70
commit f9a592b966
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 333 additions and 5 deletions

View File

@ -446,6 +446,7 @@ describe('CredentialsHelper', () => {
id: mockCredentialEntity.id,
name: mockCredentialEntity.name,
isResolvable: false,
type: 'testApi',
},
{ apiKey: 'static-key' },
mockAdditionalData.executionContext,

View File

@ -364,6 +364,7 @@ export class CredentialsHelper extends ICredentialsHelper {
id: credentialsEntity.id,
name: credentialsEntity.name,
isResolvable: false,
type: credentialsEntity.type,
// TODO: use the actual values from the entity once they are added
// isResolvable: credentialsEntity.isResolvable,
// resolverId: (credentialsEntity as any).resolverId,

View File

@ -47,6 +47,7 @@ describe('DynamicCredentialsProxy', () => {
id: 'cred-123',
name: 'Test Credential',
isResolvable: false,
type: 'oAuth2Api',
};
it('should return static data when no provider is set and credential is not resolvable', async () => {

View File

@ -7,6 +7,8 @@ import type {
export type CredentialResolveMetadata = {
id: string;
name: string;
/** Credential type (e.g., 'oAuth2Api') */
type: string;
resolverId?: string;
resolvableAllowFallback?: boolean;
isResolvable: boolean;

View File

@ -86,6 +86,16 @@ describe('DynamicCredentialStorageService', () => {
getCredential: jest.fn(),
} as unknown as jest.Mocked<LoadNodesAndCredentials>;
mockLoadNodesAndCredentials.getCredential.mockReturnValue({
type: {
displayName: 'OAuth2 API',
name: 'oAuth2Api',
extends: [],
properties: [],
},
sourcePath: '',
});
mockCipher = {
decrypt: jest.fn(),
} as unknown as jest.Mocked<Cipher>;
@ -216,6 +226,41 @@ describe('DynamicCredentialStorageService', () => {
const resolverEntity = createMockResolverEntity();
const mockResolver = createMockResolver();
mockLoadNodesAndCredentials.getCredential.mockReturnValue({
type: {
displayName: 'OAuth2 API',
name: 'oAuth2Api',
properties: [
{
default: '',
displayName: 'Client ID',
name: 'clientId',
type: 'string',
},
{
default: '',
displayName: 'Client Secret',
name: 'clientSecret',
type: 'string',
},
{
default: '',
displayName: 'Scopes',
name: 'scopes',
type: 'string',
},
{
default: '',
displayName: 'AccessToken',
name: 'accessToken',
type: 'string',
resolvableField: true,
},
],
},
sourcePath: '',
});
const dataWithSharedFields: ICredentialDataDecryptedObject = {
clientId: 'should-be-removed',
clientSecret: 'should-be-removed',

View File

@ -8,6 +8,7 @@ import type {
} from 'n8n-workflow';
import type { CredentialResolveMetadata } from '@/credentials/credential-resolution-provider.interface';
import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import type { DynamicCredentialResolver } from '../../database/entities/credential-resolver';
import type { DynamicCredentialResolverRepository } from '../../database/repositories/credential-resolver.repository';
@ -19,6 +20,7 @@ describe('DynamicCredentialService', () => {
let service: DynamicCredentialService;
let mockResolverRegistry: jest.Mocked<DynamicCredentialResolverRegistry>;
let mockResolverRepository: jest.Mocked<DynamicCredentialResolverRepository>;
let mockLoadNodesAndCredentials: jest.Mocked<LoadNodesAndCredentials>;
let mockCipher: jest.Mocked<Cipher>;
let mockLogger: jest.Mocked<Logger>;
@ -106,9 +108,21 @@ describe('DynamicCredentialService', () => {
decrypt: jest.fn(),
} as unknown as jest.Mocked<Cipher>;
mockLoadNodesAndCredentials = {
getCredential: jest.fn(),
loadCredentials: jest.fn(),
loadAllCredentials: jest.fn(),
} as unknown as jest.Mocked<LoadNodesAndCredentials>;
mockLoadNodesAndCredentials.getCredential.mockReturnValue({
sourcePath: 'credentials/Test Credential',
type: { name: 'testCredentialType', displayName: 'Test Credential Type', properties: [] },
});
service = new DynamicCredentialService(
mockResolverRegistry,
mockResolverRepository,
mockLoadNodesAndCredentials,
mockCipher,
mockLogger,
);

View File

@ -0,0 +1,218 @@
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
import { extractSharedFields } from '../shared-fields';
describe('extractSharedFields', () => {
describe('with only static fields', () => {
it('should return all fields when none are marked as resolvable', () => {
const credentialType: ICredentialType = {
name: 'testCredential',
displayName: 'Test Credential',
properties: [
{ name: 'clientId', type: 'string', default: '' },
{ name: 'clientSecret', type: 'string', default: '' },
{ name: 'domain', type: 'string', default: '' },
] as INodeProperties[],
};
const result = extractSharedFields(credentialType);
expect(result).toEqual(['clientId', 'clientSecret', 'domain']);
});
it('should return all fields when resolvableField is explicitly false', () => {
const credentialType: ICredentialType = {
name: 'testCredential',
displayName: 'Test Credential',
properties: [
{ name: 'clientId', type: 'string', default: '', resolvableField: false },
{ name: 'clientSecret', type: 'string', default: '', resolvableField: false },
] as INodeProperties[],
};
const result = extractSharedFields(credentialType);
expect(result).toEqual(['clientId', 'clientSecret']);
});
});
describe('with only resolvable fields', () => {
it('should return empty array when all fields are marked as resolvable', () => {
const credentialType: ICredentialType = {
name: 'httpBasicAuth',
displayName: 'HTTP Basic Auth',
properties: [
{ name: 'user', type: 'string', default: '', resolvableField: true },
{ name: 'password', type: 'string', default: '', resolvableField: true },
] as INodeProperties[],
};
const result = extractSharedFields(credentialType);
expect(result).toEqual([]);
});
});
describe('with mixed fields', () => {
it('should return only non-resolvable fields in mixed scenario', () => {
const credentialType: ICredentialType = {
name: 'oauth2Credential',
displayName: 'OAuth2 Credential',
properties: [
{ name: 'clientId', type: 'string', default: '' },
{ name: 'clientSecret', type: 'string', default: '' },
{ name: 'accessToken', type: 'string', default: '', resolvableField: true },
{ name: 'refreshToken', type: 'string', default: '', resolvableField: true },
] as INodeProperties[],
};
const result = extractSharedFields(credentialType);
expect(result).toEqual(['clientId', 'clientSecret']);
});
it('should handle multiple static fields between resolvable ones', () => {
const credentialType: ICredentialType = {
name: 'complexCredential',
displayName: 'Complex Credential',
properties: [
{ name: 'staticField1', type: 'string', default: '' },
{ name: 'dynamicField1', type: 'string', default: '', resolvableField: true },
{ name: 'staticField2', type: 'string', default: '' },
{ name: 'dynamicField2', type: 'string', default: '', resolvableField: true },
{ name: 'staticField3', type: 'string', default: '' },
] as INodeProperties[],
};
const result = extractSharedFields(credentialType);
expect(result).toEqual(['staticField1', 'staticField2', 'staticField3']);
});
});
describe('edge cases', () => {
it('should return empty array when credential has no properties', () => {
const credentialType: ICredentialType = {
name: 'emptyCredential',
displayName: 'Empty Credential',
properties: [],
};
const result = extractSharedFields(credentialType);
expect(result).toEqual([]);
});
it('should handle credentials with single field', () => {
const credentialType: ICredentialType = {
name: 'singleFieldCredential',
displayName: 'Single Field Credential',
properties: [{ name: 'apiKey', type: 'string', default: '' }] as INodeProperties[],
};
const result = extractSharedFields(credentialType);
expect(result).toEqual(['apiKey']);
});
it('should handle credentials with single resolvable field', () => {
const credentialType: ICredentialType = {
name: 'singleResolvableCredential',
displayName: 'Single Resolvable Credential',
properties: [
{ name: 'token', type: 'string', default: '', resolvableField: true },
] as INodeProperties[],
};
const result = extractSharedFields(credentialType);
expect(result).toEqual([]);
});
});
describe('real-world credential examples', () => {
it('should correctly handle HttpBasicAuth credentials', () => {
const credentialType: ICredentialType = {
name: 'httpBasicAuth',
displayName: 'HTTP Basic Auth',
properties: [
{ name: 'user', type: 'string', default: '', resolvableField: true },
{ name: 'password', type: 'string', default: '', resolvableField: true },
] as INodeProperties[],
};
const result = extractSharedFields(credentialType);
expect(result).toEqual([]);
});
it('should correctly handle HttpBearerAuth credentials', () => {
const credentialType: ICredentialType = {
name: 'httpBearerAuth',
displayName: 'HTTP Bearer Auth',
properties: [
{ name: 'token', type: 'string', default: '', resolvableField: true },
] as INodeProperties[],
};
const result = extractSharedFields(credentialType);
expect(result).toEqual([]);
});
it('should correctly handle OAuth2 credentials with mixed fields', () => {
const credentialType: ICredentialType = {
name: 'oAuth2Api',
displayName: 'OAuth2 API',
properties: [
{ name: 'clientId', type: 'string', default: '' },
{ name: 'clientSecret', type: 'string', default: '' },
{ name: 'authUrl', type: 'string', default: '' },
{ name: 'accessTokenUrl', type: 'string', default: '' },
{ name: 'scope', type: 'string', default: '' },
] as INodeProperties[],
};
const result = extractSharedFields(credentialType);
// All OAuth2 config fields should be static
// accessToken/refreshToken would come from resolver (not in schema)
expect(result).toEqual(['clientId', 'clientSecret', 'authUrl', 'accessTokenUrl', 'scope']);
});
it('should correctly handle ZulipApi credentials', () => {
const credentialType: ICredentialType = {
name: 'zulipApi',
displayName: 'Zulip API',
properties: [
{ name: 'url', type: 'string', default: '' },
{ name: 'email', type: 'string', default: '', resolvableField: true },
{ name: 'apiKey', type: 'string', default: '', resolvableField: true },
] as INodeProperties[],
};
const result = extractSharedFields(credentialType);
expect(result).toEqual(['url']);
});
});
describe('field order preservation', () => {
it('should preserve the order of fields in the result', () => {
const credentialType: ICredentialType = {
name: 'orderedCredential',
displayName: 'Ordered Credential',
properties: [
{ name: 'fieldA', type: 'string', default: '' },
{ name: 'fieldB', type: 'string', default: '', resolvableField: true },
{ name: 'fieldC', type: 'string', default: '' },
{ name: 'fieldD', type: 'string', default: '' },
] as INodeProperties[],
};
const result = extractSharedFields(credentialType);
expect(result).toEqual(['fieldA', 'fieldC', 'fieldD']);
});
});
});

View File

@ -15,6 +15,7 @@ import { CredentialStorageError } from '../errors/credential-storage.error';
import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import type { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import { extractSharedFields } from './shared-fields';
@Service()
export class DynamicCredentialStorageService implements IDynamicCredentialStorageProvider {
@ -67,12 +68,12 @@ export class DynamicCredentialStorageService implements IDynamicCredentialStorag
const decryptedConfig = this.cipher.decrypt(resolverEntity.config);
const resolverConfig = jsonParse<Record<string, unknown>>(decryptedConfig);
// @ts-ignore -- to be fixed in future PR
const _credential = this.loadNodesAndCredentials.getCredential(credentialStoreMetadata.type);
const credentialType = this.loadNodesAndCredentials.getCredential(
credentialStoreMetadata.type,
);
// TODO: Get shared fields based on credential type
// We will have to solve this in https://linear.app/n8n/issue/PAY-4301/solve-shared-fields-for-write-path-for-dynamic-credentials
const sharedFields: string[] = ['clientId', 'clientSecret', 'scopes'];
// Get shared fields based on credential type
const sharedFields = extractSharedFields(credentialType.type);
const mergedDynamicData = {
...(staticData ?? {}),

View File

@ -9,7 +9,10 @@ import type {
} from 'n8n-workflow';
import { jsonParse, toCredentialContext } from 'n8n-workflow';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { DynamicCredentialResolverRegistry } from './credential-resolver-registry.service';
import { extractSharedFields } from './shared-fields';
import type {
CredentialResolveMetadata,
ICredentialResolutionProvider,
@ -26,6 +29,7 @@ export class DynamicCredentialService implements ICredentialResolutionProvider {
constructor(
private readonly resolverRegistry: DynamicCredentialResolverRegistry,
private readonly resolverRepository: DynamicCredentialResolverRepository,
private readonly loadNodesAndCredentials: LoadNodesAndCredentials,
private readonly cipher: Cipher,
private readonly logger: Logger,
) {}
@ -80,6 +84,12 @@ export class DynamicCredentialService implements ICredentialResolutionProvider {
}
try {
const credentialType = this.loadNodesAndCredentials.getCredential(
credentialsResolveMetadata.type,
);
const sharedFields = extractSharedFields(credentialType.type);
// Decrypt and parse resolver configuration
const decryptedConfig = this.cipher.decrypt(resolverEntity.config);
const resolverConfig = jsonParse<Record<string, unknown>>(decryptedConfig);
@ -102,6 +112,13 @@ export class DynamicCredentialService implements ICredentialResolutionProvider {
identity: credentialContext.identity,
});
// Remove shared fields from dynamic data to avoid conflicts
for (const field of sharedFields) {
if (field in dynamicData) {
delete dynamicData[field];
}
}
// Adds and override static data with dynamically resolved data
return { ...staticData, ...dynamicData };
} catch (error) {

View File

@ -0,0 +1,19 @@
/**
* By default all fields that are defined in the schema are considered static.
* If a field is not defined in the schema, it is considered dynamic.
* If a field is marked as dynamic in the schema it is considered dynamic.
*/
import type { ICredentialType } from 'n8n-workflow';
export function extractSharedFields(credentialType: ICredentialType): string[] {
const sharedFields: string[] = [];
for (const property of credentialType.properties) {
// If a field is not marked as resolvable, consider it static
if (property.resolvableField !== true) {
sharedFields.push(property.name);
}
}
return sharedFields;
}

View File

@ -17,6 +17,7 @@ export class HttpBasicAuth implements ICredentialType {
name: 'user',
type: 'string',
default: '',
resolvableField: true,
},
{
displayName: 'Password',
@ -26,6 +27,7 @@ export class HttpBasicAuth implements ICredentialType {
password: true,
},
default: '',
resolvableField: true,
},
];
}

View File

@ -22,6 +22,7 @@ export class HttpBearerAuth implements ICredentialType {
password: true,
},
default: '',
resolvableField: true,
},
{
displayName:

View File

@ -17,6 +17,7 @@ export class HttpDigestAuth implements ICredentialType {
name: 'user',
type: 'string',
default: '',
resolvableField: true,
},
{
displayName: 'Password',
@ -26,6 +27,7 @@ export class HttpDigestAuth implements ICredentialType {
password: true,
},
default: '',
resolvableField: true,
},
];
}

View File

@ -21,6 +21,7 @@ export class ZulipApi implements ICredentialType {
type: 'string',
placeholder: 'name@email.com',
default: '',
resolvableField: true,
},
{
displayName: 'API Key',
@ -28,6 +29,7 @@ export class ZulipApi implements ICredentialType {
type: 'string',
typeOptions: { password: true },
default: '',
resolvableField: true,
},
];
}

View File

@ -1603,6 +1603,8 @@ export interface INodeProperties {
ignoreValidationDuringExecution?: boolean;
// for type: options | multiOptions skip validation of the value (e.g. when value is not in the list and specified via expression)
allowArbitraryValues?: boolean;
// This field indicates that the field is a resolvable field that should be resolved in dynamic credential setup
resolvableField?: boolean;
}
export interface INodePropertyModeTypeOptions {