mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
feat(gateway): emit security events for device pairing
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user