more permissions
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user