From 87cca669cb883d85be10ed0713690bbd91a1d96f Mon Sep 17 00:00:00 2001 From: kevin <5299031+kevin9327@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:09:12 +0000 Subject: [PATCH] Keep the computer fleet off a signed-in user's listing The admin computers page asks for every Bot's machine. That list was behind a session only, so anyone signed in could read private coworker ids and whether those computers were running. --- server/src/computer/routes.ts | 7 ++- server/tests/computer-routes.test.ts | 91 +++++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/server/src/computer/routes.ts b/server/src/computer/routes.ts index 5f000ad..44a5c67 100644 --- a/server/src/computer/routes.ts +++ b/server/src/computer/routes.ts @@ -184,10 +184,13 @@ export function createComputerRoutes( * The computers, for the admin surface. * * Not per-Bot in the path the way the acting routes are: this asks the computer what it holds, and - * it holds a list. `:botId` is still there because every route under this router has it and the - * gateway wants somebody to attribute the call to. + * it holds a list. `:botId` is still there because every route under this router has it. The + * list itself is every computer, so a signed-in user is not enough; an administrator has to ask. */ routes.get("/:botId/computers", requireUser, async (context) => { + const denied = requireAdmin(context); + if (denied) return denied; + try { return context.json(await gateway.computers()); } catch (error) { diff --git a/server/tests/computer-routes.test.ts b/server/tests/computer-routes.test.ts index 641abe8..78bde83 100644 --- a/server/tests/computer-routes.test.ts +++ b/server/tests/computer-routes.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { MiddlewareHandler } from "hono"; -import type { AppVariables } from "../src/auth/guards"; +import type { AppVariables, AuthenticatedActor } from "../src/auth/guards"; import type { ComputerGateway } from "../src/computer/gateway"; import type { PolicyStore } from "../src/computer/policy-store"; import { createComputerRoutes } from "../src/computer/routes"; @@ -33,3 +33,92 @@ describe("computer routes", () => { expect(requestedBotIds).toEqual(["bot-17"]); }); }); + +/** + * The fleet listing is the one route here that is not about the Bot in its path. + * + * `:botId` is ignored and the handler returns every computer, so a signed-in person asking about a + * Bot they own learned every Bot id in the deployment and whether its computer was running, + * private coworkers included. Being signed in is not the question; administering the deployment is. + */ +const member: AuthenticatedActor = { + id: "user-1", + email: "member@openbot.test", + role: "user", +}; + +const administrator: AuthenticatedActor = { + id: "admin-1", + email: "admin@openbot.test", + role: "admin", +}; + +function asActor( + actor: AuthenticatedActor, +): MiddlewareHandler<{ Variables: AppVariables }> { + return async (context, next) => { + context.set("actor", actor); + await next(); + }; +} + +function appFor(actor: AuthenticatedActor, computers: () => Promise) { + let listed = 0; + const countingGateway = { + async computers() { + listed += 1; + return computers(); + }, + } as ComputerGateway; + + return { + app: createComputerRoutes( + countingGateway, + {} as PolicyStore, + asActor(actor), + ), + listed: () => listed, + }; +} + +describe("computer fleet listing", () => { + test("refuses a signed-in user the fleet, and does not ask the gateway", async () => { + const { app, listed } = appFor(member, async () => ({ + isolation: "per-bot", + computers: [ + { botId: "private-coworker", running: true, startedAt: null }, + ], + })); + + const response = await app.request("http://openbot.test/any-bot/computers"); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: "Administrator access required.", + }); + // Refused before the gateway is asked: a check that runs after the fleet has been read is not a + // check, it is a filter on the response. + expect(listed()).toBe(0); + }); + + test("lets an administrator see the fleet", async () => { + const fleet = { + isolation: "per-bot" as const, + computers: [ + { + botId: "private-coworker", + running: true, + startedAt: "2026-08-20T00:00:00.000Z", + egress: null, + }, + ], + }; + const { app, listed } = appFor(administrator, async () => fleet); + + const response = await app.request("http://openbot.test/any-bot/computers"); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual(fleet); + expect(listed()).toBe(1); + }); +});