diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cc805c2b743..b8f37dccdab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - Block unsafe Node runtime env overrides [AI]. (#87308) Thanks @pgondhi987. - Telegram: route `sendMessage` action replies through durable outbound delivery so completed agent responses remain retryable when the gateway send path times out. (#87261) Thanks @mbelinky. +- Gateway/security: require `operator.admin` for node and other non-operator device-role pairing approvals, including trusted-proxy sessions, while keeping pairing-only approvals available for operator-role requests. (#87146) ## 2026.5.26 diff --git a/docs/cli/devices.md b/docs/cli/devices.md index 7a87467cb3b0..0be1be675e55 100644 --- a/docs/cli/devices.md +++ b/docs/cli/devices.md @@ -71,9 +71,10 @@ matching client IPs can be approved before they appear in this list. That policy is disabled by default and never applies to operator/browser clients or upgrade requests. -Approving a non-operator device role, such as `role: node`, requires -`operator.admin`. `operator.pairing` is enough for operator-device approvals -only when the requested operator scopes stay within the caller's own scopes. +Approving node or other non-operator device roles requires `operator.admin`. +`operator.pairing` is enough for operator-device approvals only when the +requested operator scopes stay within the caller's own scopes. See +[Operator scopes](/gateway/operator-scopes) for the approval-time checks. ``` openclaw devices approve diff --git a/docs/gateway/operator-scopes.md b/docs/gateway/operator-scopes.md index 25619c1d7cba..e1ca4649ac2a 100644 --- a/docs/gateway/operator-scopes.md +++ b/docs/gateway/operator-scopes.md @@ -70,7 +70,8 @@ When approving a device request: - A request with no operator role does not need operator token scope approval. - A request for a non-operator device role, such as `node`, requires - `operator.admin`. + `operator.admin`, even when `device.pair.approve` is reachable with + `operator.pairing`. - A request for `operator.read`, `operator.write`, `operator.approvals`, `operator.pairing`, or `operator.talk.secrets` requires the caller to hold those scopes, or `operator.admin`. @@ -79,9 +80,9 @@ When approving a device request: token scopes. If that existing token is admin-scoped, approval still requires `operator.admin`. -Non-admin sessions can approve operator-device requests only inside their own -operator scopes. Approving non-operator roles is admin-only even for -shared-secret or trusted-proxy sessions that can otherwise use +Non-admin shared-secret and trusted-proxy sessions can approve operator-device +requests only inside their own declared operator scopes. Approving non-operator +roles is admin-only even when those sessions can otherwise use `operator.pairing`. For paired-device token sessions, management is also self-scoped unless the diff --git a/src/gateway/server-methods/devices.test.ts b/src/gateway/server-methods/devices.test.ts index 9ede2310f9af..a63632d0c0f0 100644 --- a/src/gateway/server-methods/devices.test.ts +++ b/src/gateway/server-methods/devices.test.ts @@ -888,7 +888,7 @@ describe("deviceHandlers", () => { ); }); - it("allows non-device operator sessions to approve operator roles within caller scopes", async () => { + it("allows shared-auth operator sessions to approve operator roles within caller scopes", async () => { getPendingDevicePairingMock.mockResolvedValue({ requestId: "req-1", deviceId: "device-2", @@ -914,7 +914,7 @@ describe("deviceHandlers", () => { const opts = createOptions( "device.pair.approve", { requestId: "req-1" }, - { client: createClient(["operator.pairing"]) }, + { client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: false }) }, ); await deviceHandlers["device.pair.approve"](opts); @@ -941,11 +941,11 @@ describe("deviceHandlers", () => { ); }); - it("rejects approving node roles from non-admin shared-auth sessions", async () => { + it("rejects approving node roles for the caller device without admin scope", async () => { getPendingDevicePairingMock.mockResolvedValue({ requestId: "req-1", - deviceId: "device-2", - publicKey: "pk-2", + deviceId: " device-1 ", + publicKey: "pk-1", role: "node", roles: ["node"], ts: 100, @@ -953,7 +953,28 @@ describe("deviceHandlers", () => { const opts = createOptions( "device.pair.approve", { requestId: "req-1" }, - { client: createClient(["operator.pairing"]) }, + { client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }) }, + ); + + await deviceHandlers["device.pair.approve"](opts); + + expect(approveDevicePairingMock).not.toHaveBeenCalled(); + expectRespondedErrorMessage(opts, "device pairing approval denied"); + }); + + it("rejects approving node roles from non-admin shared-auth sessions", async () => { + getPendingDevicePairingMock.mockResolvedValue({ + requestId: "req-1", + deviceId: "device-1", + publicKey: "pk-1", + role: "node", + roles: ["node"], + ts: 100, + }); + const opts = createOptions( + "device.pair.approve", + { requestId: "req-1" }, + { client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: false }) }, ); await deviceHandlers["device.pair.approve"](opts); @@ -998,27 +1019,6 @@ describe("deviceHandlers", () => { expectRespondedErrorMessage(opts, "device pairing approval denied"); }); - it("rejects approving node roles for the caller device without admin scope", async () => { - getPendingDevicePairingMock.mockResolvedValue({ - requestId: "req-1", - deviceId: " device-1 ", - publicKey: "pk-1", - role: "node", - roles: ["node"], - ts: 100, - }); - const opts = createOptions( - "device.pair.approve", - { requestId: "req-1" }, - { client: createClient(["operator.pairing"], "device-1", { isDeviceTokenAuth: true }) }, - ); - - await deviceHandlers["device.pair.approve"](opts); - - expect(approveDevicePairingMock).not.toHaveBeenCalled(); - expectRespondedErrorMessage(opts, "device pairing approval denied"); - }); - it("rejects rejecting another device from a non-admin device session", async () => { getPendingDevicePairingMock.mockResolvedValue({ requestId: "req-2",