feat(core): Add node type policy evaluation engine (no-changelog) (#36942)

This commit is contained in:
Rúben Castro
2026-08-24 15:40:48 +00:00
committed by GitHub
parent 9dde6838c8
commit 49d8c402ab
3 changed files with 307 additions and 0 deletions
@@ -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;
};