mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
chore(core): Allow marking fields in credentials as resolvable (#23074)
This commit is contained in:
parent
90c2d2ea70
commit
f9a592b966
|
|
@ -446,6 +446,7 @@ describe('CredentialsHelper', () => {
|
|||
id: mockCredentialEntity.id,
|
||||
name: mockCredentialEntity.name,
|
||||
isResolvable: false,
|
||||
type: 'testApi',
|
||||
},
|
||||
{ apiKey: 'static-key' },
|
||||
mockAdditionalData.executionContext,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 ?? {}),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export class HttpBearerAuth implements ICredentialType {
|
|||
password: true,
|
||||
},
|
||||
default: '',
|
||||
resolvableField: true,
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user