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:
Rúben Castro 2026-10-08 10:13:44 +00:00 committed by GitHub
parent 97c54f618c
commit 370b30f919
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 669 additions and 59 deletions

View File

@ -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

View File

@ -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),

View File

@ -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);

View File

@ -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];

View File

@ -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,

View File

@ -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;

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -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();

View File

@ -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', () => {

View File

@ -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 {

View File

@ -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.

View File

@ -27,6 +27,7 @@
"@n8n/stores": "workspace:*",
"@n8n/utils": "workspace:*",
"@vueuse/core": "catalog:frontend",
"n8n-workflow": "workspace:*",
"pinia": "catalog:frontend",
"vue": "catalog:frontend"
},

View File

@ -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 };
},
);
}

View File

@ -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');
});
});
});

View File

@ -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;
}

View File

@ -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';

View File

@ -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`,
);
}

View File

@ -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);
});
});
});

View File

@ -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,
};
},

View File

@ -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';

View 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);
}

View File

@ -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';

View 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');
});
});

View File

@ -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))