refactor(gateway): centralize node pairing ownership checks (#108929)

This commit is contained in:
Peter Steinberger
2026-07-16 04:18:55 -07:00
committed by GitHub
parent 0d31fbf5f0
commit f76a0158c8
2 changed files with 67 additions and 65 deletions
+55 -56
View File
@@ -370,6 +370,43 @@ function emitNodePairingDeniedSecurityEvent(params: {
});
}
async function enforcePendingNodePairingOwnership(params: {
requestId: string;
mutation: "approve" | "reject";
client: GatewayClient | null;
context: Pick<GatewayRequestContext, "logGateway">;
respond: RespondFn;
}): Promise<boolean> {
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"));
+12 -9
View File
@@ -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<string, PairedDevice>,
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<ApproveNodePairingResult> {
return await withPairedDeviceRecords<ApproveNodePairingResult>(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 };
}