Skip to content

Commit 517ce3d

Browse files
committed
fix: require admin for node device approvals
1 parent 983759b commit 517ce3d

6 files changed

Lines changed: 390 additions & 10 deletions

File tree

docs/cli/devices.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ matching client IPs can be approved before they appear in this list. That policy
7171
is disabled by default and never applies to operator/browser clients or upgrade
7272
requests.
7373

74+
Approving a non-operator device role, such as `role: node`, requires
75+
`operator.admin`. `operator.pairing` is enough for operator-device approvals
76+
only when the requested operator scopes stay within the caller's own scopes.
77+
7478
```
7579
openclaw devices approve
7680
openclaw devices approve <requestId>
@@ -169,7 +173,8 @@ When you set `--url`, the CLI does not fall back to config or environment creden
169173
- Token rotation returns a new token (sensitive). Treat it like a secret.
170174
- These commands require `operator.pairing` (or `operator.admin`) scope. Some
171175
approvals also require the caller to hold the operator scopes that the target
172-
device would mint or inherit; see [Operator scopes](/gateway/operator-scopes).
176+
device would mint or inherit. Non-operator device roles require
177+
`operator.admin`; see [Operator scopes](/gateway/operator-scopes).
173178
- `gateway.nodes.pairing.autoApproveCidrs` is an opt-in Gateway policy for
174179
fresh node device pairing only; it does not change CLI approval authority.
175180
- Token rotation and revocation stay inside the approved pairing role set and

docs/gateway/operator-scopes.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ for a broader role or broader scopes create a new pending upgrade request.
6969
When approving a device request:
7070

7171
- A request with no operator role does not need operator token scope approval.
72+
- A request for a non-operator device role, such as `node`, requires
73+
`operator.admin`.
7274
- A request for `operator.read`, `operator.write`, `operator.approvals`,
7375
`operator.pairing`, or `operator.talk.secrets` requires the caller to hold
7476
those scopes, or `operator.admin`.
@@ -77,10 +79,15 @@ When approving a device request:
7779
token scopes. If that existing token is admin-scoped, approval still requires
7880
`operator.admin`.
7981

80-
For paired-device token sessions, management is self-scoped unless the caller
81-
also has `operator.admin`: non-admin callers see only their own pairing entries,
82-
can approve or reject only their own pending request, and can rotate, revoke, or
83-
remove only their own device entry.
82+
Non-admin sessions can approve operator-device requests only inside their own
83+
operator scopes. Approving non-operator roles is admin-only even for
84+
shared-secret or trusted-proxy sessions that can otherwise use
85+
`operator.pairing`.
86+
87+
For paired-device token sessions, management is also self-scoped unless the
88+
caller has `operator.admin`: non-admin callers see only their own pairing
89+
entries, can approve or reject only their own pending request, and can rotate,
90+
revoke, or remove only their own device entry.
8491

8592
## Node pairing approvals
8693

src/gateway/device-authz.test-helpers.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,11 @@ export async function issueOperatorToken(params: {
109109
};
110110
}
111111

112-
export async function openTrackedWs(port: number): Promise<WebSocket> {
113-
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
112+
export async function openTrackedWs(
113+
port: number,
114+
headers?: Record<string, string>,
115+
): Promise<WebSocket> {
116+
const ws = new WebSocket(`ws://127.0.0.1:${port}`, headers ? { headers } : undefined);
114117
trackConnectChallengeNonce(ws);
115118
await new Promise<void>((resolve, reject) => {
116119
const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 5_000);

src/gateway/server-methods/devices.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,116 @@ describe("deviceHandlers", () => {
888888
);
889889
});
890890

