refactor(device): share missing-scope helper

This commit is contained in:
Peter Steinberger
2026-03-17 06:11:38 +00:00
parent 520d753b27
commit 43838b1b14
4 changed files with 44 additions and 43 deletions

View File

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

View File

@@ -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<DevicePairingList> {
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 };

View File

@@ -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();
});
});

View File

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