diff --git a/packages/@n8n/api-types/src/dto/packages/import-package-request.openapi.ts b/packages/@n8n/api-types/src/dto/packages/import-package-request.openapi.ts index 2b005362b7b..8e52d25964a 100644 --- a/packages/@n8n/api-types/src/dto/packages/import-package-request.openapi.ts +++ b/packages/@n8n/api-types/src/dto/packages/import-package-request.openapi.ts @@ -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: { diff --git a/packages/@n8n/cli/docs/commands/package.md b/packages/@n8n/cli/docs/commands/package.md index fb87eb15751..de616ccda90 100644 --- a/packages/@n8n/cli/docs/commands/package.md +++ b/packages/@n8n/cli/docs/commands/package.md @@ -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`). diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-package.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-package.integration.test.ts index 8ffeaeba000..a19ccee0081 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/import-package.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-package.integration.test.ts @@ -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', () => { diff --git a/packages/cli/src/modules/n8n-packages/engine/import-gates.ts b/packages/cli/src/modules/n8n-packages/engine/import-gates.ts index 226db1c05f3..9c14d0d8162 100644 --- a/packages/cli/src/modules/n8n-packages/engine/import-gates.ts +++ b/packages/cli/src/modules/n8n-packages/engine/import-gates.ts @@ -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 diff --git a/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts b/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts index 81ef2e38ad7..b923663fa6f 100644 --- a/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts +++ b/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts @@ -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( diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/importPackage.generated.yml b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/importPackage.generated.yml index 6496c7b638e..f961a1088a9 100644 --- a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/importPackage.generated.yml +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/importPackage.generated.yml @@ -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":{"":""}}` 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.'