mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-30 18:01:23 +08:00
feat(core): Include credential expression values in package exports (#36754)
This commit is contained in:
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+64
@@ -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);
|
||||
|
||||
+97
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+86
-3
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+11
@@ -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 = {
|
||||
|
||||
+58
@@ -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();
|
||||
|
||||
|
||||
+29
@@ -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);
|
||||
|
||||
+14
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user