fix(gateway): require admin for device role approvals (#87146)

* fix(gateway): require admin for device role approvals

* fix(gateway): add trusted-proxy approval proof
This commit is contained in:
Agustin Rivera
2026-05-27 08:08:51 -07:00
committed by GitHub
parent 91590132f6
commit 0d0bddf032
4 changed files with 37 additions and 34 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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",