feat(core): Introduce credentialBindings for package import (#32228)

This commit is contained in:
Sandra Zollner
2026-06-15 08:43:57 +00:00
committed by GitHub
parent a255615086
commit f161bf0786
18 changed files with 368 additions and 30 deletions
@@ -8,6 +8,7 @@ describe('ImportPackageRequestDto', () => {
expect(result.data).toEqual({
credentialMatchingMode: 'id-only',
credentialMissingMode: 'must-preexist',
credentialBindings: {},
workflowConflictPolicy: 'fail',
workflowPublishingPolicy: 'preserve-published-state',
workflowIdPolicy: 'new',
@@ -26,6 +27,7 @@ describe('ImportPackageRequestDto', () => {
expect(result.data).toEqual({
credentialMatchingMode: 'id-only',
credentialMissingMode: 'must-preexist',
credentialBindings: {},
workflowConflictPolicy: 'fail',
workflowPublishingPolicy: 'preserve-published-state',
workflowIdPolicy: 'new',
@@ -46,6 +48,7 @@ describe('ImportPackageRequestDto', () => {
folderId: 'fld-1',
credentialMatchingMode: 'id-only',
credentialMissingMode: 'must-preexist',
credentialBindings: {},
workflowConflictPolicy: 'new-version',
workflowPublishingPolicy: 'preserve-published-state',
workflowIdPolicy: 'new',
@@ -65,6 +68,7 @@ describe('ImportPackageRequestDto', () => {
projectId: 'proj-1',
credentialMatchingMode: 'id-only',
credentialMissingMode: 'must-preexist',
credentialBindings: {},
workflowConflictPolicy: 'skip',
workflowPublishingPolicy: 'preserve-published-state',
workflowIdPolicy: 'new',
@@ -90,6 +94,33 @@ describe('ImportPackageRequestDto', () => {
).toBe(false);
});
it('parses credentialBindings from a JSON object string', () => {
const result = ImportPackageRequestDto.safeParse({
credentialBindings: '{"source-cred":"target-cred"}',
workflowConflictPolicy: 'fail',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.credentialBindings).toEqual({ 'source-cred': 'target-cred' });
}
});
it.each([
{ name: 'invalid JSON', credentialBindings: 'not json' },
{ name: 'array JSON', credentialBindings: '[]' },
{ name: 'non-string target id', credentialBindings: '{"source":1}' },
{ name: 'empty source id', credentialBindings: '{"":"target"}' },
{ name: 'empty target id', credentialBindings: '{"source":""}' },
])('rejects credentialBindings with $name', ({ credentialBindings }) => {
expect(
ImportPackageRequestDto.safeParse({
credentialBindings,
workflowConflictPolicy: 'fail',
}).success,
).toBe(false);
});
it('rejects omitted workflowConflictPolicy', () => {
expect(ImportPackageRequestDto.safeParse({}).success).toBe(false);
});
@@ -8,6 +8,7 @@ export const IMPORT_PACKAGE_REQUEST_FORM_FIELDS = [
'folderId',
'credentialMatchingMode',
'credentialMissingMode',
'credentialBindings',
'workflowConflictPolicy',
'workflowPublishingPolicy',
'workflowIdPolicy',
@@ -23,11 +24,50 @@ const optionalFormId = z
return trimmed.length > 0 ? trimmed : undefined;
});
function isStringRecord(value: unknown): value is Record<string, string> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
return Object.entries(value).every(
([sourceId, targetId]) =>
sourceId.length > 0 && typeof targetId === 'string' && targetId.length > 0,
);
}
const credentialBindingsSchema = z
.string()
.optional()
.transform((value, ctx) => {
if (value === undefined || value.trim().length === 0) return {};
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'credentialBindings must be a JSON object mapping source credential ids to target credential ids',
});
return z.NEVER;
}
if (!isStringRecord(parsed)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'credentialBindings must be a JSON object mapping source credential ids to target credential ids',
});
return z.NEVER;
}
return parsed;
});
export class ImportPackageRequestDto extends Z.class({
projectId: optionalFormId,
folderId: optionalFormId,
credentialMatchingMode: z.enum(['id-only']).optional().default('id-only'),
credentialMissingMode: z.enum(['must-preexist']).optional().default('must-preexist'),
credentialBindings: credentialBindingsSchema,
workflowConflictPolicy: z.enum(['new-version', 'fail', 'skip']),
workflowPublishingPolicy: z
.enum(['preserve-published-state', 'match-source', 'publish-all', 'unpublish-all'])
@@ -39,6 +39,7 @@ import { FORMAT_VERSION } from '../spec/constants';
import {
buildImportPackageBuffer,
githubCredentialPayload,
PACKAGE_GITHUB_CREDENTIAL_TYPE,
serializedWorkflow,
serializedWorkflowWithCredential,
} from './fixtures/package-fixtures';
@@ -49,6 +50,7 @@ type ImportPackageParams = Omit<
ImportPackageRequest,
| 'credentialMatchingMode'
| 'credentialMissingMode'
| 'credentialBindings'
| 'workflowConflictPolicy'
| 'workflowPublishingPolicy'
| 'workflowIdPolicy'
@@ -58,6 +60,7 @@ type ImportPackageParams = Omit<
ImportPackageRequest,
| 'credentialMatchingMode'
| 'credentialMissingMode'
| 'credentialBindings'
| 'workflowConflictPolicy'
| 'workflowPublishingPolicy'
| 'workflowIdPolicy'
@@ -1157,6 +1160,46 @@ describe('ImportPipeline credential resolution', () => {
expect(await Container.get(WorkflowRepository).count()).toBe(2);
});
it('should succeed when importing workflows with explicit credential bindings', async () => {
const owner = await createOwner();
const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
owner.id,
);
const targetCredential = await saveOwnedCredential(
githubCredentialPayload({ name: 'Target GitHub' }),
{
project: personalProject,
},
);
const result = await importPackage({
user: owner,
credentialBindings: new Map([['source-credential', targetCredential.id]]),
packageBuffer: await buildImportPackageBuffer(
[
serializedWorkflowWithCredential({
id: 'wf-bound-cred',
name: 'With bound cred',
credentialId: 'source-credential',
credentialName: 'Source GitHub',
}),
],
{ sourceId },
),
});
expect(result.bindings.credentials).toEqual({
'source-credential': targetCredential.id,
});
const workflow = await Container.get(WorkflowRepository).findOneOrFail({
where: { name: 'With bound cred' },
});
expect(workflow.nodes[0].credentials?.[PACKAGE_GITHUB_CREDENTIAL_TYPE]?.id).toBe(
targetCredential.id,
);
});
it('reports mixed unknown_type and not_found failures in one response', async () => {
const owner = await createOwner();
@@ -28,7 +28,7 @@ describe('createN8nPackageMulterOptions', () => {
fileSize: 8 * 1024 * 1024,
files: 1,
parts: IMPORT_PACKAGE_REQUEST_FORM_FIELDS.length + 2,
fieldSize: 128,
fieldSize: 64 * 1024,
});
});
@@ -99,11 +99,16 @@ describe('resolveImportPackageUpload', () => {
expect(file.buffer).toBe(packageBuffer);
});
it('accepts routing and workflow update policy fields in the body', () => {
it('accepts routing, credential binding, and workflow policy fields in the body', () => {
expect(() =>
resolveImportPackageUpload({
files: [makeFile('package', packageBuffer)],
body: { folderId: 'fld-1', workflowConflictPolicy: 'skip', package: '' },
body: {
folderId: 'fld-1',
credentialBindings: '{"source":"target"}',
workflowConflictPolicy: 'skip',
package: '',
},
}),
).not.toThrow();
});
@@ -75,6 +75,7 @@ export class ImportPipeline {
requirements: manifest.requirements?.credentials,
matchingMode: request.credentialMatchingMode,
missingMode: request.credentialMissingMode,
credentialBindings: request.credentialBindings,
targetProject: project,
user: request.user,
};
@@ -139,10 +140,11 @@ export class ImportPipeline {
const credentialFailures: BlockingIssue[] = this.credentialImporter
.blockingFailures(credentialResolution, credentialRequest)
.map(({ kind, sourceId, usedByWorkflows }) => ({
.map(({ kind, sourceId, targetId, usedByWorkflows }) => ({
type: 'credential-unresolved',
kind,
sourceId,
...(targetId ? { targetId } : {}),
usedByWorkflows,
}));
@@ -29,10 +29,12 @@ describe('CredentialImporter', () => {
const bindingRequest = (
requirements: CredentialBindingRequest['requirements'],
credentialBindings?: CredentialBindingRequest['credentialBindings'],
): CredentialBindingRequest => ({
requirements,
matchingMode: 'id-only',
missingMode: 'must-preexist',
credentialBindings,
targetProject,
user,
});
@@ -91,4 +93,74 @@ describe('CredentialImporter', () => {
{ kind: 'not_found', sourceId: 'cred-missing', usedByWorkflows: ['wf-1'] },
]);
});
it('uses explicit bindings before id-only matching', async () => {
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([
usable('target-cred'),
]);
const request = bindingRequest(
[
{
id: 'source-cred',
name: 'Source GitHub',
type: 'githubApi',
usedByWorkflows: ['wf-1'],
},
],
new Map([['source-cred', 'target-cred']]),
);
const credentialResolution = await importer.plan(request);
expect(credentialResolution.successes).toEqual(new Map([['source-cred', 'target-cred']]));
expect(credentialResolution.failures).toEqual([]);
});
it('should error when the source of an explicit credential binding is not in the package', async () => {
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([
usable('target-cred'),
]);
const request = bindingRequest([], new Map([['missing-source', 'target-cred']]));
const credentialResolution = await importer.plan(request);
expect(credentialResolution.successes).toEqual(new Map());
expect(credentialResolution.failures).toEqual([
{
kind: 'source_not_found',
sourceId: 'missing-source',
targetId: 'target-cred',
usedByWorkflows: [],
},
]);
});
it('should error when the target of an explicit credential binding is inaccessible', async () => {
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([
usable('source-cred'),
]);
const request = bindingRequest(
[
{
id: 'source-cred',
name: 'Source GitHub',
type: 'githubApi',
usedByWorkflows: ['wf-1'],
},
],
new Map([['source-cred', 'target-missing']]),
);
const credentialResolution = await importer.plan(request);
expect(credentialResolution.successes).toEqual(new Map());
expect(credentialResolution.failures).toEqual([
{
kind: 'not_found',
sourceId: 'source-cred',
targetId: 'target-missing',
usedByWorkflows: ['wf-1'],
},
]);
});
});
@@ -185,6 +185,50 @@ describe('IdBasedCredentialMatcher', () => {
expect(result.successes).toEqual(new Map([['cred-manifest', 'cred-manifest']]));
expect(result.failures).toEqual([]);
});
it('should resolve explicit credential bindings when the target credential is accessible', async () => {
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([
usable('target-cred'),
]);
const requirement = {
id: 'source-cred',
name: 'Source GitHub',
type: 'githubApi',
usedByWorkflows: ['wf-1'],
};
const result = await matcherFactory.getMatcher('id-only').match([requirement], {
...context,
credentialBindings: new Map([['source-cred', 'target-cred']]),
});
expect(result.successes).toEqual(new Map([['source-cred', 'target-cred']]));
expect(result.failures).toEqual([]);
});
it('should error when the target of an explicit credential binding is inaccessible', async () => {
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([
usable('source-cred'),
]);
const requirement = {
id: 'source-cred',
name: 'Source GitHub',
type: 'githubApi',
usedByWorkflows: ['wf-1'],
};
const result = await matcherFactory.getMatcher('id-only').match([requirement], {
...context,
credentialBindings: new Map([['source-cred', 'target-missing']]),
});
expect(result.successes).toEqual(new Map());
expect(result.failures).toEqual([
{ ...createFailure(requirement, 'not_found'), targetId: 'target-missing' },
]);
});
});
describe('CredentialMatcherFactory', () => {
@@ -23,6 +23,7 @@ export class CredentialImporter {
.match(request.requirements, {
targetProject: request.targetProject,
user: request.user,
credentialBindings: request.credentialBindings,
});
}
@@ -14,6 +14,7 @@ import type { PackageCredentialRequirement } from '../../spec/requirements.schem
export interface CredentialMatcherContext {
targetProject: Project;
user: User;
credentialBindings?: ImportBindingMap;
}
export abstract class CredentialMatcher {
@@ -27,15 +28,21 @@ export abstract class CredentialMatcher {
requirements: PackageCredentialRequirement[] | undefined,
context: CredentialMatcherContext,
): Promise<CredentialResolution> {
const orphanFailures = orphanBindingFailures(context.credentialBindings, requirements);
const { known, unknownTypeFailures } = partitionByKnownType(requirements, this.credentialTypes);
const successes = await this.resolve(known, context);
const notFoundFailures = known
.filter((reference) => !successes.has(reference.id))
.map((reference) => createFailure(reference, 'not_found'));
.map((reference) =>
createNotFoundFailure(reference, context.credentialBindings?.get(reference.id)),
);
return { successes, failures: [...unknownTypeFailures, ...notFoundFailures] };
return {
successes,
failures: [...orphanFailures, ...unknownTypeFailures, ...notFoundFailures],
};
}
protected abstract resolve(
@@ -64,3 +71,43 @@ function partitionByKnownType(
return { known, unknownTypeFailures };
}
/**
* When the importer supplied an explicit binding, include the requested target id on
* the failure so callers can report which credential was unreachable.
*/
function createNotFoundFailure(
reference: PackageCredentialRequirement,
requestedTargetId: string | undefined,
): CredentialResolutionFailure {
const failure = createFailure(reference, 'not_found');
return requestedTargetId === undefined ? failure : { ...failure, targetId: requestedTargetId };
}
/**
* Rejects explicit bindings whose source id is not declared in the package.
* These cannot go through normal requirement matching because there is no
* package credential entry to resolve against.
*/
function orphanBindingFailures(
bindings: ImportBindingMap | undefined,
requirements: PackageCredentialRequirement[] | undefined,
): CredentialResolutionFailure[] {
if (!bindings || bindings.size === 0) return [];
const requirementIds = new Set((requirements ?? []).map((requirement) => requirement.id));
const failures: CredentialResolutionFailure[] = [];
for (const [sourceId, targetId] of bindings) {
if (!requirementIds.has(sourceId)) {
failures.push({
kind: 'source_not_found',
sourceId,
targetId,
usedByWorkflows: [],
});
}
}
return failures;
}
@@ -14,11 +14,12 @@ export interface WorkflowCredentialRequirement {
credentialType: string;
}
export type CredentialResolutionFailureKind = 'not_found' | 'unknown_type';
export type CredentialResolutionFailureKind = 'not_found' | 'unknown_type' | 'source_not_found';
export type CredentialResolutionFailure = {
kind: CredentialResolutionFailureKind;
sourceId: string;
targetId?: string;
usedByWorkflows: string[];
};
@@ -31,6 +32,7 @@ export interface CredentialBindingRequest {
requirements: PackageCredentialRequirement[] | undefined;
matchingMode: CredentialMatchingMode;
missingMode: CredentialMissingMode;
credentialBindings?: ImportBindingMap;
targetProject: Project;
user: User;
}
@@ -25,26 +25,29 @@ export class IdBasedCredentialMatcher extends CredentialMatcher {
known: PackageCredentialRequirement[],
context: CredentialMatcherContext,
): Promise<ImportBindingMap> {
const bindings = context.credentialBindings;
const targetIds = known.map((reference) => bindings?.get(reference.id) ?? reference.id);
const resolvableIds = await this.findResolvableCredentialIds(
known.map((reference) => reference.id),
targetIds,
context.targetProject,
context.user,
);
return new Map(
known
.filter((reference) => resolvableIds.has(reference.id))
// id-only matching: the target credential id is the source id.
.map((reference) => [reference.id, reference.id]),
known.flatMap((reference) => {
const targetId = bindings?.get(reference.id) ?? reference.id;
if (!resolvableIds.has(targetId)) return [];
return [[reference.id, targetId] as const];
}),
);
}
private async findResolvableCredentialIds(
sourceIds: string[],
candidateIds: string[],
targetProject: Project,
user: User,
): Promise<Set<string>> {
const uniqueIds = new Set(sourceIds);
const uniqueIds = new Set(candidateIds);
if (uniqueIds.size === 0) {
return new Set();
}
@@ -1,4 +1,4 @@
import type { WorkflowEntity } from '@n8n/db';
import { WorkflowEntity } from '@n8n/db';
import { Service } from '@n8n/di';
import { WorkflowCreationService } from '@/workflows/workflow-creation.service';
@@ -125,7 +125,7 @@ export class WorkflowImporter {
const outcomes: WorkflowImportOutcome[] = [];
for (const item of plan.items) {
const outcome = await this.applyItem(item, context);
const outcome = await this.applyItem(item, context, bindings);
outcomes.push(outcome);
// Works for every status: created/updated/skipped all resolve to a real target id.
workflowBindings.set(outcome.sourceWorkflowId, outcome.workflow.id);
@@ -137,6 +137,7 @@ export class WorkflowImporter {
private async applyItem(
item: WorkflowPlanItem,
context: WorkflowImportContext,
bindings: PackageImportBindings,
): Promise<WorkflowImportOutcome> {
if (item.action === 'skip') {
return {
@@ -146,7 +147,7 @@ export class WorkflowImporter {
};
}
const savedWorkflow = await this.persistWorkflow(context, item);
const savedWorkflow = await this.persistWorkflow(context, item, bindings.credentials);
const workflow = await this.workflowPublisher.apply(
context.user,
item,
@@ -164,10 +165,11 @@ export class WorkflowImporter {
private async persistWorkflow(
context: WorkflowImportContext,
item: PersistedWorkflowPlanItem,
credentialBindings: PackageImportBindings['credentials'],
): Promise<WorkflowEntity> {
if (item.action === 'create') {
item.entity.id = item.decidedId;
return await this.workflowCreationService.createWorkflow(context.user, item.entity, {
const entity = prepareEntityForPersist(item.entity, credentialBindings, item.decidedId);
return await this.workflowCreationService.createWorkflow(context.user, entity, {
projectId: context.projectId,
parentFolderId: context.folderId ?? undefined,
publicApi: true,
@@ -176,18 +178,48 @@ export class WorkflowImporter {
});
}
const workflow = await this.workflowService.update(
context.user,
item.entity,
item.existing.id,
{ publicApi: true, source: 'import' },
);
const entity = prepareEntityForPersist(item.entity, credentialBindings);
const workflow = await this.workflowService.update(context.user, entity, item.existing.id, {
publicApi: true,
source: 'import',
});
// update() doesn't re-hydrate parentFolder; carry over the existing folder for the result.
workflow.parentFolder = item.existing.parentFolder;
return workflow;
}
}
/** Clones package content for persistence without mutating the import plan. */
function prepareEntityForPersist(
source: WorkflowEntity,
credentialBindings: PackageImportBindings['credentials'],
decidedId?: string,
): WorkflowEntity {
const entity = Object.assign(new WorkflowEntity(), source, {
nodes: structuredClone(source.nodes),
...(decidedId !== undefined ? { id: decidedId } : {}),
});
applyCredentialBindingsInPlace(entity, credentialBindings);
return entity;
}
/** Mutates node credential ids on `entity` using the resolved import binding map. */
function applyCredentialBindingsInPlace(
entity: WorkflowEntity,
credentialBindings: PackageImportBindings['credentials'],
): void {
for (const node of entity.nodes) {
for (const details of Object.values(node.credentials ?? {})) {
if (!details.id) continue;
const targetId = credentialBindings.get(details.id);
if (targetId) {
details.id = targetId;
}
}
}
}
function toPlanItem(
prepared: PreparedWorkflow,
existing: WorkflowEntity | null,
@@ -47,6 +47,7 @@ export type ImportPackageRequest = {
export type ImportCredentialProperties = {
credentialMatchingMode: CredentialMatchingMode;
credentialMissingMode: CredentialMissingMode;
credentialBindings?: ImportBindingMap;
};
export type ImportWorkflowProperties = {
@@ -87,8 +88,9 @@ export type BlockingIssue =
}
| {
type: 'credential-unresolved';
kind: 'not_found' | 'unknown_type';
kind: 'not_found' | 'unknown_type' | 'source_not_found';
sourceId: string;
targetId?: string;
usedByWorkflows: string[];
};
@@ -14,8 +14,8 @@ const IMPORT_PACKAGE_BODY_FIELD_SET = new Set<string>([
'package',
]);
/** Max length for optional routing ids in multipart form fields. */
const IMPORT_PACKAGE_FIELD_SIZE_BYTES = 128;
/** Max length for multipart text fields, including JSON credential bindings. */
const IMPORT_PACKAGE_FIELD_SIZE_BYTES = 64 * 1024;
/**
* `package` file + every documented form field, plus one because busboy rejects
@@ -79,6 +79,7 @@ const n8nPackagesHandlers: N8nPackagesHandlers = {
folderId: payload.data.folderId,
credentialMatchingMode: payload.data.credentialMatchingMode,
credentialMissingMode: payload.data.credentialMissingMode,
credentialBindings: new Map(Object.entries(payload.data.credentialBindings)),
workflowConflictPolicy: payload.data.workflowConflictPolicy,
workflowPublishingPolicy: payload.data.workflowPublishingPolicy,
workflowIdPolicy: payload.data.workflowIdPolicy,
@@ -11,8 +11,8 @@ post:
Imports a gzip-compressed tar package (`.n8np`) into the target project. Send the
archive as the multipart field `package`. Optional routing uses form fields
`projectId` and `folderId` (omit or send empty for defaults). The optional
`credentialMatchingMode`, `credentialMissingMode`, `workflowIdPolicy`, and
`workflowPublishingPolicy` fields take their defaults when omitted. The required
`credentialMatchingMode`, `credentialMissingMode`, `credentialBindings`,
`workflowIdPolicy`, and `workflowPublishingPolicy` fields take their defaults when omitted. The required
`workflowConflictPolicy` field controls what happens when a package workflow
matches an existing workflow by source id in the target project. Maximum upload
size is `N8N_ENDPOINTS_PAYLOAD_SIZE_MAX` MB (default 16).
@@ -67,6 +67,14 @@ post:
What to do when a credential reference cannot be resolved.
`must-preexist` requires every referenced credential to already
exist and be accessible on the target instance.
credentialBindings:
type: string
default: '{}'
description: >
Optional JSON object mapping credential ids from the package
manifest to credential ids on the target instance. These explicit
bindings are validated and applied before `credentialMatchingMode`
resolution runs.
workflowConflictPolicy:
type: string
enum:
@@ -70,8 +70,12 @@ oneOf:
enum:
- not_found
- unknown_type
- source_not_found
sourceId:
type: string
targetId:
type: string
description: Target credential id for an explicit credential binding.
usedByWorkflows:
type: array
items:
@@ -166,6 +166,7 @@ describe('POST /n8n-packages/import', () => {
.field('folderId', '')
.field('credentialMatchingMode', 'id-only')
.field('credentialMissingMode', 'must-preexist')
.field('credentialBindings', '{}')
.field('workflowConflictPolicy', 'fail')
.field('workflowIdPolicy', 'new')
.attach('package', tarBuffer, 'import.n8np');