fix purgatory for users
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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"],
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user