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