mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(gateway): add least-privilege voice-node pairing (#115712)
* feat(gateway): add least-privilege voice-node pairing * chore: leave voice-node note to release * fix(gateway): keep bootstrap helpers private * fix(ci): remove unused Code Mode type export * fix(ci): remove stale model matrix import * fix(ci): align QA evidence timeout guard
This commit is contained in:
committed by
GitHub
parent
4a108155c5
commit
e1a00726a3
+5
-1
@@ -16,6 +16,7 @@ openclaw qr --setup-code-only
|
||||
openclaw qr --json
|
||||
openclaw qr --remote
|
||||
openclaw qr --limited
|
||||
openclaw qr --voice-node
|
||||
openclaw qr --url wss://gateway.example/ws
|
||||
```
|
||||
|
||||
@@ -36,11 +37,12 @@ openclaw devices approve <requestId>
|
||||
- `--token <token>`: override the gateway token the bootstrap flow authenticates against
|
||||
- `--password <password>`: override the gateway password the bootstrap flow authenticates against
|
||||
- `--limited`: omit administrative Gateway access from the handed-off operator token
|
||||
- `--voice-node`: issue node credentials plus only `operator.read` and `operator.talk`
|
||||
- `--setup-code-only`: print only the setup code
|
||||
- `--no-ascii`: skip ASCII QR rendering
|
||||
- `--json`: emit JSON (`setupCode`, `gatewayUrl`, optional `gatewayUrls`, `auth`, `access`, optional `accessDowngraded`, `urlSource`)
|
||||
|
||||
`--token` and `--password` are mutually exclusive.
|
||||
`--token` and `--password` are mutually exclusive. `--limited` and `--voice-node` are mutually exclusive.
|
||||
|
||||
## Setup code contents
|
||||
|
||||
@@ -51,6 +53,8 @@ The setup code carries an opaque, short-lived `bootstrapToken`, not the shared g
|
||||
|
||||
Use `--limited` to keep the same node token while omitting `operator.admin` from the operator handoff. Pairing-mutation scope is never handed off by a setup code.
|
||||
|
||||
Use `--voice-node` for an embedded or room voice client. It keeps the node token and hands off a separate operator token limited to `operator.read` and `operator.talk`; it cannot send messages, mutate configuration, or invoke general write-scoped Gateway methods.
|
||||
|
||||
Plaintext LAN `ws://` setup remains available, but OpenClaw automatically uses
|
||||
the limited profile because a network observer could capture and race the bearer
|
||||
bootstrap token. Configure `wss://` or Tailscale Serve, then generate a new code
|
||||
|
||||
@@ -37,6 +37,7 @@ require the `node` role.
|
||||
| `operator.pairing` | Device and node pairing management: list, approve, reject, remove, rotate, revoke. |
|
||||
| `operator.approvals` | Exec and plugin approval APIs. |
|
||||
| `operator.questions` | Listing, reading, answering, and resolving interactive questions. |
|
||||
| `operator.talk` | Creating, steering, and closing Talk sessions without general Gateway write access. `operator.write` also satisfies this scope. |
|
||||
| `operator.talk.secrets` | Reading Talk configuration with secrets included. |
|
||||
|
||||
Unknown future `operator.*` scopes require an exact match unless the caller
|
||||
@@ -54,6 +55,8 @@ dispatch so authorization failures have one canonical structured response:
|
||||
`operator.admin` for `browser.proxy`, `fs.listDir`, and `terminal.upload`.
|
||||
- `talk.config` needs `operator.read`; `includeSecrets: true` also needs
|
||||
`operator.talk.secrets`.
|
||||
- `talk.client.*`, `talk.session.*`, `talk.speak`, and `talk.mode` need
|
||||
`operator.talk` (or the compatible broader `operator.write`).
|
||||
|
||||
Some handlers then apply stricter checks based on the concrete thing being
|
||||
approved or mutated:
|
||||
@@ -88,7 +91,8 @@ Approving a device request:
|
||||
`operator.admin`, even though `device.pair.approve` itself only needs
|
||||
`operator.pairing`.
|
||||
- A request for `operator.read`, `operator.write`, `operator.approvals`,
|
||||
`operator.questions`, `operator.pairing`, or `operator.talk.secrets` requires
|
||||
`operator.questions`, `operator.pairing`, `operator.talk`, or
|
||||
`operator.talk.secrets` requires
|
||||
the caller to already hold that scope, or `operator.admin`.
|
||||
- A request for `operator.admin` requires `operator.admin`.
|
||||
- A repair request with no explicit scopes can inherit the existing operator
|
||||
|
||||
@@ -207,8 +207,9 @@ operator token:
|
||||
```
|
||||
|
||||
This operator handoff is bounded on purpose: enough to start the mobile
|
||||
operator loop and native setup, including `operator.talk.secrets` for Talk
|
||||
config reads, but no pairing-mutation scopes and no `operator.admin`. Broader
|
||||
operator loop and native setup, with `operator.write` satisfying Talk sessions
|
||||
and `operator.talk.secrets` covering Talk config reads, but no pairing-mutation scopes
|
||||
and no `operator.admin`. Broader
|
||||
pairing/admin access needs a separate approved pairing or token flow. Persist
|
||||
`hello-ok.auth.deviceTokens` only when bootstrap auth ran over a trusted
|
||||
transport (`wss://` or loopback/local pairing).
|
||||
@@ -311,8 +312,13 @@ Operator scopes (`src/gateway/operator-scopes.ts`), the full closed set:
|
||||
- `operator.admin`
|
||||
- `operator.approvals`
|
||||
- `operator.pairing`
|
||||
- `operator.talk`
|
||||
- `operator.talk.secrets`
|
||||
|
||||
`operator.write` continues to satisfy `operator.talk` for compatibility with
|
||||
existing clients. Voice-device setup can issue the narrower Talk grant without
|
||||
general Gateway write access.
|
||||
|
||||
`talk.config` with `includeSecrets: true` requires `operator.talk.secrets` (or
|
||||
`operator.admin`). When secrets are included, read the active Talk provider
|
||||
credential from `talk.resolved.config.apiKey`; `talk.providers.<id>.apiKey`
|
||||
@@ -520,7 +526,7 @@ methods. Treat this as feature discovery, not a full enumeration of
|
||||
<Accordion title="Talk and TTS">
|
||||
- `talk.catalog` returns the read-only Talk provider catalog for speech, streaming transcription, and realtime voice: canonical provider ids, registry aliases, labels, configured state, an optional group-level `ready` result, exposed model/voice ids, canonical modes, transports, brain strategies, and realtime audio/capability flags, without returning provider secrets or mutating global config. Current gateways set `ready` after applying runtime provider selection; treat its absence as unverified on older gateways.
|
||||
- `talk.config` returns the effective Talk config payload; `includeSecrets` requires `operator.talk.secrets` (or `operator.admin`).
|
||||
- `talk.session.create` creates a gateway-owned Talk session for `realtime/gateway-relay`, `transcription/gateway-relay`, or `stt-tts/managed-room`. For `stt-tts/managed-room`, `operator.write` callers that pass `sessionKey` must also pass `spawnedBy` for scoped session-key visibility; unscoped `sessionKey` creation and `brain: "direct-tools"` require `operator.admin`.
|
||||
- `talk.session.create` (`operator.talk`) creates a gateway-owned Talk session for `realtime/gateway-relay`, `transcription/gateway-relay`, or `stt-tts/managed-room`. For `stt-tts/managed-room`, non-admin callers that pass `sessionKey` must also pass `spawnedBy` for scoped session-key visibility; unscoped `sessionKey` creation and `brain: "direct-tools"` require `operator.admin`.
|
||||
- `talk.session.join` validates a managed-room session token, emits `session.ready` or `session.replaced` as needed, and returns room/session metadata plus recent Talk events, never the plaintext token or its hash.
|
||||
- `talk.session.appendAudio` appends base64 PCM input audio to gateway-owned realtime relay and transcription sessions.
|
||||
- `talk.session.startTurn`, `talk.session.endTurn`, and `talk.session.cancelTurn` drive managed-room turn lifecycle with stale-turn rejection before state clears.
|
||||
|
||||
@@ -5,6 +5,7 @@ import { encodePairingSetupCode } from "../pairing/setup-code.js";
|
||||
import {
|
||||
FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
} from "../shared/device-bootstrap-profile.js";
|
||||
import { createCliRuntimeCapture, mockRuntimeModule } from "./test-runtime-capture.js";
|
||||
|
||||
@@ -244,6 +245,37 @@ describe("registerQrCli", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the least-privilege bootstrap profile with --voice-node", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "127.0.0.1",
|
||||
auth: { mode: "token", token: "tok" },
|
||||
},
|
||||
});
|
||||
|
||||
await runQr(["--setup-code-only", "--voice-node"]);
|
||||
|
||||
expect(issueDeviceBootstrapToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ profile: VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects combining --limited with --voice-node", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "127.0.0.1",
|
||||
auth: { mode: "token", token: "tok" },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(runQr(["--setup-code-only", "--limited", "--voice-node"])).rejects.toThrow("exit");
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"Error: Use either --limited or --voice-node, not both.",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders ASCII QR by default", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: {
|
||||
|
||||
+11
-1
@@ -12,6 +12,7 @@ import { resolvePairingSetupFromConfig, encodePairingSetupCode } from "../pairin
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js";
|
||||
import { VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js";
|
||||
import { resolveCommandSecretRefsViaGateway } from "./command-secret-gateway.js";
|
||||
import { getQrRemoteCommandSecretTargetIds } from "./command-secret-targets.js";
|
||||
|
||||
@@ -25,6 +26,7 @@ type QrCliOptions = {
|
||||
token?: string;
|
||||
password?: string;
|
||||
limited?: boolean;
|
||||
voiceNode?: boolean;
|
||||
};
|
||||
|
||||
const LIMITED_TRANSPORT_WARNING =
|
||||
@@ -114,6 +116,7 @@ export function registerQrCli(program: Command) {
|
||||
.option("--token <token>", "Override gateway token for setup payload")
|
||||
.option("--password <password>", "Override gateway password for setup payload")
|
||||
.option("--limited", "Pair with limited operator access (omit operator.admin)", false)
|
||||
.option("--voice-node", "Pair a voice node with node, read, and Talk access only", false)
|
||||
.option("--setup-code-only", "Print only the setup code", false)
|
||||
.option("--no-ascii", "Skip ASCII QR rendering")
|
||||
.option("--json", "Output JSON", false)
|
||||
@@ -122,6 +125,9 @@ export function registerQrCli(program: Command) {
|
||||
if (opts.token && opts.password) {
|
||||
throw new Error("Use either --token or --password, not both.");
|
||||
}
|
||||
if (opts.limited && opts.voiceNode) {
|
||||
throw new Error("Use either --limited or --voice-node, not both.");
|
||||
}
|
||||
|
||||
const token = trimToUndefined(opts.token) ?? "";
|
||||
const password = trimToUndefined(opts.password) ?? "";
|
||||
@@ -205,7 +211,11 @@ export function registerQrCli(program: Command) {
|
||||
const resolved = await resolvePairingSetupFromConfig(cfg, {
|
||||
publicUrl,
|
||||
preferRemoteUrl: wantsRemote,
|
||||
...(opts.limited ? { bootstrapProfile: PAIRING_SETUP_BOOTSTRAP_PROFILE } : {}),
|
||||
...(opts.voiceNode
|
||||
? { bootstrapProfile: VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE }
|
||||
: opts.limited
|
||||
? { bootstrapProfile: PAIRING_SETUP_BOOTSTRAP_PROFILE }
|
||||
: {}),
|
||||
runCommandWithTimeout: async (argv, runOpts) =>
|
||||
await runCommandWithTimeout(argv, {
|
||||
timeoutMs: runOpts.timeoutMs,
|
||||
|
||||
@@ -340,6 +340,7 @@ function makeScopedBroadcastClients() {
|
||||
const pairingSocket = makeRecordingSocket();
|
||||
const nodeSocket = makeRecordingSocket();
|
||||
const readSocket = makeRecordingSocket();
|
||||
const talkSocket = makeRecordingSocket();
|
||||
const writeSocket = makeRecordingSocket();
|
||||
const adminSocket = makeRecordingSocket();
|
||||
const clients = new Set<GatewayWsClient>([
|
||||
@@ -349,11 +350,12 @@ function makeScopedBroadcastClients() {
|
||||
scopes: ["operator.read"],
|
||||
} as GatewayWsClient["connect"]),
|
||||
makeOperatorWsClient("c-read", readSocket, ["operator.read"]),
|
||||
makeOperatorWsClient("c-talk", talkSocket, ["operator.talk"]),
|
||||
makeOperatorWsClient("c-write", writeSocket, ["operator.write"]),
|
||||
makeOperatorWsClient("c-admin", adminSocket, ["operator.admin"]),
|
||||
]);
|
||||
|
||||
return { pairingSocket, nodeSocket, readSocket, writeSocket, adminSocket, clients };
|
||||
return { pairingSocket, nodeSocket, readSocket, talkSocket, writeSocket, adminSocket, clients };
|
||||
}
|
||||
|
||||
function makeScopedBroadcastContext() {
|
||||
@@ -690,8 +692,15 @@ describe("gateway broadcaster", () => {
|
||||
});
|
||||
|
||||
it("defaults unknown events to deny and classifies remaining gateway broadcast events", () => {
|
||||
const { pairingSocket, nodeSocket, readSocket, writeSocket, adminSocket, broadcast } =
|
||||
makeScopedBroadcastContext();
|
||||
const {
|
||||
pairingSocket,
|
||||
nodeSocket,
|
||||
readSocket,
|
||||
talkSocket,
|
||||
writeSocket,
|
||||
adminSocket,
|
||||
broadcast,
|
||||
} = makeScopedBroadcastContext();
|
||||
|
||||
broadcast("cron", { jobId: "job-1" });
|
||||
broadcast("talk.mode", { enabled: true });
|
||||
@@ -738,6 +747,15 @@ describe("gateway broadcaster", () => {
|
||||
"shutdown",
|
||||
"update.available",
|
||||
]);
|
||||
expectSentEvents(talkSocket, [
|
||||
"talk.mode",
|
||||
"heartbeat",
|
||||
"presence",
|
||||
"health",
|
||||
"tick",
|
||||
"shutdown",
|
||||
"update.available",
|
||||
]);
|
||||
const writeVisibleEvents = [
|
||||
"cron",
|
||||
"talk.mode",
|
||||
|
||||
@@ -96,22 +96,22 @@ describe("method scope resolution", () => {
|
||||
["skills.curator.restore", ["operator.admin"]],
|
||||
["node.pair.approve", ["operator.pairing"]],
|
||||
["poll", ["operator.write"]],
|
||||
["talk.client.create", ["operator.write"]],
|
||||
["talk.client.transcript", ["operator.write"]],
|
||||
["talk.client.close", ["operator.write"]],
|
||||
["talk.client.toolCall", ["operator.write"]],
|
||||
["talk.client.steer", ["operator.write"]],
|
||||
["talk.session.create", ["operator.write"]],
|
||||
["talk.session.join", ["operator.write"]],
|
||||
["talk.session.appendAudio", ["operator.write"]],
|
||||
["talk.session.startTurn", ["operator.write"]],
|
||||
["talk.session.endTurn", ["operator.write"]],
|
||||
["talk.session.cancelTurn", ["operator.write"]],
|
||||
["talk.session.cancelOutput", ["operator.write"]],
|
||||
["talk.session.acknowledgeMark", ["operator.write"]],
|
||||
["talk.session.submitToolResult", ["operator.write"]],
|
||||
["talk.session.steer", ["operator.write"]],
|
||||
["talk.session.close", ["operator.write"]],
|
||||
["talk.client.create", ["operator.talk"]],
|
||||
["talk.client.transcript", ["operator.talk"]],
|
||||
["talk.client.close", ["operator.talk"]],
|
||||
["talk.client.toolCall", ["operator.talk"]],
|
||||
["talk.client.steer", ["operator.talk"]],
|
||||
["talk.session.create", ["operator.talk"]],
|
||||
["talk.session.join", ["operator.talk"]],
|
||||
["talk.session.appendAudio", ["operator.talk"]],
|
||||
["talk.session.startTurn", ["operator.talk"]],
|
||||
["talk.session.endTurn", ["operator.talk"]],
|
||||
["talk.session.cancelTurn", ["operator.talk"]],
|
||||
["talk.session.cancelOutput", ["operator.talk"]],
|
||||
["talk.session.acknowledgeMark", ["operator.talk"]],
|
||||
["talk.session.submitToolResult", ["operator.talk"]],
|
||||
["talk.session.steer", ["operator.talk"]],
|
||||
["talk.session.close", ["operator.talk"]],
|
||||
["update.status", ["operator.admin"]],
|
||||
["config.schema", ["operator.admin"]],
|
||||
["config.patch", ["operator.admin"]],
|
||||
@@ -188,6 +188,19 @@ describe("method scope resolution", () => {
|
||||
).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("accepts dedicated Talk access and preserves operator.write compatibility", () => {
|
||||
expect(authorizeOperatorScopesForMethod("talk.client.create", ["operator.talk"])).toEqual({
|
||||
allowed: true,
|
||||
});
|
||||
expect(authorizeOperatorScopesForMethod("talk.client.create", ["operator.write"])).toEqual({
|
||||
allowed: true,
|
||||
});
|
||||
expect(authorizeOperatorScopesForMethod("talk.client.create", ["operator.read"])).toEqual({
|
||||
allowed: false,
|
||||
missingScope: "operator.talk",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires admin only when DM pairing approval bootstraps a command owner", () => {
|
||||
expect(resolveLeastPrivilegeOperatorScopesForMethod("channels.pairing.approve", {})).toEqual([
|
||||
"operator.pairing",
|
||||
@@ -708,7 +721,7 @@ describe("operator scope authorization", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("allows operator.write clients to use unified Talk sessions", () => {
|
||||
it("allows operator.talk and legacy operator.write clients to use unified Talk sessions", () => {
|
||||
for (const method of [
|
||||
"talk.client.create",
|
||||
"talk.client.transcript",
|
||||
@@ -727,12 +740,15 @@ describe("operator scope authorization", () => {
|
||||
"talk.session.steer",
|
||||
"talk.session.close",
|
||||
]) {
|
||||
expect(authorizeOperatorScopesForMethod(method, ["operator.talk"])).toEqual({
|
||||
allowed: true,
|
||||
});
|
||||
expect(authorizeOperatorScopesForMethod(method, ["operator.write"])).toEqual({
|
||||
allowed: true,
|
||||
});
|
||||
expect(authorizeOperatorScopesForMethod(method, ["operator.read"])).toEqual({
|
||||
allowed: false,
|
||||
missingScope: "operator.write",
|
||||
missingScope: "operator.talk",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
PAIRING_SCOPE,
|
||||
QUESTIONS_SCOPE,
|
||||
READ_SCOPE,
|
||||
TALK_SCOPE,
|
||||
TALK_SECRETS_SCOPE,
|
||||
WRITE_SCOPE,
|
||||
isOperatorScope,
|
||||
@@ -34,6 +35,7 @@ export {
|
||||
PAIRING_SCOPE,
|
||||
QUESTIONS_SCOPE,
|
||||
READ_SCOPE,
|
||||
TALK_SCOPE,
|
||||
WRITE_SCOPE,
|
||||
type OperatorScope,
|
||||
};
|
||||
@@ -328,6 +330,12 @@ export function authorizeOperatorScopesForRequiredScope(
|
||||
}
|
||||
return { allowed: false, missingScope: READ_SCOPE };
|
||||
}
|
||||
if (requiredScope === TALK_SCOPE) {
|
||||
if (scopes.includes(TALK_SCOPE) || scopes.includes(WRITE_SCOPE)) {
|
||||
return { allowed: true };
|
||||
}
|
||||
return { allowed: false, missingScope: TALK_SCOPE };
|
||||
}
|
||||
if (scopes.includes(requiredScope)) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
@@ -91,24 +91,24 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
|
||||
{ name: "talk.catalog", scope: "operator.read", since: "<=2026.7" },
|
||||
// Params-aware: reading redacted config needs read; includeSecrets also needs talk secrets.
|
||||
{ name: "talk.config", scope: "dynamic", since: "<=2026.7" },
|
||||
{ name: "talk.client.create", scope: "operator.write", since: "<=2026.7" },
|
||||
{ name: "talk.client.transcript", scope: "operator.write", since: "<=2026.7" },
|
||||
{ name: "talk.client.close", scope: "operator.write", since: "<=2026.7" },
|
||||
{ name: "talk.client.toolCall", scope: "operator.write", since: "<=2026.7" },
|
||||
{ name: "talk.client.steer", scope: "operator.write", since: "<=2026.7" },
|
||||
{ name: "talk.session.create", scope: "operator.write", since: "<=2026.7" },
|
||||
{ name: "talk.session.join", scope: "operator.write", since: "<=2026.7" },
|
||||
{ name: "talk.session.appendAudio", scope: "operator.write", since: "<=2026.7" },
|
||||
{ name: "talk.session.startTurn", scope: "operator.write", since: "<=2026.7" },
|
||||
{ name: "talk.session.endTurn", scope: "operator.write", since: "<=2026.7" },
|
||||
{ name: "talk.session.cancelTurn", scope: "operator.write", since: "<=2026.7" },
|
||||
{ name: "talk.session.cancelOutput", scope: "operator.write", since: "<=2026.7" },
|
||||
{ name: "talk.session.acknowledgeMark", scope: "operator.write", since: "<=2026.7" },
|
||||
{ name: "talk.session.submitToolResult", scope: "operator.write", since: "<=2026.7" },
|
||||
{ name: "talk.session.steer", scope: "operator.write", since: "<=2026.7" },
|
||||
{ name: "talk.session.close", scope: "operator.write", since: "<=2026.7" },
|
||||
{ name: "talk.speak", scope: "operator.write", since: "<=2026.7" },
|
||||
{ name: "talk.mode", scope: "operator.write", since: "<=2026.7" },
|
||||
{ name: "talk.client.create", scope: "operator.talk", since: "<=2026.7" },
|
||||
{ name: "talk.client.transcript", scope: "operator.talk", since: "<=2026.7" },
|
||||
{ name: "talk.client.close", scope: "operator.talk", since: "<=2026.7" },
|
||||
{ name: "talk.client.toolCall", scope: "operator.talk", since: "<=2026.7" },
|
||||
{ name: "talk.client.steer", scope: "operator.talk", since: "<=2026.7" },
|
||||
{ name: "talk.session.create", scope: "operator.talk", since: "<=2026.7" },
|
||||
{ name: "talk.session.join", scope: "operator.talk", since: "<=2026.7" },
|
||||
{ name: "talk.session.appendAudio", scope: "operator.talk", since: "<=2026.7" },
|
||||
{ name: "talk.session.startTurn", scope: "operator.talk", since: "<=2026.7" },
|
||||
{ name: "talk.session.endTurn", scope: "operator.talk", since: "<=2026.7" },
|
||||
{ name: "talk.session.cancelTurn", scope: "operator.talk", since: "<=2026.7" },
|
||||
{ name: "talk.session.cancelOutput", scope: "operator.talk", since: "<=2026.7" },
|
||||
{ name: "talk.session.acknowledgeMark", scope: "operator.talk", since: "<=2026.7" },
|
||||
{ name: "talk.session.submitToolResult", scope: "operator.talk", since: "<=2026.7" },
|
||||
{ name: "talk.session.steer", scope: "operator.talk", since: "<=2026.7" },
|
||||
{ name: "talk.session.close", scope: "operator.talk", since: "<=2026.7" },
|
||||
{ name: "talk.speak", scope: "operator.talk", since: "<=2026.7" },
|
||||
{ name: "talk.mode", scope: "operator.talk", since: "<=2026.7" },
|
||||
{ name: "commands.list", scope: "operator.read", since: "<=2026.7" },
|
||||
{ name: "models.list", scope: "operator.read", since: "<=2026.7", startup: true },
|
||||
{ name: "models.authStatus", scope: "operator.read", since: "<=2026.7" },
|
||||
|
||||
@@ -6,6 +6,7 @@ export const WRITE_SCOPE = "operator.write" as const;
|
||||
export const APPROVALS_SCOPE = "operator.approvals" as const;
|
||||
export const QUESTIONS_SCOPE = "operator.questions" as const;
|
||||
export const PAIRING_SCOPE = "operator.pairing" as const;
|
||||
export const TALK_SCOPE = "operator.talk" as const;
|
||||
export const TALK_SECRETS_SCOPE = "operator.talk.secrets" as const;
|
||||
|
||||
/** Operator privileges advertised by gateway auth and checked by method policy. */
|
||||
@@ -16,6 +17,7 @@ export type OperatorScope =
|
||||
| typeof APPROVALS_SCOPE
|
||||
| typeof QUESTIONS_SCOPE
|
||||
| typeof PAIRING_SCOPE
|
||||
| typeof TALK_SCOPE
|
||||
| typeof TALK_SECRETS_SCOPE;
|
||||
|
||||
const KNOWN_OPERATOR_SCOPE_VALUES: readonly OperatorScope[] = [
|
||||
@@ -25,6 +27,7 @@ const KNOWN_OPERATOR_SCOPE_VALUES: readonly OperatorScope[] = [
|
||||
APPROVALS_SCOPE,
|
||||
QUESTIONS_SCOPE,
|
||||
PAIRING_SCOPE,
|
||||
TALK_SCOPE,
|
||||
TALK_SECRETS_SCOPE,
|
||||
];
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
PAIRING_SCOPE,
|
||||
QUESTIONS_SCOPE,
|
||||
READ_SCOPE,
|
||||
TALK_SCOPE,
|
||||
WRITE_SCOPE,
|
||||
} from "./method-scopes.js";
|
||||
import type {
|
||||
@@ -54,7 +55,7 @@ const EVENT_SCOPE_GUARDS: Record<string, string[]> = {
|
||||
shutdown: [],
|
||||
tick: [],
|
||||
"talk.event": [READ_SCOPE],
|
||||
"talk.mode": [WRITE_SCOPE],
|
||||
"talk.mode": [TALK_SCOPE],
|
||||
task: [READ_SCOPE],
|
||||
"task.suggestion": [READ_SCOPE],
|
||||
"update.available": [],
|
||||
@@ -179,6 +180,9 @@ function hasEventScope(
|
||||
if (required.includes(READ_SCOPE)) {
|
||||
return scopes.includes(READ_SCOPE) || scopes.includes(WRITE_SCOPE);
|
||||
}
|
||||
if (required.includes(TALK_SCOPE)) {
|
||||
return scopes.includes(TALK_SCOPE) || scopes.includes(WRITE_SCOPE);
|
||||
}
|
||||
return required.some((scope) => scopes.includes(scope));
|
||||
}
|
||||
|
||||
|
||||
@@ -924,6 +924,74 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
}
|
||||
});
|
||||
|
||||
test("voice-node setup code returns node token plus Talk-only operator handoff", async () => {
|
||||
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
|
||||
const { VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE } =
|
||||
await import("../shared/device-bootstrap-profile.js");
|
||||
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const { server, port, prevToken } = await startControlUiServer("secret");
|
||||
const { identityPath, identity } = await createOperatorIdentityFixture(
|
||||
"openclaw-bootstrap-voice-node-",
|
||||
);
|
||||
const client = {
|
||||
id: "node-host",
|
||||
version: "1.0.0",
|
||||
platform: "esp32",
|
||||
mode: "node" as const,
|
||||
deviceFamily: "ESP32",
|
||||
};
|
||||
|
||||
try {
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
profile: VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
});
|
||||
const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const initial = await connectReq(wsBootstrap, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
if (!initial.ok) {
|
||||
throw new Error(`voice-node bootstrap failed: ${JSON.stringify(initial.error)}`);
|
||||
}
|
||||
expect(initial.ok).toBe(true);
|
||||
const auth = (
|
||||
initial.payload as
|
||||
| {
|
||||
auth?: {
|
||||
role?: string;
|
||||
scopes?: string[];
|
||||
deviceToken?: string;
|
||||
deviceTokens?: Array<{
|
||||
role?: string;
|
||||
scopes?: string[];
|
||||
deviceToken?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
| undefined
|
||||
)?.auth;
|
||||
expect(auth?.role).toBe("node");
|
||||
expect(auth?.scopes).toEqual([]);
|
||||
expect(auth?.deviceToken).toEqual(expect.any(String));
|
||||
expect(auth?.deviceTokens?.find((entry) => entry.role === "operator")).toMatchObject({
|
||||
scopes: ["operator.read", "operator.talk"],
|
||||
deviceToken: expect.any(String),
|
||||
});
|
||||
expect((await listDevicePairing()).pending).toEqual([]);
|
||||
const paired = await getPairedDevice(identity.deviceId);
|
||||
expect(paired?.roles).toEqual(["node", "operator"]);
|
||||
expect(paired?.approvedScopes).toEqual(["operator.read", "operator.talk"]);
|
||||
wsBootstrap.close();
|
||||
} finally {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
|
||||
test("qr setup code returns node token plus full operator handoff", async () => {
|
||||
const { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } =
|
||||
await import("../infra/device-bootstrap.js");
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
import { hasEffectivePairedDeviceRole, type PairedDevice } from "../../../infra/device-pairing.js";
|
||||
import {
|
||||
BOOTSTRAP_HANDOFF_OPERATOR_SCOPES,
|
||||
isMobilePairingSetupBootstrapProfile,
|
||||
isVoiceNodePairingSetupBootstrapProfile,
|
||||
resolveBootstrapProfileScopesForRole,
|
||||
type DeviceBootstrapProfile,
|
||||
} from "../../../shared/device-bootstrap-profile.js";
|
||||
@@ -23,7 +25,7 @@ export function resolvePairedAccessScopes(
|
||||
return normalizeSortedUniqueTrimmedStringList(scopes);
|
||||
}
|
||||
|
||||
export function isSetupCodeMobileBootstrapClient(client: {
|
||||
function isSetupCodeMobileBootstrapClient(client: {
|
||||
id?: string;
|
||||
platform?: string;
|
||||
deviceFamily?: string;
|
||||
@@ -39,6 +41,34 @@ export function isSetupCodeMobileBootstrapClient(client: {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Embedded voice nodes must prove the canonical node-host and ESP32 metadata tuple. */
|
||||
function isSetupCodeVoiceNodeBootstrapClient(client: {
|
||||
id?: string;
|
||||
platform?: string;
|
||||
deviceFamily?: string;
|
||||
}): boolean {
|
||||
const platform = normalizeDeviceMetadataForAuth(client.platform);
|
||||
const deviceFamily = normalizeDeviceMetadataForAuth(client.deviceFamily);
|
||||
return (
|
||||
client.id === GATEWAY_CLIENT_IDS.NODE_HOST &&
|
||||
/^esp32(?:\s|$)/u.test(platform) &&
|
||||
deviceFamily === "esp32"
|
||||
);
|
||||
}
|
||||
|
||||
/** Match a closed setup profile to the client metadata class allowed to redeem it silently. */
|
||||
export function isSetupCodeHandoffBootstrapClient(params: {
|
||||
profile: DeviceBootstrapProfile;
|
||||
client: { id?: string; platform?: string; deviceFamily?: string };
|
||||
}): boolean {
|
||||
return (
|
||||
(isMobilePairingSetupBootstrapProfile(params.profile) &&
|
||||
isSetupCodeMobileBootstrapClient(params.client)) ||
|
||||
(isVoiceNodePairingSetupBootstrapProfile(params.profile) &&
|
||||
isSetupCodeVoiceNodeBootstrapClient(params.client))
|
||||
);
|
||||
}
|
||||
|
||||
export function isControlUiOperatorBootstrapProfile(params: {
|
||||
profile: DeviceBootstrapProfile | null;
|
||||
requestedScopes: readonly string[];
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
resolveEffectiveOperatorDeviceIdentity,
|
||||
} from "../../../infra/device-pairing.js";
|
||||
import {
|
||||
isMobilePairingSetupBootstrapProfile,
|
||||
resolveBootstrapProfileScopesForRole,
|
||||
resolveBootstrapProfileScopesForRoles,
|
||||
} from "../../../shared/device-bootstrap-profile.js";
|
||||
@@ -38,7 +37,7 @@ import { resolveTrustedProxyControlUiScopes } from "./connect-admission.js";
|
||||
import {
|
||||
isControlUiOperatorBootstrapProfile,
|
||||
isMobileNodeBootstrapConnect,
|
||||
isSetupCodeMobileBootstrapClient,
|
||||
isSetupCodeHandoffBootstrapClient,
|
||||
pairedDeviceAllowsBootstrapProfile,
|
||||
resolvePairedAccessScopes,
|
||||
} from "./connect-device-metadata.js";
|
||||
@@ -312,12 +311,14 @@ export async function authorizeGatewayConnectDevice(
|
||||
publicKey: devicePublicKey,
|
||||
})
|
||||
: null;
|
||||
const allowSetupCodeMobileBootstrapPairing =
|
||||
const allowSetupCodeHandoffBootstrapPairing =
|
||||
boundBootstrapProfile !== null &&
|
||||
isMobilePairingSetupBootstrapProfile(boundBootstrapProfile) &&
|
||||
isSetupCodeMobileNodeConnect &&
|
||||
isSetupCodeMobileBootstrapClient(connectParams.client);
|
||||
const setupCodeMobileBootstrapProfile = allowSetupCodeMobileBootstrapPairing
|
||||
isSetupCodeHandoffBootstrapClient({
|
||||
profile: boundBootstrapProfile,
|
||||
client: connectParams.client,
|
||||
});
|
||||
const setupCodeHandoffBootstrapProfile = allowSetupCodeHandoffBootstrapPairing
|
||||
? boundBootstrapProfile
|
||||
: null;
|
||||
const allowControlUiOperatorBootstrapPairing = isControlUiOperatorBootstrapProfile({
|
||||
@@ -332,16 +333,16 @@ export async function authorizeGatewayConnectDevice(
|
||||
// agree before the Gateway can skip owner approval and hand off the
|
||||
// selected operator profile below. Full mobile setup includes admin;
|
||||
// limited setup retains the previous bounded operator scope set.
|
||||
const bootstrapPairingRoles = setupCodeMobileBootstrapProfile
|
||||
? uniqueStrings([role, ...setupCodeMobileBootstrapProfile.roles])
|
||||
const bootstrapPairingRoles = setupCodeHandoffBootstrapProfile
|
||||
? uniqueStrings([role, ...setupCodeHandoffBootstrapProfile.roles])
|
||||
: controlUiOperatorBootstrapProfile
|
||||
? ["operator"]
|
||||
: undefined;
|
||||
const bootstrapPairingScopes = setupCodeMobileBootstrapProfile
|
||||
const bootstrapPairingScopes = setupCodeHandoffBootstrapProfile
|
||||
? resolveBootstrapProfileScopesForRoles(
|
||||
bootstrapPairingRoles ?? [],
|
||||
setupCodeMobileBootstrapProfile.scopes,
|
||||
setupCodeMobileBootstrapProfile.purpose,
|
||||
setupCodeHandoffBootstrapProfile.scopes,
|
||||
setupCodeHandoffBootstrapProfile.purpose,
|
||||
)
|
||||
: controlUiOperatorBootstrapProfile
|
||||
? resolveBootstrapProfileScopesForRole(
|
||||
@@ -351,7 +352,7 @@ export async function authorizeGatewayConnectDevice(
|
||||
)
|
||||
: undefined;
|
||||
const bootstrapApprovalProfile =
|
||||
setupCodeMobileBootstrapProfile ?? controlUiOperatorBootstrapProfile;
|
||||
setupCodeHandoffBootstrapProfile ?? controlUiOperatorBootstrapProfile;
|
||||
const pairingRequestScopes =
|
||||
allowControlUiDeviceAuthMigrationForUnpairedInstall &&
|
||||
deviceAuthMigrationScopes.length > 0 &&
|
||||
@@ -371,11 +372,11 @@ export async function authorizeGatewayConnectDevice(
|
||||
}
|
||||
: {}),
|
||||
silent:
|
||||
reason === "scope-upgrade" && !allowSetupCodeMobileBootstrapPairing
|
||||
reason === "scope-upgrade" && !allowSetupCodeHandoffBootstrapPairing
|
||||
? false
|
||||
: allowSilentLocalPairing ||
|
||||
allowSilentTrustedCidrsNodePairing ||
|
||||
allowSetupCodeMobileBootstrapPairing ||
|
||||
allowSetupCodeHandoffBootstrapPairing ||
|
||||
allowControlUiOperatorBootstrapPairing,
|
||||
});
|
||||
const trustedProxyAutoApproveScopes =
|
||||
@@ -475,7 +476,7 @@ export async function authorizeGatewayConnectDevice(
|
||||
},
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
if (!(allowSetupCodeMobileBootstrapPairing && boundBootstrapProfile)) {
|
||||
if (!(allowSetupCodeHandoffBootstrapPairing && boundBootstrapProfile)) {
|
||||
// Best-effort retirement of stale silent siblings; a prune
|
||||
// failure must never fail the fresh device's handshake.
|
||||
try {
|
||||
|
||||
@@ -5,15 +5,12 @@ import {
|
||||
listEffectivePairedDeviceRoles,
|
||||
updatePairedDeviceMetadata,
|
||||
} from "../../../infra/device-pairing.js";
|
||||
import {
|
||||
isMobilePairingSetupBootstrapProfile,
|
||||
resolveBootstrapProfileScopesForRole,
|
||||
} from "../../../shared/device-bootstrap-profile.js";
|
||||
import { resolveBootstrapProfileScopesForRole } from "../../../shared/device-bootstrap-profile.js";
|
||||
import type { DeviceBootstrapProfile } from "../../../shared/device-bootstrap-profile.js";
|
||||
import { roleScopesAllow } from "../../../shared/operator-scope-compat.js";
|
||||
import {
|
||||
isMobileNodeBootstrapConnect,
|
||||
isSetupCodeMobileBootstrapClient,
|
||||
isSetupCodeHandoffBootstrapClient,
|
||||
pairedDeviceAllowsBootstrapOperator,
|
||||
resolvePairedAccessScopes,
|
||||
resolvePinnedClientMetadata,
|
||||
@@ -148,8 +145,10 @@ export async function authorizeExistingGatewayDevice(params: {
|
||||
retryBootstrapHandoffProfile.purpose,
|
||||
);
|
||||
if (
|
||||
isMobilePairingSetupBootstrapProfile(retryBootstrapHandoffProfile) &&
|
||||
isSetupCodeMobileBootstrapClient(connectParams.client)
|
||||
isSetupCodeHandoffBootstrapClient({
|
||||
profile: retryBootstrapHandoffProfile,
|
||||
client: connectParams.client,
|
||||
})
|
||||
) {
|
||||
const pairedAllowsHandoff =
|
||||
pairedRoles.includes("operator") &&
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// Tests setup code generation and environment-derived defaults.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SecretInput } from "../config/types.secrets.js";
|
||||
import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js";
|
||||
import {
|
||||
PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
} from "../shared/device-bootstrap-profile.js";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
|
||||
vi.mock("../infra/device-bootstrap.js", () => ({
|
||||
@@ -301,6 +304,24 @@ describe("pairing setup code", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("issues a least-privilege voice-node bootstrap profile", async () => {
|
||||
await expectResolvedSetupSuccessCase({
|
||||
config: createCustomGatewayConfig({ mode: "token", token: "tok_123" }),
|
||||
options: {
|
||||
forceSecure: true,
|
||||
publicUrl: "gateway.example.test:18789/setup",
|
||||
bootstrapProfile: VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
},
|
||||
expected: {
|
||||
authLabel: "token",
|
||||
url: "wss://gateway.example.test:18789",
|
||||
urlSource: "plugins.entries.device-pair.config.publicUrl",
|
||||
bootstrapProfile: VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
access: "limited",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid gateway.remote.url before falling back to bind-derived setup urls", async () => {
|
||||
await expectResolvedSetupFailureCase({
|
||||
config: {
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
isMobilePairingSetupBootstrapProfile,
|
||||
isNodePairingSetupBootstrapProfile,
|
||||
isVoiceNodePairingSetupBootstrapProfile,
|
||||
normalizeDeviceBootstrapHandoffProfile,
|
||||
normalizeDeviceBootstrapProfile,
|
||||
resolveBootstrapProfileScopesForRole,
|
||||
@@ -134,6 +136,20 @@ describe("device bootstrap profile", () => {
|
||||
expect(isMobilePairingSetupBootstrapProfile(NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(false);
|
||||
});
|
||||
|
||||
test("voice-node setup profile carries only node, read, and Talk access", () => {
|
||||
expect(VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE).toEqual({
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.read", "operator.talk"],
|
||||
purpose: "voice-node",
|
||||
});
|
||||
expect(isMobilePairingSetupBootstrapProfile(VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isVoiceNodePairingSetupBootstrapProfile(VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("recognizes only the supported mobile setup profiles", () => {
|
||||
expect(isMobilePairingSetupBootstrapProfile(PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(true);
|
||||
expect(isMobilePairingSetupBootstrapProfile(FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { normalizeDeviceAuthRole, normalizeDeviceAuthScopes } from "./device-auth.js";
|
||||
|
||||
/** Closed purpose codes carried by specialized bootstrap tokens. */
|
||||
export type DeviceBootstrapPurpose = "control-ui" | "mobile-full";
|
||||
export type DeviceBootstrapPurpose = "control-ui" | "mobile-full" | "voice-node";
|
||||
|
||||
/** Normalized roles/scopes carried by a bootstrap token during device handoff. */
|
||||
export type DeviceBootstrapProfile = {
|
||||
@@ -36,6 +36,7 @@ const MOBILE_FULL_ACCESS_OPERATOR_SCOPES = [
|
||||
] as const;
|
||||
|
||||
const MOBILE_FULL_ACCESS_OPERATOR_SCOPE_SET = new Set<string>(MOBILE_FULL_ACCESS_OPERATOR_SCOPES);
|
||||
const VOICE_NODE_OPERATOR_SCOPE_SET = new Set<string>(["operator.read", "operator.talk"]);
|
||||
|
||||
/** Existing least-privilege setup-code/QR profile. */
|
||||
export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = {
|
||||
@@ -59,6 +60,13 @@ export const NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = {
|
||||
scopes: [],
|
||||
};
|
||||
|
||||
/** Room/embedded voice profile: node capabilities plus least-privilege Talk RPCs. */
|
||||
export const VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = {
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.read", "operator.talk"],
|
||||
purpose: "voice-node",
|
||||
};
|
||||
|
||||
/** Compare normalized bootstrap profiles, including their closed purpose. */
|
||||
export function deviceBootstrapProfilesEqual(
|
||||
left: DeviceBootstrapProfileInput | undefined,
|
||||
@@ -104,6 +112,13 @@ export function isNodePairingSetupBootstrapProfile(
|
||||
return matchesBootstrapProfile(input, NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE);
|
||||
}
|
||||
|
||||
/** Return whether an input exactly matches the embedded voice-node setup profile. */
|
||||
export function isVoiceNodePairingSetupBootstrapProfile(
|
||||
input: DeviceBootstrapProfileInput | undefined,
|
||||
): boolean {
|
||||
return matchesBootstrapProfile(input, VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE);
|
||||
}
|
||||
|
||||
/** Resolve the subset of requested scopes a bootstrap profile may carry for one role. */
|
||||
export function resolveBootstrapProfileScopesForRole(
|
||||
role: string,
|
||||
@@ -116,7 +131,9 @@ export function resolveBootstrapProfileScopesForRole(
|
||||
const allowedScopes =
|
||||
purpose === "mobile-full"
|
||||
? MOBILE_FULL_ACCESS_OPERATOR_SCOPE_SET
|
||||
: BOOTSTRAP_HANDOFF_OPERATOR_SCOPE_SET;
|
||||
: purpose === "voice-node"
|
||||
? VOICE_NODE_OPERATOR_SCOPE_SET
|
||||
: BOOTSTRAP_HANDOFF_OPERATOR_SCOPE_SET;
|
||||
return normalizedScopes.filter((scope) => allowedScopes.has(scope));
|
||||
}
|
||||
return [];
|
||||
@@ -183,7 +200,11 @@ export function normalizeDeviceBootstrapProfile(
|
||||
input: DeviceBootstrapProfileInput | undefined,
|
||||
): DeviceBootstrapProfile {
|
||||
const purpose =
|
||||
input?.purpose === "control-ui" || input?.purpose === "mobile-full" ? input.purpose : undefined;
|
||||
input?.purpose === "control-ui" ||
|
||||
input?.purpose === "mobile-full" ||
|
||||
input?.purpose === "voice-node"
|
||||
? input.purpose
|
||||
: undefined;
|
||||
return {
|
||||
roles: normalizeBootstrapRoles(input?.roles),
|
||||
scopes: normalizeDeviceAuthScopes(input?.scopes ? [...input.scopes] : []),
|
||||
|
||||
@@ -58,6 +58,25 @@ describe("roleScopesAllow", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("treats operator.talk as satisfied by talk/write/admin scopes", () => {
|
||||
for (const allowedScope of ["operator.talk", "operator.write", "operator.admin"]) {
|
||||
expect(
|
||||
roleScopesAllow({
|
||||
role: "operator",
|
||||
requestedScopes: ["operator.talk"],
|
||||
allowedScopes: [allowedScope],
|
||||
}),
|
||||
).toBe(true);
|
||||
}
|
||||
expect(
|
||||
roleScopesAllow({
|
||||
role: "operator",
|
||||
requestedScopes: ["operator.talk"],
|
||||
allowedScopes: ["operator.read"],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("treats operator.approvals/operator.pairing as satisfied by operator.admin", () => {
|
||||
expect(
|
||||
roleScopesAllow({
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
const OPERATOR_ROLE = "operator";
|
||||
const OPERATOR_ADMIN_SCOPE = "operator.admin";
|
||||
const OPERATOR_READ_SCOPE = "operator.read";
|
||||
const OPERATOR_TALK_SCOPE = "operator.talk";
|
||||
const OPERATOR_WRITE_SCOPE = "operator.write";
|
||||
const OPERATOR_SCOPE_PREFIX = "operator.";
|
||||
|
||||
@@ -29,6 +30,9 @@ function operatorScopeSatisfied(requestedScope: string, granted: Set<string>): b
|
||||
if (requestedScope === OPERATOR_WRITE_SCOPE) {
|
||||
return granted.has(OPERATOR_WRITE_SCOPE);
|
||||
}
|
||||
if (requestedScope === OPERATOR_TALK_SCOPE) {
|
||||
return granted.has(OPERATOR_TALK_SCOPE) || granted.has(OPERATOR_WRITE_SCOPE);
|
||||
}
|
||||
return granted.has(requestedScope);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user