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,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);
}