From 1831cc96e211246089e5992e373f6fb738f86a57 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Mon, 13 Apr 2026 22:45:12 -0500 Subject: [PATCH] fix purgatory for users --- Overlord-Server/src/db.ts | 85 ++++++++++-- Overlord-Server/src/rbac.test.ts | 9 ++ Overlord-Server/src/rbac.ts | 4 + .../src/server/routes/client-routes.ts | 17 +++ .../src/server/routes/enrollment-routes.ts | 122 +++++++++++++----- .../routes/websocket-lifecycle-routes.ts | 12 +- Overlord-Server/src/types.ts | 4 + Overlord-Server/src/users.ts | 8 +- 8 files changed, 219 insertions(+), 42 deletions(-) diff --git a/Overlord-Server/src/db.ts b/Overlord-Server/src/db.ts index edf8970..4ae6eb1 100644 --- a/Overlord-Server/src/db.ts +++ b/Overlord-Server/src/db.ts @@ -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( - `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 = { diff --git a/Overlord-Server/src/rbac.test.ts b/Overlord-Server/src/rbac.test.ts index bbb1c30..2ff7f30 100644 --- a/Overlord-Server/src/rbac.test.ts +++ b/Overlord-Server/src/rbac.test.ts @@ -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"], ]; diff --git a/Overlord-Server/src/rbac.ts b/Overlord-Server/src/rbac.ts index 848bbc4..c3b13ca 100644 --- a/Overlord-Server/src/rbac.ts +++ b/Overlord-Server/src/rbac.ts @@ -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; diff --git a/Overlord-Server/src/server/routes/client-routes.ts b/Overlord-Server/src/server/routes/client-routes.ts index 704ce47..a9e3243 100644 --- a/Overlord-Server/src/server/routes/client-routes.ts +++ b/Overlord-Server/src/server/routes/client-routes.ts @@ -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( diff --git a/Overlord-Server/src/server/routes/enrollment-routes.ts b/Overlord-Server/src/server/routes/enrollment-routes.ts index 19d1b38..87c5998 100644 --- a/Overlord-Server/src/server/routes/enrollment-routes.ts +++ b/Overlord-Server/src/server/routes/enrollment-routes.ts @@ -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 }); } diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts index 0924372..cb78c5c 100644 --- a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts +++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts @@ -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); } diff --git a/Overlord-Server/src/types.ts b/Overlord-Server/src/types.ts index e56f911..09ecff5 100644 --- a/Overlord-Server/src/types.ts +++ b/Overlord-Server/src/types.ts @@ -32,6 +32,8 @@ export type ClientInfo = { elevation?: string; permissions?: Record; 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[]; }; diff --git a/Overlord-Server/src/users.ts b/Overlord-Server/src/users.ts index ce04037..eba5951 100644 --- a/Overlord-Server/src/users.ts +++ b/Overlord-Server/src/users.ts @@ -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: