mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-16 21:19:43 +02:00
feat(core): Hide credential-only nodes under a credential type policy (#39652)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
97c54f618c
commit
370b30f919
|
|
@ -105,7 +105,7 @@ It implements all eight points:
|
|||
|
||||
| Point | What it reads |
|
||||
| ------------------------ | ----------------------------------------------------- |
|
||||
| the five workflow points | the keys of every node's `credentials` map |
|
||||
| the five workflow points | the keys of every node's `credentials` map, the type HTTP Request names by parameter, and `extendsCredential` |
|
||||
| `credentialSave` | the type of the credential being written |
|
||||
| `credentialTransfer` | the credential's type, against the **target** project |
|
||||
| `credentialDecrypt` | `credentialType` — the credential's own type |
|
||||
|
|
@ -128,6 +128,25 @@ Workflow-point grandfathering works the same way, one level down: the save diff
|
|||
credential **types**, so swapping which `slackApi` credential a node uses, or copying the node,
|
||||
adds nothing to police.
|
||||
|
||||
### Credential-only nodes
|
||||
|
||||
The VirusTotal, Sysdig and Zabbix nodes, and every other node the editor generates from a
|
||||
credential type with `httpRequestNode`, are HTTP Request with one credential type attached. The
|
||||
editor names them `n8n-creds-base.<credentialType>` and stores them as
|
||||
`n8n-nodes-base.httpRequest` with `extendsCredential: '<credentialType>'`. No package registers
|
||||
the generated name, so the backend never sees it.
|
||||
|
||||
Both kinds agree on such a node: it is available when `n8n-nodes-base.httpRequest` is available
|
||||
under the node kind **and** its credential type is available under the credential kind. The node
|
||||
check judges the stored `httpRequest` node; this check judges `extendsCredential` (and the
|
||||
parameter and `credentials` sources, which name the same type once configured). The frontend
|
||||
module composes the same two verdicts for the nodes panel, so a credential type rule on
|
||||
`virusTotalApi` hides the VirusTotal node and leaves HTTP Request and Sysdig in place.
|
||||
|
||||
A node type rule on `n8n-creds-base.*` would match nothing, so the service refuses it at every
|
||||
write and names the credential type rule to write instead. Blocking HTTP Request itself still
|
||||
hides every credential-only node, as before.
|
||||
|
||||
### What the second kind costs
|
||||
|
||||
The cache is keyed per kind, so the two kinds share nothing. A cold decision on a project with
|
||||
|
|
|
|||
|
|
@ -346,6 +346,116 @@ describe('CredentialTypePolicyCheck', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('a credential-only node', () => {
|
||||
const VIRUS_TOTAL_API = 'virusTotalApi';
|
||||
const SYSDIG_API = 'sysdigApi';
|
||||
|
||||
/** How the editor stores the VirusTotal node: HTTP Request extending the credential type. */
|
||||
const credentialOnly = (credentialType: string, name = `${credentialType} node`): INode => ({
|
||||
...node(HTTP_REQUEST, {}, name),
|
||||
extendsCredential: credentialType,
|
||||
});
|
||||
|
||||
const saveNew = async (nodes: INode[]) =>
|
||||
await check.onWorkflowSave({
|
||||
workflow: workflow(nodes, null),
|
||||
storedWorkflow: null,
|
||||
projectId: 'project-1',
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
denying([VIRUS_TOTAL_API]);
|
||||
});
|
||||
|
||||
it('reports the extended type when no credential is selected and HTTP Request is not installed', async () => {
|
||||
nodeTypes.getByNameAndVersion.mockImplementation(() => {
|
||||
throw new Error('not installed');
|
||||
});
|
||||
|
||||
// The parameter names the type too, but resolving it needs the missing description.
|
||||
const result = await saveNew([
|
||||
{
|
||||
...credentialOnly(VIRUS_TOTAL_API),
|
||||
parameters: {
|
||||
authentication: 'predefinedCredentialType',
|
||||
nodeCredentialType: VIRUS_TOTAL_API,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(nodeTypes.getByNameAndVersion).toHaveBeenCalledWith(HTTP_REQUEST, 1);
|
||||
expect(result.violations).toHaveLength(1);
|
||||
expect(result.violations[0]).toMatchObject({
|
||||
subject: VIRUS_TOTAL_API,
|
||||
subjectType: 'credentialType',
|
||||
});
|
||||
expect(service.evaluateComposedTypesFor).toHaveBeenCalledWith(
|
||||
'credential-types',
|
||||
'project-1',
|
||||
[VIRUS_TOTAL_API],
|
||||
);
|
||||
});
|
||||
|
||||
it('reports one violation when the extended type is also named by parameter and selected', async () => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue({
|
||||
description: {
|
||||
credentials: [],
|
||||
properties: [{ name: 'nodeCredentialType', type: 'credentialsSelect' }],
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await saveNew([
|
||||
{
|
||||
...credentialOnly(VIRUS_TOTAL_API),
|
||||
parameters: {
|
||||
authentication: 'predefinedCredentialType',
|
||||
nodeCredentialType: VIRUS_TOTAL_API,
|
||||
},
|
||||
credentials: { [VIRUS_TOTAL_API]: { id: 'cred-1', name: 'VirusTotal account' } },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.violations).toHaveLength(1);
|
||||
expect(service.evaluateComposedTypesFor).toHaveBeenCalledWith(
|
||||
'credential-types',
|
||||
'project-1',
|
||||
[VIRUS_TOTAL_API],
|
||||
);
|
||||
});
|
||||
|
||||
it('grandfathers an extended type the stored workflow already carried', async () => {
|
||||
const nodes = [credentialOnly(VIRUS_TOTAL_API)];
|
||||
|
||||
const result = await check.onWorkflowSave({
|
||||
workflow: workflow(nodes),
|
||||
storedWorkflow: workflow(nodes),
|
||||
projectId: 'project-1',
|
||||
});
|
||||
|
||||
expect(result.violations).toEqual([]);
|
||||
expect(service.evaluateComposedTypesFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves plain HTTP Request alone', async () => {
|
||||
const result = await saveNew([node(HTTP_REQUEST)]);
|
||||
|
||||
expect(result.violations).toEqual([]);
|
||||
expect(service.evaluateComposedTypesFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports only the blocked type when two credential-only nodes share the HTTP Request base', async () => {
|
||||
const result = await saveNew([credentialOnly(VIRUS_TOTAL_API), credentialOnly(SYSDIG_API)]);
|
||||
|
||||
expect(result.violations).toHaveLength(1);
|
||||
expect(result.violations[0].subject).toBe(VIRUS_TOTAL_API);
|
||||
expect(service.evaluateComposedTypesFor).toHaveBeenCalledWith(
|
||||
'credential-types',
|
||||
'project-1',
|
||||
[VIRUS_TOTAL_API, SYSDIG_API],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the points that report the full list', () => {
|
||||
const nodes = [
|
||||
node(SET),
|
||||
|
|
|
|||
|
|
@ -342,6 +342,45 @@ describe('TypeAvailabilityPolicyService', () => {
|
|||
|
||||
await expect(service.createPolicyDocument(KIND, [rule], 'user-1')).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects a node type rule naming a credential-only node, and names the rule to write instead', async () => {
|
||||
const rule: PolicyRule = {
|
||||
id: 'r1',
|
||||
action: 'deny',
|
||||
selector: { kind: 'name', value: 'n8n-creds-base.virusTotalApi' },
|
||||
};
|
||||
|
||||
await expect(service.createPolicyDocument(KIND, [rule], 'user-1')).rejects.toThrow(
|
||||
'Node type rule names the credential-only node "n8n-creds-base.virusTotalApi", which is HTTP Request with a credential attached. Write a credential type rule on "virusTotalApi" instead.',
|
||||
);
|
||||
expect(policyRepository.createPolicy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a credential type rule on the type a credential-only node wraps', async () => {
|
||||
policyRepository.createPolicy.mockResolvedValue(makePolicy({ kind: CREDENTIAL_TYPES_KIND }));
|
||||
const rule: PolicyRule = {
|
||||
id: 'r1',
|
||||
action: 'deny',
|
||||
selector: { kind: 'name', value: 'virusTotalApi' },
|
||||
};
|
||||
|
||||
await expect(
|
||||
service.createPolicyDocument(CREDENTIAL_TYPES_KIND, [rule], 'user-1'),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects a package rule on the credential-only prefix, which no loader provides', async () => {
|
||||
const rule: PolicyRule = {
|
||||
id: 'r1',
|
||||
action: 'deny',
|
||||
selector: { kind: 'package', value: 'n8n-creds-base' },
|
||||
};
|
||||
|
||||
await expect(service.createPolicyDocument(KIND, [rule], 'user-1')).rejects.toThrow(
|
||||
'Package rule names a package that is not installed: n8n-creds-base',
|
||||
);
|
||||
expect(policyRepository.createPolicy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePolicyDocument', () => {
|
||||
|
|
@ -366,6 +405,19 @@ describe('TypeAvailabilityPolicyService', () => {
|
|||
expect(transactionRunner.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a node type rule naming a credential-only node, before opening a transaction', async () => {
|
||||
const rule: PolicyRule = {
|
||||
id: 'r1',
|
||||
action: 'deny',
|
||||
selector: { kind: 'name', value: 'n8n-creds-base.virusTotalApi' },
|
||||
};
|
||||
|
||||
await expect(
|
||||
service.updatePolicyDocument(KIND, 'policy-1', [rule], 1, 'user-1'),
|
||||
).rejects.toThrow('Node type rule names the credential-only node');
|
||||
expect(transactionRunner.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a delegate rule when the document is attached to a project scope, before reading it', async () => {
|
||||
attachmentRepository.listScopeIdsAttachedToPolicy.mockResolvedValue(['scope-1']);
|
||||
scopeRepository.lockScopesByIds.mockResolvedValue(['scope-1']);
|
||||
|
|
@ -752,6 +804,25 @@ describe('TypeAvailabilityPolicyService', () => {
|
|||
expect(transactionRunner.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a node type rule naming a credential-only node, before opening a transaction', async () => {
|
||||
const rule: PolicyRule = {
|
||||
id: 'r1',
|
||||
action: 'deny',
|
||||
selector: { kind: 'name', value: 'n8n-creds-base.virusTotalApi' },
|
||||
};
|
||||
|
||||
await expect(
|
||||
service.setEffectivePolicy(
|
||||
KIND,
|
||||
null,
|
||||
{ rules: [rule], defaultAction: 'allow' },
|
||||
0,
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow('Node type rule names the credential-only node');
|
||||
expect(transactionRunner.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ConflictError when the scope has several attached documents, and writes nothing', async () => {
|
||||
const scope = makeScope({ projectId: 'project-1', defaultAction: 'allow', version: 1 });
|
||||
scopeRepository.findScopeByKindAndProject.mockResolvedValue(scope);
|
||||
|
|
|
|||
|
|
@ -163,8 +163,13 @@ export class CredentialTypePolicyCheck implements RegisteredPolicyCheck {
|
|||
|
||||
/**
|
||||
* The credential types a workflow's nodes ask for: the keys of each node's `credentials` map,
|
||||
* plus a type a node like HTTP Request names by parameter. A node that names a type without
|
||||
* selecting a credential still counts: the workflow is built around a type the policy refuses.
|
||||
* a type a node like HTTP Request names by parameter, and the type a credential-only node
|
||||
* extends. A node that names a type without selecting a credential still counts: the workflow
|
||||
* is built around a type the policy refuses.
|
||||
*
|
||||
* `extendsCredential` is read on its own because it needs no node description. It is how a
|
||||
* credential-only node (the VirusTotal node, say) is stored, so a rule on `virusTotalApi`
|
||||
* reaches that node even when HTTP Request's description is not installed here.
|
||||
*/
|
||||
private distinctCredentialTypes(nodes: PolicedWorkflow['nodes']): string[] {
|
||||
// A Set keeps first-encounter order, so violations and audit lines stay deterministic.
|
||||
|
|
@ -173,6 +178,7 @@ export class CredentialTypePolicyCheck implements RegisteredPolicyCheck {
|
|||
for (const node of nodes) {
|
||||
for (const type of Object.keys(node.credentials ?? {})) types.add(type);
|
||||
for (const type of this.parameterCredentialTypes(node)) types.add(type);
|
||||
if (node.extendsCredential) types.add(node.extendsCredential);
|
||||
}
|
||||
|
||||
return [...types];
|
||||
|
|
|
|||
|
|
@ -5,7 +5,12 @@ import { Time } from '@n8n/constants';
|
|||
import { TransactionRunner, type OperationContext } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { OperationalError, UserError } from 'n8n-workflow';
|
||||
import {
|
||||
getCredentialOnlyNodeCredentialType,
|
||||
isCredentialOnlyNodeType,
|
||||
OperationalError,
|
||||
UserError,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ConflictError, NotFoundError } from '@n8n/errors';
|
||||
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
||||
|
|
@ -16,6 +21,7 @@ import { TypeAvailabilityPolicyScopeRepository } from './database/repositories/t
|
|||
import { TypeAvailabilityPolicyRepository } from './database/repositories/type-availability-policy.repository';
|
||||
import type { TypeAvailabilityPolicy } from './database/entities/type-availability-policy.entity';
|
||||
import type { TypeAvailabilityPolicyScope } from './database/entities/type-availability-policy-scope.entity';
|
||||
import { NODE_TYPES_KIND } from './constants';
|
||||
import { isPackageInstalled, packageResolverFor, policedTypeFor } from './package-resolver';
|
||||
import { evaluateComposedType, orderedAttachments, type ComposedVerdict } from './policy-evaluator';
|
||||
import type {
|
||||
|
|
@ -242,6 +248,25 @@ function assertPackagesInstalled(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A credential-only node (`n8n-creds-base.<type>`) exists only in the editor. It is stored and
|
||||
* executed as `n8n-nodes-base.httpRequest`, so a node type rule on the generated name would
|
||||
* never match anything and never reach the builder. The credential type rule on `<type>` is
|
||||
* what hides that node and blocks it, so the write points there.
|
||||
*/
|
||||
function assertNoCredentialOnlyNodeRules(kind: string, rules: readonly PolicyRule[]): void {
|
||||
if (kind !== NODE_TYPES_KIND) return;
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.selector.kind === 'name' && isCredentialOnlyNodeType(rule.selector.value)) {
|
||||
const credentialType = getCredentialOnlyNodeCredentialType(rule.selector.value);
|
||||
throw new UserError(
|
||||
`Node type rule names the credential-only node "${rule.selector.value}", which is HTTP Request with a credential attached. Write a credential type rule on "${credentialType}" instead.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirrors the DTO-level check in `ReplaceAttachmentsDto`, as a defensive service-level guard. */
|
||||
function assertNoDuplicateAttachmentSlots(attachments: readonly AttachmentInput[]): void {
|
||||
const seenPolicyIds = new Set<string>();
|
||||
|
|
@ -438,6 +463,7 @@ export class TypeAvailabilityPolicyService {
|
|||
updatedBy: string,
|
||||
): Promise<PolicyDocumentWrite> {
|
||||
assertPackagesInstalled(rules, this.loadNodesAndCredentials);
|
||||
assertNoCredentialOnlyNodeRules(kind, rules);
|
||||
const warnings = lintRulesForShadowing(
|
||||
rules,
|
||||
packageResolverFor(kind, this.loadNodesAndCredentials),
|
||||
|
|
@ -476,6 +502,7 @@ export class TypeAvailabilityPolicyService {
|
|||
updatedBy: string,
|
||||
): Promise<PolicyDocumentWrite> {
|
||||
assertPackagesInstalled(rules, this.loadNodesAndCredentials);
|
||||
assertNoCredentialOnlyNodeRules(kind, rules);
|
||||
const warnings = lintRulesForShadowing(
|
||||
rules,
|
||||
packageResolverFor(kind, this.loadNodesAndCredentials),
|
||||
|
|
@ -730,6 +757,7 @@ export class TypeAvailabilityPolicyService {
|
|||
}> {
|
||||
assertNoDelegateAtProjectScope(projectId, input.defaultAction, input.rules);
|
||||
assertPackagesInstalled(input.rules, this.loadNodesAndCredentials);
|
||||
assertNoCredentialOnlyNodeRules(kind, input.rules);
|
||||
|
||||
const warnings = lintRulesForShadowing(
|
||||
input.rules,
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ export const DATA_TABLE_TOOL_NODE_TYPE = 'n8n-nodes-base.dataTableTool';
|
|||
export const MESSAGE_AN_AGENT_NODE_TYPE = 'n8n-nodes-base.messageAnAgent';
|
||||
export const TIME_SAVED_NODE_TYPE = 'n8n-nodes-base.timeSaved';
|
||||
|
||||
export const CREDENTIAL_ONLY_NODE_PREFIX = 'n8n-creds-base';
|
||||
export { CREDENTIAL_ONLY_NODE_PREFIX } from 'n8n-workflow';
|
||||
|
||||
export const CREDENTIAL_ONLY_HTTP_NODE_VERSION = 4.1;
|
||||
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
|
|||
|
||||
const getCredentialOnlyNodeType = computed(() => {
|
||||
return (nodeTypeName: string, version?: number): INodeTypeDescription | null => {
|
||||
const credentialName = utils.getCredentialTypeName(nodeTypeName);
|
||||
const credentialName = utils.getCredentialOnlyNodeCredentialType(nodeTypeName);
|
||||
const httpNode = getNodeType.value(
|
||||
HTTP_REQUEST_NODE_TYPE,
|
||||
version ?? CREDENTIAL_ONLY_HTTP_NODE_VERSION,
|
||||
|
|
|
|||
|
|
@ -1,23 +1,17 @@
|
|||
import { deepCopy, type ICredentialType, type INodeTypeDescription } from 'n8n-workflow';
|
||||
import { CREDENTIAL_ONLY_NODE_PREFIX, HTTP_REQUEST_NODE_TYPE } from '../constants';
|
||||
import {
|
||||
deepCopy,
|
||||
getCredentialOnlyNodeTypeName,
|
||||
type ICredentialType,
|
||||
type INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { i18n } from '@n8n/i18n';
|
||||
|
||||
export function isCredentialOnlyNodeType(nodeTypeName: string): boolean {
|
||||
return nodeTypeName?.startsWith(CREDENTIAL_ONLY_NODE_PREFIX) ?? false;
|
||||
}
|
||||
|
||||
/** A credential-only node is HTTP Request in disguise, so policy applies to the node it wraps. */
|
||||
export function toPolicyNodeType(nodeTypeName: string): string {
|
||||
return isCredentialOnlyNodeType(nodeTypeName) ? HTTP_REQUEST_NODE_TYPE : nodeTypeName;
|
||||
}
|
||||
|
||||
export function getCredentialTypeName(nodeTypeName: string): string {
|
||||
return nodeTypeName.split('.')[1];
|
||||
}
|
||||
|
||||
export function getCredentialOnlyNodeTypeName(credentialTypeName: string): string {
|
||||
return `${CREDENTIAL_ONLY_NODE_PREFIX}.${credentialTypeName}`;
|
||||
}
|
||||
// The name helpers are shared with the backend and the policy module through `n8n-workflow`.
|
||||
export {
|
||||
getCredentialOnlyNodeCredentialType,
|
||||
getCredentialOnlyNodeTypeName,
|
||||
isCredentialOnlyNodeType,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export function getCredentialOnlyNodeType(
|
||||
httpNode?: INodeTypeDescription | null,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ import {
|
|||
traverseNodeParameters,
|
||||
} from 'n8n-workflow';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { getCredentialTypeName, isCredentialOnlyNodeType } from '@/app/utils/credentialOnlyNodes';
|
||||
import {
|
||||
getCredentialOnlyNodeCredentialType,
|
||||
isCredentialOnlyNodeType,
|
||||
} from '@/app/utils/credentialOnlyNodes';
|
||||
import {
|
||||
getInactiveCredentials,
|
||||
usesParameterSelectedCredentials,
|
||||
|
|
@ -220,7 +223,7 @@ export function serializeNode(nodeTypeProvider: NodeTypeProvider, node: INodeUi)
|
|||
|
||||
if (isCredentialOnly) {
|
||||
nodeData.type = HTTP_REQUEST_NODE_TYPE;
|
||||
nodeData.extendsCredential = getCredentialTypeName(nodeType.name);
|
||||
nodeData.extendsCredential = getCredentialOnlyNodeCredentialType(nodeType.name);
|
||||
}
|
||||
|
||||
// Node-Type is known so we can save the parameters correctly
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ import {
|
|||
MESSAGE_AN_AGENT_NODE_TYPE,
|
||||
} from '@/app/constants';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { mockRestrictedNodeTypes } from '@n8n/frontend-module-type-availability-policies/__tests__/mocks';
|
||||
import {
|
||||
mockRestrictedCredentialTypes,
|
||||
mockRestrictedNodeTypes,
|
||||
} from '@n8n/frontend-module-type-availability-policies/__tests__/mocks';
|
||||
import { mockSimplifiedNodeType } from '../../__tests__/utils';
|
||||
import { useViewStacks } from '../../composables/useViewStacks';
|
||||
import NodeItem from './NodeItem.vue';
|
||||
|
|
@ -273,6 +276,24 @@ describe('NodeItem', () => {
|
|||
expect(queryByTestId('node-creator-restricted-item')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('treats a credential-only node as restricted when its credential type is restricted', () => {
|
||||
mockRestrictedNodeTypes();
|
||||
mockRestrictedCredentialTypes({ sysdigApi: 'project' });
|
||||
|
||||
const { queryByTestId } = render({
|
||||
pinia,
|
||||
props: {
|
||||
nodeType: mockSimplifiedNodeType({
|
||||
name: 'n8n-creds-base.sysdigApi',
|
||||
displayName: 'Sysdig',
|
||||
group: ['output'],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByTestId('node-creator-restricted-item')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a normal row when the type is not restricted', () => {
|
||||
mockRestrictedNodeTypes();
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,10 @@ import {
|
|||
mockSimplifiedNodeType,
|
||||
mockViewCreateElement,
|
||||
} from './__tests__/utils';
|
||||
import { mockRestrictedNodeTypes } from '@n8n/frontend-module-type-availability-policies/__tests__/mocks';
|
||||
import {
|
||||
mockRestrictedCredentialTypes,
|
||||
mockRestrictedNodeTypes,
|
||||
} from '@n8n/frontend-module-type-availability-policies/__tests__/mocks';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
|
||||
|
|
@ -1462,6 +1465,24 @@ describe('node item restriction lookups', () => {
|
|||
|
||||
expect(isNodeItemRestricted('n8n-creds-base.sysdigApi')).toBe(true);
|
||||
});
|
||||
|
||||
it('hides a credential-only node whose credential type is restricted and keeps HTTP Request and its siblings', () => {
|
||||
mockRestrictedNodeTypes();
|
||||
mockRestrictedCredentialTypes({ virusTotalApi: 'instance' });
|
||||
const items = [
|
||||
'n8n-nodes-base.httpRequest',
|
||||
'n8n-creds-base.virusTotalApi',
|
||||
'n8n-creds-base.sysdigApi',
|
||||
].map((key) => mockNodeCreateElement({ key }));
|
||||
|
||||
expect(withoutRestrictedNodes(items, isNodeItemRestricted).map((item) => item.key)).toEqual([
|
||||
'n8n-nodes-base.httpRequest',
|
||||
'n8n-creds-base.sysdigApi',
|
||||
]);
|
||||
expect(getNodeItemRestriction('n8n-creds-base.virusTotalApi')).toMatchObject({
|
||||
scope: 'instance',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('restricted node helpers', () => {
|
||||
|
|
|
|||
|
|
@ -43,13 +43,14 @@ import type { NodeViewItemSection } from './views/viewsData';
|
|||
|
||||
import { stripToolSuffix, useAiGatewayStore } from '@/app/stores/aiGateway.store';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { toPolicyNodeType } from '@/app/utils/credentialOnlyNodes';
|
||||
import { getNodeTypeRestriction } from '@n8n/frontend-module-type-availability-policies';
|
||||
import {
|
||||
getNodeTypeRestriction,
|
||||
type TypeRestriction,
|
||||
} from '@n8n/frontend-module-type-availability-policies';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import type { NodeIconSource } from '@/app/utils/nodeIcon';
|
||||
import { getN8nAgentsNodeName } from '@/experiments/inlineAgents/useInlineAgentsExperiment';
|
||||
import { SampleTemplates } from '@/features/workflows/templates/utils/workflowSamples';
|
||||
import type { NodeTypeAvailability } from '@n8n/api-types';
|
||||
import type { IconName } from '@n8n/design-system';
|
||||
import type { INodeOutputConfiguration, NodeConnectionType } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
|
||||
|
|
@ -296,9 +297,12 @@ export function searchNodes(
|
|||
export function flattenCreateElements(items: INodeCreateElement[]): INodeCreateElement[] {
|
||||
return items.map((item) => (item.type === 'section' ? item.children : item)).flat();
|
||||
}
|
||||
/** Restriction lookups for node creator items, with credential-only nodes following HTTP Request. */
|
||||
export function getNodeItemRestriction(nodeTypeName: string): NodeTypeAvailability | undefined {
|
||||
return getNodeTypeRestriction(toPolicyNodeType(nodeTypeName)) ?? undefined;
|
||||
/**
|
||||
* Restriction lookups for node creator items. The policy module composes the answer for a
|
||||
* credential-only node from HTTP Request and the credential type it wraps.
|
||||
*/
|
||||
export function getNodeItemRestriction(nodeTypeName: string): TypeRestriction | undefined {
|
||||
return getNodeTypeRestriction(nodeTypeName) ?? undefined;
|
||||
}
|
||||
|
||||
export function isNodeItemRestricted(nodeTypeName: string): boolean {
|
||||
|
|
|
|||
|
|
@ -10,12 +10,21 @@ The descriptor `id` must match the backend module id
|
|||
The module is license-gated on `feat:typeAvailabilityPolicies` and is not a default module. Enable it
|
||||
in a dev instance with `N8N_ENABLED_MODULES=type-availability-policies`.
|
||||
|
||||
`useTypeAvailabilityPoliciesStore` answers whether a project may use a node type. The store is
|
||||
passive: it does not watch the route or the active project, because the projects store lives in the
|
||||
shell. The shell calls `fetchForProject(projectId)` when a workflow opens or the active project
|
||||
changes, and `reload()` after a community node install, because the server answers only for the
|
||||
node types it has loaded. When the module is off, the request fails, a request is in flight for a
|
||||
different project, or nothing has loaded yet, every lookup reports "available".
|
||||
`useTypeAvailabilityPoliciesStore` answers whether a project may use a node type or a credential
|
||||
type. The store is passive: it does not watch the route or the active project, because the
|
||||
projects store lives in the shell. The shell calls `fetchForProject(projectId)` when a workflow
|
||||
opens or the active project changes, and `reload()` after a community node install, because the
|
||||
server answers only for the node types it has loaded. The store loads both kinds together from
|
||||
`/projects/:projectId/available-types` and `/projects/:projectId/available-credential-types`.
|
||||
When the module is off, either request fails, a request is in flight for a different project, or
|
||||
nothing has loaded yet, every lookup reports "available".
|
||||
|
||||
A credential-only node (`n8n-creds-base.<credentialType>`, generated by the editor from a
|
||||
credential type with `httpRequestNode`) is restricted when HTTP Request is restricted under the
|
||||
node kind, or when the credential type it wraps is restricted under the credential kind.
|
||||
`getNodeTypeRestriction` composes that, so blocking `virusTotalApi` hides the VirusTotal node
|
||||
and leaves HTTP Request and the other credential-only nodes alone. The backend never sees the
|
||||
generated name and refuses a node type rule on it; the credential type rule is the one to write.
|
||||
|
||||
```bash
|
||||
pnpm turbo typecheck lint test --filter=@n8n/frontend-module-type-availability-policies
|
||||
|
|
@ -28,7 +37,7 @@ and on a cold tree turbo builds them first.
|
|||
for one node type. `isNodeTypeRestricted(type)` is the plain predicate for a list of nodes. The
|
||||
shell reads restriction through these and never through the store directly.
|
||||
|
||||
Tests stub the store through `mockRestrictedNodeTypes` from the module's `__tests__/mocks`.
|
||||
Tests stub the store through `mockRestrictedNodeTypes` and `mockRestrictedCredentialTypes` from the module's `__tests__/mocks`.
|
||||
|
||||
`ContactInstanceAdminModal` lists the instance owners with a mail link. The surfaces below render it
|
||||
as a sibling of their own content, so a popover that closes never unmounts the open dialog.
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
"@n8n/stores": "workspace:*",
|
||||
"@n8n/utils": "workspace:*",
|
||||
"@vueuse/core": "catalog:frontend",
|
||||
"n8n-workflow": "workspace:*",
|
||||
"pinia": "catalog:frontend",
|
||||
"vue": "catalog:frontend"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { NodeTypeAvailabilityScope } from '@n8n/api-types';
|
||||
import type { CredentialTypeAvailabilityScope, NodeTypeAvailabilityScope } from '@n8n/api-types';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { useTypeAvailabilityPoliciesStore } from '../type-availability-policies.store';
|
||||
|
|
@ -17,3 +17,15 @@ export function mockRestrictedNodeTypes(
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** The credential-type counterpart of `mockRestrictedNodeTypes`. */
|
||||
export function mockRestrictedCredentialTypes(
|
||||
restricted: Record<string, CredentialTypeAvailabilityScope> = {},
|
||||
): void {
|
||||
vi.spyOn(useTypeAvailabilityPoliciesStore(), 'getCredentialTypeAvailability').mockImplementation(
|
||||
(name) => {
|
||||
const scope = restricted[name];
|
||||
return scope ? { name, available: false, scope } : { name, available: true };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,18 +2,39 @@ import { createPinia, setActivePinia } from 'pinia';
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { isNodeTypeRestricted, useNodeTypeRestriction } from './useNodeTypeRestriction';
|
||||
import {
|
||||
getCredentialTypeRestriction,
|
||||
getNodeTypeRestriction,
|
||||
isNodeTypeRestricted,
|
||||
useNodeTypeRestriction,
|
||||
} from './useNodeTypeRestriction';
|
||||
import { useTypeAvailabilityPoliciesStore } from '../type-availability-policies.store';
|
||||
|
||||
type Scope = 'instance' | 'project';
|
||||
|
||||
const HTTP_REQUEST = 'n8n-nodes-base.httpRequest';
|
||||
const VIRUS_TOTAL_NODE = 'n8n-creds-base.virusTotalApi';
|
||||
const SYSDIG_NODE = 'n8n-creds-base.sysdigApi';
|
||||
|
||||
function mockRestrictions(
|
||||
nodeTypes: Record<string, Scope>,
|
||||
credentialTypes: Record<string, Scope> = {},
|
||||
): void {
|
||||
const store = useTypeAvailabilityPoliciesStore();
|
||||
vi.spyOn(store, 'getNodeTypeAvailability').mockImplementation((name) => {
|
||||
const scope = nodeTypes[name];
|
||||
return scope ? { name, available: false, scope } : { name, available: true };
|
||||
});
|
||||
vi.spyOn(store, 'getCredentialTypeAvailability').mockImplementation((name) => {
|
||||
const scope = credentialTypes[name];
|
||||
return scope ? { name, available: false, scope } : { name, available: true };
|
||||
});
|
||||
}
|
||||
|
||||
describe('useNodeTypeRestriction', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.spyOn(useTypeAvailabilityPoliciesStore(), 'getNodeTypeAvailability').mockImplementation(
|
||||
(name) =>
|
||||
name === 'n8n-nodes-base.slack'
|
||||
? { name, available: false, scope: 'project' }
|
||||
: { name, available: true },
|
||||
);
|
||||
mockRestrictions({ 'n8n-nodes-base.slack': 'project' });
|
||||
});
|
||||
|
||||
it('reports a blocked type with the scope that blocked it', () => {
|
||||
|
|
@ -34,4 +55,58 @@ describe('useNodeTypeRestriction', () => {
|
|||
expect(isNodeTypeRestricted('n8n-nodes-base.slack')).toBe(true);
|
||||
expect(isNodeTypeRestricted('n8n-nodes-base.set')).toBe(false);
|
||||
});
|
||||
|
||||
it('answers the credential type verdict on its own', () => {
|
||||
mockRestrictions({}, { virusTotalApi: 'instance' });
|
||||
|
||||
expect(getCredentialTypeRestriction('virusTotalApi')).toMatchObject({ scope: 'instance' });
|
||||
expect(getCredentialTypeRestriction('sysdigApi')).toBeNull();
|
||||
});
|
||||
|
||||
describe('a credential-only node', () => {
|
||||
it('is restricted when HTTP Request is restricted', () => {
|
||||
mockRestrictions({ [HTTP_REQUEST]: 'instance' });
|
||||
|
||||
expect(getNodeTypeRestriction(VIRUS_TOTAL_NODE)).toMatchObject({
|
||||
name: HTTP_REQUEST,
|
||||
scope: 'instance',
|
||||
});
|
||||
expect(isNodeTypeRestricted(SYSDIG_NODE)).toBe(true);
|
||||
});
|
||||
|
||||
it('is restricted when the credential type it wraps is restricted', () => {
|
||||
mockRestrictions({}, { virusTotalApi: 'project' });
|
||||
|
||||
expect(getNodeTypeRestriction(VIRUS_TOTAL_NODE)).toMatchObject({
|
||||
name: 'virusTotalApi',
|
||||
scope: 'project',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves HTTP Request and its siblings available when one credential type is restricted', () => {
|
||||
mockRestrictions({}, { virusTotalApi: 'instance' });
|
||||
|
||||
expect(isNodeTypeRestricted(VIRUS_TOTAL_NODE)).toBe(true);
|
||||
expect(isNodeTypeRestricted(HTTP_REQUEST)).toBe(false);
|
||||
expect(isNodeTypeRestricted(SYSDIG_NODE)).toBe(false);
|
||||
});
|
||||
|
||||
it('reports the HTTP Request restriction first when both apply', () => {
|
||||
mockRestrictions({ [HTTP_REQUEST]: 'instance' }, { virusTotalApi: 'project' });
|
||||
|
||||
expect(getNodeTypeRestriction(VIRUS_TOTAL_NODE)).toMatchObject({
|
||||
name: HTTP_REQUEST,
|
||||
scope: 'instance',
|
||||
});
|
||||
});
|
||||
|
||||
it('follows the composed answer in the reactive helper', () => {
|
||||
mockRestrictions({}, { virusTotalApi: 'project' });
|
||||
|
||||
const { isRestricted, restrictionScope } = useNodeTypeRestriction(ref(VIRUS_TOTAL_NODE));
|
||||
|
||||
expect(isRestricted.value).toBe(true);
|
||||
expect(restrictionScope.value).toBe('project');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,52 @@
|
|||
import type { NodeTypeAvailability, NodeTypeAvailabilityScope } from '@n8n/api-types';
|
||||
import type {
|
||||
CredentialTypeAvailability,
|
||||
NodeTypeAvailability,
|
||||
NodeTypeAvailabilityScope,
|
||||
} from '@n8n/api-types';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
import {
|
||||
getCredentialOnlyNodeCredentialType,
|
||||
HTTP_REQUEST_NODE_TYPE,
|
||||
isCredentialOnlyNodeType,
|
||||
} from 'n8n-workflow';
|
||||
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
|
||||
|
||||
import { useTypeAvailabilityPoliciesStore } from '../type-availability-policies.store';
|
||||
|
||||
export function getNodeTypeRestriction(nodeType: string): NodeTypeAvailability | null {
|
||||
/** The verdict that restricts a node type: from the node kind, or the credential kind for a credential-only node. */
|
||||
export type TypeRestriction = NodeTypeAvailability | CredentialTypeAvailability;
|
||||
|
||||
function nodeRestriction(nodeType: string): NodeTypeAvailability | null {
|
||||
const availability = useTypeAvailabilityPoliciesStore().getNodeTypeAvailability(nodeType);
|
||||
return availability && !availability.available ? availability : null;
|
||||
}
|
||||
|
||||
export function getCredentialTypeRestriction(
|
||||
credentialType: string,
|
||||
): CredentialTypeAvailability | null {
|
||||
const availability =
|
||||
useTypeAvailabilityPoliciesStore().getCredentialTypeAvailability(credentialType);
|
||||
return availability && !availability.available ? availability : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A credential-only node (`n8n-creds-base.<type>`) is HTTP Request with one credential type
|
||||
* attached, so it is restricted when either is: the node kind decides for HTTP Request, the
|
||||
* credential kind for the type it wraps. HTTP Request is read first because it is the wider
|
||||
* reason and already covers every credential-only node. This mirrors the backend, where the
|
||||
* node check judges the stored `httpRequest` node and the credential check judges its type.
|
||||
*/
|
||||
export function getNodeTypeRestriction(nodeType: string): TypeRestriction | null {
|
||||
if (isCredentialOnlyNodeType(nodeType)) {
|
||||
return (
|
||||
nodeRestriction(HTTP_REQUEST_NODE_TYPE) ??
|
||||
getCredentialTypeRestriction(getCredentialOnlyNodeCredentialType(nodeType))
|
||||
);
|
||||
}
|
||||
|
||||
return nodeRestriction(nodeType);
|
||||
}
|
||||
|
||||
export function isNodeTypeRestricted(nodeType: string): boolean {
|
||||
return getNodeTypeRestriction(nodeType) !== null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export {
|
|||
getNodeTypeRestriction,
|
||||
isNodeTypeRestricted,
|
||||
useNodeTypeRestriction,
|
||||
type TypeRestriction,
|
||||
} from './composables/useNodeTypeRestriction';
|
||||
export { default as RestrictedNodePopover } from './components/RestrictedNodePopover.vue';
|
||||
export { default as RestrictedNodePanel } from './components/RestrictedNodePanel.vue';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { AvailableTypesResponse } from '@n8n/api-types';
|
||||
import type { AvailableCredentialTypesResponse, AvailableTypesResponse } from '@n8n/api-types';
|
||||
import { makeRestApiRequest } from '@n8n/rest-api-client';
|
||||
import type { IRestApiContext } from '@n8n/rest-api-client';
|
||||
|
||||
|
|
@ -8,3 +8,14 @@ export async function fetchAvailableTypes(
|
|||
): Promise<AvailableTypesResponse> {
|
||||
return await makeRestApiRequest(context, 'GET', `/projects/${projectId}/available-types`);
|
||||
}
|
||||
|
||||
export async function fetchAvailableCredentialTypes(
|
||||
context: IRestApiContext,
|
||||
projectId: string,
|
||||
): Promise<AvailableCredentialTypesResponse> {
|
||||
return await makeRestApiRequest(
|
||||
context,
|
||||
'GET',
|
||||
`/projects/${projectId}/available-credential-types`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { AvailableTypesResponse } from '@n8n/api-types';
|
||||
import type { AvailableCredentialTypesResponse, AvailableTypesResponse } from '@n8n/api-types';
|
||||
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
|
|
@ -6,11 +6,13 @@ import { useTypeAvailabilityPoliciesStore } from './type-availability-policies.s
|
|||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchAvailableTypes: vi.fn(),
|
||||
fetchAvailableCredentialTypes: vi.fn(),
|
||||
isModuleActive: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./type-availability-policies.api', () => ({
|
||||
fetchAvailableTypes: mocks.fetchAvailableTypes,
|
||||
fetchAvailableCredentialTypes: mocks.fetchAvailableCredentialTypes,
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/stores/settings.store', () => ({
|
||||
|
|
@ -37,11 +39,20 @@ const PROJECT_B_RESPONSE: AvailableTypesResponse = [
|
|||
{ name: RESTRICTED, available: true },
|
||||
];
|
||||
|
||||
const ALLOWED_CREDENTIAL = 'sysdigApi';
|
||||
const RESTRICTED_CREDENTIAL = 'virusTotalApi';
|
||||
|
||||
const PROJECT_A_CREDENTIALS: AvailableCredentialTypesResponse = [
|
||||
{ name: ALLOWED_CREDENTIAL, available: true },
|
||||
{ name: RESTRICTED_CREDENTIAL, available: false, scope: 'instance', matchedRuleId: 'rule-9' },
|
||||
];
|
||||
|
||||
describe('useTypeAvailabilityPoliciesStore', () => {
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.fetchAvailableTypes.mockReset();
|
||||
mocks.fetchAvailableCredentialTypes.mockReset().mockResolvedValue([]);
|
||||
mocks.isModuleActive.mockReset().mockReturnValue(true);
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
|
@ -292,4 +303,86 @@ describe('useTypeAvailabilityPoliciesStore', () => {
|
|||
expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('credential types', () => {
|
||||
beforeEach(() => {
|
||||
mocks.fetchAvailableTypes.mockResolvedValue(PROJECT_A_RESPONSE);
|
||||
mocks.fetchAvailableCredentialTypes.mockResolvedValue(PROJECT_A_CREDENTIALS);
|
||||
});
|
||||
|
||||
it('loads credential type availability together with the node types of the project', async () => {
|
||||
const store = useTypeAvailabilityPoliciesStore();
|
||||
|
||||
await store.fetchForProject('project-a');
|
||||
|
||||
expect(mocks.fetchAvailableCredentialTypes).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseUrl: 'http://localhost' }),
|
||||
'project-a',
|
||||
);
|
||||
expect(store.getCredentialTypeAvailability(RESTRICTED_CREDENTIAL)).toEqual({
|
||||
name: RESTRICTED_CREDENTIAL,
|
||||
available: false,
|
||||
scope: 'instance',
|
||||
matchedRuleId: 'rule-9',
|
||||
});
|
||||
expect(store.getCredentialTypeAvailability(RESTRICTED_CREDENTIAL).available).toBe(false);
|
||||
expect(store.getCredentialTypeAvailability(ALLOWED_CREDENTIAL).available).toBe(true);
|
||||
expect(store.getCredentialTypeAvailability('doesNotExistApi').available).toBe(true);
|
||||
});
|
||||
|
||||
it('reports every credential type as available while a project switch is in flight', async () => {
|
||||
const deferredB = createDeferredPromise<AvailableCredentialTypesResponse>();
|
||||
mocks.fetchAvailableCredentialTypes
|
||||
.mockResolvedValueOnce(PROJECT_A_CREDENTIALS)
|
||||
.mockReturnValueOnce(deferredB.promise);
|
||||
const store = useTypeAvailabilityPoliciesStore();
|
||||
|
||||
await store.fetchForProject('project-a');
|
||||
const pendingB = store.fetchForProject('project-b');
|
||||
|
||||
expect(store.getCredentialTypeAvailability(RESTRICTED_CREDENTIAL).available).toBe(true);
|
||||
|
||||
deferredB.resolve([]);
|
||||
await pendingB;
|
||||
|
||||
expect(store.loadedProjectId).toBe('project-b');
|
||||
expect(store.getCredentialTypeAvailability(RESTRICTED_CREDENTIAL).available).toBe(true);
|
||||
});
|
||||
|
||||
it('reloads both kinds for the requested project', async () => {
|
||||
mocks.fetchAvailableCredentialTypes
|
||||
.mockResolvedValueOnce(PROJECT_A_CREDENTIALS)
|
||||
.mockResolvedValueOnce([]);
|
||||
const store = useTypeAvailabilityPoliciesStore();
|
||||
|
||||
await store.fetchForProject('project-a');
|
||||
expect(store.getCredentialTypeAvailability(RESTRICTED_CREDENTIAL).available).toBe(false);
|
||||
|
||||
await store.reload();
|
||||
|
||||
expect(mocks.fetchAvailableCredentialTypes).toHaveBeenCalledTimes(2);
|
||||
expect(store.getCredentialTypeAvailability(RESTRICTED_CREDENTIAL).available).toBe(true);
|
||||
});
|
||||
|
||||
it('degrades both kinds to available when only the credential request fails', async () => {
|
||||
mocks.fetchAvailableCredentialTypes.mockRejectedValue(new Error('endpoint failure'));
|
||||
const store = useTypeAvailabilityPoliciesStore();
|
||||
|
||||
await store.fetchForProject('project-a');
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
expect(store.loadedProjectId).toBeNull();
|
||||
expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(true);
|
||||
expect(store.getCredentialTypeAvailability(RESTRICTED_CREDENTIAL).available).toBe(true);
|
||||
});
|
||||
|
||||
it('clears credential types on reset', async () => {
|
||||
const store = useTypeAvailabilityPoliciesStore();
|
||||
|
||||
await store.fetchForProject('project-a');
|
||||
store.reset();
|
||||
|
||||
expect(store.getCredentialTypeAvailability(RESTRICTED_CREDENTIAL).available).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,15 +1,24 @@
|
|||
import type { NodeTypeAvailability } from '@n8n/api-types';
|
||||
import type { CredentialTypeAvailability, NodeTypeAvailability } from '@n8n/api-types';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref, shallowRef } from 'vue';
|
||||
|
||||
import { fetchAvailableTypes } from './type-availability-policies.api';
|
||||
import {
|
||||
fetchAvailableCredentialTypes,
|
||||
fetchAvailableTypes,
|
||||
} from './type-availability-policies.api';
|
||||
import {
|
||||
TYPE_AVAILABILITY_POLICIES_MODULE_ID,
|
||||
TYPE_AVAILABILITY_POLICIES_STORE_ID,
|
||||
} from './type-availability-policies.constants';
|
||||
|
||||
function unavailableByName<T extends { name: string; available: boolean }>(
|
||||
entries: T[],
|
||||
): ReadonlyMap<string, T> {
|
||||
return new Map(entries.filter((entry) => !entry.available).map((entry) => [entry.name, entry]));
|
||||
}
|
||||
|
||||
export const useTypeAvailabilityPoliciesStore = defineStore(
|
||||
TYPE_AVAILABILITY_POLICIES_STORE_ID,
|
||||
() => {
|
||||
|
|
@ -17,6 +26,9 @@ export const useTypeAvailabilityPoliciesStore = defineStore(
|
|||
const settingsStore = useSettingsStore();
|
||||
|
||||
const restrictedNodeTypes = shallowRef<ReadonlyMap<string, NodeTypeAvailability>>(new Map());
|
||||
const restrictedCredentialTypes = shallowRef<ReadonlyMap<string, CredentialTypeAvailability>>(
|
||||
new Map(),
|
||||
);
|
||||
const loadedProjectId = ref<string | null>(null);
|
||||
const requestedProjectId = ref<string | null>(null);
|
||||
const isLoading = ref(false);
|
||||
|
|
@ -47,22 +59,30 @@ export const useTypeAvailabilityPoliciesStore = defineStore(
|
|||
await load(requestedProjectId.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads both kinds together. A credential-only node's answer composes the two, so they
|
||||
* must describe the same project at the same time: either both maps are the project's or
|
||||
* neither is.
|
||||
*/
|
||||
async function load(projectId: string): Promise<void> {
|
||||
const request = ++latestRequest;
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const entries = await fetchAvailableTypes(rootStore.restApiContext, projectId);
|
||||
const [nodeTypes, credentialTypes] = await Promise.all([
|
||||
fetchAvailableTypes(rootStore.restApiContext, projectId),
|
||||
fetchAvailableCredentialTypes(rootStore.restApiContext, projectId),
|
||||
]);
|
||||
if (request !== latestRequest) return;
|
||||
|
||||
restrictedNodeTypes.value = new Map(
|
||||
entries.filter((entry) => !entry.available).map((entry) => [entry.name, entry]),
|
||||
);
|
||||
restrictedNodeTypes.value = unavailableByName(nodeTypes);
|
||||
restrictedCredentialTypes.value = unavailableByName(credentialTypes);
|
||||
loadedProjectId.value = projectId;
|
||||
} catch (error) {
|
||||
if (request !== latestRequest) return;
|
||||
|
||||
console.error('Failed to fetch available types for project', projectId, error);
|
||||
restrictedNodeTypes.value = new Map();
|
||||
restrictedCredentialTypes.value = new Map();
|
||||
loadedProjectId.value = null;
|
||||
} finally {
|
||||
if (request === latestRequest) isLoading.value = false;
|
||||
|
|
@ -79,9 +99,16 @@ export const useTypeAvailabilityPoliciesStore = defineStore(
|
|||
return getNodeTypeAvailability(name).available;
|
||||
}
|
||||
|
||||
function getCredentialTypeAvailability(name: string): CredentialTypeAvailability {
|
||||
if (loadedProjectId.value !== requestedProjectId.value) return { name, available: true };
|
||||
|
||||
return restrictedCredentialTypes.value.get(name) ?? { name, available: true };
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
latestRequest++;
|
||||
restrictedNodeTypes.value = new Map();
|
||||
restrictedCredentialTypes.value = new Map();
|
||||
loadedProjectId.value = null;
|
||||
requestedProjectId.value = null;
|
||||
isLoading.value = false;
|
||||
|
|
@ -95,6 +122,7 @@ export const useTypeAvailabilityPoliciesStore = defineStore(
|
|||
reload,
|
||||
getNodeTypeAvailability,
|
||||
isNodeTypeAvailable,
|
||||
getCredentialTypeAvailability,
|
||||
reset,
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -45,6 +45,11 @@ export const UNKNOWN_ERROR_MESSAGE_CRED = 'UNKNOWN ERROR';
|
|||
export const STICKY_NODE_TYPE = 'n8n-nodes-base.stickyNote';
|
||||
export const NO_OP_NODE_TYPE = 'n8n-nodes-base.noOp';
|
||||
export const HTTP_REQUEST_NODE_TYPE = 'n8n-nodes-base.httpRequest';
|
||||
/**
|
||||
* Package prefix of the node types the editor generates from credential types that declare
|
||||
* `httpRequestNode`. No package registers them; see `credential-only-nodes.ts`.
|
||||
*/
|
||||
export const CREDENTIAL_ONLY_NODE_PREFIX = 'n8n-creds-base';
|
||||
export const WEBHOOK_NODE_TYPE = 'n8n-nodes-base.webhook';
|
||||
export const MANUAL_TRIGGER_NODE_TYPE = 'n8n-nodes-base.manualTrigger';
|
||||
export const EVALUATION_TRIGGER_NODE_TYPE = 'n8n-nodes-base.evaluationTrigger';
|
||||
|
|
|
|||
25
packages/workflow/src/credential-only-nodes.ts
Normal file
25
packages/workflow/src/credential-only-nodes.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { CREDENTIAL_ONLY_NODE_PREFIX } from './constants';
|
||||
|
||||
/**
|
||||
* A credential-only node is HTTP Request with one credential type attached, such as the
|
||||
* VirusTotal or Sysdig node. The editor generates it from a credential type that declares
|
||||
* `httpRequestNode`, so no package registers it and the backend never sees its name: it is
|
||||
* stored as `n8n-nodes-base.httpRequest` with `extendsCredential` set to the credential type.
|
||||
*
|
||||
* Its generated name is `n8n-creds-base.<credentialType>`.
|
||||
*
|
||||
* Accepts a missing name because the editor asks about nodes that have no type yet, for
|
||||
* example while a node is being placed. Such a node is not credential-only.
|
||||
*/
|
||||
export function isCredentialOnlyNodeType(nodeTypeName: string | null | undefined): boolean {
|
||||
return nodeTypeName?.startsWith(`${CREDENTIAL_ONLY_NODE_PREFIX}.`) ?? false;
|
||||
}
|
||||
|
||||
export function getCredentialOnlyNodeTypeName(credentialTypeName: string): string {
|
||||
return `${CREDENTIAL_ONLY_NODE_PREFIX}.${credentialTypeName}`;
|
||||
}
|
||||
|
||||
/** The credential type a credential-only node wraps, e.g. `virusTotalApi` for `n8n-creds-base.virusTotalApi`. */
|
||||
export function getCredentialOnlyNodeCredentialType(nodeTypeName: string): string {
|
||||
return nodeTypeName.slice(CREDENTIAL_ONLY_NODE_PREFIX.length + 1);
|
||||
}
|
||||
|
|
@ -60,6 +60,7 @@ export * from './workflow-structure-validation';
|
|||
export * from './versioned-node-type';
|
||||
export * from './type-validation';
|
||||
export * from './credential-domain-restrictions';
|
||||
export * from './credential-only-nodes';
|
||||
export * from './schemas';
|
||||
export * from './run-execution-data/run-execution-data';
|
||||
export { WorkflowExpression } from './workflow-expression';
|
||||
|
|
|
|||
31
packages/workflow/test/credential-only-nodes.test.ts
Normal file
31
packages/workflow/test/credential-only-nodes.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { CREDENTIAL_ONLY_NODE_PREFIX } from '../src/constants';
|
||||
import {
|
||||
getCredentialOnlyNodeCredentialType,
|
||||
getCredentialOnlyNodeTypeName,
|
||||
isCredentialOnlyNodeType,
|
||||
} from '../src/credential-only-nodes';
|
||||
|
||||
describe('credential-only nodes', () => {
|
||||
it('recognises a generated node type name', () => {
|
||||
expect(isCredentialOnlyNodeType('n8n-creds-base.virusTotalApi')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not recognise a registered node type or the bare prefix', () => {
|
||||
expect(isCredentialOnlyNodeType('n8n-nodes-base.httpRequest')).toBe(false);
|
||||
expect(isCredentialOnlyNodeType(CREDENTIAL_ONLY_NODE_PREFIX)).toBe(false);
|
||||
expect(isCredentialOnlyNodeType('n8n-creds-baseX.virusTotalApi')).toBe(false);
|
||||
});
|
||||
|
||||
it('answers false for a node that has no type yet', () => {
|
||||
expect(isCredentialOnlyNodeType(undefined)).toBe(false);
|
||||
expect(isCredentialOnlyNodeType(null)).toBe(false);
|
||||
expect(isCredentialOnlyNodeType('')).toBe(false);
|
||||
});
|
||||
|
||||
it('round-trips a credential type through the generated name', () => {
|
||||
const nodeTypeName = getCredentialOnlyNodeTypeName('virusTotalApi');
|
||||
|
||||
expect(nodeTypeName).toBe('n8n-creds-base.virusTotalApi');
|
||||
expect(getCredentialOnlyNodeCredentialType(nodeTypeName)).toBe('virusTotalApi');
|
||||
});
|
||||
});
|
||||
|
|
@ -7554,6 +7554,9 @@ importers:
|
|||
'@vueuse/core':
|
||||
specifier: catalog:frontend
|
||||
version: 14.3.0(vue@3.5.26(typescript@6.0.2))
|
||||
n8n-workflow:
|
||||
specifier: workspace:*
|
||||
version: link:../../../workflow
|
||||
pinia:
|
||||
specifier: catalog:frontend
|
||||
version: 2.2.4(typescript@6.0.2)(vue@3.5.26(typescript@6.0.2))
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user