feat(core): Add resolvable fields to credential entity (#22712)

This commit is contained in:
Guillaume Jacquart
2025-12-12 08:06:04 +01:00
committed by GitHub
parent 9a608dc00c
commit a19fefece5
8 changed files with 216 additions and 0 deletions
@@ -42,6 +42,25 @@ export class CredentialsEntity extends WithTimestampsAndStringId implements ICre
@Column({ default: false })
isGlobal: boolean;
/**
* Whether the credential can be dynamically resolved by a resolver.
*/
@Column({ default: false })
isResolvable: boolean;
/**
* Whether the credential resolver should allow falling back to static credentials
* if dynamic resolution fails.
*/
@Column({ default: false })
resolvableAllowFallback: boolean;
/**
* ID of the dynamic credential resolver associated with this credential.
*/
@Column({ type: 'varchar', nullable: true })
resolverId: string | null;
toJSON() {
const { shared, ...rest } = this;
return rest;
@@ -0,0 +1,38 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const credentialsTableName = 'credentials_entity';
const resolverTableName = 'dynamic_credential_resolver';
const FOREIGN_KEY_NAME = 'credentials_entity_resolverId_foreign';
export class AddResolvableFieldsToCredentials1765459448000 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, addForeignKey, column } }: MigrationContext) {
await addColumns(credentialsTableName, [
column('isResolvable').bool.notNull.default(false),
column('resolvableAllowFallback').bool.notNull.default(false),
column('resolverId').varchar(16),
]);
await addForeignKey(
credentialsTableName,
'resolverId',
[resolverTableName, 'id'],
FOREIGN_KEY_NAME,
'SET NULL',
);
}
async down({ schemaBuilder: { dropColumns, dropForeignKey } }: MigrationContext) {
await dropForeignKey(
credentialsTableName,
'resolverId',
[resolverTableName, 'id'],
FOREIGN_KEY_NAME,
);
await dropColumns(credentialsTableName, [
'isResolvable',
'resolvableAllowFallback',
'resolverId',
]);
}
}
@@ -124,6 +124,7 @@ import { AddCreatorIdToProjectTable1764276827837 } from '../common/1764276827837
import { CreateDynamicCredentialResolverTable1764682447000 } from '../common/1764682447000-CreateCredentialResolverTable';
import { AddDynamicCredentialEntryTable1764689388394 } from '../common/1764689388394-AddDynamicCredentialEntryTable';
import { BackfillMissingWorkflowHistoryRecords1765448186933 } from '../common/1765448186933-BackfillMissingWorkflowHistoryRecords';
import { AddResolvableFieldsToCredentials1765459448000 } from '../common/1765459448000-AddResolvableFieldsToCredentials';
import type { Migration } from '../migration-types';
export const mysqlMigrations: Migration[] = [
@@ -253,4 +254,5 @@ export const mysqlMigrations: Migration[] = [
CreateDynamicCredentialResolverTable1764682447000,
AddDynamicCredentialEntryTable1764689388394,
BackfillMissingWorkflowHistoryRecords1765448186933,
AddResolvableFieldsToCredentials1765459448000,
];
@@ -124,6 +124,7 @@ import { AddCreatorIdToProjectTable1764276827837 } from '../common/1764276827837
import { CreateDynamicCredentialResolverTable1764682447000 } from '../common/1764682447000-CreateCredentialResolverTable';
import { AddDynamicCredentialEntryTable1764689388394 } from '../common/1764689388394-AddDynamicCredentialEntryTable';
import { BackfillMissingWorkflowHistoryRecords1765448186933 } from '../common/1765448186933-BackfillMissingWorkflowHistoryRecords';
import { AddResolvableFieldsToCredentials1765459448000 } from '../common/1765459448000-AddResolvableFieldsToCredentials';
import type { Migration } from '../migration-types';
export const postgresMigrations: Migration[] = [
@@ -253,4 +254,5 @@ export const postgresMigrations: Migration[] = [
CreateDynamicCredentialResolverTable1764682447000,
AddDynamicCredentialEntryTable1764689388394,
BackfillMissingWorkflowHistoryRecords1765448186933,
AddResolvableFieldsToCredentials1765459448000,
];
@@ -0,0 +1,33 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const credentialsTableName = 'credentials_entity';
const resolverTableName = 'dynamic_credential_resolver';
const FOREIGN_KEY_NAME = 'credentials_entity_resolverId_foreign';
export class AddResolvableFieldsToCredentials1764689448000 implements ReversibleMigration {
transaction = false as const;
async up({ schemaBuilder: { addColumns, addForeignKey, column } }: MigrationContext) {
await addColumns(credentialsTableName, [
column('isResolvable').bool.notNull.default(false),
column('resolvableAllowFallback').bool.notNull.default(false),
column('resolverId').varchar(16),
]);
await addForeignKey(
credentialsTableName,
'resolverId',
[resolverTableName, 'id'],
FOREIGN_KEY_NAME,
'SET NULL',
);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns(credentialsTableName, [
'isResolvable',
'resolvableAllowFallback',
'resolverId',
]);
}
}
@@ -47,6 +47,7 @@ import { AddProjectIdToVariableTable1758794506893 } from './1758794506893-AddPro
import { AddWorkflowVersionColumn1761047826451 } from './1761047826451-AddWorkflowVersionColumn';
import { ChangeDependencyInfoToJson1761655473000 } from './1761655473000-ChangeDependencyInfoToJson';
import { AddCreatorIdToProjectTable1764276827837 } from './1764276827837-AddCreatorIdToProjectTable';
import { AddResolvableFieldsToCredentials1764689448000 } from './1764689448000-AddResolvableFieldsToCredentials';
import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames';
import { UpdateWorkflowCredentials1630330987096 } from '../common/1630330987096-UpdateWorkflowCredentials';
import { AddNodeIds1658930531669 } from '../common/1658930531669-AddNodeIds';
@@ -245,6 +246,7 @@ const sqliteMigrations: Migration[] = [
CreateDynamicCredentialResolverTable1764682447000,
AddDynamicCredentialEntryTable1764689388394,
BackfillMissingWorkflowHistoryRecords1765448186933,
AddResolvableFieldsToCredentials1764689448000,
];
export { sqliteMigrations };
@@ -25,6 +25,9 @@ const mockCredentialsService = (
shared: [] as SharedCredentials[],
isManaged: false,
isGlobal: false,
isResolvable: false,
resolverId: null,
resolvableAllowFallback: false,
id,
// Methods present on entities via WithTimestampsAndStringId mixin
generateId() {},
@@ -0,0 +1,117 @@
import { testDb, testModules } from '@n8n/backend-test-utils';
import { CredentialsRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import type { DynamicCredentialResolver } from '@/modules/dynamic-credentials.ee/database/entities/credential-resolver';
import { DynamicCredentialResolverRepository } from '@/modules/dynamic-credentials.ee/database/repositories/credential-resolver.repository';
describe('DynamicCredentialResolverRepository', () => {
let resolverRepository: DynamicCredentialResolverRepository;
let credentialsRepository: CredentialsRepository;
let previousEnvVar: string | undefined;
beforeAll(async () => {
previousEnvVar = process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS;
process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS = 'true';
await testModules.loadModules(['dynamic-credentials']);
await testDb.init();
resolverRepository = Container.get(DynamicCredentialResolverRepository);
credentialsRepository = Container.get(CredentialsRepository);
});
afterEach(async () => {
await testDb.truncate(['CredentialsEntity', 'DynamicCredentialResolver']);
});
afterAll(async () => {
process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS = previousEnvVar;
await testDb.terminate();
});
it('should create and find a resolver', async () => {
const resolver = resolverRepository.create({
name: 'Test Resolver',
type: 'oauth2',
config: JSON.stringify({ clientId: 'test' }),
});
const saved = await resolverRepository.save(resolver);
const found = await resolverRepository.findOne({ where: { id: saved.id } });
expect(found).toMatchObject({
id: saved.id,
name: 'Test Resolver',
type: 'oauth2',
});
});
describe('relationship with CredentialsEntity', () => {
let resolver: DynamicCredentialResolver;
beforeEach(async () => {
resolver = await resolverRepository.save(
resolverRepository.create({
name: 'Test Resolver',
type: 'oauth2',
config: JSON.stringify({}),
}),
);
});
it('should link credentials to resolver and query them', async () => {
await credentialsRepository.save([
credentialsRepository.create({
name: 'Cred 1',
type: 'oauth2',
data: '',
isResolvable: true,
resolverId: resolver.id,
}),
credentialsRepository.create({
name: 'Cred 2',
type: 'oauth2',
data: '',
isResolvable: true,
resolvableAllowFallback: true,
resolverId: resolver.id,
}),
]);
const linked = await credentialsRepository.find({ where: { resolverId: resolver.id } });
expect(linked).toHaveLength(2);
expect(linked[0].resolverId).toBe(resolver.id);
expect(linked[1].resolvableAllowFallback).toBe(true);
});
it('should handle nullable resolverId', async () => {
const credential = await credentialsRepository.save(
credentialsRepository.create({
name: 'Standalone',
type: 'apiKey',
data: '',
isResolvable: false,
}),
);
expect(credential.resolverId).toBeNull();
});
it('should set resolverId to null on resolver deletion (CASCADE SET NULL)', async () => {
const credential = await credentialsRepository.save(
credentialsRepository.create({
name: 'Linked',
type: 'oauth2',
data: '',
isResolvable: true,
resolverId: resolver.id,
}),
);
await resolverRepository.delete(resolver.id);
const orphaned = await credentialsRepository.findOneBy({ id: credential.id });
expect(orphaned?.resolverId).toBeNull();
});
});
});