This commit is contained in:
KingKDot
2026-05-19 00:46:30 -05:00
parent bbd61af672
commit 116beff515
31 changed files with 2170 additions and 521 deletions
+10
View File
@@ -72,6 +72,7 @@ export interface Config {
mode: "off" | "open" | "key" | "approval";
defaultRole: "operator" | "viewer";
maxUsersTotal: number;
defaultGroupIds: number[];
};
buildRateLimit: {
maxBuildsPerHour: number;
@@ -155,6 +156,7 @@ const DEFAULT_CONFIG: Config = {
mode: "off" as const,
defaultRole: "operator" as const,
maxUsersTotal: 0,
defaultGroupIds: [] as number[],
},
buildRateLimit: {
maxBuildsPerHour: 5,
@@ -546,6 +548,11 @@ export function loadConfig(): Config {
Number(process.env.OVERLORD_REGISTRATION_MAX_USERS) ||
fileConfig.registration?.maxUsersTotal ||
DEFAULT_CONFIG.registration.maxUsersTotal,
defaultGroupIds: Array.isArray(fileConfig.registration?.defaultGroupIds)
? fileConfig.registration.defaultGroupIds
.map((g: unknown) => Number(g))
.filter((g: number) => Number.isFinite(g) && g > 0)
: [...DEFAULT_CONFIG.registration.defaultGroupIds],
},
buildRateLimit: {
maxBuildsPerHour:
@@ -834,6 +841,9 @@ export async function updateRegistrationConfig(
next.defaultRole = current.registration.defaultRole;
}
next.maxUsersTotal = Math.max(0, Math.min(100000, Number(next.maxUsersTotal) || 0));
next.defaultGroupIds = Array.isArray(next.defaultGroupIds)
? Array.from(new Set(next.defaultGroupIds.map((g) => Number(g)).filter((g) => Number.isFinite(g) && g > 0)))
: [...current.registration.defaultGroupIds];
configCache = {
...current,
+4 -1
View File
@@ -13,7 +13,8 @@ import { v4 as uuidv4 } from "uuid";
import { authenticateRequest } from "./auth";
import { loadConfig, getConfig } from "./config";
import { flushAuditLogsSync } from "./auditLog";
import { getUserById, getUsersForNotificationDelivery, getUsersForNotificationDeliveryByClientOwnership, isClientOwnedByUser, canUserAccessClient, setUserClientAccessRule, setUserClientAccessScope, getUserClientAccessScope, hasPermission } from "./users";
import { getUserById, getUsersForNotificationDelivery, getUsersForNotificationDeliveryByClientOwnership, isClientOwnedByUser, canUserAccessClient, setUserClientAccessRule, setUserClientAccessScope, getUserClientAccessScope } from "./users";
import { hasPermission } from "./rbac";
import { requireAuth, requirePermission } from "./rbac";
import { metrics } from "./metrics";
import { ensureDataDir } from "./paths";
@@ -35,6 +36,7 @@ import { handlePageRoutes } from "./server/routes/page-routes";
import { handlePluginRoutes } from "./server/routes/plugin-routes";
import { handleFileShareRoutes } from "./server/routes/file-share-routes";
import { handleUsersRoutes } from "./server/routes/users-routes";
import { handlePermissionGroupsRoutes } from "./server/routes/permission-groups-routes";
import { handleWebSocketClose, handleWebSocketMessage, handleWebSocketOpen } from "./server/routes/websocket-lifecycle-routes";
import { handleWsUpgradeRoutes } from "./server/routes/ws-upgrade-routes";
import { handleWebrtcRoutes } from "./server/routes/webrtc-routes";
@@ -724,6 +726,7 @@ async function startServer() {
(req, url) => handleChatRoutes(req, url),
(req, url) => handleSolRoutes(req, url),
(req, url, srv) => handleUsersRoutes(req, url, srv as any),
(req, url, srv) => handlePermissionGroupsRoutes(req, url, srv as any),
(req, url, srv) => handleBuildRoutes(req, url, srv as any, routeDeps.build),
(req, url, srv) => handleDeployRoutes(req, url, srv as any, routeDeps.deploy),
(req, url, srv) => handleWinRERoutes(req, url, srv as any, routeDeps.winre),
@@ -0,0 +1,171 @@
import { afterEach, describe, expect, test } from "bun:test";
import {
createPermissionGroup,
createUser,
deletePermissionGroup,
deleteUser,
getPermissionGroup,
getUserByUsername,
getUserGrantedPermissions,
getUserGroupIds,
listPermissionGroups,
setUserExtraPermissions,
setUserGroups,
updatePermissionGroup,
} from "./users";
import { hasPermission, getUserPermissions } from "./rbac";
const createdUserIds: number[] = [];
const createdGroupIds: number[] = [];
async function tempUser(role: "viewer" | "operator" | "admin") {
const username = `pg_${role.slice(0, 3)}_${Date.now().toString(36)}${Math.floor(Math.random() * 1000).toString(36)}`;
const result = await createUser(username, "Aa1!VeryLongTestPassword_2026", role, "test");
expect(result.success).toBe(true);
const user = getUserByUsername(username);
expect(user).not.toBeNull();
createdUserIds.push(user!.id);
return user!;
}
function tempGroup(name: string, permissions: string[]) {
const result = createPermissionGroup(name + "_" + Date.now() + "_" + Math.floor(Math.random() * 1000), null, permissions, null);
expect(result.success).toBe(true);
createdGroupIds.push(result.group!.id);
return result.group!;
}
afterEach(() => {
while (createdUserIds.length > 0) {
const id = createdUserIds.pop();
if (typeof id === "number") deleteUser(id);
}
while (createdGroupIds.length > 0) {
const id = createdGroupIds.pop();
if (typeof id === "number") deletePermissionGroup(id);
}
});
describe("permission groups CRUD", () => {
test("createPermissionGroup persists the group with permissions", () => {
const group = tempGroup("crud_one", ["clients:metadata", "clients:disconnect"]);
expect(group.name).toContain("crud_one");
expect(group.permissions.sort()).toEqual(["clients:disconnect", "clients:metadata"]);
const loaded = getPermissionGroup(group.id);
expect(loaded?.permissions.sort()).toEqual(["clients:disconnect", "clients:metadata"]);
});
test("createPermissionGroup rejects duplicate names", () => {
const group = tempGroup("dup", []);
const dup = createPermissionGroup(group.name, null, [], null);
expect(dup.success).toBe(false);
expect(dup.error).toMatch(/already exists/i);
});
test("updatePermissionGroup replaces the permission set", () => {
const group = tempGroup("upd", ["clients:metadata"]);
const result = updatePermissionGroup(group.id, { permissions: ["clients:uninstall"] });
expect(result.success).toBe(true);
expect(result.group!.permissions).toEqual(["clients:uninstall"]);
});
test("listPermissionGroups returns all created groups", () => {
const a = tempGroup("list_a", ["chat:write"]);
const b = tempGroup("list_b", ["clients:metadata"]);
const ids = new Set(listPermissionGroups().map((g) => g.id));
expect(ids.has(a.id)).toBe(true);
expect(ids.has(b.id)).toBe(true);
});
});
describe("user permission groups + extras", () => {
test("setUserGroups + getUserGrantedPermissions unions group perms", async () => {
const viewer = await tempUser("viewer");
const g1 = tempGroup("gp_a", ["clients:metadata"]);
const g2 = tempGroup("gp_b", ["clients:disconnect"]);
setUserGroups(viewer.id, [g1.id, g2.id]);
const granted = getUserGrantedPermissions(viewer.id);
expect(granted.has("clients:metadata")).toBe(true);
expect(granted.has("clients:disconnect")).toBe(true);
expect(granted.has("clients:uninstall")).toBe(false);
});
test("setUserExtraPermissions adds a permission outside any group", async () => {
const viewer = await tempUser("viewer");
setUserExtraPermissions(viewer.id, ["clients:uninstall"]);
expect(getUserGrantedPermissions(viewer.id).has("clients:uninstall")).toBe(true);
});
test("hasPermission union: viewer with a group gains the perm", async () => {
const viewer = await tempUser("viewer");
const g = tempGroup("hp_a", ["clients:metadata"]);
expect(hasPermission(viewer.role, "clients:metadata", viewer.id)).toBe(false);
setUserGroups(viewer.id, [g.id]);
expect(hasPermission(viewer.role, "clients:metadata", viewer.id)).toBe(true);
});
test("groups are additive only — they cannot revoke an admin perm", async () => {
const admin = await tempUser("admin");
setUserGroups(admin.id, []);
expect(hasPermission(admin.role, "clients:metadata", admin.id)).toBe(true);
});
test("userOverride still revokes — an operator with can_build=0 keeps no clients:build via empty group", async () => {
const op = await tempUser("operator");
// Operator has clients:build by default. Removing all groups + extras does
// NOT take it away because role grants it.
setUserGroups(op.id, []);
setUserExtraPermissions(op.id, []);
expect(hasPermission(op.role, "clients:build", op.id)).toBe(true);
// Only the legacy can_build override (userOverride returning false) revokes.
const { setUserCanBuild } = await import("./users");
setUserCanBuild(op.id, false);
expect(hasPermission(op.role, "clients:build", op.id)).toBe(false);
// Now add an extra → it gets granted back.
setUserExtraPermissions(op.id, ["clients:build"]);
expect(hasPermission(op.role, "clients:build", op.id)).toBe(true);
});
test("getUserPermissions includes role + group + extra permissions for a viewer", async () => {
const viewer = await tempUser("viewer");
const g = tempGroup("up_a", ["clients:metadata"]);
setUserGroups(viewer.id, [g.id]);
setUserExtraPermissions(viewer.id, ["clients:uninstall"]);
const perms = new Set(getUserPermissions(viewer.id, viewer.role));
expect(perms.has("clients:metadata")).toBe(true);
expect(perms.has("clients:uninstall")).toBe(true);
// Viewer has no role-level perms
expect(perms.has("clients:control")).toBe(false);
expect(perms.has("users:manage")).toBe(false);
});
test("getUserGroupIds returns assigned ids", async () => {
const viewer = await tempUser("viewer");
const g = tempGroup("gid_a", []);
setUserGroups(viewer.id, [g.id]);
expect(getUserGroupIds(viewer.id)).toContain(g.id);
});
test("deleting a group removes it from users", async () => {
const viewer = await tempUser("viewer");
const g = tempGroup("del_a", ["clients:metadata"]);
setUserGroups(viewer.id, [g.id]);
expect(getUserGroupIds(viewer.id)).toContain(g.id);
deletePermissionGroup(g.id);
expect(getUserGroupIds(viewer.id)).not.toContain(g.id);
// Stop the afterEach from double-deleting
createdGroupIds.splice(createdGroupIds.indexOf(g.id), 1);
});
test("deleting a user clears their group assignments + extras from cache", async () => {
const viewer = await tempUser("viewer");
const g = tempGroup("dl_user", ["clients:metadata"]);
setUserGroups(viewer.id, [g.id]);
setUserExtraPermissions(viewer.id, ["clients:disconnect"]);
expect(getUserGrantedPermissions(viewer.id).size).toBeGreaterThan(0);
deleteUser(viewer.id);
createdUserIds.splice(createdUserIds.indexOf(viewer.id), 1);
expect(getUserGrantedPermissions(viewer.id).size).toBe(0);
});
});
+104
View File
@@ -8,6 +8,7 @@ import {
requireAnyPermission,
getPermissionDescription,
getRoleDescription,
getRolePermissions,
type Permission,
} from "./rbac";
import type { AuthenticatedUser } from "./auth";
@@ -65,6 +66,58 @@ describe("checkPermission", () => {
test("operator has audit:view", () => {
expect(checkPermission(makeUser({ role: "operator" }), "audit:view")).toBe(true);
});
test("admin has scripts:manage, operator does not", () => {
expect(checkPermission(makeUser({ role: "admin" }), "scripts:manage")).toBe(true);
expect(checkPermission(makeUser({ role: "operator" }), "scripts:manage")).toBe(false);
expect(checkPermission(makeUser({ role: "viewer" }), "scripts:manage")).toBe(false);
});
test("admin has deploys:manage, operator does not", () => {
expect(checkPermission(makeUser({ role: "admin" }), "deploys:manage")).toBe(true);
expect(checkPermission(makeUser({ role: "operator" }), "deploys:manage")).toBe(false);
});
test("admin and operator have plugins:manage", () => {
expect(checkPermission(makeUser({ role: "admin" }), "plugins:manage")).toBe(true);
expect(checkPermission(makeUser({ role: "operator" }), "plugins:manage")).toBe(true);
expect(checkPermission(makeUser({ role: "viewer" }), "plugins:manage")).toBe(false);
});
test("only admin has plugins:configure", () => {
expect(checkPermission(makeUser({ role: "admin" }), "plugins:configure")).toBe(true);
expect(checkPermission(makeUser({ role: "operator" }), "plugins:configure")).toBe(false);
expect(checkPermission(makeUser({ role: "viewer" }), "plugins:configure")).toBe(false);
});
test("only admin has network:manage-bans", () => {
expect(checkPermission(makeUser({ role: "admin" }), "network:manage-bans")).toBe(true);
expect(checkPermission(makeUser({ role: "operator" }), "network:manage-bans")).toBe(false);
});
test("only admin has clients:silent-exec", () => {
expect(checkPermission(makeUser({ role: "admin" }), "clients:silent-exec")).toBe(true);
expect(checkPermission(makeUser({ role: "operator" }), "clients:silent-exec")).toBe(false);
expect(checkPermission(makeUser({ role: "viewer" }), "clients:silent-exec")).toBe(false);
});
test("only admin has system:configure", () => {
expect(checkPermission(makeUser({ role: "admin" }), "system:configure")).toBe(true);
expect(checkPermission(makeUser({ role: "operator" }), "system:configure")).toBe(false);
expect(checkPermission(makeUser({ role: "viewer" }), "system:configure")).toBe(false);
});
test("only admin has clients:winre", () => {
expect(checkPermission(makeUser({ role: "admin" }), "clients:winre")).toBe(true);
expect(checkPermission(makeUser({ role: "operator" }), "clients:winre")).toBe(false);
expect(checkPermission(makeUser({ role: "viewer" }), "clients:winre")).toBe(false);
});
test("admin and operator have clients:reconnect", () => {
expect(checkPermission(makeUser({ role: "admin" }), "clients:reconnect")).toBe(true);
expect(checkPermission(makeUser({ role: "operator" }), "clients:reconnect")).toBe(true);
expect(checkPermission(makeUser({ role: "viewer" }), "clients:reconnect")).toBe(false);
});
});
describe("checkAnyPermission", () => {
@@ -180,7 +233,16 @@ describe("getPermissionDescription", () => {
["clients:control", "Control clients (execute commands, desktop, console, files)"],
["clients:build", "Build client binaries"],
["clients:enroll", "Manage client enrollment approvals"],
["clients:silent-exec", "Silently execute arbitrary commands on clients"],
["audit:view", "View audit logs"],
["chat:write", "Send messages in team chat"],
["scripts:manage", "Manage auto-run scripts"],
["deploys:manage", "Manage deploys and auto-deploys"],
["plugins:manage", "Upload, enable, and delete plugins"],
["plugins:configure", "Configure plugin trust, auto-load, and direct execution"],
["network:manage-bans", "Manage IP bans"],
["system:configure", "Change server-level settings (TLS, security, registration, notifications, appearance, etc.)"],
["clients:winre", "Install or uninstall WinRE persistence on clients"],
];
for (const [perm, desc] of cases) {
@@ -194,6 +256,48 @@ describe("getPermissionDescription", () => {
});
});
describe("getRolePermissions", () => {
test("admin gets every declared permission", () => {
const perms = getRolePermissions("admin");
expect(perms).toContain("users:manage");
expect(perms).toContain("clients:control");
expect(perms).toContain("clients:build");
expect(perms).toContain("clients:enroll");
expect(perms).toContain("clients:silent-exec");
expect(perms).toContain("audit:view");
expect(perms).toContain("chat:write");
expect(perms).toContain("scripts:manage");
expect(perms).toContain("deploys:manage");
expect(perms).toContain("plugins:manage");
expect(perms).toContain("plugins:configure");
expect(perms).toContain("network:manage-bans");
expect(perms).toContain("system:configure");
expect(perms).toContain("clients:winre");
});
test("operator gets the role-eligible subset", () => {
const perms = getRolePermissions("operator");
expect(perms).toContain("clients:control");
expect(perms).toContain("clients:build");
expect(perms).toContain("clients:enroll");
expect(perms).toContain("audit:view");
expect(perms).toContain("chat:write");
expect(perms).toContain("plugins:manage");
expect(perms).not.toContain("users:manage");
expect(perms).not.toContain("scripts:manage");
expect(perms).not.toContain("deploys:manage");
expect(perms).not.toContain("plugins:configure");
expect(perms).not.toContain("network:manage-bans");
expect(perms).not.toContain("clients:silent-exec");
expect(perms).not.toContain("system:configure");
expect(perms).not.toContain("clients:winre");
});
test("viewer gets nothing", () => {
expect(getRolePermissions("viewer")).toHaveLength(0);
});
});
describe("getRoleDescription", () => {
test("admin description", () => {
expect(getRoleDescription("admin")).toBe(
+173 -34
View File
@@ -1,13 +1,130 @@
import { type AuthenticatedUser } from "./auth";
import { hasPermission, type UserRole } from "./users";
import {
canBuildClients,
canChatWrite,
canUserAccessClient,
canUserAccessFeature,
canUserAccessPlugin,
getUserGrantedPermissions,
type FeatureName,
type UserRole,
} from "./users";
export type Permission =
| "users:manage"
| "clients:control"
| "clients:build"
| "clients:enroll"
| "audit:view"
| "chat:write";
type PermissionDef = {
description: string;
roles: readonly UserRole[];
userOverride?: (userId: number, role: UserRole) => boolean;
};
const PERMISSIONS = {
"users:manage": {
description: "Manage users and roles",
roles: ["admin"],
},
"clients:control": {
description: "Control clients (execute commands, desktop, console, files)",
roles: ["admin", "operator"],
},
"clients:build": {
description: "Build client binaries",
roles: ["admin", "operator"],
userOverride: canBuildClients,
},
"clients:enroll": {
description: "Manage client enrollment approvals",
roles: ["admin", "operator"],
},
"clients:silent-exec": {
description: "Silently execute arbitrary commands on clients",
roles: ["admin"],
},
"clients:disconnect": {
description: "Force a client to disconnect",
roles: ["admin", "operator"],
},
"clients:reconnect": {
description: "Force a client to drop its session and reconnect",
roles: ["admin", "operator"],
},
"clients:metadata": {
description: "Edit client metadata (nickname, tag, group, bookmark, mute)",
roles: ["admin", "operator"],
},
"clients:uninstall": {
description: "Uninstall the agent on a client (removes persistence and deletes from dashboard)",
roles: ["admin", "operator"],
},
"audit:view": {
description: "View audit logs",
roles: ["admin", "operator"],
},
"chat:write": {
description: "Send messages in team chat",
roles: ["admin", "operator"],
userOverride: canChatWrite,
},
"scripts:manage": {
description: "Manage auto-run scripts",
roles: ["admin"],
},
"deploys:manage": {
description: "Manage deploys and auto-deploys",
roles: ["admin"],
},
"plugins:manage": {
description: "Upload, enable, and delete plugins",
roles: ["admin", "operator"],
},
"plugins:configure": {
description: "Configure plugin trust, auto-load, and direct execution",
roles: ["admin"],
},
"network:manage-bans": {
description: "Manage IP bans",
roles: ["admin"],
},
"system:configure": {
description: "Change server-level settings (TLS, security, registration, notifications, appearance, etc.)",
roles: ["admin"],
},
"clients:winre": {
description: "Install or uninstall WinRE persistence on clients",
roles: ["admin"],
},
} as const satisfies Record<string, PermissionDef>;
export type Permission = keyof typeof PERMISSIONS;
function lookupPermission(permission: string): PermissionDef | undefined {
return (PERMISSIONS as Record<string, PermissionDef>)[permission];
}
export function hasPermission(
role: UserRole,
permission: Permission | string,
userId?: number,
): boolean {
const def = lookupPermission(permission);
if (!def) return false;
// The legacy role-level gate. Per-permission userOverride (canBuild,
// canChatWrite) is authoritative when present + userId is known — it can
// both grant AND deny relative to the role default (an operator with
// can_build=0 loses clients:build at the role layer). Without a userOverride,
// membership in def.roles decides.
let grantedByRole: boolean;
if (def.userOverride && userId !== undefined) {
grantedByRole = def.userOverride(userId, role);
} else {
grantedByRole = def.roles.includes(role);
}
if (grantedByRole) return true;
// Permission groups + per-user extras are *additive only*: they can grant a
// permission the role doesn't have, but they cannot revoke one the role does.
if (userId !== undefined && getUserGrantedPermissions(userId).has(permission)) {
return true;
}
return false;
}
export function checkPermission(
user: AuthenticatedUser | null,
@@ -66,23 +183,8 @@ export function requireAnyPermission(
return authedUser;
}
export function getPermissionDescription(permission: Permission): string {
switch (permission) {
case "users:manage":
return "Manage users and roles";
case "clients:control":
return "Control clients (execute commands, desktop, console, files)";
case "clients:build":
return "Build client binaries";
case "clients:enroll":
return "Manage client enrollment approvals";
case "audit:view":
return "View audit logs";
case "chat:write":
return "Send messages in team chat";
default:
return "Unknown permission";
}
export function getPermissionDescription(permission: Permission | string): string {
return lookupPermission(permission)?.description ?? "Unknown permission";
}
export function getRoleDescription(role: UserRole): string {
@@ -98,15 +200,52 @@ export function getRoleDescription(role: UserRole): string {
}
}
export function requireClientAccess(
user: AuthenticatedUser | null,
clientId: string,
): AuthenticatedUser {
const authed = requireAuth(user);
if (!canUserAccessClient(authed.userId, authed.role, clientId)) {
throw new Response("Forbidden: You do not have access to this client", { status: 403 });
}
return authed;
}
export function requirePluginAccess(
user: AuthenticatedUser | null,
pluginId: string,
): AuthenticatedUser {
const authed = requireAuth(user);
if (!canUserAccessPlugin(authed.userId, authed.role, pluginId)) {
throw new Response("Forbidden: You do not have access to this plugin", { status: 403 });
}
return authed;
}
export function requireFeatureAccess(
user: AuthenticatedUser | null,
feature: FeatureName,
): AuthenticatedUser {
const authed = requireAuth(user);
if (!canUserAccessFeature(authed.userId, authed.role, feature)) {
throw new Response("Forbidden: feature access denied", { status: 403 });
}
return authed;
}
export function getRolePermissions(role: UserRole): Permission[] {
const permissions: Permission[] = [];
if (hasPermission(role, "users:manage")) permissions.push("users:manage");
if (hasPermission(role, "clients:control"))
permissions.push("clients:control");
if (hasPermission(role, "clients:build")) permissions.push("clients:build");
if (hasPermission(role, "clients:enroll")) permissions.push("clients:enroll");
if (hasPermission(role, "audit:view")) permissions.push("audit:view");
const result: Permission[] = [];
for (const perm of Object.keys(PERMISSIONS) as Permission[]) {
const def = lookupPermission(perm);
if (def && def.roles.includes(role)) result.push(perm);
}
return result;
}
return permissions;
export function getUserPermissions(userId: number, role: UserRole): Permission[] {
const result: Permission[] = [];
for (const perm of Object.keys(PERMISSIONS) as Permission[]) {
if (hasPermission(role, perm, userId)) result.push(perm);
}
return result;
}
@@ -24,6 +24,7 @@ import {
recordSuccessfulAttempt,
} from "../../rateLimit";
import { getUserById, canUserAccessClient, canUserAccessFeature, getUserFeaturePermissions, type FeatureName, ALL_FEATURES } from "../../users";
import { getUserPermissions, requirePermission } from "../../rbac";
import { makeAuthCookie, makeAuthCookieClear } from "./auth-cookie";
type RequestIpProvider = {
@@ -214,8 +215,11 @@ export async function handleAuthRoutes(
if (!user) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
if (user.role !== "admin") {
return Response.json({ error: "Admin only" }, { status: 403 });
try {
requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return Response.json({ error: "Forbidden" }, { status: 403 });
}
const targetUserId = parseInt(url.pathname.split("/")[3]);
@@ -274,8 +278,11 @@ export async function handleAuthRoutes(
if (!user) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
if (user.role !== "admin") {
return Response.json({ error: "Admin only" }, { status: 403 });
try {
requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return Response.json({ error: "Forbidden" }, { status: 403 });
}
const targetUserId = parseInt(url.pathname.split("/")[3]);
@@ -341,6 +348,7 @@ export async function handleAuthRoutes(
const dbUser = getUserById(user.userId);
const featurePermissions = getUserFeaturePermissions(user.userId);
const permissions = getUserPermissions(user.userId, user.role);
return new Response(
JSON.stringify({
@@ -351,6 +359,7 @@ export async function handleAuthRoutes(
canBuild: dbUser ? Boolean(dbUser.can_build) : false,
telegramChatId: dbUser?.telegram_chat_id || "",
featurePermissions,
permissions,
}),
{
headers: { "Content-Type": "application/json" },
@@ -50,8 +50,11 @@ export async function handleAutoDeployRoutes(
headers: { "Content-Type": "application/json" },
});
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "deploys:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let form: FormData;
@@ -123,8 +126,11 @@ export async function handleAutoDeployRoutes(
headers: { "Content-Type": "application/json" },
});
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "deploys:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any = {};
@@ -168,8 +174,11 @@ export async function handleAutoDeployRoutes(
headers: { "Content-Type": "application/json" },
});
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "deploys:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const deploy = getAutoDeploy(autoDeployMatch[1]);
@@ -40,8 +40,11 @@ export async function handleAutoScriptsRoutes(
headers: { "Content-Type": "application/json" },
});
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "scripts:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any = {};
@@ -95,8 +98,11 @@ export async function handleAutoScriptsRoutes(
headers: { "Content-Type": "application/json" },
});
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "scripts:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any = {};
@@ -147,8 +153,11 @@ export async function handleAutoScriptsRoutes(
headers: { "Content-Type": "application/json" },
});
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "scripts:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
deleteAutoScript(autoScriptMatch[1]);
@@ -852,10 +852,7 @@ export async function handleBuildRoutes(
}
if (req.method === "POST" && url.pathname === "/api/build/update-all") {
requirePermission(user, "clients:build");
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
}
requirePermission(user, "deploys:manage");
let body: any = {};
try {
@@ -1,7 +1,7 @@
import { authenticateRequest } from "../../auth";
import { getConfig } from "../../config";
import { getChatHistory, getOnlineClientCountForUser } from "../../db";
import { hasPermission } from "../../users";
import { hasPermission } from "../../rbac";
export async function handleChatRoutes(
req: Request,
@@ -29,7 +29,7 @@ import {
} from "../../db";
import { metrics } from "../../metrics";
import { encodeMessage } from "../../protocol";
import { requirePermission } from "../../rbac";
import { requireClientAccess, requireFeatureAccess, requirePermission } from "../../rbac";
import {
canUserAccessClient,
getUserClientAccessScope,
@@ -158,8 +158,11 @@ export async function handleClientRoutes(
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "network:manage-bans");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (req.method === "GET") {
@@ -208,8 +211,11 @@ export async function handleClientRoutes(
}
const targetId = banMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
try {
requireClientAccess(user, targetId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const target = clientManager.getClient(targetId);
const targetIp = target?.ip || getClientIp(targetId);
@@ -247,8 +253,11 @@ export async function handleClientRoutes(
return new Response("Unauthorized", { status: 401 });
}
const clientId = thumbnailMatch[1];
if (!canUserAccessClient(user.userId, user.role, clientId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
try {
requireClientAccess(user, clientId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const { requestThumbnailRegen, markThumbnailRequested, getThumbnailVersion, waitForThumbnail, clearThumbnailRequest } = await import("../../thumbnails");
markThumbnailRequested(clientId);
@@ -286,8 +295,11 @@ export async function handleClientRoutes(
return new Response("Unauthorized", { status: 401 });
}
const clientId = thumbnailMatch[1];
if (!canUserAccessClient(user.userId, user.role, clientId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
try {
requireClientAccess(user, clientId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const { getThumbnailRecord } = await import("../../thumbnails");
const record = getThumbnailRecord(clientId);
@@ -349,8 +361,11 @@ export async function handleClientRoutes(
}
const targetId = clientDeleteMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
try {
requireClientAccess(user, targetId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const target = clientManager.getClient(targetId);
const isOnlineInDb = getClientOnlineState(targetId);
@@ -399,15 +414,19 @@ export async function handleClientRoutes(
if (!user) return new Response("Unauthorized", { status: 401 });
try {
requirePermission(user, "clients:control");
requirePermission(user, "clients:metadata");
requireFeatureAccess(user, "client_metadata");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const targetId = clientNicknameMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
try {
requireClientAccess(user, targetId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (!clientExists(targetId)) {
return Response.json({ error: "Client not found" }, { status: 404 });
@@ -456,15 +475,19 @@ export async function handleClientRoutes(
if (!user) return new Response("Unauthorized", { status: 401 });
try {
requirePermission(user, "clients:control");
requirePermission(user, "clients:metadata");
requireFeatureAccess(user, "client_metadata");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const targetId = clientTagMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
try {
requireClientAccess(user, targetId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (!clientExists(targetId)) {
return Response.json({ error: "Client not found" }, { status: 404 });
@@ -517,7 +540,7 @@ export async function handleClientRoutes(
if (req.method === "PATCH" && url.pathname === "/api/clients/bulk-notifications-muted") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
try { requirePermission(user, "clients:control"); } catch (error) {
try { requirePermission(user, "clients:metadata"); requireFeatureAccess(user, "client_metadata"); } catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
@@ -555,14 +578,17 @@ export async function handleClientRoutes(
if (req.method === "PATCH" && muteMatch) {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
try { requirePermission(user, "clients:control"); } catch (error) {
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 = muteMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
try {
requireClientAccess(user, targetId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (!clientExists(targetId)) {
return Response.json({ error: "Client not found" }, { status: 404 });
@@ -602,8 +628,11 @@ export async function handleClientRoutes(
if (!user) return new Response("Unauthorized", { status: 401 });
const targetId = bookmarkMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
try {
requireClientAccess(user, targetId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (!clientExists(targetId)) {
return Response.json({ error: "Client not found" }, { status: 404 });
@@ -639,8 +668,11 @@ export async function handleClientRoutes(
}
const targetId = cmdMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
try {
requireClientAccess(user, targetId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const target = clientManager.getClient(targetId);
const ip = server.requestIP(req)?.address || "unknown";
@@ -661,6 +693,13 @@ export async function handleClientRoutes(
for (let i = 0; i < count; i++) {
}
} else if (action === "disconnect") {
try {
requirePermission(user, "clients:disconnect");
requireFeatureAccess(user, "disconnect");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
target.ws.send(encodeMessage({ type: "command", commandType: "disconnect", id: uuidv4() }));
metrics.recordCommand("disconnect");
logAudit({
@@ -672,6 +711,13 @@ export async function handleClientRoutes(
success: true,
});
} else if (action === "reconnect") {
try {
requirePermission(user, "clients:reconnect");
requireFeatureAccess(user, "reconnect");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
target.ws.send(encodeMessage({ type: "command", commandType: "reconnect", id: uuidv4() }));
metrics.recordCommand("reconnect");
logAudit({
@@ -774,8 +820,11 @@ export async function handleClientRoutes(
return Response.json({ ok: false, error: error.message || "Voice capability probe failed" }, { status: 504 });
}
} else if (action === "silent_exec") {
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "clients:silent-exec");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const command = typeof body?.command === "string" ? body.command.trim() : "";
@@ -806,6 +855,13 @@ export async function handleClientRoutes(
details: JSON.stringify({ command, args, cwd }),
});
} else if (action === "uninstall") {
try {
requirePermission(user, "clients:uninstall");
requireFeatureAccess(user, "uninstall");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
target.ws.send(encodeMessage({ type: "command", commandType: "uninstall", id: uuidv4() }));
metrics.recordCommand("uninstall");
clientManager.deleteClient(targetId);
@@ -821,8 +877,11 @@ export async function handleClientRoutes(
success: true,
});
} else if (action === "elevate") {
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "clients:silent-exec");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const password = typeof body?.password === "string" ? body.password : "";
@@ -980,7 +1039,7 @@ export async function handleClientRoutes(
if (req.method === "PATCH" && url.pathname === "/api/clients/bulk-group") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
try { requirePermission(user, "clients:control"); } catch (error) {
try { requirePermission(user, "clients:metadata"); requireFeatureAccess(user, "client_metadata"); } catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
@@ -1019,14 +1078,17 @@ export async function handleClientRoutes(
if (req.method === "PATCH" && clientGroupMatch) {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
try { requirePermission(user, "clients:control"); } catch (error) {
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 = clientGroupMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
try {
requireClientAccess(user, targetId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (!clientExists(targetId)) {
return Response.json({ error: "Client not found" }, { status: 404 });
@@ -7,6 +7,7 @@ import { AuditAction, logAudit } from "../../auditLog";
import * as clientManager from "../../clientManager";
import { metrics } from "../../metrics";
import { encodeMessage } from "../../protocol";
import { requirePermission } from "../../rbac";
import { createUploadPull } from "./file-download-routes";
type RequestIpProvider = {
@@ -44,8 +45,11 @@ export async function handleDeployRoutes(
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "deploys:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let form: FormData;
@@ -87,8 +91,11 @@ export async function handleDeployRoutes(
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "deploys:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any = {};
@@ -166,8 +173,11 @@ export async function handleDeployRoutes(
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "deploys:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any = {};
@@ -289,8 +299,11 @@ export async function handleDeployRoutes(
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "deploys:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any = {};
@@ -346,8 +346,11 @@ export async function handleEnrollmentRoutes(
if (req.method === "GET" && url.pathname === "/api/enrollment/banned-ips") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "network:manage-bans");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
return Response.json({ items: listBannedIps() });
@@ -357,8 +360,11 @@ export async function handleEnrollmentRoutes(
if (req.method === "DELETE" && url.pathname === "/api/enrollment/banned-ips") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "network:manage-bans");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const ipToUnban = (url.searchParams.get("ip") || "").trim();
@@ -384,8 +390,11 @@ export async function handleEnrollmentRoutes(
if (req.method === "POST" && url.pathname === "/api/enrollment/ban-ip") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "network:manage-bans");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any;
@@ -395,7 +395,7 @@ describe("file route feature_browser enforcement", () => {
expect(res).not.toBeNull();
expect(res!.status).toBe(403);
expect(await res!.text()).toContain("client access denied");
expect(await res!.text()).toContain("do not have access to this client");
} finally {
clientManager.deleteClient(clientId);
deleteUser(auth.userId);
@@ -8,7 +8,7 @@ import { logger } from "../../logger";
import { encodeMessage } from "../../protocol";
import { getConfig } from "../../config";
import { isAuthorizedAgentRequest } from "../agent-auth";
import { canUserAccessClient, canUserAccessFeature } from "../../users";
import { requireClientAccess, requireFeatureAccess, requirePermission } from "../../rbac";
type RequestIpProvider = {
requestIP: (req: Request) => { address?: string } | null | undefined;
@@ -156,12 +156,18 @@ async function serveDownloadById(
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin" && user.role !== "operator") {
return new Response("Forbidden: Admin or operator access required", { status: 403 });
try {
requirePermission(user, "clients:control");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (!canUserAccessFeature(user.userId, user.role as any, "file_browser")) {
return new Response("Forbidden: feature access denied", { status: 403 });
try {
requireFeatureAccess(user, "file_browser");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
logger.debug("[filebrowser] http download request", {
@@ -345,8 +351,11 @@ export async function handleFileDownloadRoutes(
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin" && user.role !== "operator") {
return new Response("Forbidden: Admin or operator access required", { status: 403 });
try {
requirePermission(user, "clients:control");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any = {};
@@ -363,12 +372,12 @@ export async function handleFileDownloadRoutes(
return new Response("Bad request", { status: 400 });
}
if (!canUserAccessClient(user.userId, user.role as any, clientId)) {
return new Response("Forbidden: client access denied", { status: 403 });
}
if (!canUserAccessFeature(user.userId, user.role as any, "file_browser")) {
return new Response("Forbidden: feature access denied", { status: 403 });
try {
requireClientAccess(user, clientId);
requireFeatureAccess(user, "file_browser");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
logger.debug("[filebrowser] http upload request", {
@@ -412,12 +421,18 @@ export async function handleFileDownloadRoutes(
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin" && user.role !== "operator") {
return new Response("Forbidden: Admin or operator access required", { status: 403 });
try {
requirePermission(user, "clients:control");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (!canUserAccessFeature(user.userId, user.role as any, "file_browser")) {
return new Response("Forbidden: feature access denied", { status: 403 });
try {
requireFeatureAccess(user, "file_browser");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let uploadId = "";
@@ -642,8 +657,11 @@ export async function handleFileDownloadRoutes(
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin" && user.role !== "operator") {
return new Response("Forbidden: Admin or operator access required", { status: 403 });
try {
requirePermission(user, "clients:control");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any = {};
@@ -659,12 +677,12 @@ export async function handleFileDownloadRoutes(
return new Response("Bad request", { status: 400 });
}
if (!canUserAccessClient(user.userId, user.role as any, clientId)) {
return new Response("Forbidden: client access denied", { status: 403 });
}
if (!canUserAccessFeature(user.userId, user.role as any, "file_browser")) {
return new Response("Forbidden: feature access denied", { status: 403 });
try {
requireClientAccess(user, clientId);
requireFeatureAccess(user, "file_browser");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const target = clientManager.getClient(clientId);
@@ -110,8 +110,11 @@ export async function handleMiscRoutes(
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "system:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (req.method === "GET") {
@@ -157,8 +160,11 @@ export async function handleMiscRoutes(
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "system:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (req.method === "GET") {
@@ -209,8 +215,11 @@ export async function handleMiscRoutes(
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "system:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any = {};
@@ -447,8 +456,11 @@ export async function handleMiscRoutes(
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "system:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const exportData = getExportableConfig(deps.SERVER_VERSION);
@@ -477,8 +489,11 @@ export async function handleMiscRoutes(
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "system:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any = {};
@@ -526,8 +541,11 @@ export async function handleMiscRoutes(
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "system:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (req.method === "GET") {
@@ -564,8 +582,11 @@ export async function handleMiscRoutes(
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "system:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (req.method === "GET") {
@@ -627,7 +648,7 @@ export async function handleMiscRoutes(
if (url.pathname === "/api/settings/registration") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden: Admin access required", { status: 403 });
try { requirePermission(user, "system:configure"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
if (req.method === "GET") {
return Response.json({ registration: getConfig().registration }, { headers: deps.CORS_HEADERS });
@@ -643,6 +664,7 @@ export async function handleMiscRoutes(
mode: body?.mode,
defaultRole: body?.defaultRole,
maxUsersTotal: body?.maxUsersTotal !== undefined ? Number(body.maxUsersTotal) : undefined,
defaultGroupIds: Array.isArray(body?.defaultGroupIds) ? body.defaultGroupIds : undefined,
});
logAudit({
@@ -662,7 +684,7 @@ export async function handleMiscRoutes(
if (url.pathname === "/api/settings/build-rate-limit") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden: Admin access required", { status: 403 });
try { requirePermission(user, "system:configure"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
if (req.method === "GET") {
return Response.json({ buildRateLimit: getConfig().buildRateLimit }, { headers: deps.CORS_HEADERS });
@@ -703,7 +725,7 @@ export async function handleMiscRoutes(
}
if (req.method === "PUT") {
if (user.role !== "admin") return new Response("Forbidden: Admin access required", { status: 403 });
try { requirePermission(user, "system:configure"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
let body: any = {};
try { body = await req.json(); } catch {
@@ -9,6 +9,7 @@ import {
getUserNotificationSettings,
updateUserNotificationSettings,
} from "../../users";
import { requirePermission } from "../../rbac";
import {
DEFAULT_WEBHOOK_TEMPLATE,
DEFAULT_TELEGRAM_TEMPLATE,
@@ -76,8 +77,11 @@ export async function handleNotificationsConfigRoutes(
headers: { "Content-Type": "application/json" },
});
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "system:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
return Response.json({ notifications: getConfig().notifications });
}
@@ -90,10 +94,11 @@ export async function handleNotificationsConfigRoutes(
headers: { "Content-Type": "application/json" },
});
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", {
status: 403,
});
try {
requirePermission(user, "system:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any = {};
@@ -0,0 +1,258 @@
import { authenticateRequest } from "../../auth";
import { AuditAction, logAudit } from "../../auditLog";
import {
createPermissionGroup,
deletePermissionGroup,
getPermissionGroup,
getUserById,
getUserExtraPermissions,
getUserGroupIds,
listPermissionGroups,
setUserExtraPermissions,
setUserGroups,
updatePermissionGroup,
} from "../../users";
import {
getPermissionDescription,
type Permission,
requirePermission,
} from "../../rbac";
const KNOWN_PERMISSIONS: Permission[] = [
"users:manage",
"clients:control",
"clients:build",
"clients:enroll",
"clients:silent-exec",
"clients:disconnect",
"clients:reconnect",
"clients:metadata",
"clients:uninstall",
"clients:winre",
"audit:view",
"chat:write",
"scripts:manage",
"deploys:manage",
"plugins:manage",
"plugins:configure",
"network:manage-bans",
"system:configure",
];
function sanitizePermissions(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const known = new Set<string>(KNOWN_PERMISSIONS);
return Array.from(
new Set(
raw
.filter((p): p is string => typeof p === "string")
.map((p) => p.trim())
.filter((p) => known.has(p)),
),
);
}
type RequestIpProvider = {
requestIP: (req: Request) => { address?: string } | null | undefined;
};
export async function handlePermissionGroupsRoutes(
req: Request,
url: URL,
server: RequestIpProvider,
): Promise<Response | null> {
if (req.method === "GET" && url.pathname === "/api/permissions") {
const user = await authenticateRequest(req);
try {
requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
return Response.json({
permissions: KNOWN_PERMISSIONS.map((p) => ({
id: p,
description: getPermissionDescription(p),
})),
});
}
if (req.method === "GET" && url.pathname === "/api/permission-groups") {
const user = await authenticateRequest(req);
try {
requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
return Response.json({ groups: listPermissionGroups() });
}
if (req.method === "POST" && url.pathname === "/api/permission-groups") {
const user = await authenticateRequest(req);
let authed;
try {
authed = requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any;
try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON" }, { status: 400 }); }
const name = typeof body?.name === "string" ? body.name : "";
const description = typeof body?.description === "string" ? body.description : null;
const permissions = sanitizePermissions(body?.permissions);
const result = createPermissionGroup(name, description, permissions, authed.userId);
if (!result.success) return Response.json({ error: result.error }, { status: 400 });
logAudit({
timestamp: Date.now(),
username: authed.username,
ip: server.requestIP(req)?.address || "unknown",
action: AuditAction.COMMAND,
details: `Created permission group "${result.group!.name}" with ${result.group!.permissions.length} permissions`,
success: true,
});
return Response.json({ group: result.group });
}
const singleGroupMatch = url.pathname.match(/^\/api\/permission-groups\/(\d+)$/);
if (singleGroupMatch) {
const groupId = Number(singleGroupMatch[1]);
const user = await authenticateRequest(req);
let authed;
try {
authed = requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (req.method === "GET") {
const group = getPermissionGroup(groupId);
if (!group) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json({ group });
}
if (req.method === "PATCH" || req.method === "PUT") {
let body: any;
try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON" }, { status: 400 }); }
const updates: Parameters<typeof updatePermissionGroup>[1] = {};
if (body?.name !== undefined) updates.name = String(body.name);
if (body?.description !== undefined) updates.description = body.description === null ? null : String(body.description);
if (body?.permissions !== undefined) updates.permissions = sanitizePermissions(body.permissions);
const result = updatePermissionGroup(groupId, updates);
if (!result.success) return Response.json({ error: result.error }, { status: 400 });
logAudit({
timestamp: Date.now(),
username: authed.username,
ip: server.requestIP(req)?.address || "unknown",
action: AuditAction.COMMAND,
details: `Updated permission group "${result.group!.name}"`,
success: true,
});
return Response.json({ group: result.group });
}
if (req.method === "DELETE") {
const existing = getPermissionGroup(groupId);
const result = deletePermissionGroup(groupId);
if (!result.success) return Response.json({ error: result.error }, { status: 404 });
logAudit({
timestamp: Date.now(),
username: authed.username,
ip: server.requestIP(req)?.address || "unknown",
action: AuditAction.COMMAND,
details: `Deleted permission group "${existing?.name ?? groupId}"`,
success: true,
});
return Response.json({ ok: true });
}
}
const userGroupsMatch = url.pathname.match(/^\/api\/users\/(\d+)\/permission-groups$/);
if (userGroupsMatch) {
const targetUserId = Number(userGroupsMatch[1]);
const user = await authenticateRequest(req);
let authed;
try {
authed = requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (!getUserById(targetUserId)) {
return Response.json({ error: "User not found" }, { status: 404 });
}
if (req.method === "GET") {
return Response.json({ groupIds: getUserGroupIds(targetUserId) });
}
if (req.method === "PUT") {
let body: any;
try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON" }, { status: 400 }); }
const ids = Array.isArray(body?.groupIds)
? body.groupIds.map((g: any) => Number(g)).filter((g: number) => Number.isFinite(g))
: [];
const result = setUserGroups(targetUserId, ids);
if (!result.success) return Response.json({ error: result.error }, { status: 400 });
logAudit({
timestamp: Date.now(),
username: authed.username,
ip: server.requestIP(req)?.address || "unknown",
action: AuditAction.COMMAND,
details: `Assigned ${ids.length} permission group(s) to user #${targetUserId}`,
success: true,
});
return Response.json({ ok: true, groupIds: ids });
}
}
const userExtrasMatch = url.pathname.match(/^\/api\/users\/(\d+)\/extra-permissions$/);
if (userExtrasMatch) {
const targetUserId = Number(userExtrasMatch[1]);
const user = await authenticateRequest(req);
let authed;
try {
authed = requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (!getUserById(targetUserId)) {
return Response.json({ error: "User not found" }, { status: 404 });
}
if (req.method === "GET") {
return Response.json({ permissions: Array.from(getUserExtraPermissions(targetUserId)).sort() });
}
if (req.method === "PUT") {
let body: any;
try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON" }, { status: 400 }); }
const perms = sanitizePermissions(body?.permissions);
const result = setUserExtraPermissions(targetUserId, perms);
if (!result.success) return Response.json({ error: result.error }, { status: 400 });
logAudit({
timestamp: Date.now(),
username: authed.username,
ip: server.requestIP(req)?.address || "unknown",
action: AuditAction.COMMAND,
details: `Set ${perms.length} extra permission(s) on user #${targetUserId}`,
success: true,
});
return Response.json({ ok: true, permissions: perms });
}
}
return null;
}
@@ -4,8 +4,8 @@ import path from "path";
import AdmZip from "adm-zip";
import { v4 as uuidv4 } from "uuid";
import { authenticateRequest } from "../../auth";
import { requirePermission } from "../../rbac";
import { canUserAccessClient, canUserAccessPlugin } from "../../users";
import { requireClientAccess, requirePermission, requirePluginAccess } from "../../rbac";
import { canUserAccessPlugin } from "../../users";
import * as clientManager from "../../clientManager";
import { metrics } from "../../metrics";
import { encodeMessage, type PluginSignatureInfo } from "../../protocol";
@@ -146,8 +146,11 @@ export async function handlePluginRoutes(
if (req.method === "GET" && url.pathname === "/api/plugins/trusted-keys") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "plugins:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const config = getConfig();
const allKeys = Array.from(new Set([...BUILTIN_TRUSTED_KEYS, ...config.plugins.trustedKeys]));
@@ -157,8 +160,11 @@ export async function handlePluginRoutes(
if (req.method === "POST" && url.pathname === "/api/plugins/trusted-keys") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "plugins:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any = {};
try { body = await req.json(); } catch {}
@@ -179,8 +185,11 @@ export async function handlePluginRoutes(
if (req.method === "DELETE" && trustedKeyDeleteMatch) {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "plugins:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const fingerprint = trustedKeyDeleteMatch[1].toLowerCase();
const config = getConfig();
@@ -203,8 +212,11 @@ export async function handlePluginRoutes(
}
const clientId = clientPluginsMatch[1];
if (!canUserAccessClient(user.userId, user.role, clientId)) {
return new Response("Forbidden: You do not have access to this client", { status: 403 });
try {
requireClientAccess(user, clientId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const loaded = deps.pluginLoadedByClient.get(clientId) || new Set<string>();
const manifests = await deps.listPluginManifests();
@@ -226,8 +238,11 @@ export async function handlePluginRoutes(
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin" && user.role !== "operator") {
return new Response("Forbidden: Admin or operator access required", { status: 403 });
try {
requirePermission(user, "plugins:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let form: FormData;
@@ -321,8 +336,11 @@ export async function handlePluginRoutes(
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin" && user.role !== "operator") {
return new Response("Forbidden: Admin or operator access required", { status: 403 });
try {
requirePermission(user, "plugins:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let pluginId = "";
try {
@@ -368,8 +386,11 @@ export async function handlePluginRoutes(
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "plugins:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let pluginId = "";
try {
@@ -460,8 +481,11 @@ export async function handlePluginRoutes(
try { requirePermission(user, "clients:control"); } catch (e) { if (e instanceof Response) return e; return new Response("Forbidden", { status: 403 }); }
let pluginId = "";
try { pluginId = deps.sanitizePluginId(pluginDataListMatch[1]); } catch { return new Response("Invalid plugin id", { status: 400 }); }
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
try {
requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const dataDir = path.join(deps.PLUGIN_ROOT, pluginId, "data");
await fs.mkdir(dataDir, { recursive: true });
@@ -492,8 +516,11 @@ export async function handlePluginRoutes(
try { requirePermission(user, "clients:control"); } catch (e) { if (e instanceof Response) return e; return new Response("Forbidden", { status: 403 }); }
let pluginId = "";
try { pluginId = deps.sanitizePluginId(pluginDataReadMatch[1]); } catch { return new Response("Invalid plugin id", { status: 400 }); }
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
try {
requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let relPath = pluginDataReadMatch[2];
try { relPath = decodeURIComponent(relPath); } catch { return new Response("Bad request", { status: 400 }); }
@@ -517,8 +544,11 @@ export async function handlePluginRoutes(
try { requirePermission(user, "clients:control"); } catch (e) { if (e instanceof Response) return e; return new Response("Forbidden", { status: 403 }); }
let pluginId = "";
try { pluginId = deps.sanitizePluginId(pluginDataWriteMatch[1]); } catch { return new Response("Invalid plugin id", { status: 400 }); }
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
try {
requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let relPath = pluginDataWriteMatch[2];
try { relPath = decodeURIComponent(relPath); } catch { return new Response("Bad request", { status: 400 }); }
@@ -558,8 +588,11 @@ export async function handlePluginRoutes(
try { requirePermission(user, "clients:control"); } catch (e) { if (e instanceof Response) return e; return new Response("Forbidden", { status: 403 }); }
let pluginId = "";
try { pluginId = deps.sanitizePluginId(pluginDataDeleteMatch[1]); } catch { return new Response("Invalid plugin id", { status: 400 }); }
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
try {
requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let relPath = pluginDataDeleteMatch[2];
try { relPath = decodeURIComponent(relPath); } catch { return new Response("Bad request", { status: 400 }); }
@@ -589,13 +622,19 @@ export async function handlePluginRoutes(
if (req.method === "POST" && pluginExecMatch) {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "plugins:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let pluginId = "";
try { pluginId = deps.sanitizePluginId(pluginExecMatch[1]); } catch { return new Response("Invalid plugin id", { status: 400 }); }
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
try {
requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any = {};
try { body = await req.json(); } catch { return new Response("Bad request", { status: 400 }); }
@@ -657,8 +696,11 @@ export async function handlePluginRoutes(
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin" && user.role !== "operator") {
return new Response("Forbidden: Admin or operator access required", { status: 403 });
try {
requirePermission(user, "plugins:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let pluginId = "";
@@ -733,12 +775,18 @@ export async function handlePluginRoutes(
return new Response("Forbidden", { status: 403 });
}
const targetId = pluginLoadMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) {
return new Response("Forbidden: You do not have access to this client", { status: 403 });
try {
requireClientAccess(user, targetId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const pluginId = pluginLoadMatch[2];
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
try {
requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const target = clientManager.getClient(targetId);
if (!target) return new Response("Not found", { status: 404 });
@@ -793,12 +841,18 @@ export async function handlePluginRoutes(
return new Response("Forbidden", { status: 403 });
}
const targetId = pluginEventsPollMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) {
return new Response("Forbidden: You do not have access to this client", { status: 403 });
try {
requireClientAccess(user, targetId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const pluginId = pluginEventsPollMatch[2];
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
try {
requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const events = deps.drainPluginUIEvents(targetId, pluginId);
return Response.json({ events });
@@ -816,12 +870,18 @@ export async function handlePluginRoutes(
}
const targetId = pluginEventMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) {
return new Response("Forbidden: You do not have access to this client", { status: 403 });
try {
requireClientAccess(user, targetId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const pluginId = pluginEventMatch[2];
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
try {
requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const target = clientManager.getClient(targetId);
if (!target) return new Response("Not found", { status: 404 });
@@ -881,12 +941,18 @@ export async function handlePluginRoutes(
}
const targetId = pluginUnloadMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) {
return new Response("Forbidden: You do not have access to this client", { status: 403 });
try {
requireClientAccess(user, targetId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const pluginId = pluginUnloadMatch[2];
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
try {
requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const target = clientManager.getClient(targetId);
if (!target) return new Response("Not found", { status: 404 });
@@ -923,8 +989,11 @@ export async function handlePluginRoutes(
return new Response("Invalid plugin id", { status: 400 });
}
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
try {
requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (deps.pluginState.enabled[pluginId] === false) {
@@ -976,8 +1045,11 @@ export async function handlePluginRoutes(
return new Response("Invalid plugin id", { status: 400 });
}
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
try {
requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (deps.pluginState.enabled[pluginId] === false) {
@@ -1041,8 +1113,11 @@ export async function handlePluginRoutes(
return new Response("Invalid plugin id", { status: 400 });
}
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
try {
requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const htmlFile = path.join(deps.PLUGIN_ROOT, pluginId, "assets", `${pluginId}.html`);
@@ -1085,13 +1160,21 @@ export async function handlePluginRoutes(
return new Response("Invalid plugin id", { status: 400 });
}
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
try {
requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const clientId = url.searchParams.get("clientId") || "";
if (clientId && !canUserAccessClient(user.userId, user.role, clientId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
if (clientId) {
try {
requireClientAccess(user, clientId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
}
const htmlFile = path.join(deps.PLUGIN_ROOT, pluginId, "assets", `${pluginId}.html`);
@@ -1180,8 +1263,11 @@ export async function handlePluginRoutes(
}
const [, pluginId, assetPath] = pluginAssetMatch;
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
try {
requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let decodedPath = assetPath;
try {
@@ -2,6 +2,7 @@ import { authenticateRequest } from "../../auth";
import { AuditAction, logAudit } from "../../auditLog";
import { getConfig } from "../../config";
import { logger } from "../../logger";
import { requirePermission } from "../../rbac";
import {
registerUser,
deleteUser,
@@ -225,7 +226,12 @@ export async function handleRegistrationRoutes(
if (req.method === "GET" && url.pathname === "/api/registration/keys") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
try {
requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const keys = listRegistrationKeys();
return Response.json({ keys });
@@ -234,7 +240,12 @@ export async function handleRegistrationRoutes(
if (req.method === "POST" && url.pathname === "/api/registration/keys") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
try {
requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any;
try {
@@ -267,7 +278,12 @@ export async function handleRegistrationRoutes(
if (req.method === "DELETE" && url.pathname.match(/^\/api\/registration\/keys\/\d+$/)) {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
try {
requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const keyId = Number(url.pathname.split("/").pop());
if (!keyId || !Number.isFinite(keyId)) {
@@ -296,7 +312,12 @@ export async function handleRegistrationRoutes(
if (req.method === "GET" && url.pathname === "/api/registration/keys/export") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
try {
requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const keys = listRegistrationKeys();
const exportData = keys.map((k) => ({
@@ -319,7 +340,12 @@ export async function handleRegistrationRoutes(
if (req.method === "GET" && url.pathname === "/api/registration/pending") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
try {
requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const pending = listPendingRegistrations();
return Response.json({
@@ -336,7 +362,12 @@ export async function handleRegistrationRoutes(
if (req.method === "POST" && url.pathname.match(/^\/api\/registration\/pending\/\d+\/approve$/)) {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
try {
requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const pendingId = Number(url.pathname.split("/")[4]);
if (!pendingId || !Number.isFinite(pendingId)) {
@@ -371,7 +402,12 @@ export async function handleRegistrationRoutes(
if (req.method === "POST" && url.pathname.match(/^\/api\/registration\/pending\/\d+\/deny$/)) {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
try {
requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const pendingId = Number(url.pathname.split("/")[4]);
if (!pendingId || !Number.isFinite(pendingId)) {
@@ -2,6 +2,7 @@ import { createHash, randomBytes, createCipheriv } from "crypto";
import { authenticateRequest } from "../../auth";
import { getConfig } from "../../config";
import { logger } from "../../logger";
import { requirePermission } from "../../rbac";
let _solana: typeof import("@solana/web3.js") | null = null;
async function getSolana() {
@@ -71,8 +72,11 @@ export async function handleSolRoutes(
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "system:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (req.method === "POST" && url.pathname === "/api/sol/preview") {
@@ -1,6 +1,6 @@
import { randomBytes } from "node:crypto";
import { authenticateRequest } from "../../auth";
import { canUserAccessClient } from "../../users";
import { requireClientAccess } from "../../rbac";
import { logger } from "../../logger";
const MEDIAMTX_URL = (process.env.OVERLORD_MEDIAMTX_URL || "http://localhost:8889").replace(/\/+$/, "");
@@ -57,7 +57,10 @@ export async function handleWebrtcRoutes(req: Request, url: URL): Promise<Respon
} else {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (!canUserAccessClient(user.userId, user.role as any, clientId)) {
try {
requireClientAccess(user, clientId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
}
@@ -6,6 +6,7 @@ import { AuditAction, logAudit } from "../../auditLog";
import * as clientManager from "../../clientManager";
import { metrics } from "../../metrics";
import { encodeMessage } from "../../protocol";
import { requirePermission } from "../../rbac";
import { createUploadPull } from "./file-download-routes";
type RequestIpProvider = {
@@ -39,8 +40,11 @@ export async function handleWinRERoutes(
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "clients:winre");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let form: FormData;
@@ -80,8 +84,11 @@ export async function handleWinRERoutes(
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "clients:winre");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any = {};
@@ -169,8 +176,11 @@ export async function handleWinRERoutes(
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "clients:winre");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any = {};
@@ -232,8 +242,11 @@ export async function handleWinRERoutes(
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
try {
requirePermission(user, "clients:winre");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any = {};
@@ -1,8 +1,8 @@
import { authenticateRequest } from "../../auth";
import { logger } from "../../logger";
import { isIpBanned } from "../../db";
import { canUserAccessClient, canUserAccessFeature, hasPermission } from "../../users";
import type { FeatureName } from "../../users";
import { hasPermission, requireClientAccess, requireFeatureAccess } from "../../rbac";
type RequestServer = {
requestIP: (req: Request) => { address?: string } | null | undefined;
@@ -47,11 +47,12 @@ function checkOperatorAccess(
clientId: string,
feature: FeatureName,
): Response | null {
if (!canUserAccessClient(user.userId, user.role as any, clientId)) {
return new Response("Forbidden: client access denied", { status: 403 });
}
if (!canUserAccessFeature(user.userId, user.role as any, feature)) {
return new Response("Forbidden: feature access denied", { status: 403 });
try {
requireClientAccess(user as any, clientId);
requireFeatureAccess(user as any, feature);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
return null;
}
@@ -4,9 +4,9 @@ import {
createUser,
deleteUser,
getUserByUsername,
hasPermission,
setUserCanBuild,
} from "./users";
import { hasPermission } from "./rbac";
const createdUserIds: number[] = [];
@@ -44,6 +44,10 @@ describe("feature permissions", () => {
"processes",
"keylogger",
"voice",
"disconnect",
"reconnect",
"uninstall",
"client_metadata",
]);
});
+482 -224
View File
@@ -63,6 +63,15 @@ type UserAccessCacheEntry = {
const userAccessCache = new Map<number, UserAccessCacheEntry>();
let notificationDeliveryCache: UserDeliveryRow[] | null = null;
type AccessCheck<E extends any[]> = (userId: number, role: UserRole, ...extra: E) => boolean;
function withAdminBypass<E extends any[]>(check: AccessCheck<E>): AccessCheck<E> {
return (userId, role, ...extra) => {
if (role === "admin") return true;
return check(userId, role, ...extra);
};
}
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -115,147 +124,170 @@ db.exec(
`CREATE INDEX IF NOT EXISTS idx_user_plugin_access_rules_user ON user_plugin_access_rules(user_id)`,
);
try {
db.exec(
`ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0`,
);
logger.info("[users] Added must_change_password column to existing database");
} catch (err: any) {
if (!err.message?.includes("duplicate column name")) {
logger.error("[users] Migration error:", err);
}
}
try {
db.exec(
`ALTER TABLE users ADD COLUMN client_scope TEXT NOT NULL DEFAULT 'none' CHECK(client_scope IN ('none', 'allowlist', 'denylist', 'all'))`,
);
logger.info("[users] Added client_scope column to existing database");
} catch (err: any) {
if (!err.message?.includes("duplicate column name")) {
logger.error("[users] Migration error:", err);
}
}
try {
db.exec(`UPDATE users SET client_scope='all' WHERE role='admin'`);
} catch (err: any) {
logger.error("[users] Failed to normalize admin client_scope:", err);
}
try {
db.exec(
`ALTER TABLE users ADD COLUMN can_build INTEGER NOT NULL DEFAULT 0`,
);
logger.info("[users] Added can_build column to existing database");
function addColumnIfMissing(table: string, definition: string): void {
try {
db.exec(`UPDATE users SET can_build=1 WHERE role='admin' OR role='operator'`);
db.exec(`ALTER TABLE ${table} ADD COLUMN ${definition}`);
} catch (err: any) {
logger.error("[users] Failed to backfill admin/operator can_build:", err);
}
} catch (err: any) {
if (!err.message?.includes("duplicate column name")) {
logger.error("[users] Migration error:", err);
if (!err?.message?.includes("duplicate column name")) throw err;
}
}
try {
db.exec(
`ALTER TABLE users ADD COLUMN telegram_chat_id TEXT DEFAULT NULL`,
);
logger.info("[users] Added telegram_chat_id column to existing database");
} catch (err: any) {
if (!err.message?.includes("duplicate column name")) {
logger.error("[users] Migration error:", err);
}
}
type Migration = { id: string; run: () => void };
const notificationColumns: Array<{ sql: string; label: string }> = [
{ sql: `ALTER TABLE users ADD COLUMN webhook_enabled INTEGER DEFAULT 0`, label: "webhook_enabled" },
{ sql: `ALTER TABLE users ADD COLUMN webhook_url TEXT DEFAULT NULL`, label: "webhook_url" },
{ sql: `ALTER TABLE users ADD COLUMN webhook_template TEXT DEFAULT NULL`, label: "webhook_template" },
{ sql: `ALTER TABLE users ADD COLUMN telegram_enabled INTEGER DEFAULT 0`, label: "telegram_enabled" },
{ sql: `ALTER TABLE users ADD COLUMN telegram_bot_token TEXT DEFAULT NULL`, label: "telegram_bot_token" },
{ sql: `ALTER TABLE users ADD COLUMN telegram_template TEXT DEFAULT NULL`, label: "telegram_template" },
const userMigrations: Migration[] = [
{
id: "001_users_must_change_password",
run: () => addColumnIfMissing("users", `must_change_password INTEGER DEFAULT 0`),
},
{
id: "002_users_client_scope",
run: () => {
addColumnIfMissing(
"users",
`client_scope TEXT NOT NULL DEFAULT 'none' CHECK(client_scope IN ('none', 'allowlist', 'denylist', 'all'))`,
);
db.exec(`UPDATE users SET client_scope='all' WHERE role='admin'`);
},
},
{
id: "003_users_can_build",
run: () => {
addColumnIfMissing("users", `can_build INTEGER NOT NULL DEFAULT 0`);
db.exec(`UPDATE users SET can_build=1 WHERE role='admin' OR role='operator'`);
},
},
{
id: "004_users_telegram_chat_id",
run: () => addColumnIfMissing("users", `telegram_chat_id TEXT DEFAULT NULL`),
},
{
id: "005_users_notification_delivery",
run: () => {
addColumnIfMissing("users", `webhook_enabled INTEGER DEFAULT 0`);
addColumnIfMissing("users", `webhook_url TEXT DEFAULT NULL`);
addColumnIfMissing("users", `webhook_template TEXT DEFAULT NULL`);
addColumnIfMissing("users", `telegram_enabled INTEGER DEFAULT 0`);
addColumnIfMissing("users", `telegram_bot_token TEXT DEFAULT NULL`);
addColumnIfMissing("users", `telegram_template TEXT DEFAULT NULL`);
},
},
{
id: "006_users_can_upload_files",
run: () => {
addColumnIfMissing("users", `can_upload_files INTEGER NOT NULL DEFAULT 0`);
db.exec(`UPDATE users SET can_upload_files=1 WHERE role='admin'`);
},
},
{
id: "007_users_client_event_filters",
run: () => {
addColumnIfMissing("users", `client_event_webhook INTEGER DEFAULT 1`);
addColumnIfMissing("users", `client_event_telegram INTEGER DEFAULT 1`);
addColumnIfMissing("users", `client_event_push INTEGER DEFAULT 1`);
},
},
{
id: "008_users_chat_write",
run: () => addColumnIfMissing("users", `chat_write INTEGER DEFAULT NULL`),
},
{
id: "009_users_registered_via",
run: () => addColumnIfMissing("users", `registered_via TEXT DEFAULT NULL`),
},
{
id: "010_users_plugin_scope",
run: () => {
addColumnIfMissing(
"users",
`plugin_scope TEXT NOT NULL DEFAULT 'none' CHECK(plugin_scope IN ('none', 'allowlist', 'all'))`,
);
db.exec(`UPDATE users SET plugin_scope='all' WHERE role='admin'`);
},
},
{
id: "011_permission_groups",
run: () => {
db.exec(`
CREATE TABLE IF NOT EXISTS permission_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
description TEXT,
created_at INTEGER NOT NULL,
created_by INTEGER,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS permission_group_permissions (
group_id INTEGER NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (group_id, permission),
FOREIGN KEY (group_id) REFERENCES permission_groups(id) ON DELETE CASCADE
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS user_permission_groups (
user_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
PRIMARY KEY (user_id, group_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES permission_groups(id) ON DELETE CASCADE
)
`);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_user_permission_groups_user ON user_permission_groups(user_id)`,
);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_permission_group_permissions_group ON permission_group_permissions(group_id)`,
);
},
},
{
id: "012_user_extra_permissions",
run: () => {
db.exec(`
CREATE TABLE IF NOT EXISTS user_extra_permissions (
user_id INTEGER NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (user_id, permission),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_user_extra_permissions_user ON user_extra_permissions(user_id)`,
);
},
},
];
for (const col of notificationColumns) {
try {
db.exec(col.sql);
logger.info(`[users] Added ${col.label} column to existing database`);
} catch (err: any) {
if (!err.message?.includes("duplicate column name")) {
logger.error("[users] Migration error:", err);
function runMigrations(migrations: ReadonlyArray<Migration>): void {
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
id TEXT PRIMARY KEY,
applied_at INTEGER NOT NULL
)`);
const appliedRows = db
.prepare("SELECT id FROM schema_migrations")
.all() as Array<{ id: string }>;
const applied = new Set(appliedRows.map((r) => r.id));
const markApplied = db.prepare(
"INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)",
);
for (const migration of migrations) {
if (applied.has(migration.id)) continue;
try {
migration.run();
markApplied.run(migration.id, Date.now());
logger.info(`[migration] Applied ${migration.id}`);
} catch (err) {
logger.error(`[migration] Failed: ${migration.id}`, err);
throw err;
}
}
}
try {
db.exec(
`ALTER TABLE users ADD COLUMN can_upload_files INTEGER NOT NULL DEFAULT 0`,
);
logger.info("[users] Added can_upload_files column to existing database");
try {
db.exec(`UPDATE users SET can_upload_files=1 WHERE role='admin'`);
} catch (err: any) {
logger.error("[users] Failed to backfill admin can_upload_files:", err);
}
} catch (err: any) {
if (!err.message?.includes("duplicate column name")) {
logger.error("[users] Migration error:", err);
}
}
const clientEventColumns: Array<{ sql: string; label: string }> = [
{ sql: `ALTER TABLE users ADD COLUMN client_event_webhook INTEGER DEFAULT 1`, label: "client_event_webhook" },
{ sql: `ALTER TABLE users ADD COLUMN client_event_telegram INTEGER DEFAULT 1`, label: "client_event_telegram" },
{ sql: `ALTER TABLE users ADD COLUMN client_event_push INTEGER DEFAULT 1`, label: "client_event_push" },
];
for (const col of clientEventColumns) {
try {
db.exec(col.sql);
logger.info(`[users] Added ${col.label} column to existing database`);
} catch (err: any) {
if (!err.message?.includes("duplicate column name")) {
logger.error("[users] Migration error:", err);
}
}
}
try {
db.exec(`ALTER TABLE users ADD COLUMN chat_write INTEGER DEFAULT NULL`);
logger.info("[users] Added chat_write column to existing database");
} catch (err: any) {
if (!err.message?.includes("duplicate column name")) {
logger.error("[users] Migration error:", err);
}
}
try {
db.exec(`ALTER TABLE users ADD COLUMN registered_via TEXT DEFAULT NULL`);
logger.info("[users] Added registered_via column to existing database");
} catch (err: any) {
if (!err.message?.includes("duplicate column name")) {
logger.error("[users] Migration error:", err);
}
}
try {
db.exec(
`ALTER TABLE users ADD COLUMN plugin_scope TEXT NOT NULL DEFAULT 'none' CHECK(plugin_scope IN ('none', 'allowlist', 'all'))`,
);
logger.info("[users] Added plugin_scope column to existing database");
try {
db.exec(`UPDATE users SET plugin_scope='all' WHERE role='admin'`);
} catch (err: any) {
logger.error("[users] Failed to backfill admin plugin_scope:", err);
}
} catch (err: any) {
if (!err.message?.includes("duplicate column name")) {
logger.error("[users] Migration error:", err);
}
}
runMigrations(userMigrations);
db.exec(`
CREATE TABLE IF NOT EXISTS registration_keys (
@@ -477,26 +509,17 @@ export function removeUserClientAccessRule(
}
}
export function canUserAccessClient(
userId: number,
role: UserRole,
clientId: string,
): boolean {
if (role === "admin") return true;
const access = getUserAccessCacheEntry(userId);
const scope = access.scope;
if (scope === "none") return false;
if (scope === "all") return true;
if (scope === "allowlist") {
return access.allow.has(clientId);
}
if (scope === "denylist") {
return !access.deny.has(clientId);
}
return false;
}
export const canUserAccessClient = withAdminBypass(
(userId, _role, clientId: string): boolean => {
const access = getUserAccessCacheEntry(userId);
const scope = access.scope;
if (scope === "none") return false;
if (scope === "all") return true;
if (scope === "allowlist") return access.allow.has(clientId);
if (scope === "denylist") return !access.deny.has(clientId);
return false;
},
);
type PluginAccessCacheEntry = {
scope: PluginAccessScope;
@@ -549,19 +572,15 @@ export function listUserPluginAccessRules(userId: number): UserPluginAccessRule[
.all(userId) as UserPluginAccessRule[];
}
export function canUserAccessPlugin(
userId: number,
role: UserRole,
pluginId: string,
): boolean {
if (role === "admin") return true;
const access = getPluginAccessCacheEntry(userId);
if (access.scope === "none") return false;
if (access.scope === "all") return true;
if (access.scope === "allowlist") return access.allowed.has(pluginId);
return false;
}
export const canUserAccessPlugin = withAdminBypass(
(userId, _role, pluginId: string): boolean => {
const access = getPluginAccessCacheEntry(userId);
if (access.scope === "none") return false;
if (access.scope === "all") return true;
if (access.scope === "allowlist") return access.allowed.has(pluginId);
return false;
},
);
export function setUserPluginAccessScope(
userId: number,
@@ -652,7 +671,11 @@ export type FeatureName =
| "file_browser"
| "processes"
| "keylogger"
| "voice";
| "voice"
| "disconnect"
| "reconnect"
| "uninstall"
| "client_metadata";
export const ALL_FEATURES: FeatureName[] = [
"console",
@@ -663,6 +686,10 @@ export const ALL_FEATURES: FeatureName[] = [
"processes",
"keylogger",
"voice",
"disconnect",
"reconnect",
"uninstall",
"client_metadata",
];
const featurePermCache = new Map<number, Map<string, boolean>>();
@@ -687,19 +714,14 @@ function invalidateFeaturePermCache(userId: number): void {
featurePermCache.delete(userId);
}
export function canUserAccessFeature(
userId: number,
role: UserRole,
feature: FeatureName,
): boolean {
if (role === "admin") return true;
if (role === "viewer") return false;
const perms = getFeaturePermCacheEntry(userId);
const entry = perms.get(feature);
if (entry === undefined) return true;
return entry;
}
export const canUserAccessFeature = withAdminBypass(
(userId, role, feature: FeatureName): boolean => {
if (role === "viewer") return false;
const perms = getFeaturePermCacheEntry(userId);
const entry = perms.get(feature);
return entry === undefined ? true : entry;
},
);
export function getUserFeaturePermissions(
userId: number,
@@ -915,10 +937,21 @@ export function deleteUser(userId: number): {
}
try {
db.prepare("DELETE FROM users WHERE id = ?").run(userId);
// SQLite foreign keys aren't enforced by default, so manually purge the
// user's dependent rows so the caches and the DB agree.
db.transaction(() => {
db.prepare("DELETE FROM user_permission_groups WHERE user_id = ?").run(userId);
db.prepare("DELETE FROM user_extra_permissions WHERE user_id = ?").run(userId);
db.prepare("DELETE FROM user_client_access_rules WHERE user_id = ?").run(userId);
db.prepare("DELETE FROM user_plugin_access_rules WHERE user_id = ?").run(userId);
db.prepare("DELETE FROM user_feature_permissions WHERE user_id = ?").run(userId);
db.prepare("DELETE FROM users WHERE id = ?").run(userId);
})();
invalidateUserAccessCache(userId);
invalidatePluginAccessCache(userId);
invalidateNotificationDeliveryCache();
userGroupIdsCache.delete(userId);
userExtraPermsCache.delete(userId);
return { success: true };
} catch (err: any) {
console.error("[users] Delete user error:", err);
@@ -965,48 +998,10 @@ export async function verifyPassword(
return user;
}
export function canManageUsers(role: UserRole): boolean {
return role === "admin";
}
export function canControlClients(role: UserRole): boolean {
return role === "admin" || role === "operator";
}
export function canBuildClients(userId: number, role: UserRole): boolean {
if (role === "admin") return true;
export const canBuildClients = withAdminBypass((userId): boolean => {
const user = getUserById(userId);
return user ? user.can_build === 1 : false;
}
export function canViewAuditLogs(role: UserRole): boolean {
return role === "admin" || role === "operator";
}
export function canManageEnrollment(role: UserRole): boolean {
return role === "admin" || role === "operator";
}
export function hasPermission(role: UserRole, permission: string, userId?: number): boolean {
switch (permission) {
case "users:manage":
return canManageUsers(role);
case "clients:control":
return canControlClients(role);
case "clients:build":
if (userId !== undefined) return canBuildClients(userId, role);
return role === "admin" || role === "operator";
case "clients:enroll":
return canManageEnrollment(role);
case "audit:view":
return canViewAuditLogs(role);
case "chat:write":
if (userId !== undefined) return canChatWrite(userId, role);
return role === "admin" || role === "operator";
default:
return false;
}
}
});
export function setUserCanBuild(
userId: number,
@@ -1021,11 +1016,10 @@ export function setUserCanBuild(
}
}
export function canUploadFiles(userId: number, role: UserRole): boolean {
if (role === "admin") return true;
export const canUploadFiles = withAdminBypass((userId): boolean => {
const user = getUserById(userId);
return user ? user.can_upload_files === 1 : false;
}
});
export function setUserCanUploadFiles(
userId: number,
@@ -1040,8 +1034,7 @@ export function setUserCanUploadFiles(
}
}
export function canChatWrite(userId: number, role: UserRole): boolean {
if (role === "admin") return true;
export const canChatWrite = withAdminBypass((userId, role): boolean => {
const user = getUserById(userId);
if (!user) return false;
const chatWrite = (user as any).chat_write;
@@ -1049,7 +1042,7 @@ export function canChatWrite(userId: number, role: UserRole): boolean {
return role === "operator";
}
return chatWrite === 1;
}
});
export function setUserChatWrite(
userId: number,
@@ -1064,6 +1057,252 @@ export function setUserChatWrite(
}
}
export interface PermissionGroup {
id: number;
name: string;
description: string | null;
created_at: number;
created_by: number | null;
permissions: string[];
}
const groupPermsCache = new Map<number, Set<string>>();
const userGroupIdsCache = new Map<number, number[]>();
const userExtraPermsCache = new Map<number, Set<string>>();
function invalidateUserPermissionCaches(userId?: number): void {
if (userId === undefined) {
userGroupIdsCache.clear();
userExtraPermsCache.clear();
return;
}
userGroupIdsCache.delete(userId);
userExtraPermsCache.delete(userId);
}
function invalidateGroupPermsCache(groupId?: number): void {
if (groupId === undefined) {
groupPermsCache.clear();
return;
}
groupPermsCache.delete(groupId);
}
export function listPermissionGroups(): PermissionGroup[] {
const rows = db
.prepare(
`SELECT id, name, description, created_at, created_by FROM permission_groups ORDER BY name ASC`,
)
.all() as Array<Omit<PermissionGroup, "permissions">>;
return rows.map((row) => ({
...row,
permissions: Array.from(getPermissionsForGroup(row.id)).sort(),
}));
}
export function getPermissionGroup(groupId: number): PermissionGroup | null {
const row = db
.prepare(
`SELECT id, name, description, created_at, created_by FROM permission_groups WHERE id = ?`,
)
.get(groupId) as Omit<PermissionGroup, "permissions"> | undefined;
if (!row) return null;
return {
...row,
permissions: Array.from(getPermissionsForGroup(row.id)).sort(),
};
}
function getPermissionsForGroup(groupId: number): Set<string> {
const cached = groupPermsCache.get(groupId);
if (cached) return cached;
const rows = db
.prepare("SELECT permission FROM permission_group_permissions WHERE group_id = ?")
.all(groupId) as Array<{ permission: string }>;
const set = new Set(rows.map((r) => r.permission));
groupPermsCache.set(groupId, set);
return set;
}
export function createPermissionGroup(
name: string,
description: string | null,
permissions: string[],
createdBy: number | null,
): { success: boolean; error?: string; group?: PermissionGroup } {
const trimmedName = (name || "").trim();
if (!trimmedName || trimmedName.length > 64) {
return { success: false, error: "Name must be 1-64 characters" };
}
const trimmedDesc = description ? String(description).slice(0, 256).trim() || null : null;
const perms = Array.from(new Set(permissions.filter((p) => typeof p === "string" && p.length > 0)));
try {
const tx = db.transaction(() => {
const result = db
.prepare(
"INSERT INTO permission_groups (name, description, created_at, created_by) VALUES (?, ?, ?, ?)",
)
.run(trimmedName, trimmedDesc, Date.now(), createdBy);
const groupId = result.lastInsertRowid as number;
const insertPerm = db.prepare(
"INSERT INTO permission_group_permissions (group_id, permission) VALUES (?, ?)",
);
for (const perm of perms) insertPerm.run(groupId, perm);
return groupId;
});
const groupId = tx();
invalidateGroupPermsCache(groupId);
invalidateUserPermissionCaches();
const group = getPermissionGroup(groupId);
return group ? { success: true, group } : { success: false, error: "Failed to load created group" };
} catch (err: any) {
if (err.message?.includes("UNIQUE constraint")) {
return { success: false, error: "A group with that name already exists" };
}
logger.error("[users] createPermissionGroup error:", err);
return { success: false, error: err.message || "Failed to create permission group" };
}
}
export function updatePermissionGroup(
groupId: number,
updates: { name?: string; description?: string | null; permissions?: string[] },
): { success: boolean; error?: string; group?: PermissionGroup } {
const existing = getPermissionGroup(groupId);
if (!existing) return { success: false, error: "Group not found" };
try {
const tx = db.transaction(() => {
if (updates.name !== undefined) {
const trimmed = String(updates.name).trim();
if (!trimmed || trimmed.length > 64) throw new Error("Name must be 1-64 characters");
db.prepare("UPDATE permission_groups SET name = ? WHERE id = ?").run(trimmed, groupId);
}
if (updates.description !== undefined) {
const trimmed = updates.description ? String(updates.description).slice(0, 256).trim() || null : null;
db.prepare("UPDATE permission_groups SET description = ? WHERE id = ?").run(trimmed, groupId);
}
if (updates.permissions !== undefined) {
db.prepare("DELETE FROM permission_group_permissions WHERE group_id = ?").run(groupId);
const insertPerm = db.prepare(
"INSERT INTO permission_group_permissions (group_id, permission) VALUES (?, ?)",
);
const perms = Array.from(new Set(updates.permissions.filter((p) => typeof p === "string" && p.length > 0)));
for (const perm of perms) insertPerm.run(groupId, perm);
}
});
tx();
invalidateGroupPermsCache(groupId);
invalidateUserPermissionCaches();
const group = getPermissionGroup(groupId);
return group ? { success: true, group } : { success: false, error: "Failed to load updated group" };
} catch (err: any) {
if (err.message?.includes("UNIQUE constraint")) {
return { success: false, error: "A group with that name already exists" };
}
logger.error("[users] updatePermissionGroup error:", err);
return { success: false, error: err.message || "Failed to update permission group" };
}
}
export function deletePermissionGroup(groupId: number): { success: boolean; error?: string } {
try {
// SQLite foreign keys aren't enforced unless PRAGMA foreign_keys=ON, so
// clean up dependent rows explicitly to avoid orphans.
const result = db.transaction(() => {
db.prepare("DELETE FROM permission_group_permissions WHERE group_id = ?").run(groupId);
db.prepare("DELETE FROM user_permission_groups WHERE group_id = ?").run(groupId);
return db.prepare("DELETE FROM permission_groups WHERE id = ?").run(groupId);
})();
invalidateGroupPermsCache(groupId);
invalidateUserPermissionCaches();
if (result.changes === 0) return { success: false, error: "Group not found" };
return { success: true };
} catch (err: any) {
logger.error("[users] deletePermissionGroup error:", err);
return { success: false, error: err.message || "Failed to delete permission group" };
}
}
export function getUserGroupIds(userId: number): number[] {
const cached = userGroupIdsCache.get(userId);
if (cached) return cached;
const rows = db
.prepare("SELECT group_id FROM user_permission_groups WHERE user_id = ?")
.all(userId) as Array<{ group_id: number }>;
const ids = rows.map((r) => r.group_id);
userGroupIdsCache.set(userId, ids);
return ids;
}
export function setUserGroups(
userId: number,
groupIds: number[],
): { success: boolean; error?: string } {
try {
const tx = db.transaction(() => {
db.prepare("DELETE FROM user_permission_groups WHERE user_id = ?").run(userId);
const insert = db.prepare(
"INSERT OR IGNORE INTO user_permission_groups (user_id, group_id) VALUES (?, ?)",
);
const unique = Array.from(new Set(groupIds.filter((id) => Number.isFinite(id))));
for (const gid of unique) insert.run(userId, gid);
});
tx();
invalidateUserPermissionCaches(userId);
return { success: true };
} catch (err: any) {
logger.error("[users] setUserGroups error:", err);
return { success: false, error: err.message || "Failed to update user groups" };
}
}
export function getUserExtraPermissions(userId: number): Set<string> {
const cached = userExtraPermsCache.get(userId);
if (cached) return cached;
const rows = db
.prepare("SELECT permission FROM user_extra_permissions WHERE user_id = ?")
.all(userId) as Array<{ permission: string }>;
const set = new Set(rows.map((r) => r.permission));
userExtraPermsCache.set(userId, set);
return set;
}
export function setUserExtraPermissions(
userId: number,
permissions: string[],
): { success: boolean; error?: string } {
try {
const tx = db.transaction(() => {
db.prepare("DELETE FROM user_extra_permissions WHERE user_id = ?").run(userId);
const insert = db.prepare(
"INSERT OR IGNORE INTO user_extra_permissions (user_id, permission) VALUES (?, ?)",
);
const unique = Array.from(new Set(permissions.filter((p) => typeof p === "string" && p.length > 0)));
for (const perm of unique) insert.run(userId, perm);
});
tx();
invalidateUserPermissionCaches(userId);
return { success: true };
} catch (err: any) {
logger.error("[users] setUserExtraPermissions error:", err);
return { success: false, error: err.message || "Failed to update extra permissions" };
}
}
// Collects every permission string a user has via assigned groups + direct
// extras. Returns a Set for cheap membership tests; getUserPermissions in
// rbac.ts unions this with role-level permissions.
export function getUserGrantedPermissions(userId: number): Set<string> {
const result = new Set<string>();
for (const groupId of getUserGroupIds(userId)) {
for (const perm of getPermissionsForGroup(groupId)) result.add(perm);
}
for (const perm of getUserExtraPermissions(userId)) result.add(perm);
return result;
}
export function setUserTelegramChatId(
userId: number,
chatId: string | null,
@@ -1432,6 +1671,7 @@ export async function approvePendingRegistration(
`UPDATE pending_registrations SET status = 'approved', reviewed_by = ?, reviewed_at = ? WHERE id = ?`,
).run(reviewedBy, Date.now(), pendingId);
applyRegistrationDefaultGroups(result.lastInsertRowid as number);
invalidateNotificationDeliveryCache();
return { success: true, userId: result.lastInsertRowid as number };
} catch (err: any) {
@@ -1489,6 +1729,7 @@ export async function registerUser(
"allowlist", role === "operator" ? 1 : 0, 0, registeredVia,
);
applyRegistrationDefaultGroups(result.lastInsertRowid as number);
invalidateNotificationDeliveryCache();
return { success: true, userId: result.lastInsertRowid as number };
} catch (err: any) {
@@ -1500,6 +1741,23 @@ export async function registerUser(
}
}
function applyRegistrationDefaultGroups(userId: number): void {
let defaults: number[] = [];
try {
defaults = getConfig().registration.defaultGroupIds || [];
} catch {
return;
}
if (defaults.length === 0) return;
const validIds: number[] = [];
const check = db.prepare("SELECT 1 AS ok FROM permission_groups WHERE id = ?");
for (const id of defaults) {
if ((check.get(id) as { ok?: number } | undefined)?.ok) validIds.push(id);
}
if (validIds.length === 0) return;
setUserGroups(userId, validIds);
}
export function getTotalUserCount(): number {
const row = db.prepare("SELECT COUNT(*) as count FROM users").get() as { count: number };
return row.count;