diff --git a/src/gateway/server-methods/devices.ts b/src/gateway/server-methods/devices.ts index 862aaf95f06..3917f49d301 100644 --- a/src/gateway/server-methods/devices.ts +++ b/src/gateway/server-methods/devices.ts @@ -11,7 +11,7 @@ import { summarizeDeviceTokens, } from "../../infra/device-pairing.js"; import { normalizeDeviceAuthScopes } from "../../shared/device-auth.js"; -import { roleScopesAllow } from "../../shared/operator-scope-compat.js"; +import { resolveMissingRequestedScope } from "../../shared/operator-scope-compat.js"; import { ErrorCodes, errorShape, @@ -37,25 +37,6 @@ function redactPairedDevice( }; } -function resolveMissingRequestedScope(params: { - role: string; - requestedScopes: readonly string[]; - callerScopes: readonly string[]; -}): string | null { - for (const scope of params.requestedScopes) { - if ( - !roleScopesAllow({ - role: params.role, - requestedScopes: [scope], - allowedScopes: params.callerScopes, - }) - ) { - return scope; - } - } - return null; -} - function logDeviceTokenRotationDenied(params: { log: { warn: (message: string) => void }; deviceId: string; @@ -234,7 +215,7 @@ export const deviceHandlers: GatewayRequestHandlers = { const missingScope = resolveMissingRequestedScope({ role, requestedScopes, - callerScopes, + allowedScopes: callerScopes, }); if (missingScope) { logDeviceTokenRotationDenied({ diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index e6cf9259a66..063834a17de 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { normalizeDeviceAuthScopes } from "../shared/device-auth.js"; -import { roleScopesAllow } from "../shared/operator-scope-compat.js"; +import { resolveMissingRequestedScope, roleScopesAllow } from "../shared/operator-scope-compat.js"; import { createAsyncLock, pruneExpiredPending, @@ -256,25 +256,6 @@ function scopesWithinApprovedDeviceBaseline(params: { }); } -function resolveMissingRequestedScope(params: { - role: string; - requestedScopes: readonly string[]; - callerScopes: readonly string[]; -}): string | null { - for (const scope of params.requestedScopes) { - if ( - !roleScopesAllow({ - role: params.role, - requestedScopes: [scope], - allowedScopes: params.callerScopes, - }) - ) { - return scope; - } - } - return null; -} - export async function listDevicePairing(baseDir?: string): Promise { const state = await loadState(baseDir); const pending = Object.values(state.pendingById).toSorted((a, b) => b.ts - a.ts); @@ -377,7 +358,7 @@ export async function approveDevicePairing( const missingScope = resolveMissingRequestedScope({ role: pending.role, requestedScopes: normalizeDeviceAuthScopes(pending.scopes), - callerScopes: options.callerScopes, + allowedScopes: options.callerScopes, }); if (missingScope) { return { status: "forbidden", missingScope }; diff --git a/src/shared/operator-scope-compat.test.ts b/src/shared/operator-scope-compat.test.ts index 44236ca7341..895b9665d12 100644 --- a/src/shared/operator-scope-compat.test.ts +++ b/src/shared/operator-scope-compat.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { roleScopesAllow } from "./operator-scope-compat.js"; +import { resolveMissingRequestedScope, roleScopesAllow } from "./operator-scope-compat.js"; describe("roleScopesAllow", () => { it("allows empty requested scope lists regardless of granted scopes", () => { @@ -130,4 +130,24 @@ describe("roleScopesAllow", () => { }), ).toBe(false); }); + + it("returns the first missing requested scope with operator compatibility", () => { + expect( + resolveMissingRequestedScope({ + role: "operator", + requestedScopes: ["operator.read", "operator.write", "operator.approvals"], + allowedScopes: ["operator.write"], + }), + ).toBe("operator.approvals"); + }); + + it("returns null when all requested scopes are satisfied", () => { + expect( + resolveMissingRequestedScope({ + role: "node", + requestedScopes: ["system.run"], + allowedScopes: ["system.run", "operator.admin"], + }), + ).toBeNull(); + }); }); diff --git a/src/shared/operator-scope-compat.ts b/src/shared/operator-scope-compat.ts index 4b1d954b70f..cf184558caa 100644 --- a/src/shared/operator-scope-compat.ts +++ b/src/shared/operator-scope-compat.ts @@ -47,3 +47,22 @@ export function roleScopesAllow(params: { } return requested.every((scope) => operatorScopeSatisfied(scope, allowedSet)); } + +export function resolveMissingRequestedScope(params: { + role: string; + requestedScopes: readonly string[]; + allowedScopes: readonly string[]; +}): string | null { + for (const scope of params.requestedScopes) { + if ( + !roleScopesAllow({ + role: params.role, + requestedScopes: [scope], + allowedScopes: params.allowedScopes, + }) + ) { + return scope; + } + } + return null; +}