891+
it("allows non-device operator sessions to approve operator roles within caller scopes", async () => {
892+
getPendingDevicePairingMock.mockResolvedValue({
893+
requestId: "req-1",
894+
deviceId: "device-2",
895+
publicKey: "pk-2",
896+
role: "operator",
897+
roles: ["operator"],
898+
scopes: ["operator.pairing"],
899+
ts: 100,
900+
});
901+
approveDevicePairingMock.mockResolvedValue({
902+
status: "approved",
903+
requestId: "req-1",
904+
device: {
905+
deviceId: "device-2",
906+
publicKey: "pk-2",
907+
role: "operator",
908+
roles: ["operator"],
909+
approvedScopes: ["operator.pairing"],
910+
approvedAtMs: 100,
911+
createdAtMs: 50,
912+
},
913+
});
914+
const opts = createOptions(
915+
"device.pair.approve",
916+
{ requestId: "req-1" },
917+
{ client: createClient(["operator.pairing"]) },
918+
);
919+
920+
await deviceHandlers["device.pair.approve"](opts);
921+
922+
expect(getPendingDevicePairingMock).toHaveBeenCalledWith("req-1");
923+
expect(approveDevicePairingMock).toHaveBeenCalledWith("req-1", {
924+
callerScopes: ["operator.pairing"],
925+
});
926+
expect(opts.respond).toHaveBeenCalledWith(
927+
true,
928+
{
929+
requestId: "req-1",
930+
device: {
931+
deviceId: "device-2",
932+
publicKey: "pk-2",
933+
role: "operator",
934+
roles: ["operator"],
935+
approvedAtMs: 100,
936+
createdAtMs: 50,
937+
tokens: undefined,
938+
},
939+
},
940+
undefined,
941+
);
942+
});
943+
944+
it("rejects approving node roles from non-admin shared-auth sessions", async () => {
945+
getPendingDevicePairingMock.mockResolvedValue({
946+
requestId: "req-1",
947+
deviceId: "device-2",
948+
publicKey: "pk-2",
949+
role: "node",
950+
roles: ["node"],
951+
ts: 100,
952+
});
953+
const opts = createOptions(
954+
"device.pair.approve",
955+
{ requestId: "req-1" },
956+
{ client: createClient(["operator.pairing"]) },
957+
);
958+
959+
await deviceHandlers["device.pair.approve"](opts);
960+
961+
expect(approveDevicePairingMock).not.toHaveBeenCalled();
962+
expectRespondedErrorMessage(opts, "device pairing approval denied");
963+
});
964+
965+
it("rejects approving mixed operator and node roles from non-admin sessions", async () => {
966+
getPendingDevicePairingMock.mockResolvedValue({
967+
requestId: "req-1",
968+
deviceId: "device-2",
969+
publicKey: "pk-2",
970+
role: "operator",
971+
roles: [" operator ", " node "],
972+
scopes: ["operator.pairing"],
973+
ts: 100,
974+
});
975+
const opts = createOptions(
976+
"device.pair.approve",
977+
{ requestId: "req-1" },
978+
{ client: createClient(["operator.pairing"]) },
979+
);
980+
981+
await deviceHandlers["device.pair.approve"](opts);
982+
983+
expect(approveDevicePairingMock).not.toHaveBeenCalled();
984+
expectRespondedErrorMessage(opts, "device pairing approval denied");
985+
});
986+
987+
it("denies unknown approvals from non-admin non-device sessions", async () => {
988+
getPendingDevicePairingMock.mockResolvedValue(null);
989+
const opts = createOptions(
990+
"device.pair.approve",
991+
{ requestId: "missing" },
992+
{ client: createClient(["operator.pairing"]) },
993+
);
994+
995+
await deviceHandlers["device.pair.approve"](opts);
996+
997+
expect(approveDevicePairingMock).not.toHaveBeenCalled();
998+
expectRespondedErrorMessage(opts, "device pairing approval denied");
999+
});
1000+
8911001
it("rejects approving node roles for the caller device without admin scope", async () => {
8921002
getPendingDevicePairingMock.mockResolvedValue({
8931003
requestId: "req-1",

src/gateway/server-methods/devices.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
222222
}
223223
const { requestId } = params as { requestId: string };
224224
const authz = resolveDeviceSessionAuthz(client);
225-
if (authz.callerDeviceId && !authz.isAdminCaller) {
225+
if (!authz.isAdminCaller) {
226226
const pending = await getPendingDevicePairing(requestId);
227227
if (!pending) {
228228
respond(
@@ -232,7 +232,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
232232
);
233233
return;
234234
}
235-
if (pending.deviceId.trim() !== authz.callerDeviceId) {
235+
if (authz.callerDeviceId && pending.deviceId.trim() !== authz.callerDeviceId) {
236236
context.logGateway.warn(
237237
`device pairing approval denied request=${requestId} reason=device-ownership-mismatch`,
238238
);

0 commit comments

Comments
 (0)