diff --git a/packages/@n8n/api-types/src/dto/packages/__tests__/export-package-request.dto.test.ts b/packages/@n8n/api-types/src/dto/packages/__tests__/export-package-request.dto.test.ts index 0c6c2e3abd7..9a934d0d8c3 100644 --- a/packages/@n8n/api-types/src/dto/packages/__tests__/export-package-request.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/packages/__tests__/export-package-request.dto.test.ts @@ -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); + }); + }); }); diff --git a/packages/@n8n/api-types/src/dto/packages/export-package-request.dto.ts b/packages/@n8n/api-types/src/dto/packages/export-package-request.dto.ts index d9e9d14fe4a..bbd2e3d3cf8 100644 --- a/packages/@n8n/api-types/src/dto/packages/export-package-request.dto.ts +++ b/packages/@n8n/api-types/src/dto/packages/export-package-request.dto.ts @@ -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'), }) {} diff --git a/packages/@n8n/cli/docs/commands/package.md b/packages/@n8n/cli/docs/commands/package.md index a3dab279049..36595d41ef6 100644 --- a/packages/@n8n/cli/docs/commands/package.md +++ b/packages/@n8n/cli/docs/commands/package.md @@ -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 diff --git a/packages/@n8n/cli/src/__tests__/client.test.ts b/packages/@n8n/cli/src/__tests__/client.test.ts index 286a06ce203..ae53ce36154 100644 --- a/packages/@n8n/cli/src/__tests__/client.test.ts +++ b/packages/@n8n/cli/src/__tests__/client.test.ts @@ -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]))); diff --git a/packages/@n8n/cli/src/__tests__/package-export.test.ts b/packages/@n8n/cli/src/__tests__/package-export.test.ts index 2cd3f946fdd..768bcfdfc89 100644 --- a/packages/@n8n/cli/src/__tests__/package-export.test.ts +++ b/packages/@n8n/cli/src/__tests__/package-export.test.ts @@ -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'], diff --git a/packages/@n8n/cli/src/client.ts b/packages/@n8n/cli/src/client.ts index bfaf19ea7a1..0d054e98b94 100644 --- a/packages/@n8n/cli/src/client.ts +++ b/packages/@n8n/cli/src/client.ts @@ -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('POST', '/n8n-packages/export', { diff --git a/packages/@n8n/cli/src/commands/package/export.ts b/packages/@n8n/cli/src/commands/package/export.ts index 250acf68ce8..ba6cff52738 100644 --- a/packages/@n8n/cli/src/commands/package/export.ts +++ b/packages/@n8n/cli/src/commands/package/export.ts @@ -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 { @@ -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) { diff --git a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts index ff360ab3ae7..0cf4c1447c4 100644 --- a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts @@ -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); diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index 6cb0e7c806c..467660c10de 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -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', }); }); diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index cb852d71576..faf92404c6c 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -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': { diff --git a/packages/cli/src/events/relays/log-streaming.event-relay.ts b/packages/cli/src/events/relays/log-streaming.event-relay.ts index a561166ad82..38fea60225b 100644 --- a/packages/cli/src/events/relays/log-streaming.event-relay.ts +++ b/packages/cli/src/events/relays/log-streaming.event-relay.ts @@ -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 }, diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index cf4158a5012..99c9fa6763d 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -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, }); } diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-credentials.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-credentials.integration.test.ts index 5f591e50059..447047f2a7b 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-credentials.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-credentials.integration.test.ts @@ -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>(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>(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); diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-export-policy.test.ts b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-export-policy.test.ts new file mode 100644 index 00000000000..8060ece0053 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-export-policy.test.ts @@ -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) => 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(); + }); + }); +}); diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential.exporter.test.ts b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential.exporter.test.ts index 86d62df6cae..79ed71438ef 100644 --- a/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential.exporter.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential.exporter.test.ts @@ -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({ id: 'user-1' }); +async function encryptedData(data: ICredentialDataDecryptedObject): Promise { + 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 { 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>(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>(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); + }); + }); }); diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential.serializer.test.ts b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential.serializer.test.ts index 5a65ff88d09..4036a8ed3bc 100644 --- a/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential.serializer.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential.serializer.test.ts @@ -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', diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/credential-export-policy.ts b/packages/cli/src/modules/n8n-packages/entities/credential/credential-export-policy.ts new file mode 100644 index 00000000000..07fd5807dc5 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/entities/credential/credential-export-policy.ts @@ -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): 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, + ) => Promise +> = { + '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, +): Promise { + return await SELECT_EXPORTED_DATA[policy](getDecryptedData); +} diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/credential.exporter.ts b/packages/cli/src/modules/n8n-packages/entities/credential/credential.exporter.ts index fbe959ff44e..efbcf99d131 100644 --- a/packages/cli/src/modules/n8n-packages/entities/credential/credential.exporter.ts +++ b/packages/cli/src/modules/n8n-packages/entities/credential/credential.exporter.ts @@ -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; @@ -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 }); } diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/credential.serializer.ts b/packages/cli/src/modules/n8n-packages/entities/credential/credential.serializer.ts index fc4262ce753..cdf079aed7e 100644 --- a/packages/cli/src/modules/n8n-packages/entities/credential/credential.serializer.ts +++ b/packages/cli/src/modules/n8n-packages/entities/credential/credential.serializer.ts @@ -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 } : {}), }), ); } diff --git a/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts b/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts index d1cd36f9d38..4fb3cd5bf1f 100644 --- a/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts +++ b/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts @@ -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 }; diff --git a/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts b/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts index bce30404e72..94a296dc02e 100644 --- a/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts +++ b/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts @@ -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 = { diff --git a/packages/cli/src/modules/n8n-packages/spec/serialized/__tests__/credential.schema.test.ts b/packages/cli/src/modules/n8n-packages/spec/serialized/__tests__/credential.schema.test.ts new file mode 100644 index 00000000000..225b7eec982 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/spec/serialized/__tests__/credential.schema.test.ts @@ -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(); + }); +}); diff --git a/packages/cli/src/modules/n8n-packages/spec/serialized/credential.schema.ts b/packages/cli/src/modules/n8n-packages/spec/serialized/credential.schema.ts index 4de23752dd1..f54e0f0d6d9 100644 --- a/packages/cli/src/modules/n8n-packages/spec/serialized/credential.schema.ts +++ b/packages/cli/src/modules/n8n-packages/spec/serialized/credential.schema.ts @@ -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 = z.lazy(() => + z.union([ + expressionStringSchema, + z.array(serializedCredentialDataValueSchema), + z.record(serializedCredentialDataValueSchema), + ]), +); + +export const serializedCredentialDataSchema = z.record(serializedCredentialDataValueSchema); + +export type SerializedCredentialData = z.infer; + export const serializedCredentialSchema = z .object({ id: z.string().min(1), name: z.string().min(1), type: z.string().min(1), + data: serializedCredentialDataSchema.optional(), }) .strict(); diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts b/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts index 01062525b45..11a15dc71ca 100644 --- a/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts @@ -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', }); }); }); diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts b/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts index dafc8a6d380..fd085ee2430 100644 --- a/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts @@ -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); diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/exportPackageRequest.yml b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/exportPackageRequest.yml index 3538fe39be0..2e7ef59750f 100644 --- a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/exportPackageRequest.yml +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/exportPackageRequest.yml @@ -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 diff --git a/packages/cli/test/integration/public-api/n8n-packages-export.test.ts b/packages/cli/test/integration/public-api/n8n-packages-export.test.ts index f8d32268dcb..a09513c3575 100644 --- a/packages/cli/test/integration/public-api/n8n-packages-export.test.ts +++ b/packages/cli/test/integration/public-api/n8n-packages-export.test.ts @@ -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); + }); });