fix purgatory for users

This commit is contained in:
KingKDot
2026-04-13 22:45:12 -05:00
parent 30e5d4d67b
commit 1831cc96e2
8 changed files with 219 additions and 42 deletions
+77 -8
View File
@@ -28,7 +28,9 @@ db.run(`
last_seen INTEGER,
online INTEGER,
ping_ms INTEGER,
bookmarked INTEGER NOT NULL DEFAULT 0
bookmarked INTEGER NOT NULL DEFAULT 0,
build_tag TEXT,
built_by_user_id INTEGER
);
`);
try {
@@ -52,6 +54,12 @@ try {
try {
db.run(`ALTER TABLE clients ADD COLUMN bookmarked INTEGER NOT NULL DEFAULT 0`);
} catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN build_tag TEXT`);
} catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN built_by_user_id INTEGER`);
} catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN enrollment_status TEXT NOT NULL DEFAULT 'pending'`);
} catch {}
@@ -100,6 +108,9 @@ db.run(
db.run(
`CREATE INDEX IF NOT EXISTS idx_clients_ping_ms ON clients(ping_ms);`,
);
db.run(
`CREATE INDEX IF NOT EXISTS idx_clients_built_by_user_id ON clients(built_by_user_id);`,
);
try {
db.run(`ALTER TABLE clients ADD COLUMN disconnect_reason TEXT`);
} catch {}
@@ -445,8 +456,8 @@ export function upsertClientRow(
) {
const now = partial.lastSeen ?? Date.now();
db.run(
`INSERT INTO clients (id, hwid, role, ip, host, os, arch, version, user, nickname, custom_tag, custom_tag_note, monitors, country, last_seen, online, ping_ms, enrollment_status, public_key, key_fingerprint, cpu, gpu, ram, is_admin, elevation, permissions)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, 0), ?, ?)
`INSERT INTO clients (id, hwid, role, ip, host, os, arch, version, user, nickname, custom_tag, custom_tag_note, monitors, country, last_seen, online, ping_ms, build_tag, built_by_user_id, enrollment_status, public_key, key_fingerprint, cpu, gpu, ram, is_admin, elevation, permissions)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, 0), ?, ?)
ON CONFLICT(id) DO UPDATE SET
hwid=COALESCE(excluded.hwid, clients.hwid),
role=COALESCE(excluded.role, clients.role),
@@ -464,6 +475,8 @@ export function upsertClientRow(
last_seen=excluded.last_seen,
online=COALESCE(excluded.online, clients.online),
ping_ms=COALESCE(excluded.ping_ms, clients.ping_ms),
build_tag=COALESCE(excluded.build_tag, clients.build_tag),
built_by_user_id=COALESCE(excluded.built_by_user_id, clients.built_by_user_id),
enrollment_status=CASE WHEN excluded.enrollment_status <> 'pending' THEN excluded.enrollment_status ELSE clients.enrollment_status END,
public_key=COALESCE(excluded.public_key, clients.public_key),
key_fingerprint=COALESCE(excluded.key_fingerprint, clients.key_fingerprint),
@@ -491,6 +504,8 @@ export function upsertClientRow(
now,
partial.online ?? 0,
partial.pingMs ?? null,
partial.buildTag ?? null,
partial.builtByUserId ?? null,
partial.enrollmentStatus ?? "pending",
partial.publicKey ?? null,
partial.keyFingerprint ?? null,
@@ -779,6 +794,8 @@ export function listClients(filters: ListFilters): ListResult {
osFilter,
countryFilter,
enrollmentFilter,
builtByUserId,
requireBuildOwner,
allowedClientIds,
deniedClientIds,
} = filters;
@@ -818,6 +835,15 @@ export function listClients(filters: ListFilters): ListResult {
params.push(countryFilter.toUpperCase());
}
if (typeof builtByUserId === "number") {
where.push("built_by_user_id=?");
params.push(builtByUserId);
}
if (requireBuildOwner) {
where.push("built_by_user_id IS NOT NULL");
}
if (Array.isArray(allowedClientIds)) {
if (allowedClientIds.length === 0) {
where.push("1=0");
@@ -871,7 +897,7 @@ export function listClients(filters: ListFilters): ListResult {
const rows = db
.query<any>(
`SELECT id, hwid, role, host, os, arch, version, user, nickname, custom_tag as customTag, custom_tag_note as customTagNote, monitors, country, last_seen as lastSeen, online, ping_ms as pingMs, bookmarked, enrollment_status as enrollmentStatus, public_key as publicKey, key_fingerprint as keyFingerprint, cpu, gpu, ram, is_admin as isAdmin, elevation, permissions, disconnect_reason as disconnectReason, disconnect_detail as disconnectDetail
`SELECT id, hwid, role, host, os, arch, version, user, nickname, custom_tag as customTag, custom_tag_note as customTagNote, monitors, country, last_seen as lastSeen, online, ping_ms as pingMs, bookmarked, build_tag as buildTag, built_by_user_id as builtByUserId, enrollment_status as enrollmentStatus, public_key as publicKey, key_fingerprint as keyFingerprint, cpu, gpu, ram, is_admin as isAdmin, elevation, permissions, disconnect_reason as disconnectReason, disconnect_detail as disconnectDetail
FROM clients
${whereSql}
${orderBy}
@@ -897,6 +923,8 @@ export function listClients(filters: ListFilters): ListResult {
pingMs: c.pingMs ?? null,
online: c.online === 1,
bookmarked: c.bookmarked === 1,
buildTag: c.buildTag || null,
builtByUserId: typeof c.builtByUserId === "number" ? c.builtByUserId : null,
enrollmentStatus: c.enrollmentStatus || "pending",
publicKey: c.publicKey || null,
keyFingerprint: c.keyFingerprint || null,
@@ -1348,6 +1376,8 @@ export function lookupClientByPublicKey(
export function getEnrollmentStats(opts?: {
allowedClientIds?: string[];
deniedClientIds?: string[];
builtByUserId?: number;
requireBuildOwner?: boolean;
}): {
pending: number;
approved: number;
@@ -1356,16 +1386,29 @@ export function getEnrollmentStats(opts?: {
let sql = `SELECT COALESCE(enrollment_status,'pending') as status, COUNT(*) as c FROM clients`;
const params: any[] = [];
const where: string[] = [];
if (opts?.allowedClientIds) {
if (opts.allowedClientIds.length === 0) return { pending: 0, approved: 0, denied: 0 };
const placeholders = opts.allowedClientIds.map(() => "?").join(",");
sql += ` WHERE id IN (${placeholders})`;
where.push(`id IN (${placeholders})`);
params.push(...opts.allowedClientIds);
} else if (opts?.deniedClientIds && opts.deniedClientIds.length > 0) {
}
if (opts?.deniedClientIds && opts.deniedClientIds.length > 0) {
const placeholders = opts.deniedClientIds.map(() => "?").join(",");
sql += ` WHERE id NOT IN (${placeholders})`;
where.push(`id NOT IN (${placeholders})`);
params.push(...opts.deniedClientIds);
}
if (typeof opts?.builtByUserId === "number") {
where.push("built_by_user_id = ?");
params.push(opts.builtByUserId);
}
if (opts?.requireBuildOwner) {
where.push("built_by_user_id IS NOT NULL");
}
if (where.length > 0) {
sql += ` WHERE ${where.join(" AND ")}`;
}
sql += ` GROUP BY enrollment_status`;
@@ -1435,6 +1478,8 @@ export function getAllPushSubscriptions(): PushSubscriptionRecord[] {
export function getPendingClients(opts?: {
allowedClientIds?: string[];
deniedClientIds?: string[];
builtByUserId?: number;
requireBuildOwner?: boolean;
}): {
id: string;
host: string | null;
@@ -1455,11 +1500,19 @@ export function getPendingClients(opts?: {
const placeholders = opts.allowedClientIds.map(() => "?").join(",");
sql += ` AND id IN (${placeholders})`;
params.push(...opts.allowedClientIds);
} else if (opts?.deniedClientIds && opts.deniedClientIds.length > 0) {
}
if (opts?.deniedClientIds && opts.deniedClientIds.length > 0) {
const placeholders = opts.deniedClientIds.map(() => "?").join(",");
sql += ` AND id NOT IN (${placeholders})`;
params.push(...opts.deniedClientIds);
}
if (typeof opts?.builtByUserId === "number") {
sql += ` AND built_by_user_id = ?`;
params.push(opts.builtByUserId);
}
if (opts?.requireBuildOwner) {
sql += ` AND built_by_user_id IS NOT NULL`;
}
sql += ` ORDER BY last_seen DESC`;
@@ -1479,6 +1532,22 @@ export function getPendingClients(opts?: {
}));
}
export function getClientBuildOwnership(
id: string,
): { buildTag: string | null; builtByUserId: number | null } | null {
const row = db
.query<{ build_tag: string | null; built_by_user_id: number | null }>(
`SELECT build_tag, built_by_user_id FROM clients WHERE id=?`,
)
.get(id);
if (!row) return null;
return {
buildTag: row.build_tag ?? null,
builtByUserId:
typeof row.built_by_user_id === "number" ? row.built_by_user_id : null,
};
}
export type AutoDeployTrigger = "on_connect" | "on_first_connect" | "on_connect_once";
export type AutoDeploy = {
+9
View File
@@ -46,6 +46,14 @@ describe("checkPermission", () => {
expect(checkPermission(makeUser({ role: "operator" }), "clients:control")).toBe(true);
});
test("operator has clients:enroll", () => {
expect(checkPermission(makeUser({ role: "operator" }), "clients:enroll")).toBe(true);
});
test("viewer does not have clients:enroll", () => {
expect(checkPermission(makeUser({ role: "viewer" }), "clients:enroll")).toBe(false);
});
test("viewer does not have clients:control", () => {
expect(checkPermission(makeUser({ role: "viewer" }), "clients:control")).toBe(false);
});
@@ -172,6 +180,7 @@ describe("getPermissionDescription", () => {
["clients:control", "Control clients (execute commands, desktop, console, files)"],
["clients:view", "View connected clients"],
["clients:build", "Build client binaries"],
["clients:enroll", "Manage client enrollment approvals"],
["audit:view", "View audit logs"],
];
+4
View File
@@ -6,6 +6,7 @@ export type Permission =
| "clients:control"
| "clients:view"
| "clients:build"
| "clients:enroll"
| "audit:view";
export function checkPermission(
@@ -75,6 +76,8 @@ export function getPermissionDescription(permission: Permission): string {
return "View connected clients";
case "clients:build":
return "Build client binaries";
case "clients:enroll":
return "Manage client enrollment approvals";
case "audit:view":
return "View audit logs";
default:
@@ -103,6 +106,7 @@ export function getRolePermissions(role: UserRole): Permission[] {
permissions.push("clients:control");
if (hasPermission(role, "clients:view")) permissions.push("clients:view");
if (hasPermission(role, "clients:build")) permissions.push("clients:build");
if (hasPermission(role, "clients:enroll")) permissions.push("clients:enroll");
if (hasPermission(role, "audit:view")) permissions.push("audit:view");
return permissions;
@@ -77,11 +77,28 @@ export async function handleClientRoutes(
const osFilter = url.searchParams.get("os") || "all";
const countryFilter = url.searchParams.get("country") || "all";
const enrollmentFilter = url.searchParams.get("enrollmentFilter") || "approved";
const isEnrollmentRequest = url.searchParams.has("enrollmentFilter");
if (user.role === "admin") {
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter });
return Response.json(result, { headers: deps.CORS_HEADERS });
}
if (user.role === "operator" && isEnrollmentRequest) {
const result = listClients({
page,
pageSize,
search,
sort,
statusFilter,
osFilter,
countryFilter,
enrollmentFilter,
builtByUserId: user.userId,
requireBuildOwner: true,
});
return Response.json(result, { headers: deps.CORS_HEADERS });
}
const scope = getUserClientAccessScope(user.userId);
if (scope === "none") {
return Response.json(
@@ -10,28 +10,37 @@ import {
unbanIp,
isIpBanned,
listBannedIps,
getClientBuildOwnership,
} from "../../db";
import { logAudit, AuditAction } from "../../auditLog";
import * as clientManager from "../../clientManager";
import { setOnlineState } from "../../db";
import {
canUserAccessClient,
getUserClientAccessScope,
listUserClientRuleIdsByAccess,
} from "../../users";
import { requirePermission } from "../../rbac";
function getClientScopeFilters(userId: number, role: string): { allowedClientIds?: string[]; deniedClientIds?: string[] } {
function getClientScopeFilters(userId: number, role: string): {
allowedClientIds?: string[];
deniedClientIds?: string[];
builtByUserId?: number;
requireBuildOwner?: boolean;
} {
if (role === "admin") return {};
const scope = getUserClientAccessScope(userId);
if (scope === "none") return { allowedClientIds: [] };
if (scope === "all") return {};
if (scope === "allowlist") {
return { allowedClientIds: listUserClientRuleIdsByAccess(userId, "allow") };
}
if (scope === "denylist") {
return { deniedClientIds: listUserClientRuleIdsByAccess(userId, "deny") };
}
return { allowedClientIds: [] };
return {
builtByUserId: userId,
requireBuildOwner: true,
};
}
function canManageEnrollmentClient(
userId: number,
role: string,
clientId: string,
): boolean {
if (role === "admin") return true;
if (role !== "operator") return false;
const ownership = getClientBuildOwnership(clientId);
if (!ownership || ownership.builtByUserId == null) return false;
return ownership.builtByUserId === userId;
}
let _postApproveHook: ((clientId: string) => void) | undefined;
@@ -48,7 +57,12 @@ export async function handleEnrollmentRoutes(
if (req.method === "GET" && url.pathname === "/api/enrollment/pending") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
try {
requirePermission(user, "clients:enroll");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const scopeFilters = getClientScopeFilters(user.userId, user.role);
const clients = getPendingClients(scopeFilters);
@@ -59,6 +73,12 @@ export async function handleEnrollmentRoutes(
if (req.method === "GET" && url.pathname === "/api/enrollment/stats") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
try {
requirePermission(user, "clients:enroll");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const scopeFilters = getClientScopeFilters(user.userId, user.role);
const stats = getEnrollmentStats(scopeFilters);
@@ -81,13 +101,18 @@ export async function handleEnrollmentRoutes(
if (req.method === "POST" && approveMatch) {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
try {
requirePermission(user, "clients:enroll");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const clientId = decodeURIComponent(approveMatch[1]);
const current = getClientEnrollmentStatus(clientId);
if (!current) return Response.json({ error: "Client not found" }, { status: 404 });
if (!canUserAccessClient(user.userId, user.role, clientId)) {
if (!canManageEnrollmentClient(user.userId, user.role, clientId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
@@ -113,13 +138,18 @@ export async function handleEnrollmentRoutes(
if (req.method === "POST" && denyMatch) {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
try {
requirePermission(user, "clients:enroll");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const clientId = decodeURIComponent(denyMatch[1]);
const current = getClientEnrollmentStatus(clientId);
if (!current) return Response.json({ error: "Client not found" }, { status: 404 });
if (!canUserAccessClient(user.userId, user.role, clientId)) {
if (!canManageEnrollmentClient(user.userId, user.role, clientId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
@@ -143,13 +173,18 @@ export async function handleEnrollmentRoutes(
if (req.method === "POST" && resetMatch) {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
try {
requirePermission(user, "clients:enroll");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const clientId = decodeURIComponent(resetMatch[1]);
const current = getClientEnrollmentStatus(clientId);
if (!current) return Response.json({ error: "Client not found" }, { status: 404 });
if (!canUserAccessClient(user.userId, user.role, clientId)) {
if (!canManageEnrollmentClient(user.userId, user.role, clientId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
@@ -162,7 +197,12 @@ export async function handleEnrollmentRoutes(
if (req.method === "POST" && url.pathname === "/api/enrollment/bulk") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
try {
requirePermission(user, "clients:enroll");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any;
try {
@@ -184,7 +224,7 @@ export async function handleEnrollmentRoutes(
if (action === "ban-ip") {
let banned = 0;
for (const id of ids) {
if (!canUserAccessClient(user.userId, user.role, id)) continue;
if (!canManageEnrollmentClient(user.userId, user.role, id)) continue;
const clientIp = getClientIp(id);
if (!clientIp) continue;
banIp(clientIp, `Bulk banned from purgatory by ${user.username}`);
@@ -212,7 +252,7 @@ export async function handleEnrollmentRoutes(
const status = action === "approve" ? "approved" : action === "deny" ? "denied" : "pending";
let updated = 0;
for (const id of ids) {
if (!canUserAccessClient(user.userId, user.role, id)) continue;
if (!canManageEnrollmentClient(user.userId, user.role, id)) continue;
const ok = setClientEnrollmentStatus(
id,
status as "approved" | "denied" | "pending",
@@ -244,13 +284,18 @@ export async function handleEnrollmentRoutes(
if (req.method === "POST" && banMatch) {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
try {
requirePermission(user, "clients:enroll");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const clientId = decodeURIComponent(banMatch[1]);
const targetIp = getClientIp(clientId);
if (!targetIp) return Response.json({ error: "Client IP not found" }, { status: 404 });
if (!canUserAccessClient(user.userId, user.role, clientId)) {
if (!canManageEnrollmentClient(user.userId, user.role, clientId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
@@ -280,7 +325,12 @@ export async function handleEnrollmentRoutes(
if (req.method === "GET" && url.pathname === "/api/enrollment/banned-ips") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
try {
requirePermission(user, "clients:enroll");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
return Response.json({ items: listBannedIps() });
}
@@ -289,7 +339,12 @@ export async function handleEnrollmentRoutes(
if (req.method === "DELETE" && url.pathname === "/api/enrollment/banned-ips") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
try {
requirePermission(user, "clients:enroll");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const ipToUnban = (url.searchParams.get("ip") || "").trim();
if (!ipToUnban) return Response.json({ error: "Missing ip parameter" }, { status: 400 });
@@ -314,7 +369,12 @@ export async function handleEnrollmentRoutes(
if (req.method === "POST" && url.pathname === "/api/enrollment/ban-ip") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
try {
requirePermission(user, "clients:enroll");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any;
try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON" }, { status: 400 }); }
@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from "uuid";
import geoip from "geoip-lite";
import { logAudit, AuditAction } from "../../auditLog";
import * as clientManager from "../../clientManager";
import { clientExists, setOnlineState, upsertClientRow, getClientEnrollmentStatus, setClientEnrollmentStatus, lookupClientByPublicKey, getClientPublicKeyById } from "../../db";
import { clientExists, setOnlineState, upsertClientRow, getClientEnrollmentStatus, setClientEnrollmentStatus, lookupClientByPublicKey, getClientPublicKeyById, getBuildByTag } from "../../db";
import { logger } from "../../logger";
import { metrics } from "../../metrics";
import { decodeMessage, encodeMessage, type WireMessage } from "../../protocol";
@@ -272,6 +272,11 @@ export async function handleWebSocketMessage(
}
const resolvedId = ws.data.clientId;
const buildTag = typeof (payload as any).buildTag === "string"
? (payload as any).buildTag.trim()
: "";
const build = buildTag ? getBuildByTag(buildTag) : null;
const builtByUserId = build?.builtByUserId;
if (enrollmentStatus === "denied") {
logger.info(`[purgatory] denied client ${resolvedId} tried to connect`);
@@ -305,6 +310,8 @@ export async function handleWebSocketMessage(
publicKey,
keyFingerprint,
enrollmentStatus: "pending",
buildTag: buildTag || undefined,
builtByUserId,
});
logger.info(`[purgatory] client ${resolvedId} is pending approval`);
@@ -362,6 +369,8 @@ export async function handleWebSocketMessage(
publicKey,
keyFingerprint,
enrollmentStatus: "approved",
buildTag: buildTag || undefined,
builtByUserId,
online: 1 as any,
lastSeen: Date.now(),
});
@@ -418,7 +427,6 @@ export async function handleWebSocketMessage(
});
(ws as any).data.wasKnown = true;
const buildTag = typeof (payload as any).buildTag === "string" ? (payload as any).buildTag : "";
if (buildTag) {
deps.handleBuildTagConnection(infoObj.id, buildTag);
}
+4
View File
@@ -32,6 +32,8 @@ export type ClientInfo = {
elevation?: string;
permissions?: Record<string, boolean>;
enrollmentStatus?: EnrollmentStatus;
buildTag?: string;
builtByUserId?: number;
publicKey?: string;
keyFingerprint?: string;
disconnectReason?: string;
@@ -47,6 +49,8 @@ export type ListFilters = {
osFilter?: string;
countryFilter?: string;
enrollmentFilter?: string;
builtByUserId?: number;
requireBuildOwner?: boolean;
allowedClientIds?: string[];
deniedClientIds?: string[];
};
+7 -1
View File
@@ -277,7 +277,7 @@ export function listUserClientRuleIdsByAccess(
access: ClientAccessRuleKind,
): string[] {
const entry = getUserAccessCacheEntry(userId);
const values = access === "allowlist" ? [] : access === "allow" ? entry.allow : entry.deny;
const values = access === "allow" ? entry.allow : entry.deny;
return Array.from(values).sort((left, right) => left.localeCompare(right));
}
@@ -728,6 +728,10 @@ export function canViewAuditLogs(role: UserRole): boolean {
return role === "admin" || role === "operator";
}
export function canManageEnrollment(role: UserRole): boolean {
return role === "admin" || role === "operator";
}
export function hasPermission(role: UserRole, permission: string, userId?: number): boolean {
switch (permission) {
case "users:manage":
@@ -739,6 +743,8 @@ export function hasPermission(role: UserRole, permission: string, userId?: numbe
case "clients:build":
if (userId !== undefined) return canBuildClients(userId, role);
return role === "admin" || role === "operator";
case "clients:enroll":
return canManageEnrollment(role);
case "audit:view":
return canViewAuditLogs(role);
default: