feat(core): Send telemetry when a policy blocks an action (no-changelog) (#40871)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guillaume Jacquart 2026-10-12 14:51:40 +00:00 committed by GitHub
parent 0209caafff
commit 89733cdb9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 401 additions and 0 deletions

View File

@ -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(),
}),
},
});

View File

@ -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,

View File

@ -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'] = {

View File

@ -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({