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(() => {
|
afterEach(() => {
|
||||||
while (createdClientIds.length > 0) {
|
while (createdClientIds.length > 0) {
|
||||||
const id = createdClientIds.pop();
|
const id = createdClientIds.pop();
|
||||||
@@ -227,6 +241,28 @@ describe("clients:metadata gates", () => {
|
|||||||
const res = await patchNickname(auth.token, clientId, "test-nick");
|
const res = await patchNickname(auth.token, clientId, "test-nick");
|
||||||
expect(res!.status).toBe(200);
|
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", () => {
|
describe("layered semantics: groups + feature toggles", () => {
|
||||||
|
|||||||
@@ -490,6 +490,10 @@ export async function handleClientRoutes(
|
|||||||
if (req.method === "PATCH" && bookmarkMatch) {
|
if (req.method === "PATCH" && bookmarkMatch) {
|
||||||
const user = await authenticateRequest(req);
|
const user = await authenticateRequest(req);
|
||||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
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];
|
const targetId = bookmarkMatch[1];
|
||||||
try { requireClientAccess(user, targetId); } catch (error) {
|
try { requireClientAccess(user, targetId); } catch (error) {
|
||||||
if (error instanceof Response) return 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 { v4 as uuidv4 } from "uuid";
|
||||||
import { authenticateRequest } from "../../auth";
|
import { authenticateRequest } from "../../auth";
|
||||||
import { requireAnyPermission, requireClientAccess, requirePermission, requirePluginAccess } from "../../rbac";
|
import { requireAnyPermission, requireClientAccess, requirePermission, requirePluginAccess } from "../../rbac";
|
||||||
import { canUserAccessPlugin } from "../../users";
|
import { canUserAccessClient, canUserAccessPlugin } from "../../users";
|
||||||
import * as clientManager from "../../clientManager";
|
import * as clientManager from "../../clientManager";
|
||||||
import { metrics } from "../../metrics";
|
import { metrics } from "../../metrics";
|
||||||
import { encodeMessage, type PluginSignatureInfo } from "../../protocol";
|
import { encodeMessage, type PluginSignatureInfo } from "../../protocol";
|
||||||
@@ -256,10 +256,13 @@ export async function handlePluginRoutes(
|
|||||||
|
|
||||||
let body: any = {};
|
let body: any = {};
|
||||||
try { body = await req.json(); } catch {}
|
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)
|
? 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 manifests = await deps.listPluginManifests();
|
||||||
const plugins = manifests
|
const plugins = manifests
|
||||||
@@ -274,13 +277,13 @@ export async function handlePluginRoutes(
|
|||||||
const result: any = await deps.pluginRuntime.rpc(
|
const result: any = await deps.pluginRuntime.rpc(
|
||||||
plugin.id,
|
plugin.id,
|
||||||
"dashboardContributions",
|
"dashboardContributions",
|
||||||
{ clientIds: requestedClientIds },
|
{ clientIds: authorizedClientIds },
|
||||||
{ id: user.userId, role: user.role },
|
{ id: user.userId, role: user.role },
|
||||||
);
|
);
|
||||||
const rows = Array.isArray(result?.contributions) ? result.contributions : Array.isArray(result) ? result : [];
|
const rows = Array.isArray(result?.contributions) ? result.contributions : Array.isArray(result) ? result : [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const clientId = String(row?.clientId || "").trim();
|
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) : [];
|
const badges = Array.isArray(row?.badges) ? row.badges.slice(0, 8) : [];
|
||||||
if (!badges.length) continue;
|
if (!badges.length) continue;
|
||||||
contributions.push({ pluginId: plugin.id, clientId, badges });
|
contributions.push({ pluginId: plugin.id, clientId, badges });
|
||||||
|
|||||||
@@ -41,12 +41,20 @@ import {
|
|||||||
addDashboardSession,
|
addDashboardSession,
|
||||||
deleteDashboardSession,
|
deleteDashboardSession,
|
||||||
getDashboardSessionCount,
|
getDashboardSessionCount,
|
||||||
|
notifyDashboardClientEvent,
|
||||||
safeSendViewerFrame,
|
safeSendViewerFrame,
|
||||||
getAllConsoleSessions,
|
getAllConsoleSessions,
|
||||||
getAllRdSessions,
|
getAllRdSessions,
|
||||||
} from "./sessionManager";
|
} from "./sessionManager";
|
||||||
import type { ServerWebSocket } from "bun";
|
import type { ServerWebSocket } from "bun";
|
||||||
import type { SocketData } from "./types";
|
import type { SocketData } from "./types";
|
||||||
|
import {
|
||||||
|
createUser,
|
||||||
|
deleteUser,
|
||||||
|
getUserById,
|
||||||
|
setUserClientAccessRule,
|
||||||
|
setUserClientAccessScope,
|
||||||
|
} from "../users";
|
||||||
|
|
||||||
// Minimal mock ws that records sends
|
// Minimal mock ws that records sends
|
||||||
function mockWs(): ServerWebSocket<SocketData> {
|
function mockWs(): ServerWebSocket<SocketData> {
|
||||||
@@ -220,6 +228,47 @@ describe("sessionManager - dashboard sessions", () => {
|
|||||||
expect(getDashboardSessionCount()).toBeGreaterThanOrEqual(1);
|
expect(getDashboardSessionCount()).toBeGreaterThanOrEqual(1);
|
||||||
expect(deleteDashboardSession("ds-1")).toBe(true);
|
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", () => {
|
describe("safeSendViewerFrame", () => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
ChatViewer,
|
ChatViewer,
|
||||||
SocketData,
|
SocketData,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import { canUserAccessClient, type UserRole } from "../users";
|
||||||
|
|
||||||
export type DashboardViewer = {
|
export type DashboardViewer = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -542,6 +543,15 @@ export function notifyDashboardClientEvent(
|
|||||||
});
|
});
|
||||||
for (const [id, session] of dashboardSessions) {
|
for (const [id, session] of dashboardSessions) {
|
||||||
try {
|
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;
|
if (viewerHasBackpressure(session.viewer)) continue;
|
||||||
session.viewer.send(msg);
|
session.viewer.send(msg);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user