mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
fe3627914a
commit
eba25caa70
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user