major RBAC imrpovements + fix thumbnails
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user