mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 21:34:44 +00:00
Gateway: align pairing scope checks for read access
This commit is contained in:
53
src/gateway/probe.test.ts
Normal file
53
src/gateway/probe.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const gatewayClientState = vi.hoisted(() => ({
|
||||||
|
options: null as Record<string, unknown> | null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
class MockGatewayClient {
|
||||||
|
private readonly opts: Record<string, unknown>;
|
||||||
|
|
||||||
|
constructor(opts: Record<string, unknown>) {
|
||||||
|
this.opts = opts;
|
||||||
|
gatewayClientState.options = opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
void Promise.resolve()
|
||||||
|
.then(async () => {
|
||||||
|
const onHelloOk = this.opts.onHelloOk;
|
||||||
|
if (typeof onHelloOk === "function") {
|
||||||
|
await onHelloOk();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {}
|
||||||
|
|
||||||
|
async request(method: string): Promise<unknown> {
|
||||||
|
if (method === "system-presence") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("./client.js", () => ({
|
||||||
|
GatewayClient: MockGatewayClient,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { probeGateway } = await import("./probe.js");
|
||||||
|
|
||||||
|
describe("probeGateway", () => {
|
||||||
|
it("connects with operator.read scope", async () => {
|
||||||
|
const result = await probeGateway({
|
||||||
|
url: "ws://127.0.0.1:18789",
|
||||||
|
auth: { token: "secret" },
|
||||||
|
timeoutMs: 1_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(gatewayClientState.options?.scopes).toEqual(["operator.read"]);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { formatErrorMessage } from "../infra/errors.js";
|
|||||||
import type { SystemPresence } from "../infra/system-presence.js";
|
import type { SystemPresence } from "../infra/system-presence.js";
|
||||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||||
import { GatewayClient } from "./client.js";
|
import { GatewayClient } from "./client.js";
|
||||||
|
import { READ_SCOPE } from "./method-scopes.js";
|
||||||
|
|
||||||
export type GatewayProbeAuth = {
|
export type GatewayProbeAuth = {
|
||||||
token?: string;
|
token?: string;
|
||||||
@@ -54,6 +55,7 @@ export async function probeGateway(opts: {
|
|||||||
url: opts.url,
|
url: opts.url,
|
||||||
token: opts.auth?.token,
|
token: opts.auth?.token,
|
||||||
password: opts.auth?.password,
|
password: opts.auth?.password,
|
||||||
|
scopes: [READ_SCOPE],
|
||||||
clientName: GATEWAY_CLIENT_NAMES.CLI,
|
clientName: GATEWAY_CLIENT_NAMES.CLI,
|
||||||
clientVersion: "dev",
|
clientVersion: "dev",
|
||||||
mode: GATEWAY_CLIENT_MODES.PROBE,
|
mode: GATEWAY_CLIENT_MODES.PROBE,
|
||||||
|
|||||||
@@ -948,6 +948,71 @@ describe("gateway server auth/connect", () => {
|
|||||||
restoreGatewayToken(prevToken);
|
restoreGatewayToken(prevToken);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("allows operator.read connect when device is paired with operator.admin", async () => {
|
||||||
|
const { mkdtemp } = await import("node:fs/promises");
|
||||||
|
const { tmpdir } = await import("node:os");
|
||||||
|
const { join } = await import("node:path");
|
||||||
|
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
|
||||||
|
await import("../infra/device-identity.js");
|
||||||
|
const { listDevicePairing } = await import("../infra/device-pairing.js");
|
||||||
|
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
||||||
|
const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-scope-"));
|
||||||
|
const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json"));
|
||||||
|
const client = {
|
||||||
|
id: GATEWAY_CLIENT_NAMES.TEST,
|
||||||
|
version: "1.0.0",
|
||||||
|
platform: "test",
|
||||||
|
mode: GATEWAY_CLIENT_MODES.TEST,
|
||||||
|
};
|
||||||
|
const buildDevice = (scopes: string[]) => {
|
||||||
|
const signedAtMs = Date.now();
|
||||||
|
const payload = buildDeviceAuthPayload({
|
||||||
|
deviceId: identity.deviceId,
|
||||||
|
clientId: client.id,
|
||||||
|
clientMode: client.mode,
|
||||||
|
role: "operator",
|
||||||
|
scopes,
|
||||||
|
signedAtMs,
|
||||||
|
token: "secret",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: identity.deviceId,
|
||||||
|
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
||||||
|
signature: signDevicePayload(identity.privateKeyPem, payload),
|
||||||
|
signedAt: signedAtMs,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const initial = await connectReq(ws, {
|
||||||
|
token: "secret",
|
||||||
|
scopes: ["operator.admin"],
|
||||||
|
client,
|
||||||
|
device: buildDevice(["operator.admin"]),
|
||||||
|
});
|
||||||
|
if (!initial.ok) {
|
||||||
|
await approvePendingPairingIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
|
||||||
|
const ws2 = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||||
|
await new Promise<void>((resolve) => ws2.once("open", resolve));
|
||||||
|
const res = await connectReq(ws2, {
|
||||||
|
token: "secret",
|
||||||
|
scopes: ["operator.read"],
|
||||||
|
client,
|
||||||
|
device: buildDevice(["operator.read"]),
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
ws2.close();
|
||||||
|
|
||||||
|
const list = await listDevicePairing();
|
||||||
|
expect(list.pending).toEqual([]);
|
||||||
|
|
||||||
|
await server.close();
|
||||||
|
restoreGatewayToken(prevToken);
|
||||||
|
});
|
||||||
|
|
||||||
test("allows legacy paired devices missing role/scope metadata", async () => {
|
test("allows legacy paired devices missing role/scope metadata", async () => {
|
||||||
const { resolvePairingPaths, readJsonFile } = await import("../infra/pairing-files.js");
|
const { resolvePairingPaths, readJsonFile } = await import("../infra/pairing-files.js");
|
||||||
const { writeJsonAtomic } = await import("../infra/json-files.js");
|
const { writeJsonAtomic } = await import("../infra/json-files.js");
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { upsertPresence } from "../../../infra/system-presence.js";
|
|||||||
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
|
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
|
||||||
import { rawDataToString } from "../../../infra/ws.js";
|
import { rawDataToString } from "../../../infra/ws.js";
|
||||||
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
|
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
|
||||||
|
import { roleScopesAllow } from "../../../shared/operator-scope-compat.js";
|
||||||
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
|
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
|
||||||
import { resolveRuntimeServiceVersion } from "../../../version.js";
|
import { resolveRuntimeServiceVersion } from "../../../version.js";
|
||||||
import {
|
import {
|
||||||
@@ -743,9 +744,12 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const allowedScopes = new Set(pairedScopes);
|
const scopesAllowed = roleScopesAllow({
|
||||||
const missingScope = scopes.find((scope) => !allowedScopes.has(scope));
|
role,
|
||||||
if (missingScope) {
|
requestedScopes: scopes,
|
||||||
|
allowedScopes: pairedScopes,
|
||||||
|
});
|
||||||
|
if (!scopesAllowed) {
|
||||||
logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes);
|
logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes);
|
||||||
const ok = await requirePairing("scope-upgrade");
|
const ok = await requirePairing("scope-upgrade");
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
|
|||||||
@@ -114,6 +114,31 @@ describe("device pairing tokens", () => {
|
|||||||
expect(mismatch.reason).toBe("token-mismatch");
|
expect(mismatch.reason).toBe("token-mismatch");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("accepts operator.read requests with an operator.admin token scope", async () => {
|
||||||
|
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
||||||
|
await setupPairedOperatorDevice(baseDir, ["operator.admin"]);
|
||||||
|
const paired = await getPairedDevice("device-1", baseDir);
|
||||||
|
const token = requireToken(paired?.tokens?.operator?.token);
|
||||||
|
|
||||||
|
const readOk = await verifyDeviceToken({
|
||||||
|
deviceId: "device-1",
|
||||||
|
token,
|
||||||
|
role: "operator",
|
||||||
|
scopes: ["operator.read"],
|
||||||
|
baseDir,
|
||||||
|
});
|
||||||
|
expect(readOk.ok).toBe(true);
|
||||||
|
|
||||||
|
const writeMismatch = await verifyDeviceToken({
|
||||||
|
deviceId: "device-1",
|
||||||
|
token,
|
||||||
|
role: "operator",
|
||||||
|
scopes: ["operator.write"],
|
||||||
|
baseDir,
|
||||||
|
});
|
||||||
|
expect(writeMismatch).toEqual({ ok: false, reason: "scope-mismatch" });
|
||||||
|
});
|
||||||
|
|
||||||
test("treats multibyte same-length token input as mismatch without throwing", async () => {
|
test("treats multibyte same-length token input as mismatch without throwing", async () => {
|
||||||
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
||||||
await setupPairedOperatorDevice(baseDir, ["operator.read"]);
|
await setupPairedOperatorDevice(baseDir, ["operator.read"]);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { normalizeDeviceAuthScopes } from "../shared/device-auth.js";
|
import { normalizeDeviceAuthScopes } from "../shared/device-auth.js";
|
||||||
|
import { roleScopesAllow } from "../shared/operator-scope-compat.js";
|
||||||
import {
|
import {
|
||||||
createAsyncLock,
|
createAsyncLock,
|
||||||
pruneExpiredPending,
|
pruneExpiredPending,
|
||||||
@@ -152,17 +153,6 @@ function mergeScopes(...items: Array<string[] | undefined>): string[] | undefine
|
|||||||
return [...scopes];
|
return [...scopes];
|
||||||
}
|
}
|
||||||
|
|
||||||
function scopesAllow(requested: string[], allowed: string[]): boolean {
|
|
||||||
if (requested.length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (allowed.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const allowedSet = new Set(allowed);
|
|
||||||
return requested.every((scope) => allowedSet.has(scope));
|
|
||||||
}
|
|
||||||
|
|
||||||
function newToken() {
|
function newToken() {
|
||||||
return generatePairingToken();
|
return generatePairingToken();
|
||||||
}
|
}
|
||||||
@@ -411,7 +401,7 @@ export async function verifyDeviceToken(params: {
|
|||||||
return { ok: false, reason: "token-mismatch" };
|
return { ok: false, reason: "token-mismatch" };
|
||||||
}
|
}
|
||||||
const requestedScopes = normalizeDeviceAuthScopes(params.scopes);
|
const requestedScopes = normalizeDeviceAuthScopes(params.scopes);
|
||||||
if (!scopesAllow(requestedScopes, entry.scopes)) {
|
if (!roleScopesAllow({ role, requestedScopes, allowedScopes: entry.scopes })) {
|
||||||
return { ok: false, reason: "scope-mismatch" };
|
return { ok: false, reason: "scope-mismatch" };
|
||||||
}
|
}
|
||||||
entry.lastUsedAtMs = Date.now();
|
entry.lastUsedAtMs = Date.now();
|
||||||
@@ -442,7 +432,7 @@ export async function ensureDeviceToken(params: {
|
|||||||
}
|
}
|
||||||
const { device, role, tokens, existing } = context;
|
const { device, role, tokens, existing } = context;
|
||||||
if (existing && !existing.revokedAtMs) {
|
if (existing && !existing.revokedAtMs) {
|
||||||
if (scopesAllow(requestedScopes, existing.scopes)) {
|
if (roleScopesAllow({ role, requestedScopes, allowedScopes: existing.scopes })) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
55
src/shared/operator-scope-compat.test.ts
Normal file
55
src/shared/operator-scope-compat.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { roleScopesAllow } from "./operator-scope-compat.js";
|
||||||
|
|
||||||
|
describe("roleScopesAllow", () => {
|
||||||
|
it("treats operator.read as satisfied by read/write/admin scopes", () => {
|
||||||
|
expect(
|
||||||
|
roleScopesAllow({
|
||||||
|
role: "operator",
|
||||||
|
requestedScopes: ["operator.read"],
|
||||||
|
allowedScopes: ["operator.read"],
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
roleScopesAllow({
|
||||||
|
role: "operator",
|
||||||
|
requestedScopes: ["operator.read"],
|
||||||
|
allowedScopes: ["operator.write"],
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
roleScopesAllow({
|
||||||
|
role: "operator",
|
||||||
|
requestedScopes: ["operator.read"],
|
||||||
|
allowedScopes: ["operator.admin"],
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps non-read operator scopes explicit", () => {
|
||||||
|
expect(
|
||||||
|
roleScopesAllow({
|
||||||
|
role: "operator",
|
||||||
|
requestedScopes: ["operator.write"],
|
||||||
|
allowedScopes: ["operator.admin"],
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses strict matching for non-operator roles", () => {
|
||||||
|
expect(
|
||||||
|
roleScopesAllow({
|
||||||
|
role: "node",
|
||||||
|
requestedScopes: ["system.run"],
|
||||||
|
allowedScopes: ["operator.admin", "system.run"],
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
roleScopesAllow({
|
||||||
|
role: "node",
|
||||||
|
requestedScopes: ["system.run"],
|
||||||
|
allowedScopes: ["operator.admin"],
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
46
src/shared/operator-scope-compat.ts
Normal file
46
src/shared/operator-scope-compat.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
const OPERATOR_ROLE = "operator";
|
||||||
|
const OPERATOR_ADMIN_SCOPE = "operator.admin";
|
||||||
|
const OPERATOR_READ_SCOPE = "operator.read";
|
||||||
|
const OPERATOR_WRITE_SCOPE = "operator.write";
|
||||||
|
|
||||||
|
function normalizeScopeList(scopes: readonly string[]): string[] {
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const scope of scopes) {
|
||||||
|
const trimmed = scope.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
out.add(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...out];
|
||||||
|
}
|
||||||
|
|
||||||
|
function operatorScopeSatisfied(requestedScope: string, granted: Set<string>): boolean {
|
||||||
|
if (requestedScope === OPERATOR_READ_SCOPE) {
|
||||||
|
return (
|
||||||
|
granted.has(OPERATOR_READ_SCOPE) ||
|
||||||
|
granted.has(OPERATOR_WRITE_SCOPE) ||
|
||||||
|
granted.has(OPERATOR_ADMIN_SCOPE)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return granted.has(requestedScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roleScopesAllow(params: {
|
||||||
|
role: string;
|
||||||
|
requestedScopes: readonly string[];
|
||||||
|
allowedScopes: readonly string[];
|
||||||
|
}): boolean {
|
||||||
|
const requested = normalizeScopeList(params.requestedScopes);
|
||||||
|
if (requested.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const allowed = normalizeScopeList(params.allowedScopes);
|
||||||
|
if (allowed.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const allowedSet = new Set(allowed);
|
||||||
|
if (params.role.trim() !== OPERATOR_ROLE) {
|
||||||
|
return requested.every((scope) => allowedSet.has(scope));
|
||||||
|
}
|
||||||
|
return requested.every((scope) => operatorScopeSatisfied(scope, allowedSet));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user