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