feat(core): Add node type policy telemetry events (no-changelog) (#38331)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guillaume Jacquart 2026-09-14 07:26:52 +00:00 committed by GitHub
parent ac3e312fa0
commit 1020dfd188
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 824 additions and 10 deletions

View File

@ -0,0 +1,106 @@
import { z } from 'zod/v4';
import { defineTelemetryEvents } from '../define';
/**
* Absent for a write that no user made — an environment bootstrap writes the policy with no
* actor, and `user_id` is what builds the RudderStack user, so a placeholder would create one.
*/
const userId = z.string().optional();
const source = z
.enum(['user', 'environment'])
.describe("'environment' is a bootstrap write from configuration, with no acting user");
const scope = z
.enum(['instance', 'project'])
.describe('Which scope the policy was written at; project policies compose under instance');
const projectId = z.string().optional().describe('Absent at instance scope');
const ruleCounts = {
rule_count: z.number(),
allow_rule_count: z.number(),
deny_rule_count: z.number(),
delegate_rule_count: z
.number()
.describe('Always 0 at project scope, which rejects the delegate action'),
};
export const NODE_TYPE_POLICIES_TELEMETRY = defineTelemetryEvents({
USER_SAVED_NODE_TYPE_POLICY: {
name: 'User saved node type policy',
description:
'A node type availability policy was saved for one scope through the composed write that the policy UI uses. One event per save, covering both the scope default action and its rules. Rule-level edits through the advanced document and attachment APIs report separately. The type counts and list report what this scope decides on its own, so at project scope they are the project layer before it composes with the instance policy.',
properties: z.object({
user_id: userId,
source,
scope,
project_id: projectId,
default_action: z
.enum(['allow', 'deny', 'delegate'])
.describe('What the scope decides for a type that no rule matches'),
previous_default_action: z
.enum(['allow', 'deny', 'delegate'])
.nullable()
.describe('Null on the first write to this scope, which had no stored default'),
is_first_write: z
.boolean()
.describe('Whether this write created the scope; an unwritten scope allows every type'),
...ruleCounts,
name_selector_count: z.number().describe('Rules that target one exact node type name'),
package_selector_count: z.number().describe('Rules that target a whole node package'),
evaluated_type_count: z
.number()
.describe('Node types this instance knows, and so the denominator for the counts below'),
blocked_type_count: z.number(),
allowed_type_count: z.number(),
delegated_type_count: z
.number()
.describe('Types a project can opt into; always 0 at project scope'),
blocked_types: z
.array(z.string())
.describe(
'The node types this policy makes unavailable, capped at 100. Node type names, not user data. Compare the length against blocked_type_count to see whether the cap dropped any',
),
allowed_types: z
.array(z.string())
.describe('The node types this policy leaves available, capped at 100 the same way'),
previous_rule_count: z.number().nullable().describe('Null on the first write to this scope'),
shadow_warning_count: z
.number()
.describe('Saved rules that can never match, because an earlier rule already covers them'),
version: z.number().describe('The scope version after the write'),
}),
},
USER_UPDATED_NODE_TYPE_POLICY_DOCUMENT: {
name: 'User updated node type policy document',
description:
'A policy document was created, edited, or deleted through the advanced instance-only document API. The composed save that the policy UI uses reports as "User saved node type policy" instead. Rule counts describe the document after the operation, so a delete reports 0 and carries the deleted size in previous_rule_count.',
properties: z.object({
user_id: userId,
source,
operation: z.enum(['created', 'updated', 'deleted']),
policy_id: z.string(),
...ruleCounts,
previous_rule_count: z.number().nullable().describe('Null on a created document'),
}),
},
USER_UPDATED_NODE_TYPE_POLICY_ATTACHMENTS: {
name: 'User updated node type policy attachments',
description:
'The set of policy documents attached to one scope was replaced through the advanced instance-only attachment API. Reports the resulting set, not the individual add or remove.',
properties: z.object({
user_id: userId,
source,
scope,
project_id: projectId,
scope_id: z.string(),
attachment_count: z.number(),
floor_attachment_count: z
.number()
.describe('Attachments that a narrower scope cannot override'),
previous_attachment_count: z.number(),
}),
},
});

View File

@ -4,6 +4,7 @@ import { CREDENTIALS_TELEMETRY } from './events/credentials';
import { INSTANCE_TELEMETRY } from './events/instance';
import { INSTANCE_AI_TELEMETRY } from './events/instance-ai';
import { MCP_TELEMETRY } from './events/mcp';
import { NODE_TYPE_POLICIES_TELEMETRY } from './events/node-type-policies';
import { PLATFORM_TELEMETRY } from './events/platform';
import { WORKFLOW_TELEMETRY } from './events/workflow';
import { WORKFLOW_REVIEWS_TELEMETRY } from './events/workflow-reviews';
@ -15,6 +16,7 @@ export const TELEMETRY_EVENT = {
INSTANCE: INSTANCE_TELEMETRY,
INSTANCE_AI: INSTANCE_AI_TELEMETRY,
MCP: MCP_TELEMETRY,
NODE_TYPE_POLICIES: NODE_TYPE_POLICIES_TELEMETRY,
WORKFLOW: WORKFLOW_TELEMETRY,
WORKFLOW_REVIEWS: WORKFLOW_REVIEWS_TELEMETRY,
} satisfies TelemetryEventRegistry;

View File

@ -34,6 +34,7 @@ import type { RelayEventMap } from '@/events/maps/relay.event-map';
import { TelemetryEventRelay, getSemanticVersioning } from '@/events/relays/telemetry.event-relay';
import type { License } from '@/license';
import { OtelConfig } from '@/modules/otel/otel.config';
import type { PolicyRule } from '@/modules/type-availability-policies/policy-rule.types';
import type { NodeTypes } from '@/node-types';
import type { Telemetry } from '@/telemetry';
@ -661,6 +662,360 @@ describe('TelemetryEventRelay', () => {
});
});
describe('node type policy events', () => {
const denyRule: PolicyRule = {
id: 'rule-1',
action: 'deny',
selector: { kind: 'name', value: 'n8n-nodes-base.executeCommand' },
};
const allowPackageRule: PolicyRule = {
id: 'rule-2',
action: 'allow',
selector: { kind: 'package', value: 'n8n-nodes-base' },
};
const delegateRule: PolicyRule = {
id: 'rule-3',
action: 'delegate',
selector: { kind: 'name', value: 'n8n-nodes-base.code' },
};
const knownTypes = (...names: string[]) =>
Object.fromEntries(names.map((name) => [name, {}])) as ReturnType<NodeTypes['getKnownTypes']>;
beforeEach(() => {
nodeTypes.getKnownTypes.mockReturnValue(
knownTypes(
'n8n-nodes-base.code',
'n8n-nodes-base.executeCommand',
'@acme/n8n-nodes-acme.thing',
),
);
});
it('should track a first instance-scope save', () => {
const event: RelayEventMap['node-type-policy-saved'] = {
updatedBy: 'user123',
kind: 'node-types',
projectId: null,
scopeId: 'scope-1',
before: null,
after: { defaultAction: 'deny', version: 1 },
rulesBefore: null,
rulesAfter: [denyRule, allowPackageRule, delegateRule],
warningCount: 2,
};
eventService.emit('node-type-policy-saved', event);
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_SAVED_NODE_TYPE_POLICY,
{
user_id: 'user123',
source: 'user',
scope: 'instance',
default_action: 'deny',
previous_default_action: null,
is_first_write: true,
rule_count: 3,
allow_rule_count: 1,
deny_rule_count: 1,
delegate_rule_count: 1,
name_selector_count: 2,
package_selector_count: 1,
evaluated_type_count: 3,
blocked_type_count: 2,
allowed_type_count: 1,
delegated_type_count: 0,
blocked_types: ['n8n-nodes-base.executeCommand', '@acme/n8n-nodes-acme.thing'],
allowed_types: ['n8n-nodes-base.code'],
previous_rule_count: null,
shadow_warning_count: 2,
version: 1,
},
);
});
it('should track a project-scope save over an existing policy', () => {
const event: RelayEventMap['node-type-policy-saved'] = {
updatedBy: 'user123',
kind: 'node-types',
projectId: 'project-1',
scopeId: 'scope-2',
before: { defaultAction: 'allow', version: 4 },
after: { defaultAction: 'deny', version: 5 },
rulesBefore: [denyRule],
rulesAfter: [allowPackageRule],
warningCount: 0,
};
eventService.emit('node-type-policy-saved', event);
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_SAVED_NODE_TYPE_POLICY,
expect.objectContaining({
scope: 'project',
project_id: 'project-1',
previous_default_action: 'allow',
is_first_write: false,
previous_rule_count: 1,
rule_count: 1,
allow_rule_count: 1,
version: 5,
}),
);
});
it('should report an environment write as a source instead of a user', () => {
const event: RelayEventMap['node-type-policy-saved'] = {
updatedBy: 'environment',
kind: 'node-types',
projectId: null,
scopeId: 'scope-1',
before: null,
after: { defaultAction: 'deny', version: 1 },
rulesBefore: null,
rulesAfter: [],
warningCount: 0,
};
eventService.emit('node-type-policy-saved', event);
const [, properties] = vi.mocked(telemetry.track).mock.calls.at(-1)!;
expect(properties).not.toHaveProperty('user_id');
expect(properties).toMatchObject({ source: 'environment' });
});
it('should track a created policy document', () => {
const event: RelayEventMap['node-type-policy-document-created'] = {
updatedBy: 'user123',
kind: 'node-types',
policyId: 'policy-1',
origin: 'document-api',
after: { rules: [denyRule], version: 1 },
};
eventService.emit('node-type-policy-document-created', event);
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_UPDATED_NODE_TYPE_POLICY_DOCUMENT,
{
user_id: 'user123',
source: 'user',
operation: 'created',
policy_id: 'policy-1',
rule_count: 1,
allow_rule_count: 0,
deny_rule_count: 1,
delegate_rule_count: 0,
previous_rule_count: null,
},
);
});
it('should track an updated policy document', () => {
const event: RelayEventMap['node-type-policy-document-updated'] = {
updatedBy: 'user123',
kind: 'node-types',
policyId: 'policy-1',
origin: 'document-api',
before: { rules: [denyRule], version: 1 },
after: { rules: [denyRule, allowPackageRule], version: 2 },
};
eventService.emit('node-type-policy-document-updated', event);
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_UPDATED_NODE_TYPE_POLICY_DOCUMENT,
expect.objectContaining({
operation: 'updated',
rule_count: 2,
previous_rule_count: 1,
}),
);
});
it('should report a deleted policy document as empty, keeping its prior size', () => {
const event: RelayEventMap['node-type-policy-document-deleted'] = {
updatedBy: 'user123',
kind: 'node-types',
policyId: 'policy-1',
before: { rules: [denyRule, allowPackageRule], version: 3 },
};
eventService.emit('node-type-policy-document-deleted', event);
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_UPDATED_NODE_TYPE_POLICY_DOCUMENT,
expect.objectContaining({
operation: 'deleted',
rule_count: 0,
deny_rule_count: 0,
previous_rule_count: 2,
}),
);
});
it('should list the blocked types when the policy allows by default', () => {
eventService.emit('node-type-policy-saved', {
updatedBy: 'user123',
kind: 'node-types',
projectId: null,
scopeId: 'scope-1',
before: null,
after: { defaultAction: 'allow', version: 1 },
rulesBefore: null,
rulesAfter: [denyRule],
warningCount: 0,
});
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_SAVED_NODE_TYPE_POLICY,
expect.objectContaining({
blocked_type_count: 1,
allowed_type_count: 2,
blocked_types: ['n8n-nodes-base.executeCommand'],
allowed_types: ['n8n-nodes-base.code', '@acme/n8n-nodes-acme.thing'],
}),
);
});
it('should expand a package rule into the types it actually blocks', () => {
eventService.emit('node-type-policy-saved', {
updatedBy: 'user123',
kind: 'node-types',
projectId: null,
scopeId: 'scope-1',
before: null,
after: { defaultAction: 'allow', version: 1 },
rulesBefore: null,
rulesAfter: [{ ...allowPackageRule, action: 'deny' }],
warningCount: 0,
});
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_SAVED_NODE_TYPE_POLICY,
expect.objectContaining({
rule_count: 1,
blocked_type_count: 2,
blocked_types: ['n8n-nodes-base.code', 'n8n-nodes-base.executeCommand'],
}),
);
});
it('should cap the listed types and flag that it did', () => {
const names = Array.from({ length: 250 }, (_, i) => `n8n-nodes-base.node${i}`);
nodeTypes.getKnownTypes.mockReturnValue(knownTypes(...names));
eventService.emit('node-type-policy-saved', {
updatedBy: 'user123',
kind: 'node-types',
projectId: null,
scopeId: 'scope-1',
before: null,
after: { defaultAction: 'deny', version: 1 },
rulesBefore: null,
rulesAfter: names.slice(0, 120).map((name, i) => ({
id: `allow-${i}`,
action: 'allow' as const,
selector: { kind: 'name' as const, value: name },
})),
warningCount: 0,
});
const properties = vi.mocked(telemetry.track).mock.calls.at(-1)?.[1];
expect(properties).toMatchObject({
evaluated_type_count: 250,
allowed_type_count: 120,
blocked_type_count: 130,
});
expect(properties?.blocked_types).toHaveLength(100);
expect(properties?.allowed_types).toHaveLength(100);
});
it('should report every known type as blocked when nothing is allowed', () => {
eventService.emit('node-type-policy-saved', {
updatedBy: 'user123',
kind: 'node-types',
projectId: null,
scopeId: 'scope-1',
before: null,
after: { defaultAction: 'deny', version: 1 },
rulesBefore: null,
rulesAfter: [],
warningCount: 0,
});
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_SAVED_NODE_TYPE_POLICY,
expect.objectContaining({
blocked_type_count: 3,
allowed_type_count: 0,
blocked_types: [
'n8n-nodes-base.code',
'n8n-nodes-base.executeCommand',
'@acme/n8n-nodes-acme.thing',
],
allowed_types: [],
}),
);
});
it.each(['created', 'updated'] as const)(
'should not report a composed save as a %s document, which would double-count it',
(operation) => {
eventService.emit(`node-type-policy-document-${operation}`, {
updatedBy: 'user123',
kind: 'node-types',
policyId: 'policy-1',
origin: 'composed-save',
before: { rules: [], version: 1 },
after: { rules: [denyRule], version: 2 },
});
expect(telemetry.track).not.toHaveBeenCalledWith(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_UPDATED_NODE_TYPE_POLICY_DOCUMENT,
expect.anything(),
);
},
);
it('should track replaced attachments', () => {
const event: RelayEventMap['node-type-policy-attachments-updated'] = {
updatedBy: 'user123',
kind: 'node-types',
projectId: 'project-1',
scopeId: 'scope-2',
before: {
attachments: [{ policyId: 'policy-1', rules: [], priority: 0, isFloor: false }],
version: 1,
},
after: {
attachments: [
{ policyId: 'policy-1', rules: [], priority: 0, isFloor: false },
{ policyId: 'policy-2', rules: [denyRule], priority: 0, isFloor: true },
],
version: 2,
},
};
eventService.emit('node-type-policy-attachments-updated', event);
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_UPDATED_NODE_TYPE_POLICY_ATTACHMENTS,
{
user_id: 'user123',
source: 'user',
scope: 'project',
project_id: 'project-1',
scope_id: 'scope-2',
attachment_count: 2,
floor_attachment_count: 1,
previous_attachment_count: 1,
},
);
});
});
describe('external secrets events', () => {
it('should track on `external-secrets-provider-settings-saved` event', () => {
const event: RelayEventMap['external-secrets-provider-settings-saved'] = {

View File

@ -51,6 +51,13 @@ export type UserLike = {
};
};
/**
* Which write path produced a policy document event. A composed save emits a document event
* of its own, so a consumer that already reports the composed save uses this to skip it
* instead of counting one save twice.
*/
export type PolicyWriteOrigin = 'composed-save' | 'document-api';
export type ProjectSummary = {
id: string;
name: string;
@ -1317,6 +1324,7 @@ export type RelayEventMap = {
updatedBy: string;
kind: string;
policyId: string;
origin: PolicyWriteOrigin;
after: { rules: readonly PolicyRule[]; version: number };
};
@ -1324,6 +1332,7 @@ export type RelayEventMap = {
updatedBy: string;
kind: string;
policyId: string;
origin: PolicyWriteOrigin;
before: { rules: readonly PolicyRule[]; version: number };
after: { rules: readonly PolicyRule[]; version: number };
};
@ -1335,6 +1344,23 @@ export type RelayEventMap = {
before: { rules: readonly PolicyRule[]; version: number };
};
/**
* One composed save of a scope's whole effective policy: its default action and its rules.
* Emitted alongside the granular scope and document events, which the audit log needs, so a
* consumer that wants one row per save listens to this one instead of joining those two.
*/
'node-type-policy-saved': {
updatedBy: string;
kind: string;
projectId: string | null;
scopeId: string;
before: { defaultAction: PolicyAction; version: number } | null;
after: { defaultAction: PolicyAction; version: number };
rulesBefore: readonly PolicyRule[] | null;
rulesAfter: readonly PolicyRule[];
warningCount: number;
};
'node-type-policy-attachments-updated': {
updatedBy: string;
kind: string;

View File

@ -39,6 +39,11 @@ import type { RelayEventMap } from '@/events/maps/relay.event-map';
import { determineFinalExecutionStatus } from '@/execution-lifecycle/shared/shared-hook-functions';
import type { IExecutionTrackProperties } from '@/interfaces';
import { License } from '@/license';
import { partitionTypesByAction } from '@/modules/type-availability-policies/policy-evaluator';
import type {
PolicyAction,
PolicyRule,
} from '@/modules/type-availability-policies/policy-rule.types';
import { NodeTypes } from '@/node-types';
import { EventRelay } from './event-relay';
@ -59,6 +64,66 @@ function countNodeCustomTelemetryTags(nodes: INode[]): number {
return nodes.reduce((total, node) => total + (node.customTelemetryTags?.tag?.length ?? 0), 0);
}
/**
* A policy write has no acting user when configuration bootstraps it. `user_id` builds the
* RudderStack user, so the literal actor is reported as a source instead of as a user id.
*/
function policyActor(updatedBy: string): { user_id?: string; source: 'user' | 'environment' } {
return updatedBy === 'environment'
? { source: 'environment' }
: { user_id: updatedBy, source: 'user' };
}
function policyScope(projectId: string | null): {
scope: 'instance' | 'project';
project_id?: string;
} {
return projectId === null ? { scope: 'instance' } : { scope: 'project', project_id: projectId };
}
function countRuleActions(rules: readonly PolicyRule[]) {
return {
rule_count: rules.length,
allow_rule_count: rules.filter((rule) => rule.action === 'allow').length,
deny_rule_count: rules.filter((rule) => rule.action === 'deny').length,
delegate_rule_count: rules.filter((rule) => rule.action === 'delegate').length,
};
}
/**
* A default-deny policy blocks nearly every known type, so an uncapped list would blow the
* 32 KB payload cap. The counts alongside each list carry the total either way.
*/
const MAX_LISTED_POLICY_TYPES = 100;
/**
* What a saved policy makes of every node type this instance knows. Runs the same evaluation
* the node panel runs, once per save rather than once per workflow open.
*/
function summarizeTypeAvailability(
rules: readonly PolicyRule[],
defaultAction: PolicyAction,
typeNames: readonly string[],
) {
const partition = partitionTypesByAction(rules, defaultAction, typeNames);
return {
evaluated_type_count: typeNames.length,
blocked_type_count: partition.deny.length,
allowed_type_count: partition.allow.length,
delegated_type_count: partition.delegate.length,
blocked_types: partition.deny.slice(0, MAX_LISTED_POLICY_TYPES),
allowed_types: partition.allow.slice(0, MAX_LISTED_POLICY_TYPES),
};
}
function countSelectorKinds(rules: readonly PolicyRule[]) {
return {
name_selector_count: rules.filter((rule) => rule.selector.kind === 'name').length,
package_selector_count: rules.filter((rule) => rule.selector.kind === 'package').length,
};
}
function limitNodeGraphStringSize(nodeGraphString: string): string {
if (Buffer.byteLength(nodeGraphString, 'utf8') > MAX_NODE_GRAPH_STRING_SIZE) return '{}';
@ -124,6 +189,12 @@ export class TelemetryEventRelay extends EventRelay {
'variable-created': (event) => this.variableCreated(event),
'variable-updated': (event) => this.variableUpdated(event),
'variable-deleted': (event) => this.variableDeleted(event),
'node-type-policy-saved': (event) => this.nodeTypePolicySaved(event),
'node-type-policy-document-created': (event) => this.nodeTypePolicyDocumentCreated(event),
'node-type-policy-document-updated': (event) => this.nodeTypePolicyDocumentUpdated(event),
'node-type-policy-document-deleted': (event) => this.nodeTypePolicyDocumentDeleted(event),
'node-type-policy-attachments-updated': (event) =>
this.nodeTypePolicyAttachmentsUpdated(event),
'external-secrets-provider-settings-saved': (event) =>
this.externalSecretsProviderSettingsSaved(event),
'external-secrets-provider-reloaded': (event) => this.externalSecretsProviderReloaded(event),
@ -453,6 +524,112 @@ export class TelemetryEventRelay extends EventRelay {
// #endregion
// #region Node type policy
private nodeTypePolicySaved({
updatedBy,
projectId,
before,
after,
rulesBefore,
rulesAfter,
warningCount,
}: RelayEventMap['node-type-policy-saved']) {
this.telemetry.track(TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_SAVED_NODE_TYPE_POLICY, {
...policyActor(updatedBy),
...policyScope(projectId),
default_action: after.defaultAction,
previous_default_action: before?.defaultAction ?? null,
is_first_write: before === null,
...countRuleActions(rulesAfter),
...countSelectorKinds(rulesAfter),
...summarizeTypeAvailability(
rulesAfter,
after.defaultAction,
Object.keys(this.nodeTypes.getKnownTypes()),
),
previous_rule_count: rulesBefore?.length ?? null,
shadow_warning_count: warningCount,
version: after.version,
});
}
/**
* A composed save reports itself, and also emits a document event for the audit log. Only
* the advanced document API reaches telemetry here, so one save stays one row.
*/
private nodeTypePolicyDocumentCreated({
updatedBy,
policyId,
origin,
after,
}: RelayEventMap['node-type-policy-document-created']) {
if (origin === 'composed-save') return;
this.trackPolicyDocument(updatedBy, policyId, 'created', after.rules, null);
}
private nodeTypePolicyDocumentUpdated({
updatedBy,
policyId,
origin,
before,
after,
}: RelayEventMap['node-type-policy-document-updated']) {
if (origin === 'composed-save') return;
this.trackPolicyDocument(updatedBy, policyId, 'updated', after.rules, before.rules);
}
private nodeTypePolicyDocumentDeleted({
updatedBy,
policyId,
before,
}: RelayEventMap['node-type-policy-document-deleted']) {
this.trackPolicyDocument(updatedBy, policyId, 'deleted', [], before.rules);
}
private trackPolicyDocument(
updatedBy: string,
policyId: string,
operation: 'created' | 'updated' | 'deleted',
rulesAfter: readonly PolicyRule[],
rulesBefore: readonly PolicyRule[] | null,
) {
this.telemetry.track(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_UPDATED_NODE_TYPE_POLICY_DOCUMENT,
{
...policyActor(updatedBy),
operation,
policy_id: policyId,
...countRuleActions(rulesAfter),
previous_rule_count: rulesBefore?.length ?? null,
},
);
}
private nodeTypePolicyAttachmentsUpdated({
updatedBy,
projectId,
scopeId,
before,
after,
}: RelayEventMap['node-type-policy-attachments-updated']) {
this.telemetry.track(
TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_UPDATED_NODE_TYPE_POLICY_ATTACHMENTS,
{
...policyActor(updatedBy),
...policyScope(projectId),
scope_id: scopeId,
attachment_count: after.attachments.length,
floor_attachment_count: after.attachments.filter((a) => a.isFloor).length,
previous_attachment_count: before.attachments.length,
},
);
}
// #endregion
// #region External secrets
private externalSecretsProviderSettingsSaved({

View File

@ -1,5 +1,10 @@
import { evaluateComposedType, evaluateType, type ScopePolicy } from '../policy-evaluator';
import type { PolicyAttachment } from '../policy-rule.types';
import {
evaluateComposedType,
evaluateType,
partitionTypesByAction,
type ScopePolicy,
} from '../policy-evaluator';
import type { PolicyAttachment, PolicyRule } from '../policy-rule.types';
const attachment = (overrides: Partial<PolicyAttachment>): PolicyAttachment => ({
policyId: 'policy-1',
@ -325,3 +330,66 @@ describe('evaluateComposedType', () => {
});
});
});
describe('partitionTypesByAction', () => {
const rule = (id: string, action: PolicyRule['action'], selector: PolicyRule['selector']) => ({
id,
action,
selector,
});
const TYPES = [
'n8n-nodes-base.code',
'n8n-nodes-base.executeCommand',
'@acme/n8n-nodes-acme.thing',
];
it('falls every type back to the default action when no rule matches', () => {
expect(partitionTypesByAction([], 'deny', TYPES)).toEqual({
allow: [],
deny: TYPES,
delegate: [],
});
});
it('expands a package selector across every type in that package', () => {
const rules = [rule('r1', 'deny', { kind: 'package', value: 'n8n-nodes-base' })];
expect(partitionTypesByAction(rules, 'allow', TYPES)).toEqual({
allow: ['@acme/n8n-nodes-acme.thing'],
deny: ['n8n-nodes-base.code', 'n8n-nodes-base.executeCommand'],
delegate: [],
});
});
it('keeps first-match order, so an earlier name rule survives a later package rule', () => {
const rules = [
rule('r1', 'allow', { kind: 'name', value: 'n8n-nodes-base.code' }),
rule('r2', 'deny', { kind: 'package', value: 'n8n-nodes-base' }),
];
expect(partitionTypesByAction(rules, 'allow', TYPES)).toEqual({
allow: ['n8n-nodes-base.code', '@acme/n8n-nodes-acme.thing'],
deny: ['n8n-nodes-base.executeCommand'],
delegate: [],
});
});
it('separates delegated types from allowed and denied ones', () => {
const rules = [rule('r1', 'delegate', { kind: 'name', value: 'n8n-nodes-base.code' })];
expect(partitionTypesByAction(rules, 'allow', TYPES)).toEqual({
allow: ['n8n-nodes-base.executeCommand', '@acme/n8n-nodes-acme.thing'],
deny: [],
delegate: ['n8n-nodes-base.code'],
});
});
it('returns empty buckets when the instance knows no types', () => {
expect(partitionTypesByAction([], 'deny', [])).toEqual({
allow: [],
deny: [],
delegate: [],
});
});
});

View File

@ -242,6 +242,7 @@ describe('TypeAvailabilityPolicyService', () => {
updatedBy: 'user-1',
kind: KIND,
policyId: created.id,
origin: 'document-api',
after: { rules: created.rules, version: created.version },
});
});
@ -358,6 +359,7 @@ describe('TypeAvailabilityPolicyService', () => {
updatedBy: 'user-2',
kind: KIND,
policyId: before.id,
origin: 'document-api',
before: { rules: [], version: 1 },
after: { rules: [RULE], version: 2 },
});
@ -796,15 +798,26 @@ describe('TypeAvailabilityPolicyService', () => {
[{ policyId: createdPolicy.id, priority: 0, isFloor: false }],
ROOT,
);
expect(eventService.emit).toHaveBeenCalledTimes(2);
expect(eventService.emit).toHaveBeenCalledTimes(3);
expect(eventService.emit).toHaveBeenCalledWith(
'node-type-policy-scope-updated',
expect.objectContaining({ before: null }),
);
expect(eventService.emit).toHaveBeenCalledWith(
'node-type-policy-document-created',
expect.objectContaining({ policyId: createdPolicy.id }),
expect.objectContaining({ policyId: createdPolicy.id, origin: 'composed-save' }),
);
expect(eventService.emit).toHaveBeenCalledWith('node-type-policy-saved', {
updatedBy: 'user-1',
kind: KIND,
projectId: null,
scopeId: createdScope.id,
before: null,
after: { defaultAction: 'deny', version: 2 },
rulesBefore: null,
rulesAfter: [RULE],
warningCount: 0,
});
});
it('updates the existing scope and document, emitting both facets', async () => {
@ -835,14 +848,26 @@ describe('TypeAvailabilityPolicyService', () => {
'user-2',
ROOT,
);
expect(eventService.emit).toHaveBeenCalledTimes(2);
expect(eventService.emit).toHaveBeenCalledTimes(3);
expect(eventService.emit).toHaveBeenCalledWith(
'node-type-policy-document-updated',
expect.objectContaining({
origin: 'composed-save',
before: { rules: [], version: 1 },
after: { rules: [RULE], version: 2 },
}),
);
expect(eventService.emit).toHaveBeenCalledWith('node-type-policy-saved', {
updatedBy: 'user-2',
kind: KIND,
projectId: null,
scopeId: scope.id,
before: { defaultAction: 'allow', version: 1 },
after: { defaultAction: 'deny', version: 3 },
rulesBefore: [],
rulesAfter: [RULE],
warningCount: 0,
});
});
it('throws NotFoundError when the document update unexpectedly finds no row', async () => {

View File

@ -3,6 +3,7 @@ import type { NodeTypeAvailabilityScope } from '@n8n/api-types';
import type {
PolicyAction,
PolicyAttachment,
PolicyRule,
PolicySelector,
PolicyVerdict,
} from './policy-rule.types';
@ -78,16 +79,54 @@ export function evaluateType(
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 };
}
}
const matched = firstMatch(attachment.rules, typeName);
if (matched) return matched;
}
return { action: defaultAction, matchedRuleId: null };
}
/** The first rule matching `typeName`, or `null` when none does. */
function firstMatch(rules: readonly PolicyRule[], typeName: string): PolicyVerdict | null {
for (const rule of rules) {
if (selectorMatches(rule.selector, typeName)) {
return { action: rule.action, matchedRuleId: rule.id };
}
}
return null;
}
/**
* Same first-match evaluation as `evaluateType`, for a caller that already holds the flattened
* rule sequence rather than the attachments it came from.
*/
export function evaluateRules(
rules: readonly PolicyRule[],
defaultAction: PolicyAction,
typeName: string,
): PolicyVerdict {
return firstMatch(rules, typeName) ?? { action: defaultAction, matchedRuleId: null };
}
/**
* Sorts every known type name by what one scope's own policy decides for it. At project scope
* this is the project's layer alone, before composition with the instance policy.
*/
export function partitionTypesByAction(
rules: readonly PolicyRule[],
defaultAction: PolicyAction,
typeNames: readonly string[],
): Record<PolicyAction, string[]> {
const partition: Record<PolicyAction, string[]> = { allow: [], deny: [], delegate: [] };
for (const typeName of typeNames) {
partition[evaluateRules(rules, defaultAction, typeName).action].push(typeName);
}
return partition;
}
/**
* Composes an instance scope's verdict with a project scope's, per the finalized RFC:
* conjunction with monotonic restriction. A project can only restrict further, never re-allow

View File

@ -260,6 +260,7 @@ export class TypeAvailabilityPolicyService {
updatedBy,
kind,
policyId: policy.id,
origin: 'document-api',
after: { rules: policy.rules, version: policy.version },
});
@ -346,6 +347,7 @@ export class TypeAvailabilityPolicyService {
updatedBy,
kind: result.existing.kind,
policyId,
origin: 'document-api',
before: { rules: result.existing.rules, version: result.existing.version },
after: { rules: result.updated.rules, version: result.updated.version },
});
@ -662,6 +664,7 @@ export class TypeAvailabilityPolicyService {
updatedBy,
kind,
policyId: result.policyId,
origin: 'composed-save',
after: result.documentAfter,
});
} else {
@ -669,11 +672,24 @@ export class TypeAvailabilityPolicyService {
updatedBy,
kind,
policyId: result.policyId,
origin: 'composed-save',
before: result.documentBefore ?? { rules: [], version: UNCONFIGURED_VERSION },
after: result.documentAfter,
});
}
this.eventService.emit('node-type-policy-saved', {
updatedBy,
kind,
projectId,
scopeId: result.scopeId,
before: result.scopeBefore,
after: result.scopeAfter,
rulesBefore: result.documentBefore?.rules ?? null,
rulesAfter: result.documentAfter.rules,
warningCount: warnings.length,
});
return {
scopeId: result.scopeId,
defaultAction: result.scopeAfter.defaultAction,