diff --git a/packages/@n8n/telemetry/src/events/policies.ts b/packages/@n8n/telemetry/src/events/policies.ts new file mode 100644 index 00000000000..83096344efe --- /dev/null +++ b/packages/@n8n/telemetry/src/events/policies.ts @@ -0,0 +1,67 @@ +import { z } from 'zod/v4'; + +import { defineTelemetryEvents } from '../define'; + +/** Open strings, like the violation they come from, so a new policy feature needs no change here. */ +const blockedViolation = z.object({ + check_id: z.string(), + kind: z.string().describe("e.g. 'node-type-unavailable' or 'credential-type-unavailable'"), + subject_type: z.string().nullable().describe("e.g. 'nodeType' or 'credentialType'"), + subject: z + .string() + .nullable() + .describe('The blocked type name. Null for a subject type that is not a type name'), + scope: z.string().nullable().describe("Which level objected, usually 'instance' or 'project'"), + matched_rule: z + .boolean() + .describe( + "Whether a specific rule decided. False when the scope's default action decided, or for a check that has no rules", + ), +}); + +export const POLICIES_TELEMETRY = defineTelemetryEvents({ + POLICY_BLOCKED_ACTION: { + name: 'Policy blocked action', + description: + 'A policy blocked an action at an enforcement point. Fires once per blocked action, for every policy feature, on the instance that ran the check. Allowed actions do not report. A batch import that reports violations instead of refusing does not fire this event.', + properties: z.object({ + user_id: z + .string() + .optional() + .describe('Absent when the actor is the system, for example inside a run'), + actor_type: z.enum(['user', 'system']), + system_reason: z + .string() + .optional() + .describe("Why the system acted, e.g. 'execution' or 'cli-import'. Absent for a user"), + point: z + .string() + .describe( + "The enforcement point, e.g. 'workflowSave', 'workflowStart', 'credentialDecrypt'", + ), + outcome: z + .enum(['violation', 'checkFailure']) + .describe( + "'checkFailure' means a check did not answer in time or threw, so the action was blocked anyway", + ), + duration_ms: z.number(), + check_ids: z.array(z.string()).describe('Every check consulted at this point'), + violation_count: z.number(), + violations: z + .array(blockedViolation) + .describe( + 'Capped at 50. Compare the length against violation_count to see whether the cap dropped any', + ), + target_type: z.enum(['workflow', 'agent', 'credential']), + workflow_id: z.string().nullable().optional().describe('Null for a create'), + agent_id: z.string().nullable().optional().describe('Null for a create'), + credential_id: z.string().nullable().optional().describe('Null for a create'), + credential_type: z.string().optional(), + consumer_node_type: z + .string() + .optional() + .describe('The node that asked to decrypt the credential'), + project_id: z.string().nullable(), + }), + }, +}); diff --git a/packages/@n8n/telemetry/src/telemetry-events.ts b/packages/@n8n/telemetry/src/telemetry-events.ts index d23835f510e..e31b96aa5ac 100644 --- a/packages/@n8n/telemetry/src/telemetry-events.ts +++ b/packages/@n8n/telemetry/src/telemetry-events.ts @@ -7,12 +7,14 @@ import { INSTANCE_AI_TELEMETRY } from './events/instance-ai'; import { MCP_TELEMETRY } from './events/mcp'; import { MIGRATION_REPORT_TELEMETRY } from './events/migration-report'; import { PLATFORM_TELEMETRY } from './events/platform'; +import { POLICIES_TELEMETRY } from './events/policies'; import { TYPE_AVAILABILITY_POLICIES_TELEMETRY } from './events/type-availability-policies'; import { WORKFLOW_TELEMETRY } from './events/workflow'; import { WORKFLOW_REVIEWS_TELEMETRY } from './events/workflow-reviews'; export const TELEMETRY_EVENT = { PLATFORM: PLATFORM_TELEMETRY, + POLICIES: POLICIES_TELEMETRY, AGENTS: AGENTS_TELEMETRY, CREDENTIALS: CREDENTIALS_TELEMETRY, CONTEXT: CONTEXT_TELEMETRY, 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 4068d260ecc..65ba6a605c6 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -1285,6 +1285,286 @@ describe('TelemetryEventRelay', () => { }); }); + describe('policy enforcement events', () => { + it('should track a user-blocked node-type violation on a workflow save', () => { + const event: RelayEventMap['policy-decision-blocked'] = { + actorType: 'user', + user: { id: 'user-1' }, + point: 'workflowSave', + outcome: 'violation', + durationMs: 12, + checkIds: ['node-type-availability'], + violations: [ + { + checkId: 'node-type-availability', + kind: 'node-type-unavailable', + subject: 'n8n-nodes-base.executeCommand', + subjectType: 'nodeType', + scope: 'instance', + matchedRuleId: 'rule-1', + }, + ], + workflowId: 'workflow-1', + projectId: 'project-1', + }; + + eventService.emit('policy-decision-blocked', event); + + expect(telemetry.track).toHaveBeenCalledWith(TELEMETRY_EVENT.POLICIES.POLICY_BLOCKED_ACTION, { + user_id: 'user-1', + actor_type: 'user', + point: 'workflowSave', + outcome: 'violation', + duration_ms: 12, + check_ids: ['node-type-availability'], + violation_count: 1, + violations: [ + { + check_id: 'node-type-availability', + kind: 'node-type-unavailable', + subject_type: 'nodeType', + subject: 'n8n-nodes-base.executeCommand', + scope: 'instance', + matched_rule: true, + }, + ], + target_type: 'workflow', + workflow_id: 'workflow-1', + agent_id: undefined, + credential_id: undefined, + credential_type: undefined, + consumer_node_type: undefined, + project_id: 'project-1', + }); + }); + + it('should never leak workflow, agent, user, or audit-only names', () => { + const event = { + actorType: 'user', + user: { id: 'user-1', email: 'alice@example.com' }, + point: 'workflowSave', + outcome: 'violation', + durationMs: 12, + checkIds: ['node-type-availability'], + violations: [ + { + checkId: 'node-type-availability', + kind: 'node-type-unavailable', + subject: 'n8n-nodes-base.executeCommand', + subjectType: 'nodeType', + scope: 'instance', + matchedRuleId: 'rule-1', + }, + ], + policyVersions: [{ scope: 'instance', version: 1 }], + correlationIds: ['corr-1'], + workflowId: 'workflow-1', + workflowName: 'My Secret Workflow', + agentName: 'My Agent', + projectId: 'project-1', + } as RelayEventMap['policy-decision-blocked']; + + eventService.emit('policy-decision-blocked', event); + + const [, properties] = vi.mocked(telemetry.track).mock.calls.at(-1)!; + expect(properties).not.toHaveProperty('workflowName'); + expect(properties).not.toHaveProperty('agentName'); + expect(properties).not.toHaveProperty('email'); + expect(properties).not.toHaveProperty('matchedRuleId'); + expect(properties).not.toHaveProperty('policyVersions'); + expect(properties).not.toHaveProperty('correlationIds'); + expect(JSON.stringify(properties)).not.toContain('alice@example.com'); + expect(JSON.stringify(properties)).not.toContain('My Secret Workflow'); + expect(JSON.stringify(properties)).not.toContain('My Agent'); + }); + + it('should report a system actor without a user id', () => { + const event: RelayEventMap['policy-decision-blocked'] = { + actorType: 'system', + user: null, + systemReason: 'execution', + executionId: 'exec-1', + point: 'workflowStart', + outcome: 'violation', + durationMs: 5, + checkIds: ['node-type-availability'], + violations: [], + projectId: null, + }; + + eventService.emit('policy-decision-blocked', event); + + const [, properties] = vi.mocked(telemetry.track).mock.calls.at(-1)!; + expect(properties).not.toHaveProperty('user_id'); + expect(properties).not.toHaveProperty('execution_id'); + expect(properties).toMatchObject({ actor_type: 'system', system_reason: 'execution' }); + }); + + it('should null out the subject for a non-type-name subject type', () => { + const event: RelayEventMap['policy-decision-blocked'] = { + actorType: 'user', + user: { id: 'user-1' }, + point: 'workflowSave', + outcome: 'violation', + durationMs: 5, + checkIds: ['domain-restriction'], + violations: [ + { + checkId: 'domain-restriction', + kind: 'domain-not-allowed', + subject: 'internal.acme.com', + subjectType: 'domain', + scope: 'instance', + matchedRuleId: null, + }, + ], + workflowId: 'workflow-1', + projectId: null, + }; + + eventService.emit('policy-decision-blocked', event); + + expect(telemetry.track).toHaveBeenCalledWith( + TELEMETRY_EVENT.POLICIES.POLICY_BLOCKED_ACTION, + expect.objectContaining({ + violations: [expect.objectContaining({ subject_type: 'domain', subject: null })], + }), + ); + }); + + it("should report matched_rule false when the scope's default action decided", () => { + const event: RelayEventMap['policy-decision-blocked'] = { + actorType: 'user', + user: { id: 'user-1' }, + point: 'workflowSave', + outcome: 'violation', + durationMs: 5, + checkIds: ['node-type-availability'], + violations: [ + { + checkId: 'node-type-availability', + kind: 'node-type-unavailable', + subject: 'n8n-nodes-base.executeCommand', + subjectType: 'nodeType', + scope: 'instance', + matchedRuleId: null, + }, + ], + workflowId: 'workflow-1', + projectId: null, + }; + + eventService.emit('policy-decision-blocked', event); + + expect(telemetry.track).toHaveBeenCalledWith( + TELEMETRY_EVENT.POLICIES.POLICY_BLOCKED_ACTION, + expect.objectContaining({ + violations: [expect.objectContaining({ matched_rule: false })], + }), + ); + }); + + it('should target a credential on a credential decrypt block', () => { + const event: RelayEventMap['policy-decision-blocked'] = { + actorType: 'user', + user: { id: 'user-1' }, + point: 'credentialDecrypt', + outcome: 'violation', + durationMs: 5, + checkIds: ['credential-type-availability'], + violations: [], + credentialId: 'credential-1', + credentialType: 'slackApi', + consumerNodeType: 'n8n-nodes-base.slack', + projectId: 'project-1', + }; + + eventService.emit('policy-decision-blocked', event); + + expect(telemetry.track).toHaveBeenCalledWith( + TELEMETRY_EVENT.POLICIES.POLICY_BLOCKED_ACTION, + expect.objectContaining({ + target_type: 'credential', + credential_type: 'slackApi', + consumer_node_type: 'n8n-nodes-base.slack', + }), + ); + }); + + it('should target an agent on an agent create block', () => { + const event: RelayEventMap['policy-decision-blocked'] = { + actorType: 'user', + user: { id: 'user-1' }, + point: 'workflowSave', + outcome: 'violation', + durationMs: 5, + checkIds: ['node-type-availability'], + violations: [], + agentId: null, + agentName: 'My Agent', + projectId: 'project-1', + }; + + eventService.emit('policy-decision-blocked', event); + + expect(telemetry.track).toHaveBeenCalledWith( + TELEMETRY_EVENT.POLICIES.POLICY_BLOCKED_ACTION, + expect.objectContaining({ target_type: 'agent', agent_id: null }), + ); + }); + + it('should cap listed violations at 50 while keeping the true count', () => { + const violations = Array.from({ length: 60 }, (_, index) => ({ + checkId: `check-${index}`, + kind: 'node-type-unavailable', + subject: `n8n-nodes-base.node${index}`, + subjectType: 'nodeType', + scope: 'instance', + matchedRuleId: null, + })); + + const event: RelayEventMap['policy-decision-blocked'] = { + actorType: 'user', + user: { id: 'user-1' }, + point: 'workflowSave', + outcome: 'violation', + durationMs: 5, + checkIds: ['node-type-availability'], + violations, + workflowId: 'workflow-1', + projectId: null, + }; + + eventService.emit('policy-decision-blocked', event); + + const [, properties] = vi.mocked(telemetry.track).mock.calls.at(-1)!; + expect(properties).toMatchObject({ violation_count: 60 }); + expect((properties as { violations: unknown[] }).violations).toHaveLength(50); + }); + + it('should track a checkFailure outcome without leaking correlation ids', () => { + const event = { + actorType: 'user', + user: { id: 'user-1' }, + point: 'workflowSave', + outcome: 'checkFailure', + durationMs: 5, + checkIds: ['node-type-availability'], + violations: [], + correlationIds: ['corr-1', 'corr-2'], + workflowId: 'workflow-1', + projectId: null, + } as RelayEventMap['policy-decision-blocked']; + + eventService.emit('policy-decision-blocked', event); + + const [, properties] = vi.mocked(telemetry.track).mock.calls.at(-1)!; + expect(properties).toMatchObject({ outcome: 'checkFailure' }); + expect(properties).not.toHaveProperty('correlation_ids'); + expect(properties).not.toHaveProperty('correlationIds'); + }); + }); + 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/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index 4bca4c1d3b5..9ca10b3a032 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -133,6 +133,21 @@ function countRuleActions(rules: readonly PolicyRule[]) { */ const MAX_LISTED_POLICY_TYPES = 100; +/** Keeps a block on a workflow with many blocked nodes under the 32 KB payload cap. */ +const MAX_LISTED_VIOLATIONS = 50; + +/** Subject types whose subject is a type name. Any other subject is open text and stays out. */ +const TYPE_NAME_SUBJECT_TYPES = new Set(['nodeType', 'credentialType']); + +/** An agent or credential create has a `null` id, so test for the key's presence, not its value. */ +function policedTargetType( + event: RelayEventMap['policy-decision-blocked'], +): 'workflow' | 'agent' | 'credential' { + if (event.credentialType !== undefined) return 'credential'; + if (event.agentId !== undefined) return 'agent'; + return 'workflow'; +} + /** * What a saved policy makes of every type this instance knows, within the policy's own kind * (node types or credential types). Runs the same evaluation the node panel runs, once per @@ -239,6 +254,7 @@ export class TelemetryEventRelay extends EventRelay { 'node-type-policy-document-deleted': (event) => this.nodeTypePolicyDocumentDeleted(event), 'node-type-policy-attachments-updated': (event) => this.nodeTypePolicyAttachmentsUpdated(event), + 'policy-decision-blocked': (event) => this.policyDecisionBlocked(event), 'external-secrets-provider-settings-saved': (event) => this.externalSecretsProviderSettingsSaved(event), 'external-secrets-provider-reloaded': (event) => this.externalSecretsProviderReloaded(event), @@ -696,6 +712,42 @@ export class TelemetryEventRelay extends EventRelay { // #endregion + // #region Policy enforcement + + /** Workflow, agent and user names stay in the audit log; only ids and type names leave. */ + private policyDecisionBlocked(event: RelayEventMap['policy-decision-blocked']) { + this.telemetry.track(TELEMETRY_EVENT.POLICIES.POLICY_BLOCKED_ACTION, { + ...(event.actorType === 'user' + ? { user_id: event.user.id, actor_type: 'user' } + : { actor_type: 'system', system_reason: event.systemReason }), + point: event.point, + outcome: event.outcome, + duration_ms: event.durationMs, + check_ids: event.checkIds, + violation_count: event.violations.length, + violations: event.violations.slice(0, MAX_LISTED_VIOLATIONS).map((violation) => ({ + check_id: violation.checkId, + kind: violation.kind, + subject_type: violation.subjectType, + subject: + violation.subjectType !== null && TYPE_NAME_SUBJECT_TYPES.has(violation.subjectType) + ? violation.subject + : null, + scope: violation.scope, + matched_rule: violation.matchedRuleId !== null, + })), + target_type: policedTargetType(event), + workflow_id: event.workflowId, + agent_id: event.agentId, + credential_id: event.credentialId, + credential_type: event.credentialType, + consumer_node_type: event.consumerNodeType, + project_id: event.projectId, + }); + } + + // #endregion + // #region External secrets private externalSecretsProviderSettingsSaved({