feat(core): Require credential:create scope for create-with-values package imports (no-changelog) (#41131)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan Kalkan 2026-10-16 09:55:56 +00:00 committed by GitHub
parent fe3627914a
commit eba25caa70
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 128 additions and 3 deletions

View File

@ -30,6 +30,8 @@ export const importPackageRequestFieldDocs = {
'was retained. ' +
'Otherwise, also when the package has no completeness flag, it is listed under ' +
'`credentials.incomplete` and blocks workflow publishing with `incomplete-credential`. ' +
'`create-with-values` requires the `credential:create` scope when the import creates ' +
'a credential. ' +
'`must-preexist` requires every referenced credential to already exist.',
},
bindings: {

View File

@ -96,6 +96,7 @@ Requires the API key to hold:
- `dataTable:update` — when `--data-table-schema-conflict-policy` is `overwrite` or `overwrite-non-destructive` and changes at least one matched table
- `variable:create` — when the import actually creates a variable, i.e. `--variable-missing-mode` is `create-with-value` (the default) or `create-stub` and at least one referenced variable does not already resolve. A package whose variables all resolve creates nothing and needs neither this scope nor a variables-enabled license.
- `variable:update` — when the import would overwrite a variable, i.e. `--variable-conflict-policy=overwrite` and at least one resolved variable's value differs from the package's. `keep-existing` (the default) never overwrites and needs neither this scope nor a variables-enabled license.
- `credential:create` — when `--credential-missing-mode=create-with-values` and the import creates at least one credential.
- `tag:create` — when the import would create a tag (under `--tag-missing-mode create`, the instance default; tags that match, are dropped, or belong only to skipped workflows need no scope)
- `tag:update` — when the import would rename or reconcile a tag (under `--tag-conflict-policy rename`).

View File

@ -24,7 +24,7 @@ import { Credentials } from 'n8n-core';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { CredentialTypes } from '@/credential-types';
import { BadRequestError, UnprocessableRequestError } from '@n8n/errors';
import { BadRequestError, ForbiddenError, UnprocessableRequestError } from '@n8n/errors';
import type { RelayEventMap } from '@/events/maps/relay.event-map';
import {
affixRoleToSaveCredential,
@ -2387,6 +2387,29 @@ describe('credential-missing-mode: create-stub', () => {
const stored = await Container.get(WorkflowRepository).findOneByOrFail({ id: active.id });
expect(stored.activeVersionId).toBe(originalActiveVersionId);
});
it('does not require the credential:create scope for create-stub', async () => {
const owner = await createOwner();
const result = await importPackage({
user: owner,
credentialMissingMode: 'create-stub',
apiKeyScopes: ['workflow:import'],
packageBuffer: await buildImportPackageBuffer(
[
serializedWorkflowWithCredential({
id: 'wf-missing-cred',
name: 'Missing cred workflow',
credentialId: 'missing-cred',
credentialName: 'Missing GitHub',
}),
],
{ sourceId },
),
});
expect(result.credentials.stubbed).toEqual(['missing-cred']);
});
});
describe('credential-missing-mode: create-with-values', () => {
@ -2498,6 +2521,8 @@ describe('credential-missing-mode: create-with-values', () => {
),
}),
).rejects.toThrow('Lacking permissions to reference external secrets in credentials');
expect(await Container.get(CredentialsRepository).count()).toBe(0);
expect(await Container.get(WorkflowRepository).count()).toBe(0);
});
it('publishes workflows with seeded credentials but blocks those with fallback stubs', async () => {
@ -2763,6 +2788,74 @@ describe('credential-missing-mode: create-with-values', () => {
expect(await Container.get(CredentialsRepository).count()).toBe(0);
});
it('requires the credential:create scope when create-with-values creates a credential, and writes nothing without it', async () => {
const owner = await createOwner();
const packageBuffer = await buildImportPackageBuffer(
[
serializedWorkflowWithCredential({
id: 'wf-missing-cred',
name: 'Missing cred workflow',
credentialId: 'missing-cred',
credentialName: 'Missing GitHub',
}),
],
{ sourceId },
);
await expect(
importPackage({
user: owner,
credentialMissingMode: 'create-with-values',
apiKeyScopes: ['workflow:import'],
packageBuffer,
}),
).rejects.toThrow(ForbiddenError);
expect(await Container.get(CredentialsRepository).count()).toBe(0);
expect(await Container.get(WorkflowRepository).count()).toBe(0);
const result = await importPackage({
user: owner,
credentialMissingMode: 'create-with-values',
apiKeyScopes: ['workflow:import', 'credential:create'],
packageBuffer,
});
expect(result.credentials.stubbed).toEqual(['missing-cred']);
});
it('does not require the credential:create scope when every credential resolves', async () => {
const owner = await createOwner();
const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
owner.id,
);
const credential = await saveOwnedCredential(githubCredentialPayload({ name: 'GitHub' }), {
project: personalProject,
});
const result = await importPackage({
user: owner,
credentialMissingMode: 'create-with-values',
apiKeyScopes: ['workflow:import'],
packageBuffer: await buildImportPackageBuffer(
[
serializedWorkflowWithCredential({
id: 'wf-resolved-cred',
name: 'Resolved cred workflow',
credentialId: credential.id,
credentialName: credential.name,
}),
],
{ sourceId },
),
});
expect(result.credentials).toEqual({
matched: [credential.id],
stubbed: [],
seeded: [],
incomplete: [],
});
});
});
describe('Package import workflow publishing policy', () => {

View File

@ -2,9 +2,15 @@ import type { LicenseState } from '@n8n/backend-common';
import { ForbiddenError } from '@n8n/errors';
import {
credentialMissingModeUsesPackageData,
credentialsToStub,
} from '../entities/credential/credential-missing-mode';
import type { CredentialResolution } from '../entities/credential/credential.types';
import type { DataTableImportPlan } from '../entities/data-table/data-table.types';
import type { TagImportPlan } from '../entities/tag/tag.types';
import type { WorkflowImportPlan } from '../entities/workflow/workflow-import.types';
import type { CredentialMissingMode } from '../n8n-packages.types';
export function assertPackageImportApiKeyScopes(
apiKeyScopes: string[] | undefined,
@ -56,6 +62,18 @@ export function assertDataTableWritesAllowed(
}
}
export function assertCredentialWritesAllowed(
apiKeyScopes: string[] | undefined,
credentialPlans: Array<{ missingMode: CredentialMissingMode; resolution: CredentialResolution }>,
): void {
const createsCredential = credentialPlans.some(
({ missingMode, resolution }) =>
credentialMissingModeUsesPackageData(missingMode) &&
credentialsToStub(missingMode, resolution).length > 0,
);
if (createsCredential) assertPackageImportApiKeyScopes(apiKeyScopes, ['credential:create']);
}
/**
* Plan-derived: a tag must never block an import that would not write it
* (skipped consumers, disabled tags, dropped conflicts), so the assert looks

View File

@ -84,7 +84,11 @@ import type {
import { ContentImportPolicyGate, contentImportTransport } from './content-import-policy';
import { CredentialSavePolicyGate } from './credential-save-policy';
import { toImportBlockedError } from './import-blocked.error';
import { assertDataTableWritesAllowed, assertVariableWritesAllowed } from './import-gates';
import {
assertCredentialWritesAllowed,
assertDataTableWritesAllowed,
assertVariableWritesAllowed,
} from './import-gates';
export interface ImportOrchestrationInput {
context: ImportContext;
@ -179,6 +183,13 @@ export class ImportOrchestrator {
options.apiKeyScopes,
plans.map((plan) => plan.dataTablePlan),
);
assertCredentialWritesAllowed(
options.apiKeyScopes,
plans.map(({ input, credentialPlan }) => ({
missingMode: input.credentialRequest.missingMode,
resolution: credentialPlan,
})),
);
for (const { input, variablePlan, credentialPlan } of plans) {
await this.credentialImporter.assertCanCreate(

View File

@ -43,7 +43,7 @@ requestBody:
- create-stub
- create-with-values
default: create-stub
description: What to do when a credential reference cannot be resolved. `create-stub` (default) creates empty credential placeholders in the target project for missing references. `create-with-values` also creates them, seeded from the expression data bundled in the package (see the export option `credentialExportPolicy`); a reference the package carries no data for falls back to an empty stub. A credential created with package data is listed under `credentials.seeded` when the package confirms that all source data was retained. Otherwise, also when the package has no completeness flag, it is listed under `credentials.incomplete` and blocks workflow publishing with `incomplete-credential`. `must-preexist` requires every referenced credential to already exist.
description: What to do when a credential reference cannot be resolved. `create-stub` (default) creates empty credential placeholders in the target project for missing references. `create-with-values` also creates them, seeded from the expression data bundled in the package (see the export option `credentialExportPolicy`); a reference the package carries no data for falls back to an empty stub. A credential created with package data is listed under `credentials.seeded` when the package confirms that all source data was retained. Otherwise, also when the package has no completeness flag, it is listed under `credentials.incomplete` and blocks workflow publishing with `incomplete-credential`. `create-with-values` requires the `credential:create` scope when the import creates a credential. `must-preexist` requires every referenced credential to already exist.
bindings:
type: string
description: 'Optional JSON object of explicit source→target id bindings, keyed by entity type. Only `credentials` is supported today: send `{"credentials":{"<packageCredentialId>":"<targetCredentialId>"}}` to map credential ids from the package to credential ids on the target instance. These explicit bindings are validated on type and applied before `credentialMatchingMode` resolution runs.'