feat(core): Include credential expression values in package exports (#36754)

This commit is contained in:
James Gee
2026-08-21 08:13:23 +00:00
committed by GitHub
parent 5153b2937c
commit c5beffa018
27 changed files with 628 additions and 8 deletions
@@ -207,4 +207,33 @@ describe('ExportPackageRequestDto', () => {
expect(result.success).toBe(false);
});
});
describe('credentialExportPolicy', () => {
it.each(['expression-values-only', 'no-values'])('accepts %s', (credentialExportPolicy) => {
const result = ExportPackageRequestDto.safeParse({
workflowIds: ['wf-1'],
credentialExportPolicy,
});
expect(result.success).toBe(true);
});
it('defaults to expression-values-only', () => {
const result = ExportPackageRequestDto.safeParse({ workflowIds: ['wf-1'] });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.credentialExportPolicy).toBe('expression-values-only');
}
});
it('rejects unknown values', () => {
const result = ExportPackageRequestDto.safeParse({
workflowIds: ['wf-1'],
credentialExportPolicy: 'all-values',
});
expect(result.success).toBe(false);
});
});
});
@@ -16,4 +16,8 @@ export class ExportPackageRequestDto extends Z.class({
.enum(['published-strict', 'prefer-published', 'ignore-unpublished', 'latest'])
.optional()
.default('latest'),
credentialExportPolicy: z
.enum(['expression-values-only', 'no-values'])
.optional()
.default('expression-values-only'),
}) {}
@@ -30,6 +30,7 @@ n8n-cli package export -w abc --include-tags=false -o export.n8np
| `--include-tags` | `true` (default) or `false`. Whether tags assigned to the exported workflows are bundled into the package. When `false`, no tag data is included in the package. |
| `--missing-workflow-dependency-policy` | Policy for missing static sub-workflow dependencies: `fail` aborts when any dependency is missing, `include-in-package` automatically adds missing static sub-workflows, and `reference-only` keeps them out of the package, listing them in the package requirements as workflows expected to already exist on the target. |
| `--workflow-version-policy` | Which version of each workflow travels in the package: `latest` (default) exports the latest version whether or not it is published, `published-strict` exports the published version and aborts when any workflow has none, `prefer-published` falls back to the latest version where there is no published one, and `ignore-unpublished` leaves unpublished workflows out of the package entirely. |
| `--credential-export-policy` | Whether expression values from credential data are bundled into the package: `expression-values-only` (default on the instance) includes credential fields whose value is an n8n expression (for example `={{ $secrets.apiKey }}`); `no-values` keeps credential data out of the package, so each credential file carries only its id, name and type. Literal values never travel either way. |
Provide at least one `--workflow-id`, `--folder-id`, or `--project-id`. Requires
the API key to hold `workflow:export` when exporting workflows or folders, or
@@ -109,6 +109,23 @@ describe('N8nClient packages', () => {
);
});
it('includes the credential export policy when provided', async () => {
fetchMock.mockResolvedValue(binaryResponse(200, new Uint8Array([1])));
await client.exportPackage({
workflowIds: ['a'],
credentialExportPolicy: 'no-values',
});
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.body).toBe(
JSON.stringify({
workflowIds: ['a'],
credentialExportPolicy: 'no-values',
}),
);
});
it('omits an empty collection from the body', async () => {
fetchMock.mockResolvedValue(binaryResponse(200, new Uint8Array([1])));
@@ -18,6 +18,7 @@ interface ExportFlags {
includeTags?: string;
missingWorkflowDependencyPolicy?: string;
workflowVersionPolicy?: string;
credentialExportPolicy?: string;
}
/** The command methods we stub to isolate behaviour from oclif/networking. */
@@ -172,6 +173,43 @@ describe('package export command', () => {
});
});
it('forwards a non-default credential export policy for workflows and folders', async () => {
const { command, exportPackage } = stubCommand({
workflowId: ['wf-1'],
output: '/tmp/team.n8np',
credentialExportPolicy: 'no-values',
});
await command.run();
expect(exportPackage).toHaveBeenCalledWith({
workflowIds: ['wf-1'],
folderIds: [],
includeVariableValues: true,
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
credentialExportPolicy: 'no-values',
});
});
it('forwards a non-default credential export policy for projects', async () => {
const { command, exportPackage } = stubCommand({
projectId: ['proj-1'],
output: '/tmp/projects.n8np',
credentialExportPolicy: 'no-values',
});
await command.run();
expect(exportPackage).toHaveBeenCalledWith({
projectIds: ['proj-1'],
includeVariableValues: true,
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
credentialExportPolicy: 'no-values',
});
});
it('forwards project ids and writes the archive', async () => {
const { command, exportPackage } = stubCommand({
projectId: ['proj-1', 'proj-2'],
+4
View File
@@ -39,6 +39,7 @@ export interface ExportPackageFields {
includeTags?: boolean;
missingWorkflowDependencyPolicy?: string;
workflowVersionPolicy?: string;
credentialExportPolicy?: string;
}
/** True per-entity counts of what ended up in an exported package. */
@@ -506,6 +507,7 @@ export class N8nClient {
includeTags?: boolean;
missingWorkflowDependencyPolicy?: string;
workflowVersionPolicy?: string;
credentialExportPolicy?: string;
} = {};
if (fields.workflowIds?.length) body.workflowIds = fields.workflowIds;
if (fields.folderIds?.length) body.folderIds = fields.folderIds;
@@ -516,6 +518,8 @@ export class N8nClient {
if (fields.missingWorkflowDependencyPolicy)
body.missingWorkflowDependencyPolicy = fields.missingWorkflowDependencyPolicy;
if (fields.workflowVersionPolicy) body.workflowVersionPolicy = fields.workflowVersionPolicy;
// Only sent when set, so an older server without this field in its schema never sees it.
if (fields.credentialExportPolicy) body.credentialExportPolicy = fields.credentialExportPolicy;
let counts: ExportPackageCounts | undefined;
const archive = await this.request<Buffer>('POST', '/n8n-packages/export', {
@@ -87,6 +87,13 @@ export default class PackageExport extends BaseCommand {
description: 'Which version of each workflow travels in the package',
aliases: ['workflow-version-policy'],
}),
// No default: the key is only sent when set, so older servers that reject unknown fields keep working.
credentialExportPolicy: Flags.string({
options: ['expression-values-only', 'no-values'],
description:
'Whether expression values from credential data are bundled into the package; literal values never travel (default on the instance: expression-values-only)',
aliases: ['credential-export-policy'],
}),
};
async run(): Promise<void> {
@@ -98,6 +105,7 @@ export default class PackageExport extends BaseCommand {
const includeTags = flags.includeTags !== 'false';
const missingWorkflowDependencyPolicy = flags.missingWorkflowDependencyPolicy;
const workflowVersionPolicy = flags.workflowVersionPolicy;
const credentialExportPolicy = flags.credentialExportPolicy;
// A package is either loose workflows/folders or whole projects, not both.
if (projectIds.length > 0 && (workflowIds.length > 0 || folderIds.length > 0)) {
@@ -119,6 +127,7 @@ export default class PackageExport extends BaseCommand {
includeTags,
missingWorkflowDependencyPolicy,
workflowVersionPolicy,
credentialExportPolicy,
}
: {
workflowIds,
@@ -127,6 +136,7 @@ export default class PackageExport extends BaseCommand {
includeTags,
missingWorkflowDependencyPolicy,
workflowVersionPolicy,
credentialExportPolicy,
},
);
} catch (error) {
@@ -186,6 +186,8 @@ describe('LogStreamingEventRelay', () => {
variables: 1,
tags: 1,
},
// Telemetry-only; must not appear in the audit payload below.
credentialExportPolicy: 'expression-values-only',
};
eventService.emit('n8n-package-exported', event);
@@ -2393,6 +2393,7 @@ describe('TelemetryEventRelay', () => {
variables: 4,
tags: 2,
},
credentialExportPolicy: 'expression-values-only',
};
eventService.emit('n8n-package-exported', event);
@@ -2405,6 +2406,7 @@ describe('TelemetryEventRelay', () => {
data_table_count: 1,
variable_count: 4,
tag_count: 2,
credential_export_policy: 'expression-values-only',
});
});
@@ -15,6 +15,7 @@ import type {
import type { ConcurrencyQueueType } from '@/concurrency/concurrency-control.service';
import type { CredentialAuthProbeOutcome } from '@/services/credentials-tester.service';
import type {
CredentialExportPolicy,
ExportPackageEventCounts,
ImportAuditCredentialIds,
ImportPackageEventCounts,
@@ -118,6 +119,7 @@ export type RelayEventMap = {
folderIds?: string[];
projectIds?: string[];
counts: ExportPackageEventCounts;
credentialExportPolicy: CredentialExportPolicy;
};
'n8n-package-export-failed': {
@@ -159,7 +159,12 @@ export class LogStreamingEventRelay extends EventRelay {
}
@Redactable()
private packageExported({ user, counts, ...rest }: RelayEventMap['n8n-package-exported']) {
private packageExported({
user,
counts,
credentialExportPolicy,
...rest
}: RelayEventMap['n8n-package-exported']) {
void this.eventBus.sendAuditEvent({
eventName: 'n8n.audit.n8n-package.export.success',
payload: { ...user, ...rest },
@@ -1103,7 +1103,11 @@ export class TelemetryEventRelay extends EventRelay {
});
}
private packageExported({ user, counts }: RelayEventMap['n8n-package-exported']) {
private packageExported({
user,
counts,
credentialExportPolicy,
}: RelayEventMap['n8n-package-exported']) {
this.telemetry.track('User exported n8n package', {
user_id: user.id,
workflow_count: counts.workflows,
@@ -1112,6 +1116,7 @@ export class TelemetryEventRelay extends EventRelay {
data_table_count: counts.dataTables,
variable_count: counts.variables,
tag_count: counts.tags,
credential_export_policy: credentialExportPolicy,
});
}
@@ -101,6 +101,70 @@ describe('workflow package export — with credentials', () => {
expect(Object.keys(parsed).sort()).toEqual(['id', 'name', 'type']);
});
it('bundles expression values from credential data under the default policy', async () => {
const owner = await createOwner();
const project = await createTeamProject('Project A', owner);
const credential = await saveCredential(
{
name: 'Expression credential',
type: 'httpHeaderAuth',
data: { name: 'X-Plaintext-Header', value: '={{ $secrets.api.key }}' },
},
{ project, role: 'credential:owner' },
);
const workflow = await buildWorkflowReferencingCredential({
name: 'Workflow with expression creds',
project,
credential,
});
const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
const { manifest, entries } = await readExport(stream);
const credentialFile = entries.find(
(e) => e.name === `${manifest.credentials![0].target}/credential.json`,
);
const parsed = jsonParse<Record<string, unknown>>(credentialFile!.content.toString());
expect(Object.keys(parsed).sort()).toEqual(['data', 'id', 'name', 'type']);
expect(parsed.data).toEqual({ value: '={{ $secrets.api.key }}' });
// The literal field value must not appear anywhere in the archive.
for (const entry of entries) {
expect(entry.content.toString()).not.toContain('X-Plaintext-Header');
}
});
it('keeps credential data out of the package under no-values', async () => {
const owner = await createOwner();
const project = await createTeamProject('Project A', owner);
const credential = await saveCredential(
{
name: 'Expression credential',
type: 'httpHeaderAuth',
data: { name: 'X-Auth', value: '={{ $secrets.api.key }}' },
},
{ project, role: 'credential:owner' },
);
const workflow = await buildWorkflowReferencingCredential({
name: 'Workflow with expression creds',
project,
credential,
});
const { stream } = await service.exportPackage({
user: owner,
workflowIds: [workflow.id],
credentialExportPolicy: 'no-values',
});
const { manifest, entries } = await readExport(stream);
const credentialFile = entries.find(
(e) => e.name === `${manifest.credentials![0].target}/credential.json`,
);
const parsed = jsonParse<Record<string, unknown>>(credentialFile!.content.toString());
expect(Object.keys(parsed).sort()).toEqual(['id', 'name', 'type']);
});
it('dedupes a credential referenced by two workflows in a single export', async () => {
const owner = await createOwner();
const project = await createTeamProject('Project A', owner);
@@ -0,0 +1,97 @@
import { selectCredentialDataForExport } from '../credential-export-policy';
// Decrypted JSON can carry nulls at runtime even though CredentialInformation excludes them.
const dataThunk = (data: Record<string, unknown>) => vi.fn().mockResolvedValue(data);
describe('selectCredentialDataForExport', () => {
describe('expression-values-only', () => {
it('keeps expression strings and drops literal siblings', async () => {
const result = await selectCredentialDataForExport(
'expression-values-only',
dataThunk({
host: 'db.internal',
port: 5432,
ssl: true,
empty: '',
missing: null,
user: '={{ $vars.dbUser }}',
password: '={{ $secrets.db.pw }}',
}),
);
expect(result).toEqual({
user: '={{ $vars.dbUser }}',
password: '={{ $secrets.db.pw }}',
});
});
it('drops a literal that starts with = but is not an expression', async () => {
const result = await selectCredentialDataForExport(
'expression-values-only',
dataThunk({ apiKey: '=foo', other: '={value}', broken: '={{}}' }),
);
expect(result).toBeUndefined();
});
it('keeps a literal matching the expression pattern — the accepted boundary of the regex', async () => {
const result = await selectCredentialDataForExport(
'expression-values-only',
dataThunk({ formula: '=SUM({{A1}})' }),
);
expect(result).toEqual({ formula: '=SUM({{A1}})' });
});
it('recurses into objects and arrays, pruning emptied containers', async () => {
const result = await selectCredentialDataForExport(
'expression-values-only',
dataThunk({
nested: { deep: { token: '={{ $secrets.t }}', literal: 'x' } },
headers: [{ name: 'Authorization', value: '={{ $secrets.h }}' }, { name: 'X-Plain' }],
allLiteral: { a: 'x', b: [1, 2] },
}),
);
expect(result).toEqual({
nested: { deep: { token: '={{ $secrets.t }}' } },
headers: [{ value: '={{ $secrets.h }}' }],
});
});
it('drops oauthTokenData at any depth', async () => {
const result = await selectCredentialDataForExport(
'expression-values-only',
dataThunk({
oauthTokenData: { access_token: '={{ $secrets.fake }}' },
nested: {
oauthTokenData: { refresh_token: '={{ $secrets.fake }}' },
ok: '={{ $vars.x }}',
},
}),
);
expect(result).toEqual({ nested: { ok: '={{ $vars.x }}' } });
});
it('returns undefined for all-literal data, so the data key is omitted', async () => {
const result = await selectCredentialDataForExport(
'expression-values-only',
dataThunk({ user: 'admin', password: 'hunter2' }),
);
expect(result).toBeUndefined();
});
});
describe('no-values', () => {
it('returns undefined without decrypting', async () => {
const thunk = dataThunk({ password: '={{ $secrets.db.pw }}' });
const result = await selectCredentialDataForExport('no-values', thunk);
expect(result).toBeUndefined();
expect(thunk).not.toHaveBeenCalled();
});
});
});
@@ -1,5 +1,6 @@
import type { CredentialsEntity, User } from '@n8n/db';
import { jsonParse } from 'n8n-workflow';
import { CredentialDataError, Credentials } from 'n8n-core';
import { jsonParse, type ICredentialDataDecryptedObject } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import type { CredentialsFinderService } from '@/credentials/credentials-finder.service';
@@ -11,12 +12,24 @@ import type { WorkflowCredentialRequirement } from '../credential.types';
const user = mock<User>({ id: 'user-1' });
async function encryptedData(data: ICredentialDataDecryptedObject): Promise<string> {
const credentials = new Credentials({ id: 'fixture', name: 'fixture' }, 'httpHeaderAuth');
await credentials.setData(data);
return credentials.getDataToSave().data!;
}
let literalOnlyCiphertext: string;
beforeAll(async () => {
literalOnlyCiphertext = await encryptedData({ apiKey: 'literal-secret' });
});
function makeCredential(overrides: Partial<CredentialsEntity> = {}): CredentialsEntity {
return {
id: 'cred-1',
name: 'My Credential',
type: 'httpHeaderAuth',
data: '',
data: literalOnlyCiphertext,
isManaged: false,
isGlobal: false,
isResolvable: false,
@@ -51,7 +64,12 @@ describe('CredentialExporter', () => {
const { exporter, finder } = makeExporter();
const writer = new CapturingWriter();
const result = await exporter.export({ user, requirements: [], writer });
const result = await exporter.export({
user,
requirements: [],
writer,
credentialExportPolicy: 'expression-values-only',
});
expect(result).toEqual({ entries: [], requirements: [] });
expect(writer.files).toEqual([]);
@@ -70,6 +88,7 @@ describe('CredentialExporter', () => {
user,
requirements: [makeRequirement()],
writer,
credentialExportPolicy: 'expression-values-only',
});
expect(finder.findCredentialForUser).toHaveBeenCalledWith('cred-1', user, [
@@ -112,6 +131,7 @@ describe('CredentialExporter', () => {
makeRequirement({ workflowId: 'wf-b' }),
],
writer,
credentialExportPolicy: 'expression-values-only',
});
expect(finder.findCredentialForUser).toHaveBeenCalledTimes(1);
@@ -143,6 +163,7 @@ describe('CredentialExporter', () => {
makeRequirement({ credentialId: 'cred-b', credentialName: 'Same Name' }),
],
writer,
credentialExportPolicy: 'expression-values-only',
});
const targets = result.entries.map((e) => e.target);
@@ -174,6 +195,7 @@ describe('CredentialExporter', () => {
}),
],
writer,
credentialExportPolicy: 'expression-values-only',
});
expect(finder.findCredentialForUser).toHaveBeenCalledWith('cred-unavailable', user, [
@@ -213,6 +235,7 @@ describe('CredentialExporter', () => {
}),
],
writer,
credentialExportPolicy: 'expression-values-only',
});
expect(result.entries).toEqual([
@@ -235,4 +258,64 @@ describe('CredentialExporter', () => {
expect(writer.files).toHaveLength(1);
});
});
describe('credential data policy', () => {
it('bundles only expression values under expression-values-only', async () => {
const { exporter, finder } = makeExporter();
finder.findCredentialForUser.mockResolvedValue(
makeCredential({
data: await encryptedData({
name: 'X-Api-Key',
value: '={{ $secrets.api.key }}',
}),
}),
);
const writer = new CapturingWriter();
await exporter.export({
user,
requirements: [makeRequirement()],
writer,
credentialExportPolicy: 'expression-values-only',
});
const parsed = jsonParse<Record<string, unknown>>(writer.files[0].content);
expect(parsed.data).toEqual({ value: '={{ $secrets.api.key }}' });
expect(writer.files[0].content).not.toContain('X-Api-Key');
});
it('writes no data and never decrypts under no-values', async () => {
const { exporter, finder } = makeExporter();
finder.findCredentialForUser.mockResolvedValue(makeCredential({ data: 'not-ciphertext' }));
const writer = new CapturingWriter();
await exporter.export({
user,
requirements: [makeRequirement()],
writer,
credentialExportPolicy: 'no-values',
});
expect(jsonParse<Record<string, unknown>>(writer.files[0].content)).toEqual({
id: 'cred-1',
name: 'My Credential',
type: 'httpHeaderAuth',
});
});
it('fails the export when credential data cannot be decrypted', async () => {
const { exporter, finder } = makeExporter();
finder.findCredentialForUser.mockResolvedValue(makeCredential({ data: 'not-ciphertext' }));
const writer = new CapturingWriter();
await expect(
exporter.export({
user,
requirements: [makeRequirement()],
writer,
credentialExportPolicy: 'expression-values-only',
}),
).rejects.toThrow(CredentialDataError);
});
});
});
@@ -36,6 +36,17 @@ describe('CredentialSerializer', () => {
});
});
it('emits bundled expression data when provided', () => {
const credential = makeCredential();
const serialized = serializer.serialize(credential, {
data: { value: '={{ $secrets.api.key }}' },
});
expect(Object.keys(serialized).sort()).toEqual(['data', 'id', 'name', 'type']);
expect(serialized.data).toEqual({ value: '={{ $secrets.api.key }}' });
});
it('does not leak encrypted data or secret-adjacent flags', () => {
const credential = makeCredential({
data: 'sensitive-encrypted-payload',
@@ -0,0 +1,62 @@
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
import { containsExpression, isObject } from '@/utils';
import type { CredentialExportPolicy } from '../../n8n-packages.types';
import type {
SerializedCredentialData,
SerializedCredentialDataValue,
} from '../../spec/serialized/credential.schema';
// oauthTokenData never travels, at any depth — tokens are secrets and the target must reconnect.
const NEVER_EXPORTED_KEYS = new Set(['oauthTokenData']);
function filterValue(value: unknown): SerializedCredentialDataValue | undefined {
if (typeof value === 'string') {
return containsExpression(value) ? value : undefined;
}
if (Array.isArray(value)) {
const kept = value
.map(filterValue)
.filter((entry): entry is SerializedCredentialDataValue => entry !== undefined);
return kept.length > 0 ? kept : undefined;
}
if (isObject(value)) {
const kept = filterObject(value);
return Object.keys(kept).length > 0 ? kept : undefined;
}
return undefined;
}
function filterObject(value: Record<string, unknown>): SerializedCredentialData {
const kept: SerializedCredentialData = {};
for (const [key, entry] of Object.entries(value)) {
if (NEVER_EXPORTED_KEYS.has(key)) continue;
const filtered = filterValue(entry);
if (filtered !== undefined) kept[key] = filtered;
}
return kept;
}
/* eslint-disable @typescript-eslint/naming-convention -- API credential export policy keys */
const SELECT_EXPORTED_DATA: Record<
CredentialExportPolicy,
(
getDecryptedData: () => Promise<ICredentialDataDecryptedObject>,
) => Promise<SerializedCredentialData | undefined>
> = {
'expression-values-only': async (getDecryptedData) => {
const filtered = filterObject(await getDecryptedData());
return Object.keys(filtered).length > 0 ? filtered : undefined;
},
'no-values': async () => undefined,
};
/* eslint-enable @typescript-eslint/naming-convention */
/** Decides what of a credential's decrypted data travels in the package; `no-values` never decrypts. */
export async function selectCredentialDataForExport(
policy: CredentialExportPolicy,
getDecryptedData: () => Promise<ICredentialDataDecryptedObject>,
): Promise<SerializedCredentialData | undefined> {
return await SELECT_EXPORTED_DATA[policy](getDecryptedData);
}
@@ -1,12 +1,15 @@
import type { CredentialsEntity, User } from '@n8n/db';
import { Service } from '@n8n/di';
import { Credentials } from 'n8n-core';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { selectCredentialDataForExport } from './credential-export-policy';
import { CredentialSerializer } from './credential.serializer';
import type { WorkflowCredentialRequirement } from './credential.types';
import type { PackageWriter } from '../../io/package-writer';
import { UniqueFilenameAllocator } from '../../io/unique-filename-allocator';
import type { CredentialExportPolicy } from '../../n8n-packages.types';
import type { ManifestEntry } from '../../spec/manifest.schema';
import type { PackageCredentialRequirement } from '../../spec/requirements.schema';
@@ -20,6 +23,7 @@ export interface CredentialExportRequest {
user: User;
requirements: WorkflowCredentialRequirement[];
writer: PackageWriter;
credentialExportPolicy: CredentialExportPolicy;
// Contains a map of projectId to export location
// p123 -> /project/p123/
projectTargetsById?: Map<string, string>;
@@ -68,12 +72,21 @@ export class CredentialExporter {
};
if (credential) {
const data = await selectCredentialDataForExport(
request.credentialExportPolicy,
async () =>
await new Credentials(
{ id: credential.id, name: credential.name },
credential.type,
credential.data,
).getData(),
);
const baseDir = this.resolveBaseDir(credential, request.projectTargetsById);
const target = allocatorFor(baseDir).allocate(name);
request.writer.writeDirectory(target);
request.writer.writeFile(
`${target}/credential.json`,
JSON.stringify(this.credentialSerializer.serialize(credential), null, '\t'),
JSON.stringify(this.credentialSerializer.serialize(credential, { data }), null, '\t'),
);
entries.push({ id, name, target });
}
@@ -4,6 +4,7 @@ import { Service } from '@n8n/di';
import {
serializedCredentialSchema,
type SerializedCredential,
type SerializedCredentialData,
} from '../../spec/serialized/credential.schema';
import { definePackageSerializationPayload } from '../package-serialization.types';
@@ -12,7 +13,7 @@ type CredentialPackageKeyHandling = {
createdAt: 'exclude';
updatedAt: 'exclude';
name: 'copy';
data: 'exclude';
data: 'transform';
type: 'copy';
shared: 'exclude';
isManaged: 'exclude';
@@ -31,12 +32,16 @@ const serializePayload = definePackageSerializationPayload<
@Service()
export class CredentialSerializer {
serialize(credential: CredentialsEntity): SerializedCredential {
serialize(
credential: CredentialsEntity,
{ data }: { data?: SerializedCredentialData } = {},
): SerializedCredential {
return serializedCredentialSchema.parse(
serializePayload({
id: credential.id,
name: credential.name,
type: credential.type,
...(data !== undefined ? { data } : {}),
}),
);
}
@@ -31,6 +31,7 @@ import { TarPackageReader } from './io/tar/tar-package-reader';
import { TarPackageWriter } from './io/tar/tar-package-writer';
import { PackageImportConfig } from './n8n-packages.config';
import {
CredentialExportPolicy,
MissingWorkflowDependencyPolicy,
WorkflowVersionPolicy,
type ExportPackageEventCounts,
@@ -81,6 +82,8 @@ export class N8nPackagesService {
const projectIds = request.projectIds ?? [];
const includeTags = (request.includeTags ?? true) && !this.globalConfig.tags.disabled;
const workflowVersionPolicy = request.workflowVersionPolicy ?? WorkflowVersionPolicy.Latest;
const credentialExportPolicy =
request.credentialExportPolicy ?? CredentialExportPolicy.ExpressionValuesOnly;
const folderExportResult =
folderIds.length > 0
@@ -213,6 +216,7 @@ export class N8nPackagesService {
user: request.user,
requirements: requirements.credentials,
writer,
credentialExportPolicy,
// Routes project-owned credentials into their project namespace; others stay top-level.
projectTargetsById,
});
@@ -295,6 +299,7 @@ export class N8nPackagesService {
...(allFolders.length ? { folderIds: allFolders.map(({ id }) => id) } : {}),
...(allProjects.length ? { projectIds: allProjects.map(({ id }) => id) } : {}),
counts,
credentialExportPolicy,
});
return { stream, counts };
@@ -91,6 +91,13 @@ export const WorkflowVersionPolicy = {
Latest: 'latest',
} as const;
export const CredentialExportPolicy = {
/** Bundles only expression-valued fields from credential data; literal values never travel. */
ExpressionValuesOnly: 'expression-values-only',
/** Keeps credential data out of the package; credential.json carries id, name and type only. */
NoValues: 'no-values',
} as const;
export const DataTableMatchingMode = {
/** Matches a package table to the target-project table with the same id. Never falls back to name matching. */
ById: 'by-id',
@@ -172,6 +179,9 @@ export type MissingWorkflowDependencyPolicy =
export type WorkflowVersionPolicy =
(typeof WorkflowVersionPolicy)[keyof typeof WorkflowVersionPolicy];
export type CredentialExportPolicy =
(typeof CredentialExportPolicy)[keyof typeof CredentialExportPolicy];
export type DataTableMatchingMode =
(typeof DataTableMatchingMode)[keyof typeof DataTableMatchingMode];
@@ -201,6 +211,7 @@ export interface ExportPackageRequest {
includeTags?: boolean;
missingWorkflowDependencyPolicy?: MissingWorkflowDependencyPolicy;
workflowVersionPolicy?: WorkflowVersionPolicy;
credentialExportPolicy?: CredentialExportPolicy;
}
export type ImportPackageRequest = {
@@ -0,0 +1,58 @@
import { serializedCredentialSchema } from '../credential.schema';
describe('serializedCredentialSchema', () => {
it('accepts a data-less credential', () => {
const credential = { id: 'cred-1', name: 'GitHub', type: 'githubApi' };
expect(() => serializedCredentialSchema.parse(credential)).not.toThrow();
});
it('accepts nested expression data', () => {
const credential = {
id: 'cred-1',
name: 'GitHub',
type: 'githubApi',
data: {
token: '={{ $secrets.github.token }}',
nested: { deep: '={{ $vars.x }}' },
list: ['={{ $vars.y }}'],
},
};
expect(() => serializedCredentialSchema.parse(credential)).not.toThrow();
});
it('rejects a literal leaf in data', () => {
const credential = {
id: 'cred-1',
name: 'GitHub',
type: 'githubApi',
data: { token: 'ghp_plaintextsecret' },
};
expect(() => serializedCredentialSchema.parse(credential)).toThrow();
});
it('rejects a nested literal leaf in data', () => {
const credential = {
id: 'cred-1',
name: 'GitHub',
type: 'githubApi',
data: { nested: { token: '={{ $vars.ok }}', leak: 'secret' } },
};
expect(() => serializedCredentialSchema.parse(credential)).toThrow();
});
it('rejects unknown keys such as encrypted DB data', () => {
const credential = { id: 'cred-1', name: 'GitHub', type: 'githubApi', shared: [] };
expect(() => serializedCredentialSchema.parse(credential)).toThrow();
});
it('rejects an empty id', () => {
const credential = { id: '', name: 'GitHub', type: 'githubApi' };
expect(() => serializedCredentialSchema.parse(credential)).toThrow();
});
});
@@ -1,10 +1,34 @@
import { z } from 'zod';
import { containsExpression } from '@/utils';
const expressionStringSchema = z
.string()
.refine(containsExpression, { message: 'credential data values must be n8n expressions' });
export type SerializedCredentialDataValue =
| string
| SerializedCredentialDataValue[]
| { [key: string]: SerializedCredentialDataValue };
const serializedCredentialDataValueSchema: z.ZodType<SerializedCredentialDataValue> = z.lazy(() =>
z.union([
expressionStringSchema,
z.array(serializedCredentialDataValueSchema),
z.record(serializedCredentialDataValueSchema),
]),
);
export const serializedCredentialDataSchema = z.record(serializedCredentialDataValueSchema);
export type SerializedCredentialData = z.infer<typeof serializedCredentialDataSchema>;
export const serializedCredentialSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
type: z.string().min(1),
data: serializedCredentialDataSchema.optional(),
})
.strict();
@@ -61,6 +61,7 @@ describe('n8n-packages handler', () => {
includeTags?: boolean;
missingWorkflowDependencyPolicy?: string;
workflowVersionPolicy?: string;
credentialExportPolicy?: string;
},
apiKeyScopes?: string[],
) {
@@ -254,6 +255,7 @@ describe('n8n-packages handler', () => {
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
workflowVersionPolicy: 'latest',
credentialExportPolicy: 'expression-values-only',
});
});
@@ -280,6 +282,7 @@ describe('n8n-packages handler', () => {
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
workflowVersionPolicy: 'latest',
credentialExportPolicy: 'expression-values-only',
});
});
@@ -380,6 +383,7 @@ describe('n8n-packages handler', () => {
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
workflowVersionPolicy: 'latest',
credentialExportPolicy: 'expression-values-only',
});
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/gzip');
expect(res.setHeader).toHaveBeenCalledWith(
@@ -426,6 +430,7 @@ describe('n8n-packages handler', () => {
includeTags: true,
missingWorkflowDependencyPolicy: 'reference-only',
workflowVersionPolicy: 'latest',
credentialExportPolicy: 'expression-values-only',
});
});
@@ -449,6 +454,26 @@ describe('n8n-packages handler', () => {
);
});
it('forwards a non-default credential export policy', async () => {
const stream = new PassThrough();
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
const res = makeResponse();
const resultPromise = run(
makeRequest({ workflowIds: ['wf-1'], credentialExportPolicy: 'no-values' }, [
'workflow:export',
]),
res,
);
stream.end(Buffer.from('package-bytes'));
const caught = await resultPromise;
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith(
expect.objectContaining({ credentialExportPolicy: 'no-values' }),
);
});
it('streams the export for a valid project request', async () => {
const stream = new PassThrough();
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
@@ -472,6 +497,7 @@ describe('n8n-packages handler', () => {
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
workflowVersionPolicy: 'latest',
credentialExportPolicy: 'expression-values-only',
});
});
@@ -498,6 +524,7 @@ describe('n8n-packages handler', () => {
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
workflowVersionPolicy: 'latest',
credentialExportPolicy: 'expression-values-only',
});
});
@@ -524,6 +551,7 @@ describe('n8n-packages handler', () => {
includeTags: true,
missingWorkflowDependencyPolicy: 'fail',
workflowVersionPolicy: 'latest',
credentialExportPolicy: 'expression-values-only',
});
});
@@ -550,6 +578,7 @@ describe('n8n-packages handler', () => {
includeTags: false,
missingWorkflowDependencyPolicy: 'fail',
workflowVersionPolicy: 'latest',
credentialExportPolicy: 'expression-values-only',
});
});
});
@@ -41,6 +41,7 @@ type ExportPackageRequest = AuthenticatedRequest<
| 'prefer-published'
| 'ignore-unpublished'
| 'latest';
credentialExportPolicy?: 'expression-values-only' | 'no-values';
}
>;
@@ -156,6 +157,7 @@ const n8nPackagesHandlers: N8nPackagesHandlers = {
includeTags: payload.data.includeTags,
missingWorkflowDependencyPolicy: payload.data.missingWorkflowDependencyPolicy,
workflowVersionPolicy: payload.data.workflowVersionPolicy,
credentialExportPolicy: payload.data.credentialExportPolicy,
});
return await streamPackageExport(res, exportResult);
@@ -82,3 +82,17 @@ properties:
the latest version.
example: latest
default: latest
credentialExportPolicy:
type: string
enum:
- expression-values-only
- no-values
description: >-
Whether expression values from credential data are bundled into the
package. `expression-values-only` includes credential fields whose value
is an n8n expression (for example `={{ $secrets.apiKey }}`); literal
values never travel either way. `no-values` keeps credential data out of
the package entirely, so each credential file carries only its id, name
and type.
example: expression-values-only
default: expression-values-only
@@ -196,4 +196,27 @@ describe('POST /n8n-packages/export', () => {
expect(response.statusCode).toBe(200);
});
// Acceptance proves `credentialExportPolicy` is declared in exportPackageRequest.yml.
test('accepts credentialExportPolicy=no-values through the OpenAPI request validator', async () => {
const project = await createTeamProject('Export project', owner);
const folder = await createFolder(project, { name: 'to_production' });
const response = await authOwnerAgent
.post('/n8n-packages/export')
.send({ folderIds: [folder.id], credentialExportPolicy: 'no-values' });
expect(response.statusCode).toBe(200);
});
test('rejects an unknown credentialExportPolicy value', async () => {
const project = await createTeamProject('Export project', owner);
const folder = await createFolder(project, { name: 'to_production' });
const response = await authOwnerAgent
.post('/n8n-packages/export')
.send({ folderIds: [folder.id], credentialExportPolicy: 'all-values' });
expect(response.statusCode).toBe(400);
});
});