feat(gateway): emit security events for device pairing

This commit is contained in:
Vincent Koc
2026-06-16 17:52:21 +08:00
parent d491018a45
commit d6774e46e0
2 changed files with 382 additions and 6 deletions
+112 -6
View File
@@ -1,6 +1,11 @@
// Device method tests cover pairing approval/rejection, paired-device lookup,
// token rotation/revocation, and operator scope enforcement.
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
onInternalDiagnosticEvent,
resetDiagnosticEventsForTest,
type DiagnosticSecurityEvent,
} from "../../infra/diagnostic-events.js";
import { deviceHandlers } from "./devices.js";
import type { GatewayRequestHandlerOptions } from "./types.js";
@@ -122,8 +127,22 @@ function expectRespondedErrorMessage(opts: GatewayRequestHandlerOptions, message
expect(call[2]?.message).toBe(message);
}
function captureSecurityEvents(): {
events: DiagnosticSecurityEvent[];
stop: () => void;
} {
const events: DiagnosticSecurityEvent[] = [];
const stop = onInternalDiagnosticEvent((event, metadata) => {
if (metadata.trusted && event.type === "security.event") {
events.push(event);
}
});
return { events, stop };
}
describe("deviceHandlers", () => {
beforeEach(() => {
resetDiagnosticEventsForTest();
vi.clearAllMocks();
});
@@ -243,15 +262,20 @@ describe("deviceHandlers", () => {
it("disconnects active clients after revoking a device token", async () => {
revokeDeviceTokenMock.mockResolvedValue({
ok: true,
entry: { role: "operator", revokedAtMs: 456 },
entry: { token: "raw-revoked-token", role: "operator", scopes: [], revokedAtMs: 456 },
});
const opts = createOptions("device.token.revoke", {
deviceId: " device-1 ",
role: " operator ",
});
const captured = captureSecurityEvents();
await deviceHandlers["device.token.revoke"](opts);
await Promise.resolve();
try {
await deviceHandlers["device.token.revoke"](opts);
await Promise.resolve();
} finally {
captured.stop();
}
expect(revokeDeviceTokenMock).toHaveBeenCalledWith({
deviceId: " device-1 ",
@@ -266,6 +290,21 @@ describe("deviceHandlers", () => {
{ deviceId: "device-1", role: "operator", revokedAtMs: 456 },
undefined,
);
expect(captured.events).toHaveLength(1);
expect(captured.events[0]).toMatchObject({
type: "security.event",
category: "auth",
action: "device.token.revoked",
outcome: "success",
severity: "high",
target: { kind: "device", idHash: expect.stringMatching(/^sha256:[a-f0-9]{12}$/u) },
policy: { id: "gateway.device-token", decision: "allow" },
control: { id: "device.token.revoke", family: "auth" },
attributes: { role: "operator" },
});
const serialized = JSON.stringify(captured.events);
expect(serialized).not.toContain("device-1");
expect(serialized).not.toContain("raw-revoked-token");
});
it("allows admin-scoped callers to revoke another device's token", async () => {
@@ -299,12 +338,37 @@ describe("deviceHandlers", () => {
{ deviceId: "device-1", role: "node" },
{ client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }) },
);
const captured = captureSecurityEvents();
await deviceHandlers["device.token.revoke"](opts);
try {
await deviceHandlers["device.token.revoke"](opts);
} finally {
captured.stop();
}
expect(revokeDeviceTokenMock).not.toHaveBeenCalled();
expect(opts.context.disconnectClientsForDevice).not.toHaveBeenCalled();
expectRespondedErrorMessage(opts, "device token revocation denied");
expect(captured.events).toHaveLength(1);
expect(captured.events[0]).toMatchObject({
action: "device.token.revocation_denied",
outcome: "denied",
reason: "role-management-requires-admin",
actor: {
kind: "operator",
deviceIdHash: expect.stringMatching(/^sha256:[a-f0-9]{12}$/u),
role: "operator",
},
target: { kind: "device", idHash: expect.stringMatching(/^sha256:[a-f0-9]{12}$/u) },
policy: {
id: "gateway.device-token",
decision: "deny",
reason: "role-management-requires-admin",
},
control: { id: "device.token.revoke", family: "auth" },
attributes: { role: "node" },
});
expect(JSON.stringify(captured.events)).not.toContain("device-1");
});
it("treats normalized device ids as self-owned for token revocation", async () => {
@@ -823,8 +887,13 @@ describe("deviceHandlers", () => {
}),
},
);
const captured = captureSecurityEvents();
await deviceHandlers["device.pair.approve"](opts);
try {
await deviceHandlers["device.pair.approve"](opts);
} finally {
captured.stop();
}
expect(getPendingDevicePairingMock).not.toHaveBeenCalled();
expect(approveDevicePairingMock).toHaveBeenCalledWith("req-2", {
@@ -844,6 +913,25 @@ describe("deviceHandlers", () => {
},
undefined,
);
expect(captured.events).toHaveLength(1);
expect(captured.events[0]).toMatchObject({
action: "device.pairing.approved",
outcome: "success",
severity: "low",
actor: {
kind: "operator",
deviceIdHash: expect.stringMatching(/^sha256:[a-f0-9]{12}$/u),
role: "admin",
},
target: { kind: "device", idHash: expect.stringMatching(/^sha256:[a-f0-9]{12}$/u) },
policy: { id: "gateway.device-pairing", decision: "allow" },
control: { id: "device.pair.approve", family: "auth" },
attributes: { role_count: 0, scope_count: 0 },
});
const serialized = JSON.stringify(captured.events);
expect(serialized).not.toContain("device-1");
expect(serialized).not.toContain("device-2");
expect(serialized).not.toContain("pk-2");
});
it("allows approving the caller device from a non-admin device session", async () => {
@@ -957,11 +1045,29 @@ describe("deviceHandlers", () => {
{ requestId: "req-1" },
{ client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }) },
);
const captured = captureSecurityEvents();
await deviceHandlers["device.pair.approve"](opts);
try {
await deviceHandlers["device.pair.approve"](opts);
} finally {
captured.stop();
}
expect(approveDevicePairingMock).not.toHaveBeenCalled();
expectRespondedErrorMessage(opts, "device pairing approval denied");
expect(captured.events).toHaveLength(1);
expect(captured.events[0]).toMatchObject({
action: "device.pairing.denied",
outcome: "denied",
reason: "role-management-requires-admin",
policy: {
id: "gateway.device-pairing",
decision: "deny",
reason: "role-management-requires-admin",
},
control: { id: "device.pair.approve", family: "auth" },
});
expect(JSON.stringify(captured.events)).not.toContain("device-1");
});
it("rejects approving node roles from non-admin shared-auth sessions", async () => {
+270
View File
@@ -1,4 +1,5 @@
// Gateway RPC handlers for device pairing and device-token lifecycle operations.
import { createHash } from "node:crypto";
import {
ErrorCodes,
errorShape,
@@ -25,6 +26,10 @@ import {
rotateDeviceToken,
summarizeDeviceTokens,
} from "../../infra/device-pairing.js";
import {
emitTrustedSecurityEvent,
type DiagnosticSecurityEventInput,
} from "../../infra/diagnostic-events.js";
import type { GatewayClient, GatewayRequestHandlers } from "./types.js";
const DEVICE_TOKEN_ROTATION_DENIED_MESSAGE = "device token rotation denied";
@@ -40,6 +45,8 @@ type DeviceManagementAuthz = DeviceSessionAuthz & {
normalizedTargetDeviceId: string;
};
type DeviceSecurityDecision = NonNullable<DiagnosticSecurityEventInput["policy"]>["decision"];
const DEVICE_PAIR_APPROVAL_DENIED_MESSAGE = "device pairing approval denied";
const DEVICE_PAIR_REJECTION_DENIED_MESSAGE = "device pairing rejection denied";
@@ -178,6 +185,144 @@ function pairedDeviceHasNonOperatorRole(device: {
return hasNonOperatorDeviceRole(device) || hasNonOperatorDeviceTokenRole(device.tokens);
}
function hashDeviceSecurityId(value: string | undefined): string | undefined {
const normalized = value?.trim();
if (!normalized) {
return undefined;
}
return `sha256:${createHash("sha256").update(normalized).digest("hex").slice(0, 12)}`;
}
function emitDeviceSecurityEvent(params: {
action: string;
outcome: DiagnosticSecurityEventInput["outcome"];
severity: DiagnosticSecurityEventInput["severity"];
authz: DeviceSessionAuthz;
targetDeviceId?: string;
policyId: string;
decision: DeviceSecurityDecision;
controlId: string;
reason?: string;
attributes?: Record<string, string | number | boolean>;
}) {
emitTrustedSecurityEvent({
category: "auth",
action: params.action,
outcome: params.outcome,
severity: params.severity,
actor: {
kind: "operator",
...(params.authz.callerDeviceId
? { deviceIdHash: hashDeviceSecurityId(params.authz.callerDeviceId) }
: {}),
role: params.authz.isAdminCaller ? "admin" : "operator",
},
target: {
kind: "device",
...(params.targetDeviceId ? { idHash: hashDeviceSecurityId(params.targetDeviceId) } : {}),
},
policy: {
id: params.policyId,
decision: params.decision,
...(params.reason ? { reason: params.reason } : {}),
},
control: {
id: params.controlId,
family: "auth",
},
...(params.reason ? { reason: params.reason } : {}),
...(params.attributes ? { attributes: params.attributes } : {}),
});
}
function emitDevicePairingDeniedSecurityEvent(params: {
authz: DeviceSessionAuthz;
targetDeviceId?: string;
controlId: string;
reason: string;
severity?: DiagnosticSecurityEventInput["severity"];
}) {
emitDeviceSecurityEvent({
action: "device.pairing.denied",
outcome: "denied",
severity: params.severity ?? "medium",
authz: params.authz,
targetDeviceId: params.targetDeviceId,
policyId: "gateway.device-pairing",
decision: "deny",
controlId: params.controlId,
reason: params.reason,
});
}
function emitDevicePairingLifecycleSecurityEvent(params: {
action: "device.pairing.approved" | "device.pairing.removed";
severity: DiagnosticSecurityEventInput["severity"];
authz: DeviceSessionAuthz;
targetDeviceId: string;
controlId: string;
attributes?: Record<string, string | number | boolean>;
}) {
emitDeviceSecurityEvent({
action: params.action,
outcome: "success",
severity: params.severity,
authz: params.authz,
targetDeviceId: params.targetDeviceId,
policyId: "gateway.device-pairing",
decision: "allow",
controlId: params.controlId,
attributes: params.attributes,
});
}
function emitDeviceTokenDeniedSecurityEvent(params: {
action: "device.token.rotation_denied" | "device.token.revocation_denied";
authz: DeviceSessionAuthz;
targetDeviceId: string;
controlId: string;
reason: string;
role: string;
}) {
emitDeviceSecurityEvent({
action: params.action,
outcome: "denied",
severity: "medium",
authz: params.authz,
targetDeviceId: params.targetDeviceId,
policyId: "gateway.device-token",
decision: "deny",
controlId: params.controlId,
reason: params.reason,
attributes: { role: params.role.trim() },
});
}
function emitDeviceTokenLifecycleSecurityEvent(params: {
action: "device.token.rotated" | "device.token.revoked";
severity: DiagnosticSecurityEventInput["severity"];
authz: DeviceSessionAuthz;
targetDeviceId: string;
controlId: string;
role: string;
scopeCount?: number;
}) {
emitDeviceSecurityEvent({
action: params.action,
outcome: "success",
severity: params.severity,
authz: params.authz,
targetDeviceId: params.targetDeviceId,
policyId: "gateway.device-token",
decision: "allow",
controlId: params.controlId,
attributes: {
role: params.role,
...(params.scopeCount !== undefined ? { scope_count: params.scopeCount } : {}),
},
});
}
/** Gateway request handlers for device pair approval, removal, token rotation, and revocation. */
export const deviceHandlers: GatewayRequestHandlers = {
"device.pair.list": async ({ params, respond, client }) => {
@@ -244,6 +389,12 @@ export const deviceHandlers: GatewayRequestHandlers = {
context.logGateway.warn(
`device pairing approval denied request=${requestId} reason=device-ownership-mismatch`,
);
emitDevicePairingDeniedSecurityEvent({
authz,
targetDeviceId: pending.deviceId,
controlId: "device.pair.approve",
reason: "device-ownership-mismatch",
});
respond(
false,
undefined,
@@ -255,6 +406,12 @@ export const deviceHandlers: GatewayRequestHandlers = {
context.logGateway.warn(
`device pairing approval denied request=${requestId} reason=role-management-requires-admin`,
);
emitDevicePairingDeniedSecurityEvent({
authz,
targetDeviceId: pending.deviceId,
controlId: "device.pair.approve",
reason: "role-management-requires-admin",
});
respond(
false,
undefined,
@@ -269,6 +426,11 @@ export const deviceHandlers: GatewayRequestHandlers = {
return;
}
if (approved.status === "forbidden") {
emitDevicePairingDeniedSecurityEvent({
authz,
controlId: "device.pair.approve",
reason: approved.reason,
});
respond(
false,
undefined,
@@ -279,6 +441,17 @@ export const deviceHandlers: GatewayRequestHandlers = {
context.logGateway.info(
`device pairing approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
);
emitDevicePairingLifecycleSecurityEvent({
action: "device.pairing.approved",
severity: "low",
authz,
targetDeviceId: approved.device.deviceId,
controlId: "device.pair.approve",
attributes: {
role_count: approved.device.roles?.length ?? (approved.device.role ? 1 : 0),
scope_count: approved.device.approvedScopes?.length ?? approved.device.scopes?.length ?? 0,
},
});
context.broadcast(
"device.pair.resolved",
{
@@ -321,6 +494,12 @@ export const deviceHandlers: GatewayRequestHandlers = {
context.logGateway.warn(
`device pairing rejection denied request=${requestId} reason=device-ownership-mismatch`,
);
emitDevicePairingDeniedSecurityEvent({
authz,
targetDeviceId: pending.deviceId,
controlId: "device.pair.reject",
reason: "device-ownership-mismatch",
});
respond(
false,
undefined,
@@ -334,6 +513,13 @@ export const deviceHandlers: GatewayRequestHandlers = {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
return;
}
emitDevicePairingDeniedSecurityEvent({
authz,
targetDeviceId: rejected.deviceId,
controlId: "device.pair.reject",
reason: "operator-rejected",
severity: "low",
});
context.broadcast(
"device.pair.resolved",
{
@@ -366,6 +552,12 @@ export const deviceHandlers: GatewayRequestHandlers = {
context.logGateway.warn(
`device pairing removal denied device=${deviceId} reason=device-ownership-mismatch`,
);
emitDevicePairingDeniedSecurityEvent({
authz,
targetDeviceId: deviceId,
controlId: "device.pair.remove",
reason: "device-ownership-mismatch",
});
respond(
false,
undefined,
@@ -379,6 +571,12 @@ export const deviceHandlers: GatewayRequestHandlers = {
context.logGateway.warn(
`device pairing removal denied device=${deviceId} reason=role-management-requires-admin`,
);
emitDevicePairingDeniedSecurityEvent({
authz,
targetDeviceId: deviceId,
controlId: "device.pair.remove",
reason: "role-management-requires-admin",
});
respond(
false,
undefined,
@@ -393,6 +591,13 @@ export const deviceHandlers: GatewayRequestHandlers = {
return;
}
context.logGateway.info(`device pairing removed device=${removed.deviceId}`);
emitDevicePairingLifecycleSecurityEvent({
action: "device.pairing.removed",
severity: "medium",
authz,
targetDeviceId: removed.deviceId,
controlId: "device.pair.remove",
});
// Mark affected clients invalid *before* responding so any RPCs already
// pipelined into their WS socket buffer are rejected at the per-request
// dispatch check, closing the race between queueMicrotask-scheduled
@@ -432,6 +637,14 @@ export const deviceHandlers: GatewayRequestHandlers = {
role,
reason: "device-ownership-mismatch",
});
emitDeviceTokenDeniedSecurityEvent({
action: "device.token.rotation_denied",
authz,
targetDeviceId: deviceId,
controlId: "device.token.rotate",
reason: "device-ownership-mismatch",
role,
});
respond(
false,
undefined,
@@ -446,6 +659,14 @@ export const deviceHandlers: GatewayRequestHandlers = {
role,
reason: "role-management-requires-admin",
});
emitDeviceTokenDeniedSecurityEvent({
action: "device.token.rotation_denied",
authz,
targetDeviceId: deviceId,
controlId: "device.token.rotate",
reason: "role-management-requires-admin",
role,
});
respond(
false,
undefined,
@@ -467,6 +688,14 @@ export const deviceHandlers: GatewayRequestHandlers = {
reason: rotated.reason,
scope: rotated.scope,
});
emitDeviceTokenDeniedSecurityEvent({
action: "device.token.rotation_denied",
authz,
targetDeviceId: deviceId,
controlId: "device.token.rotate",
reason: rotated.reason,
role,
});
respond(
false,
undefined,
@@ -478,6 +707,15 @@ export const deviceHandlers: GatewayRequestHandlers = {
context.logGateway.info(
`device token rotated device=${deviceId} role=${entry.role} scopes=${entry.scopes.join(",")}`,
);
emitDeviceTokenLifecycleSecurityEvent({
action: "device.token.rotated",
severity: "medium",
authz,
targetDeviceId: deviceId,
controlId: "device.token.rotate",
role: entry.role,
scopeCount: entry.scopes.length,
});
// Mark affected clients invalid *before* responding so any RPCs already
// pipelined into their WS socket buffer are rejected at the per-request
// dispatch check, closing the race between queueMicrotask-scheduled
@@ -521,6 +759,14 @@ export const deviceHandlers: GatewayRequestHandlers = {
context.logGateway.warn(
`device token revocation denied device=${deviceId} role=${role} reason=device-ownership-mismatch`,
);
emitDeviceTokenDeniedSecurityEvent({
action: "device.token.revocation_denied",
authz,
targetDeviceId: deviceId,
controlId: "device.token.revoke",
reason: "device-ownership-mismatch",
role,
});
respond(
false,
undefined,
@@ -535,6 +781,14 @@ export const deviceHandlers: GatewayRequestHandlers = {
role,
reason: "role-management-requires-admin",
});
emitDeviceTokenDeniedSecurityEvent({
action: "device.token.revocation_denied",
authz,
targetDeviceId: deviceId,
controlId: "device.token.revoke",
reason: "role-management-requires-admin",
role,
});
respond(
false,
undefined,
@@ -551,6 +805,14 @@ export const deviceHandlers: GatewayRequestHandlers = {
reason: revoked.reason,
scope: revoked.scope,
});
emitDeviceTokenDeniedSecurityEvent({
action: "device.token.revocation_denied",
authz,
targetDeviceId: deviceId,
controlId: "device.token.revoke",
reason: revoked.reason,
role,
});
respond(
false,
undefined,
@@ -561,6 +823,14 @@ export const deviceHandlers: GatewayRequestHandlers = {
const entry = revoked.entry;
const normalizedDeviceId = deviceId.trim();
context.logGateway.info(`device token revoked device=${normalizedDeviceId} role=${entry.role}`);
emitDeviceTokenLifecycleSecurityEvent({
action: "device.token.revoked",
severity: "high",
authz,
targetDeviceId: normalizedDeviceId,
controlId: "device.token.revoke",
role: entry.role,
});
// Mark affected clients invalid *before* responding so any RPCs already
// pipelined into their WS socket buffer are rejected at the per-request
// dispatch check, closing the race between queueMicrotask-scheduled