mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
refactor(gateway): centralize node pairing ownership checks (#108929)
This commit is contained in:
committed by
GitHub
parent
0d31fbf5f0
commit
f76a0158c8
@@ -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"));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user