mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
0209caafff
commit
89733cdb9a
67
packages/@n8n/telemetry/src/events/policies.ts
Normal file
67
packages/@n8n/telemetry/src/events/policies.ts
Normal 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(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'] = {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user