mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
feat(core): Introduce credentialBindings for package import (#32228)
This commit is contained in:
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
+72
@@ -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'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+44
@@ -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;
|
||||
}
|
||||
|
||||
+10
-7
@@ -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,
|
||||
|
||||
+10
-2
@@ -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:
|
||||
|
||||
+4
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user