diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index d97ffbfee17c..357b9b5659a4 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -370,6 +370,43 @@ function emitNodePairingDeniedSecurityEvent(params: { }); } +async function enforcePendingNodePairingOwnership(params: { + requestId: string; + mutation: "approve" | "reject"; + client: GatewayClient | null; + context: Pick; + respond: RespondFn; +}): Promise { + const action = params.mutation === "approve" ? "approval" : "rejection"; + const controlId = params.mutation === "approve" ? "node.pair.approve" : "node.pair.reject"; + const deniedMessage = `node pairing ${action} denied`; + const pending = await getPendingNodePairing(params.requestId); + const sessionAuthz = resolveDeviceSessionAuthz(params.client); + if (!pending) { + if (sessionAuthz.callerDeviceId && !sessionAuthz.isAdminCaller) { + params.respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, deniedMessage)); + return false; + } + return true; + } + + const authz = resolveDeviceManagementAuthz(params.client, pending.nodeId); + if (!deniesCrossDeviceManagement(authz)) { + return true; + } + params.context.logGateway.warn( + `${deniedMessage} node=${pending.nodeId} reason=device-ownership-mismatch`, + ); + emitNodePairingDeniedSecurityEvent({ + authz, + nodeId: pending.nodeId, + controlId, + reason: "device-ownership-mismatch", + }); + params.respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, deniedMessage)); + return false; +} + function emitNodeRoleRemovalSecurityEvent(params: { authz: DeviceManagementAuthz; deviceId: string; @@ -916,36 +953,17 @@ export const nodeHandlers: GatewayRequestHandlers = { // Intentionally fail closed for RPC callers without an explicit scoped session. const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : []; await respondUnavailableOnThrow(respond, async () => { - const pending = await getPendingNodePairing(requestId); - const sessionAuthz = resolveDeviceSessionAuthz(client); - if (!pending && sessionAuthz.callerDeviceId && !sessionAuthz.isAdminCaller) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "node pairing approval denied"), - ); + if ( + !(await enforcePendingNodePairingOwnership({ + requestId, + mutation: "approve", + client, + context, + respond, + })) + ) { return; } - if (pending) { - const authz = resolveDeviceManagementAuthz(client, pending.nodeId); - if (deniesCrossDeviceManagement(authz)) { - context.logGateway.warn( - `node pairing approval denied node=${pending.nodeId} reason=device-ownership-mismatch`, - ); - emitNodePairingDeniedSecurityEvent({ - authz, - nodeId: pending.nodeId, - controlId: "node.pair.approve", - reason: "device-ownership-mismatch", - }); - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "node pairing approval denied"), - ); - return; - } - } const approved = await approveNodePairing(requestId, { callerScopes }); if (!approved) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId")); @@ -1011,36 +1029,17 @@ export const nodeHandlers: GatewayRequestHandlers = { } const { requestId } = params as { requestId: string }; await respondUnavailableOnThrow(respond, async () => { - const pending = await getPendingNodePairing(requestId); - const sessionAuthz = resolveDeviceSessionAuthz(client); - if (!pending && sessionAuthz.callerDeviceId && !sessionAuthz.isAdminCaller) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "node pairing rejection denied"), - ); + if ( + !(await enforcePendingNodePairingOwnership({ + requestId, + mutation: "reject", + client, + context, + respond, + })) + ) { return; } - if (pending) { - const authz = resolveDeviceManagementAuthz(client, pending.nodeId); - if (deniesCrossDeviceManagement(authz)) { - context.logGateway.warn( - `node pairing rejection denied node=${pending.nodeId} reason=device-ownership-mismatch`, - ); - emitNodePairingDeniedSecurityEvent({ - authz, - nodeId: pending.nodeId, - controlId: "node.pair.reject", - reason: "device-ownership-mismatch", - }); - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "node pairing rejection denied"), - ); - return; - } - } const rejected = await rejectNodePairing(requestId); if (!rejected) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId")); diff --git a/src/infra/node-pairing.ts b/src/infra/node-pairing.ts index 78bfbeca0ee8..34448998cf94 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -489,6 +489,15 @@ type ApprovedNodePairingResult = { requestId: string; node: NodePairingPairedNod type ForbiddenNodePairingResult = { status: "forbidden"; missingScope: string }; type ApproveNodePairingResult = ApprovedNodePairingResult | ForbiddenNodePairingResult | null; +function findPendingNodePairingDevice( + pairedByDeviceId: Record, + requestId: string, +): PairedDevice | undefined { + return Object.values(pairedByDeviceId).find( + (device) => device.pendingNodeSurface?.requestId === requestId, + ); +} + /** Approve a pending node request when caller scopes cover the requested command surface. */ export async function approveNodePairing( requestId: string, @@ -496,9 +505,7 @@ export async function approveNodePairing( baseDir?: string, ): Promise { return await withPairedDeviceRecords(baseDir, (pairedByDeviceId) => { - const device = Object.values(pairedByDeviceId).find( - (entry) => entry.pendingNodeSurface?.requestId === requestId, - ); + const device = findPendingNodePairingDevice(pairedByDeviceId, requestId); const pending = device?.pendingNodeSurface; if (!device || !pending) { return { value: null, persist: false }; @@ -548,9 +555,7 @@ export async function rejectNodePairing( baseDir?: string, ): Promise<{ requestId: string; nodeId: string } | null> { return await withPairedDeviceRecords(baseDir, (pairedByDeviceId) => { - const device = Object.values(pairedByDeviceId).find( - (entry) => entry.pendingNodeSurface?.requestId === requestId, - ); + const device = findPendingNodePairingDevice(pairedByDeviceId, requestId); if (!device) { return { value: null, persist: false }; } @@ -565,9 +570,7 @@ export async function getPendingNodePairing( baseDir?: string, ): Promise<{ requestId: string; nodeId: string } | null> { return await withPairedDeviceRecords(baseDir, (pairedByDeviceId) => { - const device = Object.values(pairedByDeviceId).find( - (entry) => entry.pendingNodeSurface?.requestId === requestId, - ); + const device = findPendingNodePairingDevice(pairedByDeviceId, requestId); if (!device?.pendingNodeSurface) { return { value: null, persist: false }; }