diff --git a/src/shared/device-pairing-access.test.ts b/src/shared/device-pairing-access.test.ts new file mode 100644 index 00000000000..fe2ef41a149 --- /dev/null +++ b/src/shared/device-pairing-access.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { resolvePendingDeviceApprovalState } from "./device-pairing-access.js"; + +describe("resolvePendingDeviceApprovalState", () => { + it("treats legacy singular approved role fields as approved access", () => { + expect( + resolvePendingDeviceApprovalState( + { + role: "operator", + scopes: ["operator.read"], + }, + { + role: "operator", + scopes: ["operator.read"], + }, + ), + ).toEqual({ + kind: "re-approval", + requested: { + roles: ["operator"], + scopes: ["operator.read"], + }, + approved: { + roles: ["operator"], + scopes: ["operator.read"], + }, + }); + }); + + it("treats revoked approved-role tokens as a role upgrade", () => { + expect( + resolvePendingDeviceApprovalState( + { + role: "operator", + scopes: ["operator.read"], + }, + { + role: "operator", + scopes: ["operator.read"], + tokens: { + operator: { + role: "operator", + revokedAtMs: Date.now(), + }, + }, + }, + ), + ).toEqual({ + kind: "role-upgrade", + requested: { + roles: ["operator"], + scopes: ["operator.read"], + }, + approved: { + roles: [], + scopes: ["operator.read"], + }, + }); + }); +}); diff --git a/src/shared/device-pairing-access.ts b/src/shared/device-pairing-access.ts new file mode 100644 index 00000000000..30be1448f60 --- /dev/null +++ b/src/shared/device-pairing-access.ts @@ -0,0 +1,114 @@ +import { normalizeDeviceAuthScopes } from "./device-auth.js"; + +export type DevicePairingAccessSummary = { + roles: string[]; + scopes: string[]; +}; + +export type PendingDeviceApprovalKind = + | "new-pairing" + | "role-upgrade" + | "scope-upgrade" + | "re-approval"; + +export type PendingDeviceApprovalState = { + kind: PendingDeviceApprovalKind; + requested: DevicePairingAccessSummary; + approved: DevicePairingAccessSummary | null; +}; + +type PendingLike = { + role?: string; + roles?: string[]; + scopes?: string[]; +}; + +type PairedLike = { + role?: string; + roles?: string[]; + scopes?: string[]; + tokens?: + | Array<{ + role?: string; + revokedAtMs?: number | null; + }> + | Record< + string, + { + role?: string; + revokedAtMs?: number | null; + } + >; +}; + +function normalizeRoleList(...items: Array): string[] { + const roles = new Set(); + for (const item of items) { + if (!item) { + continue; + } + if (Array.isArray(item)) { + for (const role of item) { + const trimmed = role.trim(); + if (trimmed) { + roles.add(trimmed); + } + } + continue; + } + const trimmed = item.trim(); + if (trimmed) { + roles.add(trimmed); + } + } + return [...roles].toSorted(); +} + +function includesAll(allowed: readonly string[], requested: readonly string[]): boolean { + const allowedSet = new Set(allowed); + return requested.every((value) => allowedSet.has(value)); +} + +export function summarizePendingDeviceAccess(request: PendingLike): DevicePairingAccessSummary { + return { + roles: normalizeRoleList(request.roles, request.role), + scopes: normalizeDeviceAuthScopes(request.scopes), + }; +} + +export function summarizeApprovedDeviceAccess(device: PairedLike): DevicePairingAccessSummary { + const approvedRoles = normalizeRoleList(device.roles, device.role); + const tokenList = Array.isArray(device.tokens) + ? device.tokens + : device.tokens + ? Object.values(device.tokens) + : undefined; + const activeTokenRoles = + tokenList === undefined + ? approvedRoles + : normalizeRoleList( + tokenList.filter((token) => !token.revokedAtMs).flatMap((token) => token.role ?? []), + ).filter((role) => approvedRoles.includes(role)); + return { + roles: activeTokenRoles, + scopes: normalizeDeviceAuthScopes(device.scopes), + }; +} + +export function resolvePendingDeviceApprovalState( + request: PendingLike, + paired?: PairedLike, +): PendingDeviceApprovalState { + const requested = summarizePendingDeviceAccess(request); + const approved = paired ? summarizeApprovedDeviceAccess(paired) : null; + if (!approved) { + return { kind: "new-pairing", requested, approved: null }; + } + if (!includesAll(approved.roles, requested.roles)) { + return { kind: "role-upgrade", requested, approved }; + } + if (!includesAll(approved.scopes, requested.scopes)) { + return { kind: "scope-upgrade", requested, approved }; + } + return { kind: "re-approval", requested, approved }; +}