From b619658a95ed8ec404942129e79cabc077b8649e Mon Sep 17 00:00:00 2001 From: KingKDot Date: Fri, 17 Jul 2026 22:48:43 -0500 Subject: [PATCH] more permissions --- .../server/routes/client-routes-gates.test.ts | 36 +++++++++ .../src/server/routes/client-routes.ts | 4 + .../plugin-routes.dashboard-auth.test.ts | 79 +++++++++++++++++++ .../src/server/routes/plugin-routes.ts | 13 +-- .../src/sessions/sessionManager.test.ts | 49 ++++++++++++ .../src/sessions/sessionManager.ts | 10 +++ 6 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 Overlord-Server/src/server/routes/plugin-routes.dashboard-auth.test.ts diff --git a/Overlord-Server/src/server/routes/client-routes-gates.test.ts b/Overlord-Server/src/server/routes/client-routes-gates.test.ts index e0d464b..80b5ebd 100644 --- a/Overlord-Server/src/server/routes/client-routes-gates.test.ts +++ b/Overlord-Server/src/server/routes/client-routes-gates.test.ts @@ -93,6 +93,20 @@ async function patchNickname(token: string, clientId: string, nickname: string) ); } +async function patchBookmark(token: string, clientId: string, bookmarked: boolean) { + const url = new URL(`https://localhost/api/clients/${encodeURIComponent(clientId)}/bookmark`); + return handleClientRoutes( + new Request(url, { + method: "PATCH", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ bookmarked }), + }), + url, + mockServer, + deps, + ); +} + afterEach(() => { while (createdClientIds.length > 0) { const id = createdClientIds.pop(); @@ -227,6 +241,28 @@ describe("clients:metadata gates", () => { const res = await patchNickname(auth.token, clientId, "test-nick"); expect(res!.status).toBe(200); }); + + test("operator (default) can bookmark a client", async () => { + const auth = await makeUserWithToken("operator"); + const clientId = registerTestClient(); + const res = await patchBookmark(auth.token, clientId, true); + expect(res!.status).toBe(200); + }); + + test("operator with client_metadata feature OFF cannot bookmark a client", async () => { + const auth = await makeUserWithToken("operator"); + const clientId = registerTestClient(); + setUserFeaturePermission(auth.user.id, "client_metadata", false); + const res = await patchBookmark(auth.token, clientId, true); + expect(res!.status).toBe(403); + }); + + test("viewer cannot bookmark a client", async () => { + const auth = await makeUserWithToken("viewer"); + const clientId = registerTestClient(); + const res = await patchBookmark(auth.token, clientId, true); + expect(res!.status).toBe(403); + }); }); describe("layered semantics: groups + feature toggles", () => { diff --git a/Overlord-Server/src/server/routes/client-routes.ts b/Overlord-Server/src/server/routes/client-routes.ts index b369410..5aeba25 100644 --- a/Overlord-Server/src/server/routes/client-routes.ts +++ b/Overlord-Server/src/server/routes/client-routes.ts @@ -490,6 +490,10 @@ export async function handleClientRoutes( if (req.method === "PATCH" && bookmarkMatch) { const user = await authenticateRequest(req); if (!user) return new Response("Unauthorized", { status: 401 }); + try { requirePermission(user, "clients:metadata"); requireFeatureAccess(user, "client_metadata"); } catch (error) { + if (error instanceof Response) return error; + return new Response("Forbidden", { status: 403 }); + } const targetId = bookmarkMatch[1]; try { requireClientAccess(user, targetId); } catch (error) { if (error instanceof Response) return error; diff --git a/Overlord-Server/src/server/routes/plugin-routes.dashboard-auth.test.ts b/Overlord-Server/src/server/routes/plugin-routes.dashboard-auth.test.ts new file mode 100644 index 0000000..c9be892 --- /dev/null +++ b/Overlord-Server/src/server/routes/plugin-routes.dashboard-auth.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test"; +import { generateToken } from "../../auth"; +import { + createUser, + deleteUser, + getUserById, + setUserClientAccessRule, + setUserClientAccessScope, + setUserPluginAccessScope, +} from "../../users"; +import { handlePluginRoutes } from "./plugin-routes"; + +describe("plugin dashboard contribution authorization", () => { + test("only exposes authorized client IDs to plugins and their responses", async () => { + const username = `pd_${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`; + const created = await createUser(username, "Aa1!PluginDashboardScopePass123", "operator", "test"); + expect(created.success).toBe(true); + const user = getUserById(created.userId!); + expect(user).not.toBeNull(); + expect(setUserClientAccessScope(user!.id, "allowlist").success).toBe(true); + expect(setUserClientAccessRule(user!.id, "client-allowed", "allow").success).toBe(true); + expect(setUserPluginAccessScope(user!.id, "all").success).toBe(true); + + const rpcCalls: any[] = []; + const deps = { + pluginState: { + enabled: {}, + lastError: {}, + autoLoad: {}, + autoStartEvents: {}, + approvedNeeds: {}, + }, + listPluginManifests: async () => [{ + id: "dashboard-test", + name: "Dashboard Test", + dashboard: { clientBadges: [] }, + }], + pluginRuntime: { + isRunning: () => true, + rpc: async (...args: any[]) => { + rpcCalls.push(args); + return [ + { clientId: "client-allowed", badges: [{ text: "visible" }] }, + { clientId: "client-denied", badges: [{ text: "hidden" }] }, + ]; + }, + }, + } as any; + + try { + const token = await generateToken(user!); + const url = new URL("https://localhost/api/plugins/dashboard-contributions"); + const response = await handlePluginRoutes( + new Request(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ clientIds: ["client-allowed", "client-denied"] }), + }), + url, + deps, + ); + + expect(response?.status).toBe(200); + expect(rpcCalls).toHaveLength(1); + expect(rpcCalls[0][2]).toEqual({ clientIds: ["client-allowed"] }); + const body = await response!.json() as any; + expect(body.contributions).toEqual([{ + pluginId: "dashboard-test", + clientId: "client-allowed", + badges: [{ text: "visible" }], + }]); + } finally { + deleteUser(user!.id); + } + }); +}); diff --git a/Overlord-Server/src/server/routes/plugin-routes.ts b/Overlord-Server/src/server/routes/plugin-routes.ts index 348d97c..158d486 100644 --- a/Overlord-Server/src/server/routes/plugin-routes.ts +++ b/Overlord-Server/src/server/routes/plugin-routes.ts @@ -5,7 +5,7 @@ import AdmZip from "adm-zip"; import { v4 as uuidv4 } from "uuid"; import { authenticateRequest } from "../../auth"; import { requireAnyPermission, requireClientAccess, requirePermission, requirePluginAccess } from "../../rbac"; -import { canUserAccessPlugin } from "../../users"; +import { canUserAccessClient, canUserAccessPlugin } from "../../users"; import * as clientManager from "../../clientManager"; import { metrics } from "../../metrics"; import { encodeMessage, type PluginSignatureInfo } from "../../protocol"; @@ -256,10 +256,13 @@ export async function handlePluginRoutes( let body: any = {}; try { body = await req.json(); } catch {} - const requestedClientIds = Array.isArray(body.clientIds) + const requestedClientIds: string[] = Array.isArray(body.clientIds) ? body.clientIds.map((id: unknown) => String(id || "").trim()).filter(Boolean).slice(0, 500) : []; - const clientIdSet = new Set(requestedClientIds); + const authorizedClientIds = requestedClientIds.filter((clientId) => + canUserAccessClient(user.userId, user.role, clientId), + ); + const clientIdSet = new Set(authorizedClientIds); const manifests = await deps.listPluginManifests(); const plugins = manifests @@ -274,13 +277,13 @@ export async function handlePluginRoutes( const result: any = await deps.pluginRuntime.rpc( plugin.id, "dashboardContributions", - { clientIds: requestedClientIds }, + { clientIds: authorizedClientIds }, { id: user.userId, role: user.role }, ); const rows = Array.isArray(result?.contributions) ? result.contributions : Array.isArray(result) ? result : []; for (const row of rows) { const clientId = String(row?.clientId || "").trim(); - if (!clientId || (clientIdSet.size && !clientIdSet.has(clientId))) continue; + if (!clientId || !clientIdSet.has(clientId)) continue; const badges = Array.isArray(row?.badges) ? row.badges.slice(0, 8) : []; if (!badges.length) continue; contributions.push({ pluginId: plugin.id, clientId, badges }); diff --git a/Overlord-Server/src/sessions/sessionManager.test.ts b/Overlord-Server/src/sessions/sessionManager.test.ts index 03c4af9..751f8ed 100644 --- a/Overlord-Server/src/sessions/sessionManager.test.ts +++ b/Overlord-Server/src/sessions/sessionManager.test.ts @@ -41,12 +41,20 @@ import { addDashboardSession, deleteDashboardSession, getDashboardSessionCount, + notifyDashboardClientEvent, safeSendViewerFrame, getAllConsoleSessions, getAllRdSessions, } from "./sessionManager"; import type { ServerWebSocket } from "bun"; import type { SocketData } from "./types"; +import { + createUser, + deleteUser, + getUserById, + setUserClientAccessRule, + setUserClientAccessScope, +} from "../users"; // Minimal mock ws that records sends function mockWs(): ServerWebSocket { @@ -220,6 +228,47 @@ describe("sessionManager - dashboard sessions", () => { expect(getDashboardSessionCount()).toBeGreaterThanOrEqual(1); expect(deleteDashboardSession("ds-1")).toBe(true); }); + + test("only sends client metadata events to dashboard viewers with client access", async () => { + const username = `dashboard_scope_${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`; + const created = await createUser(username, "Aa1!DashboardScopeTestPass123", "operator", "test"); + expect(created.success).toBe(true); + const user = getUserById(created.userId!); + expect(user).not.toBeNull(); + expect(setUserClientAccessScope(user!.id, "allowlist").success).toBe(true); + expect(setUserClientAccessRule(user!.id, "dashboard-allowed", "allow").success).toBe(true); + + const ws = mockWs() as any; + const sessionId = `ds-scope-${user!.id}`; + addDashboardSession({ + id: sessionId, + viewer: ws, + createdAt: Date.now(), + userId: user!.id, + userRole: user!.role, + }); + + try { + notifyDashboardClientEvent("client_online", { + id: "dashboard-allowed", + host: "allowed-host", + ip: "192.0.2.10", + }); + notifyDashboardClientEvent("client_online", { + id: "dashboard-denied", + host: "secret-host", + ip: "192.0.2.20", + }); + + const events = ws._sent.map((message: string) => JSON.parse(message)); + expect(events).toHaveLength(1); + expect(events[0].clientId).toBe("dashboard-allowed"); + expect(events[0].host).toBe("allowed-host"); + } finally { + deleteDashboardSession(sessionId); + deleteUser(user!.id); + } + }); }); describe("safeSendViewerFrame", () => { diff --git a/Overlord-Server/src/sessions/sessionManager.ts b/Overlord-Server/src/sessions/sessionManager.ts index 3e44b34..bafbc7e 100644 --- a/Overlord-Server/src/sessions/sessionManager.ts +++ b/Overlord-Server/src/sessions/sessionManager.ts @@ -11,6 +11,7 @@ import type { ChatViewer, SocketData, } from "./types"; +import { canUserAccessClient, type UserRole } from "../users"; export type DashboardViewer = { id: string; @@ -542,6 +543,15 @@ export function notifyDashboardClientEvent( }); for (const [id, session] of dashboardSessions) { try { + const userId = session.userId ?? session.viewer.data.userId; + const userRole = session.userRole ?? session.viewer.data.userRole; + if ( + userId === undefined || + !userRole || + !canUserAccessClient(userId, userRole as UserRole, info.id) + ) { + continue; + } if (viewerHasBackpressure(session.viewer)) continue; session.viewer.send(msg); } catch {