From 1020dfd1889e49dcc24097592365de6ca5472903 Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Mon, 14 Sep 2026 07:26:52 +0000 Subject: [PATCH] feat(core): Add node type policy telemetry events (no-changelog) (#38331) Co-authored-by: Claude Opus 5 (1M context) --- .../src/events/node-type-policies.ts | 106 ++++++ .../@n8n/telemetry/src/telemetry-events.ts | 2 + .../__tests__/telemetry-event-relay.test.ts | 355 ++++++++++++++++++ .../cli/src/events/maps/relay.event-map.ts | 26 ++ .../events/relays/telemetry.event-relay.ts | 177 +++++++++ .../__tests__/policy-evaluator.test.ts | 72 +++- .../type-availability-policy.service.test.ts | 31 +- .../policy-evaluator.ts | 49 ++- .../type-availability-policy.service.ts | 16 + 9 files changed, 824 insertions(+), 10 deletions(-) create mode 100644 packages/@n8n/telemetry/src/events/node-type-policies.ts diff --git a/packages/@n8n/telemetry/src/events/node-type-policies.ts b/packages/@n8n/telemetry/src/events/node-type-policies.ts new file mode 100644 index 00000000000..4c242f5a786 --- /dev/null +++ b/packages/@n8n/telemetry/src/events/node-type-policies.ts @@ -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(), + }), + }, +}); diff --git a/packages/@n8n/telemetry/src/telemetry-events.ts b/packages/@n8n/telemetry/src/telemetry-events.ts index d19226724ec..52cc877e611 100644 --- a/packages/@n8n/telemetry/src/telemetry-events.ts +++ b/packages/@n8n/telemetry/src/telemetry-events.ts @@ -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; diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index 6923fe705c6..80e14494a39 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -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; + + 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'] = { diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index 9b85808fa63..2f549fd0689 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -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; diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index cdf257c348d..8e1dbb1c5ea 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -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({ diff --git a/packages/cli/src/modules/type-availability-policies/__tests__/policy-evaluator.test.ts b/packages/cli/src/modules/type-availability-policies/__tests__/policy-evaluator.test.ts index add1824b1de..877c1c5f88d 100644 --- a/packages/cli/src/modules/type-availability-policies/__tests__/policy-evaluator.test.ts +++ b/packages/cli/src/modules/type-availability-policies/__tests__/policy-evaluator.test.ts @@ -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 => ({ 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: [], + }); + }); +}); diff --git a/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policy.service.test.ts b/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policy.service.test.ts index b48221c4ce8..190370cb800 100644 --- a/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policy.service.test.ts +++ b/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policy.service.test.ts @@ -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 () => { diff --git a/packages/cli/src/modules/type-availability-policies/policy-evaluator.ts b/packages/cli/src/modules/type-availability-policies/policy-evaluator.ts index 22f6c2394ba..ae04d5f8e19 100644 --- a/packages/cli/src/modules/type-availability-policies/policy-evaluator.ts +++ b/packages/cli/src/modules/type-availability-policies/policy-evaluator.ts @@ -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 { + const partition: Record = { 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 diff --git a/packages/cli/src/modules/type-availability-policies/type-availability-policy.service.ts b/packages/cli/src/modules/type-availability-policies/type-availability-policy.service.ts index e4d748d99c6..93ae89f4e05 100644 --- a/packages/cli/src/modules/type-availability-policies/type-availability-policy.service.ts +++ b/packages/cli/src/modules/type-availability-policies/type-availability-policy.service.ts @@ -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,