huge update to RBAC

This commit is contained in:
KingKDot
2026-04-13 17:15:06 -05:00
parent f28c0d33a9
commit e80736ec37
22 changed files with 1205 additions and 31 deletions
@@ -8,13 +8,21 @@ import {
revokeToken,
} from "../../auth";
import { AuditAction, logAudit } from "../../auditLog";
import {
listUserSessions,
revokeSessionById,
revokeAllUserSessions,
hashTokenForSession,
persistRevokedTokenHash,
getSessionById,
} from "../../db";
import { logger } from "../../logger";
import {
isRateLimited,
recordFailedAttempt,
recordSuccessfulAttempt,
} from "../../rateLimit";
import { getUserById } from "../../users";
import { getUserById, canUserAccessClient, canUserAccessFeature, type FeatureName, ALL_FEATURES } from "../../users";
import { makeAuthCookie, makeAuthCookieClear } from "./auth-cookie";
type RequestIpProvider = {
@@ -63,7 +71,8 @@ export async function handleAuthRoutes(
const user = await authenticateUser(username, password);
if (user) {
const token = await generateToken(user);
const userAgent = req.headers.get("User-Agent") || null;
const token = await generateToken(user, { ip, userAgent: userAgent || undefined });
const sessionTtlSeconds = getSessionTtlSeconds();
logger.info(
@@ -154,6 +163,151 @@ export async function handleAuthRoutes(
});
}
if (req.method === "GET" && url.pathname === "/api/sessions") {
const user = await authenticateRequest(req);
if (!user) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
const sessions = listUserSessions(user.userId);
const currentToken = extractTokenFromRequest(req);
const currentTokenHash = currentToken ? hashTokenForSession(currentToken) : null;
return Response.json({
sessions: sessions.map((s) => ({
id: s.id,
ip: s.ip,
userAgent: s.userAgent,
createdAt: s.createdAt,
lastActivity: s.lastActivity,
expiresAt: s.expiresAt,
revoked: s.revoked,
current: s.tokenHash === currentTokenHash,
})),
});
}
if (req.method === "GET" && url.pathname.match(/^\/api\/users\/\d+\/sessions$/)) {
const user = await authenticateRequest(req);
if (!user) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
if (user.role !== "admin") {
return Response.json({ error: "Admin only" }, { status: 403 });
}
const targetUserId = parseInt(url.pathname.split("/")[3]);
const sessions = listUserSessions(targetUserId);
return Response.json({
sessions: sessions.map((s) => ({
id: s.id,
ip: s.ip,
userAgent: s.userAgent,
createdAt: s.createdAt,
lastActivity: s.lastActivity,
expiresAt: s.expiresAt,
revoked: s.revoked,
})),
});
}
if (req.method === "DELETE" && url.pathname.match(/^\/api\/sessions\/[^/]+$/)) {
const user = await authenticateRequest(req);
if (!user) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
const sessionId = url.pathname.split("/")[3];
const session = getSessionById(sessionId);
if (!session) {
return Response.json({ error: "Session not found" }, { status: 404 });
}
if (session.userId !== user.userId && user.role !== "admin") {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
const result = revokeSessionById(sessionId);
if (result.tokenHash) {
persistRevokedTokenHash(result.tokenHash, session.expiresAt);
}
const ip = server.requestIP(req)?.address || "unknown";
logAudit({
timestamp: Date.now(),
username: user.username,
ip,
action: AuditAction.LOGOUT,
details: `Revoked session ${sessionId}${session.userId !== user.userId ? ` (user ${session.userId})` : ""}`,
success: true,
});
return Response.json({ ok: true });
}
if (req.method === "DELETE" && url.pathname.match(/^\/api\/users\/\d+\/sessions$/)) {
const user = await authenticateRequest(req);
if (!user) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
if (user.role !== "admin") {
return Response.json({ error: "Admin only" }, { status: 403 });
}
const targetUserId = parseInt(url.pathname.split("/")[3]);
const sessions = listUserSessions(targetUserId).filter((s) => !s.revoked);
for (const s of sessions) {
persistRevokedTokenHash(s.tokenHash, s.expiresAt);
}
const count = revokeAllUserSessions(targetUserId);
const ip = server.requestIP(req)?.address || "unknown";
logAudit({
timestamp: Date.now(),
username: user.username,
ip,
action: AuditAction.LOGOUT,
details: `Revoked all ${count} sessions for user ${targetUserId}`,
success: true,
});
return Response.json({ ok: true, revokedCount: count });
}
if (req.method === "GET" && url.pathname === "/api/auth/feature-check") {
const user = await authenticateRequest(req);
if (!user) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
const feature = url.searchParams.get("feature") as FeatureName | null;
const clientId = url.searchParams.get("clientId");
if (!feature || !ALL_FEATURES.includes(feature as FeatureName)) {
return Response.json({ error: "Invalid feature" }, { status: 400 });
}
const reasons: string[] = [];
if (!canUserAccessFeature(user.userId, user.role, feature)) {
reasons.push("feature");
}
if (clientId && !canUserAccessClient(user.userId, user.role, clientId)) {
reasons.push("client");
}
if (reasons.length > 0) {
return Response.json({ allowed: false, denied: reasons });
}
return Response.json({ allowed: true });
}
if (req.method === "GET" && url.pathname === "/api/auth/me") {
const user = await authenticateRequest(req);
if (!user) {
@@ -8,6 +8,7 @@ import { logger } from "../../logger";
import { encodeMessage } from "../../protocol";
import { getConfig } from "../../config";
import { isAuthorizedAgentRequest } from "../agent-auth";
import { canUserAccessClient } from "../../users";
type RequestIpProvider = {
requestIP: (req: Request) => { address?: string } | null | undefined;
@@ -358,6 +359,10 @@ 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 });
}
logger.debug("[filebrowser] http upload request", {
userId: user.userId,
clientId,
@@ -598,6 +603,10 @@ 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 });
}
const target = clientManager.getClient(clientId);
if (!target) {
return new Response("Client offline", { status: 404 });
@@ -5,10 +5,13 @@ import { requirePermission } from "../../rbac";
import {
type ClientAccessRuleKind,
type ClientAccessScope,
type FeatureName,
ALL_FEATURES,
createUser,
deleteUser,
getUserById,
getUserClientAccessScope,
getUserFeaturePermissions,
listUsers,
listUserClientAccessRules,
removeUserClientAccessRule,
@@ -16,6 +19,8 @@ import {
setUserClientAccessScope,
setUserCanBuild,
setUserCanUploadFiles,
setUserFeaturePermissions,
resetUserFeaturePermissions,
updateUserPassword,
updateUserRole,
} from "../../users";
@@ -123,7 +128,8 @@ export async function handleUsersRoutes(
});
if (user.userId === userId && targetUser) {
const newToken = await generateToken(targetUser);
const userAgent = req.headers.get("User-Agent") || undefined;
const newToken = await generateToken(targetUser, { ip, userAgent });
const sessionTtlSeconds = getSessionTtlSeconds();
return new Response(
JSON.stringify({
@@ -382,6 +388,83 @@ export async function handleUsersRoutes(
return Response.json({ success: true, canUploadFiles });
}
if (req.method === "GET" && url.pathname.match(/^\/api\/users\/\d+\/feature-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 });
}
const permissions = getUserFeaturePermissions(userId);
return Response.json({ success: true, permissions, features: ALL_FEATURES });
}
if (req.method === "PUT" && url.pathname.match(/^\/api\/users\/\d+\/feature-permissions$/)) {
const authedUser = 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 });
}
const body = await req.json();
const permissions = body?.permissions;
if (!permissions || typeof permissions !== "object") {
return Response.json({ error: "Invalid permissions object" }, { status: 400 });
}
const validated: Partial<Record<FeatureName, boolean>> = {};
for (const [key, value] of Object.entries(permissions)) {
if (ALL_FEATURES.includes(key as FeatureName)) {
validated[key as FeatureName] = !!value;
}
}
const result = setUserFeaturePermissions(userId, validated);
if (!result.success) {
return Response.json({ error: result.error }, { status: 400 });
}
const ip = server.requestIP(req)?.address || "unknown";
logAudit({
timestamp: Date.now(),
username: authedUser.username,
ip,
action: AuditAction.COMMAND,
details: `Updated feature permissions for ${targetUser.username}: ${JSON.stringify(validated)}`,
success: true,
});
return Response.json({ success: true, permissions: getUserFeaturePermissions(userId) });
}
if (req.method === "DELETE" && url.pathname.match(/^\/api\/users\/\d+\/feature-permissions$/)) {
const authedUser = 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 });
}
const result = resetUserFeaturePermissions(userId);
if (!result.success) {
return Response.json({ error: result.error }, { status: 400 });
}
const ip = server.requestIP(req)?.address || "unknown";
logAudit({
timestamp: Date.now(),
username: authedUser.username,
ip,
action: AuditAction.COMMAND,
details: `Reset feature permissions for ${targetUser.username} to defaults`,
success: true,
});
return Response.json({ success: true });
}
return new Response("Not found", { status: 404 });
} catch (error) {
if (error instanceof Response) {
@@ -1,6 +1,8 @@
import { authenticateRequest } from "../../auth";
import { logger } from "../../logger";
import { isIpBanned } from "../../db";
import { canUserAccessClient, canUserAccessFeature } from "../../users";
import type { FeatureName } from "../../users";
type RequestServer = {
requestIP: (req: Request) => { address?: string } | null | undefined;
@@ -40,6 +42,20 @@ function isWsRateLimited(ip: string): boolean {
return false;
}
function checkOperatorAccess(
user: { userId: number; role: string },
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 });
}
return null;
}
export async function handleWsUpgradeRoutes(
req: Request,
url: URL,
@@ -62,9 +78,11 @@ export async function handleWsUpgradeRoutes(
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
}
const clientId = consoleWsMatch[1];
const denied = checkOperatorAccess(user, clientId, "console");
if (denied) return denied;
const sessionId = crypto.randomUUID();
const ip = server.requestIP(req)?.address || "";
if (server.upgrade(req, { data: { role: "console_viewer", clientId, sessionId, ip, userRole: user.role } })) {
if (server.upgrade(req, { data: { role: "console_viewer", clientId, sessionId, ip, userRole: user.role, userId: user.userId } })) {
return new Response();
}
return new Response("Upgrade failed", { status: 500 });
@@ -77,7 +95,7 @@ export async function handleWsUpgradeRoutes(
return new Response("Unauthorized", { status: 401 });
}
const clientId = wsMatch[1];
const role = (url.searchParams.get("role") as any) || "viewer";
const role = "client";
const ip = server.requestIP(req)?.address || "";
if (ip && isIpBanned(ip)) {
logger.warn(`[auth] Rejected banned IP ${ip} for client ${clientId}`);
@@ -100,8 +118,10 @@ export async function handleWsUpgradeRoutes(
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
}
const clientId = rdMatch[1];
const denied = checkOperatorAccess(user, clientId, "remote_desktop");
if (denied) return denied;
const ip = server.requestIP(req)?.address || "";
if (server.upgrade(req, { data: { role: "rd_viewer", clientId, ip, userRole: user.role } })) {
if (server.upgrade(req, { data: { role: "rd_viewer", clientId, ip, userRole: user.role, userId: user.userId } })) {
return new Response();
}
return new Response("Upgrade failed", { status: 500 });
@@ -118,8 +138,10 @@ export async function handleWsUpgradeRoutes(
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
}
const clientId = hvncMatch[1];
const denied = checkOperatorAccess(user, clientId, "hvnc");
if (denied) return denied;
const ip = server.requestIP(req)?.address || "";
if (server.upgrade(req, { data: { role: "hvnc_viewer", clientId, ip, userRole: user.role } })) {
if (server.upgrade(req, { data: { role: "hvnc_viewer", clientId, ip, userRole: user.role, userId: user.userId } })) {
return new Response();
}
return new Response("Upgrade failed", { status: 500 });
@@ -136,8 +158,10 @@ export async function handleWsUpgradeRoutes(
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
}
const clientId = webcamMatch[1];
const denied = checkOperatorAccess(user, clientId, "webcam");
if (denied) return denied;
const ip = server.requestIP(req)?.address || "";
if (server.upgrade(req, { data: { role: "webcam_viewer", clientId, ip, userRole: user.role } })) {
if (server.upgrade(req, { data: { role: "webcam_viewer", clientId, ip, userRole: user.role, userId: user.userId } })) {
return new Response();
}
return new Response("Upgrade failed", { status: 500 });
@@ -154,8 +178,10 @@ export async function handleWsUpgradeRoutes(
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
}
const clientId = fbMatch[1];
const denied = checkOperatorAccess(user, clientId, "file_browser");
if (denied) return denied;
const ip = server.requestIP(req)?.address || "";
if (server.upgrade(req, { data: { role: "file_browser_viewer", clientId, ip, userRole: user.role } })) {
if (server.upgrade(req, { data: { role: "file_browser_viewer", clientId, ip, userRole: user.role, userId: user.userId } })) {
return new Response();
}
return new Response("Upgrade failed", { status: 500 });
@@ -172,8 +198,10 @@ export async function handleWsUpgradeRoutes(
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
}
const clientId = processMatch[1];
const denied = checkOperatorAccess(user, clientId, "processes");
if (denied) return denied;
const ip = server.requestIP(req)?.address || "";
if (server.upgrade(req, { data: { role: "process_viewer", clientId, ip, userRole: user.role } })) {
if (server.upgrade(req, { data: { role: "process_viewer", clientId, ip, userRole: user.role, userId: user.userId } })) {
return new Response();
}
return new Response("Upgrade failed", { status: 500 });
@@ -190,8 +218,10 @@ export async function handleWsUpgradeRoutes(
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
}
const clientId = keyloggerMatch[1];
const denied = checkOperatorAccess(user, clientId, "keylogger");
if (denied) return denied;
const ip = server.requestIP(req)?.address || "";
if (server.upgrade(req, { data: { role: "keylogger_viewer", clientId, ip, userRole: user.role } })) {
if (server.upgrade(req, { data: { role: "keylogger_viewer", clientId, ip, userRole: user.role, userId: user.userId } })) {
return new Response();
}
return new Response("Upgrade failed", { status: 500 });
@@ -208,8 +238,10 @@ export async function handleWsUpgradeRoutes(
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
}
const clientId = voiceMatch[1];
const denied = checkOperatorAccess(user, clientId, "voice");
if (denied) return denied;
const ip = server.requestIP(req)?.address || "";
if (server.upgrade(req, { data: { role: "voice_viewer", clientId, ip, userRole: user.role } })) {
if (server.upgrade(req, { data: { role: "voice_viewer", clientId, ip, userRole: user.role, userId: user.userId } })) {
return new Response();
}
return new Response("Upgrade failed", { status: 500 });
@@ -11,6 +11,7 @@ import { resolveRuntimeRoot } from "./runtime-paths";
import * as sessionManager from "../sessions/sessionManager";
import type { ConsoleSession, RemoteDesktopViewer, SocketData } from "../sessions/types";
import type { ClientInfo } from "../types";
import { canUserAccessClient } from "../users";
let _cachedInjectionDll: Uint8Array | null = null;
let _dllCachePath: string | null = null;
@@ -248,7 +249,11 @@ export function notifyConsoleClosed(clientId: string, reason: string) {
}
export function handleConsoleViewerOpen(ws: ServerWebSocket<SocketData>) {
const { clientId, sessionId } = ws.data;
const { clientId, sessionId, userId, userRole } = ws.data;
if (userId !== undefined && userRole && !canUserAccessClient(userId, userRole as any, clientId)) {
ws.close(1008, "Forbidden: client access denied");
return;
}
const effectiveSessionId = sessionId || uuidv4();
ws.data.sessionId = effectiveSessionId;
const target = clientManager.getClient(clientId);
@@ -272,7 +277,11 @@ export function handleConsoleViewerOpen(ws: ServerWebSocket<SocketData>) {
}
export function handleRemoteDesktopViewerOpen(ws: ServerWebSocket<SocketData>) {
const { clientId } = ws.data;
const { clientId, userId, userRole } = ws.data;
if (userId !== undefined && userRole && !canUserAccessClient(userId, userRole as any, clientId)) {
ws.close(1008, "Forbidden: client access denied");
return;
}
const sessionId = uuidv4();
ws.data.sessionId = sessionId;
const target = clientManager.getClient(clientId);
@@ -506,7 +515,11 @@ export const hvncStreamingState = new Map<string, { isStreaming: boolean; displa
export const webcamStreamingState = new Map<string, { isStreaming: boolean; deviceIndex: number; fps: number; useMax: boolean; quality: number; codec: string }>();
export function handleWebcamViewerOpen(ws: ServerWebSocket<SocketData>) {
const { clientId } = ws.data;
const { clientId, userId, userRole } = ws.data;
if (userId !== undefined && userRole && !canUserAccessClient(userId, userRole as any, clientId)) {
ws.close(1008, "Forbidden: client access denied");
return;
}
const sessionId = uuidv4();
ws.data.sessionId = sessionId;
const target = clientManager.getClient(clientId);
@@ -641,7 +654,11 @@ export function handleWebcamViewerMessage(ws: ServerWebSocket<SocketData>, raw:
}
export function handleHVNCViewerOpen(ws: ServerWebSocket<SocketData>) {
const { clientId } = ws.data;
const { clientId, userId, userRole } = ws.data;
if (userId !== undefined && userRole && !canUserAccessClient(userId, userRole as any, clientId)) {
ws.close(1008, "Forbidden: client access denied");
return;
}
const sessionId = uuidv4();
ws.data.sessionId = sessionId;
const target = clientManager.getClient(clientId);
@@ -9,6 +9,7 @@ import { encodeMessage } from "../protocol";
import * as sessionManager from "../sessions/sessionManager";
import type { SocketData } from "../sessions/types";
import { normalizeFileUploadPayload } from "../fileTransfers";
import { canUserAccessClient } from "../users";
type FileBrowserViewer = {
id: string;
@@ -61,7 +62,11 @@ function safeSendViewer(ws: ServerWebSocket<SocketData>, payload: unknown) {
}
export function handleFileBrowserViewerOpen(ws: ServerWebSocket<SocketData>) {
const { clientId } = ws.data;
const { clientId, userId, userRole } = ws.data;
if (userId !== undefined && userRole && !canUserAccessClient(userId, userRole as any, clientId)) {
ws.close(1008, "Forbidden: client access denied");
return;
}
const sessionId = uuidv4();
const target = clientManager.getClient(clientId);
const session: FileBrowserViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() };
@@ -280,7 +285,11 @@ export function handleFileBrowserMessage(clientId: string, payload: any, deps: W
}
export function handleProcessViewerOpen(ws: ServerWebSocket<SocketData>) {
const { clientId } = ws.data;
const { clientId, userId, userRole } = ws.data;
if (userId !== undefined && userRole && !canUserAccessClient(userId, userRole as any, clientId)) {
ws.close(1008, "Forbidden: client access denied");
return;
}
const sessionId = uuidv4();
const target = clientManager.getClient(clientId);
const session: ProcessViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() };
@@ -330,7 +339,11 @@ export function handleProcessMessage(clientId: string, payload: any) {
}
export function handleKeyloggerViewerOpen(ws: ServerWebSocket<SocketData>) {
const { clientId } = ws.data;
const { clientId, userId, userRole } = ws.data;
if (userId !== undefined && userRole && !canUserAccessClient(userId, userRole as any, clientId)) {
ws.close(1008, "Forbidden: client access denied");
return;
}
const sessionId = uuidv4();
const target = clientManager.getClient(clientId);
const session = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() };
+6 -1
View File
@@ -4,6 +4,7 @@ import * as clientManager from "../clientManager";
import { encodeMessage } from "../protocol";
import * as sessionManager from "../sessions/sessionManager";
import type { SocketData, VoiceViewer } from "../sessions/types";
import { canUserAccessClient } from "../users";
function sendVoiceCommand(clientId: string, commandType: "voice_session_start" | "voice_session_stop" | "voice_downlink", payload: Record<string, unknown>) {
const target = clientManager.getClient(clientId);
@@ -32,7 +33,11 @@ function safeJson(ws: ServerWebSocket<SocketData>, payload: Record<string, unkno
}
export function handleVoiceViewerOpen(ws: ServerWebSocket<SocketData>) {
const { clientId } = ws.data;
const { clientId, userId, userRole } = ws.data;
if (userId !== undefined && userRole && !canUserAccessClient(userId, userRole as any, clientId)) {
ws.close(1008, "Forbidden: client access denied");
return;
}
const sessionId = uuidv4();
ws.data.sessionId = sessionId;
const target = clientManager.getClient(clientId);