more permissions

This commit is contained in:
KingKDot
2026-07-17 22:48:43 -05:00
parent 1cdeae0bc3
commit b619658a95
6 changed files with 186 additions and 5 deletions
@@ -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", () => {
@@ -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;
@@ -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);
}
});
});
@@ -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 });
@@ -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<SocketData> {
@@ -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", () => {
@@ -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 {