mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 15:47:41 +08:00
feat(core): Add node type policy evaluation engine (no-changelog) (#36942)
This commit is contained in:
+208
@@ -0,0 +1,208 @@
|
||||
import { evaluateType } from '../policy-evaluator';
|
||||
import type { PolicyAttachment } from '../policy-rule.types';
|
||||
|
||||
const attachment = (overrides: Partial<PolicyAttachment>): PolicyAttachment => ({
|
||||
policyId: 'policy-1',
|
||||
rules: [],
|
||||
priority: 0,
|
||||
isFloor: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('evaluateType', () => {
|
||||
it('lets an earlier deny rule take precedence over a later package allow', () => {
|
||||
const attachments = [
|
||||
attachment({
|
||||
rules: [
|
||||
{
|
||||
id: 'deny-execute-command',
|
||||
action: 'deny',
|
||||
selector: { kind: 'name', value: 'n8n-nodes-base.executeCommand' },
|
||||
},
|
||||
{
|
||||
id: 'deny-code',
|
||||
action: 'deny',
|
||||
selector: { kind: 'name', value: 'n8n-nodes-base.code' },
|
||||
},
|
||||
{
|
||||
id: 'allow-package',
|
||||
action: 'allow',
|
||||
selector: { kind: 'package', value: 'n8n-nodes-base' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
expect(evaluateType(attachments, 'deny', 'n8n-nodes-base.code')).toEqual({
|
||||
action: 'deny',
|
||||
matchedRuleId: 'deny-code',
|
||||
});
|
||||
expect(evaluateType(attachments, 'deny', 'n8n-nodes-base.slack')).toEqual({
|
||||
action: 'allow',
|
||||
matchedRuleId: 'allow-package',
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['allow', 'deny', 'delegate'] as const)(
|
||||
'falls back to the default action with a null matchedRuleId when nothing matches (%s)',
|
||||
(defaultAction) => {
|
||||
const attachments = [
|
||||
attachment({
|
||||
rules: [
|
||||
{ id: 'r1', action: 'deny', selector: { kind: 'name', value: 'n8n-nodes-base.slack' } },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
expect(evaluateType(attachments, defaultAction, 'n8n-nodes-base.gmail')).toEqual({
|
||||
action: defaultAction,
|
||||
matchedRuleId: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('distinguishes an explicit rule from a default action producing the same verdict', () => {
|
||||
const explicitAllow = evaluateType(
|
||||
[
|
||||
attachment({
|
||||
rules: [
|
||||
{
|
||||
id: 'r1',
|
||||
action: 'allow',
|
||||
selector: { kind: 'name', value: 'n8n-nodes-base.slack' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
'allow',
|
||||
'n8n-nodes-base.slack',
|
||||
);
|
||||
const defaultAllow = evaluateType([], 'allow', 'n8n-nodes-base.slack');
|
||||
|
||||
expect(explicitAllow).toEqual({ action: 'allow', matchedRuleId: 'r1' });
|
||||
expect(defaultAllow).toEqual({ action: 'allow', matchedRuleId: null });
|
||||
});
|
||||
|
||||
it('returns the default action with no attachments at all', () => {
|
||||
expect(evaluateType([], 'deny', 'n8n-nodes-base.slack')).toEqual({
|
||||
action: 'deny',
|
||||
matchedRuleId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('gives floor attachments precedence over normal attachments regardless of priority number', () => {
|
||||
const attachments = [
|
||||
attachment({
|
||||
isFloor: false,
|
||||
priority: 0,
|
||||
rules: [
|
||||
{
|
||||
id: 'normal-rule',
|
||||
action: 'allow',
|
||||
selector: { kind: 'name', value: 'n8n-nodes-base.slack' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
attachment({
|
||||
isFloor: true,
|
||||
priority: 5,
|
||||
rules: [
|
||||
{
|
||||
id: 'floor-rule',
|
||||
action: 'deny',
|
||||
selector: { kind: 'name', value: 'n8n-nodes-base.slack' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
expect(evaluateType(attachments, 'allow', 'n8n-nodes-base.slack')).toEqual({
|
||||
action: 'deny',
|
||||
matchedRuleId: 'floor-rule',
|
||||
});
|
||||
});
|
||||
|
||||
it('orders attachments within the same partition by priority ascending', () => {
|
||||
const attachments = [
|
||||
attachment({
|
||||
priority: 2,
|
||||
rules: [
|
||||
{
|
||||
id: 'later',
|
||||
action: 'allow',
|
||||
selector: { kind: 'name', value: 'n8n-nodes-base.slack' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
attachment({
|
||||
priority: 1,
|
||||
rules: [
|
||||
{
|
||||
id: 'earlier',
|
||||
action: 'deny',
|
||||
selector: { kind: 'name', value: 'n8n-nodes-base.slack' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
expect(evaluateType(attachments, 'allow', 'n8n-nodes-base.slack')).toEqual({
|
||||
action: 'deny',
|
||||
matchedRuleId: 'earlier',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls through to the next attachment when the first has no matching rule', () => {
|
||||
const attachments = [
|
||||
attachment({
|
||||
priority: 1,
|
||||
rules: [
|
||||
{ id: 'r1', action: 'deny', selector: { kind: 'name', value: 'n8n-nodes-base.gmail' } },
|
||||
],
|
||||
}),
|
||||
attachment({
|
||||
priority: 2,
|
||||
rules: [
|
||||
{ id: 'r2', action: 'allow', selector: { kind: 'name', value: 'n8n-nodes-base.slack' } },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
expect(evaluateType(attachments, 'deny', 'n8n-nodes-base.slack')).toEqual({
|
||||
action: 'allow',
|
||||
matchedRuleId: 'r2',
|
||||
});
|
||||
});
|
||||
|
||||
describe('name selector', () => {
|
||||
it('requires an exact match, not a prefix', () => {
|
||||
const attachments = [
|
||||
attachment({
|
||||
rules: [
|
||||
{ id: 'r1', action: 'deny', selector: { kind: 'name', value: 'n8n-nodes-base.slack' } },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
expect(evaluateType(attachments, 'allow', 'n8n-nodes-base.slackTrigger')).toEqual({
|
||||
action: 'allow',
|
||||
matchedRuleId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('package selector', () => {
|
||||
it('matches only the segment before the first dot, not a substring anywhere in the name', () => {
|
||||
const attachments = [
|
||||
attachment({
|
||||
rules: [{ id: 'r1', action: 'deny', selector: { kind: 'package', value: 'nodes-base' } }],
|
||||
}),
|
||||
];
|
||||
|
||||
expect(evaluateType(attachments, 'allow', 'n8n-nodes-base.slack')).toEqual({
|
||||
action: 'allow',
|
||||
matchedRuleId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type {
|
||||
PolicyAction,
|
||||
PolicyAttachment,
|
||||
PolicySelector,
|
||||
PolicyVerdict,
|
||||
} from './policy-rule.types';
|
||||
|
||||
/**
|
||||
* Package selectors match the segment of the type name before the first dot (a full type
|
||||
* name is always `<packageName>.<nodeName>`), same convention used elsewhere in the
|
||||
* codebase to derive a package name from a node type.
|
||||
*/
|
||||
function selectorMatches(selector: PolicySelector, typeName: string): boolean {
|
||||
switch (selector.kind) {
|
||||
case 'name':
|
||||
return selector.value === typeName;
|
||||
case 'package':
|
||||
return typeName.split('.')[0] === selector.value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Floor attachments first, normal attachments after; each partition ordered by `priority`
|
||||
* ascending. Same-priority collisions within one partition are a write-time invariant
|
||||
* (DB unique index) — this trusts that invariant rather than re-validating or tie-breaking it.
|
||||
*/
|
||||
function orderedAttachments(attachments: readonly PolicyAttachment[]): PolicyAttachment[] {
|
||||
const byPriority = (a: PolicyAttachment, b: PolicyAttachment) => a.priority - b.priority;
|
||||
|
||||
const floor = attachments.filter((a) => a.isFloor).sort(byPriority);
|
||||
const normal = attachments.filter((a) => !a.isFloor).sort(byPriority);
|
||||
|
||||
return [...floor, ...normal];
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates one scope's effective policy for one type: flattens every attached policy's
|
||||
* rules (floor-then-normal, priority ascending) into a single first-match sequence, falling
|
||||
* back to the scope's `defaultAction` when nothing matches.
|
||||
*
|
||||
* Pure and synchronous — callers own fetching attachments and the scope's `defaultAction`
|
||||
* from storage.
|
||||
*/
|
||||
export function evaluateType(
|
||||
attachments: readonly PolicyAttachment[],
|
||||
defaultAction: PolicyAction,
|
||||
typeName: string,
|
||||
): PolicyVerdict {
|
||||
for (const attachment of orderedAttachments(attachments)) {
|
||||
for (const rule of attachment.rules) {
|
||||
if (selectorMatches(rule.selector, typeName)) {
|
||||
return { action: rule.action, matchedRuleId: rule.id };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { action: defaultAction, matchedRuleId: null };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Domain types for node type availability policies.
|
||||
*
|
||||
* Deliberately independent of the `policy`/`policy_scope`/`policy_attachment` TypeORM
|
||||
* entities (not yet built) — the repository layer will map DB rows into these shapes so
|
||||
* the evaluator never depends on the persistence layer.
|
||||
*/
|
||||
|
||||
/** Matches a node type by its exact full name, or by its package segment. */
|
||||
export type PolicySelector =
|
||||
| { readonly kind: 'name'; readonly value: string }
|
||||
| { readonly kind: 'package'; readonly value: string };
|
||||
|
||||
export type PolicyAction = 'allow' | 'deny' | 'delegate';
|
||||
|
||||
/** One first-match rule within a policy document, in document order. */
|
||||
export type PolicyRule = {
|
||||
readonly id: string;
|
||||
readonly action: PolicyAction;
|
||||
readonly selector: PolicySelector;
|
||||
};
|
||||
|
||||
/** One policy document as attached to a scope, with its evaluation-order metadata. */
|
||||
export type PolicyAttachment = {
|
||||
readonly policyId: string;
|
||||
readonly rules: readonly PolicyRule[];
|
||||
readonly priority: number;
|
||||
readonly isFloor: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* What one scope's evaluation decides for one type.
|
||||
*
|
||||
* `matchedRuleId: null` means the scope's `defaultAction` decided, not an explicit rule —
|
||||
* that distinction matters to callers composing across scopes, where only an explicit
|
||||
* `allow` (non-null `matchedRuleId`) can satisfy a `delegate`.
|
||||
*/
|
||||
export type PolicyVerdict = {
|
||||
readonly action: PolicyAction;
|
||||
readonly matchedRuleId: string | null;
|
||||
};
|
||||
Reference in New Issue
Block a user