394 lines
13 KiB
TypeScript
394 lines
13 KiB
TypeScript
import { generateToken, authenticateRequest, getSessionTtlSeconds } from "../../auth";
|
|
import { AuditAction, logAudit } from "../../auditLog";
|
|
import { logger } from "../../logger";
|
|
import { requirePermission } from "../../rbac";
|
|
import {
|
|
type ClientAccessRuleKind,
|
|
type ClientAccessScope,
|
|
createUser,
|
|
deleteUser,
|
|
getUserById,
|
|
getUserClientAccessScope,
|
|
listUsers,
|
|
listUserClientAccessRules,
|
|
removeUserClientAccessRule,
|
|
setUserClientAccessRule,
|
|
setUserClientAccessScope,
|
|
setUserCanBuild,
|
|
setUserCanUploadFiles,
|
|
updateUserPassword,
|
|
updateUserRole,
|
|
} from "../../users";
|
|
import { makeAuthCookie } from "./auth-cookie";
|
|
|
|
type RequestIpProvider = {
|
|
requestIP: (req: Request) => { address?: string } | null | undefined;
|
|
};
|
|
|
|
export async function handleUsersRoutes(
|
|
req: Request,
|
|
url: URL,
|
|
server: RequestIpProvider,
|
|
): Promise<Response | null> {
|
|
if (!url.pathname.startsWith("/api/users")) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const user = await authenticateRequest(req);
|
|
|
|
if (req.method === "GET" && url.pathname === "/api/users") {
|
|
requirePermission(user, "users:manage");
|
|
const users = listUsers();
|
|
return Response.json({ users });
|
|
}
|
|
|
|
if (req.method === "POST" && url.pathname === "/api/users") {
|
|
const authedUser = requirePermission(user, "users:manage");
|
|
const body = await req.json();
|
|
const { username, password, role } = body;
|
|
|
|
if (!username || !password || !role) {
|
|
return Response.json({ error: "Missing required fields" }, { status: 400 });
|
|
}
|
|
|
|
if (!["admin", "operator", "viewer"].includes(role)) {
|
|
return Response.json({ error: "Invalid role" }, { status: 400 });
|
|
}
|
|
|
|
const result = await createUser(username, password, role, authedUser.username);
|
|
|
|
if (result.success) {
|
|
const ip = server.requestIP(req)?.address || "unknown";
|
|
logAudit({
|
|
timestamp: Date.now(),
|
|
username: authedUser.username,
|
|
ip,
|
|
action: AuditAction.COMMAND,
|
|
details: `Created user: ${username} (${role})`,
|
|
success: true,
|
|
});
|
|
|
|
return Response.json({ success: true, userId: result.userId });
|
|
}
|
|
return Response.json({ error: result.error }, { status: 400 });
|
|
}
|
|
|
|
if (req.method === "PUT" && url.pathname.match(/^\/api\/users\/\d+\/password$/)) {
|
|
const userId = parseInt(url.pathname.split("/")[3]);
|
|
const body = await req.json();
|
|
const { password, newPassword, currentPassword } = body;
|
|
|
|
const canChange = user.userId === userId || user.role === "admin";
|
|
|
|
if (!canChange) {
|
|
return Response.json({ error: "Permission denied" }, { status: 403 });
|
|
}
|
|
|
|
if (user.userId === userId && currentPassword) {
|
|
const targetUser = getUserById(userId);
|
|
if (!targetUser) {
|
|
return Response.json({ error: "User not found" }, { status: 404 });
|
|
}
|
|
|
|
const isValid = await Bun.password.verify(
|
|
currentPassword,
|
|
targetUser.password_hash,
|
|
);
|
|
if (!isValid) {
|
|
return Response.json(
|
|
{ error: "Current password is incorrect" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
}
|
|
|
|
const finalPassword = newPassword || password;
|
|
if (!finalPassword) {
|
|
return Response.json({ error: "Password required" }, { status: 400 });
|
|
}
|
|
|
|
const result = await updateUserPassword(userId, finalPassword);
|
|
|
|
if (result.success) {
|
|
const targetUser = getUserById(userId);
|
|
const ip = server.requestIP(req)?.address || "unknown";
|
|
logAudit({
|
|
timestamp: Date.now(),
|
|
username: user.username,
|
|
ip,
|
|
action: AuditAction.COMMAND,
|
|
details: `Updated password for user: ${targetUser?.username}`,
|
|
success: true,
|
|
});
|
|
|
|
if (user.userId === userId && targetUser) {
|
|
const newToken = await generateToken(targetUser);
|
|
const sessionTtlSeconds = getSessionTtlSeconds();
|
|
return new Response(
|
|
JSON.stringify({
|
|
success: true,
|
|
token: newToken,
|
|
}),
|
|
{
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Set-Cookie": makeAuthCookie(newToken, sessionTtlSeconds, req),
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
return Response.json({ success: true });
|
|
}
|
|
return Response.json({ error: result.error }, { status: 400 });
|
|
}
|
|
|
|
if (req.method === "PUT" && url.pathname.match(/^\/api\/users\/\d+\/role$/)) {
|
|
const authedUser = requirePermission(user, "users:manage");
|
|
const userId = parseInt(url.pathname.split("/")[3]);
|
|
const body = await req.json();
|
|
const { role } = body;
|
|
|
|
if (!role || !["admin", "operator", "viewer"].includes(role)) {
|
|
return Response.json({ error: "Invalid role" }, { status: 400 });
|
|
}
|
|
|
|
if (userId === authedUser.userId) {
|
|
return Response.json({ error: "Cannot change your own role" }, { status: 400 });
|
|
}
|
|
|
|
const result = updateUserRole(userId, role);
|
|
|
|
if (result.success) {
|
|
const targetUser = getUserById(userId);
|
|
const ip = server.requestIP(req)?.address || "unknown";
|
|
logAudit({
|
|
timestamp: Date.now(),
|
|
username: authedUser.username,
|
|
ip,
|
|
action: AuditAction.COMMAND,
|
|
details: `Updated role for user: ${targetUser?.username} to ${role}`,
|
|
success: true,
|
|
});
|
|
|
|
return Response.json({ success: true });
|
|
}
|
|
return Response.json({ error: result.error }, { status: 400 });
|
|
}
|
|
|
|
if (req.method === "GET" && url.pathname.match(/^\/api\/users\/\d+\/client-access$/)) {
|
|
const authedUser = requirePermission(user, "users:manage");
|
|
const userId = parseInt(url.pathname.split("/")[3]);
|
|
const targetUser = getUserById(userId);
|
|
if (!targetUser) {
|
|
return Response.json({ error: "User not found" }, { status: 404 });
|
|
}
|
|
|
|
const scope = getUserClientAccessScope(userId);
|
|
const rules = listUserClientAccessRules(userId);
|
|
|
|
const ip = server.requestIP(req)?.address || "unknown";
|
|
logAudit({
|
|
timestamp: Date.now(),
|
|
username: authedUser.username,
|
|
ip,
|
|
action: AuditAction.COMMAND,
|
|
details: `Viewed client access policy for user: ${targetUser.username}`,
|
|
success: true,
|
|
});
|
|
|
|
return Response.json({ scope, rules });
|
|
}
|
|
|
|
if (req.method === "PUT" && url.pathname.match(/^\/api\/users\/\d+\/client-access$/)) {
|
|
const authedUser = requirePermission(user, "users:manage");
|
|
const userId = parseInt(url.pathname.split("/")[3]);
|
|
const targetUser = getUserById(userId);
|
|
if (!targetUser) {
|
|
return Response.json({ error: "User not found" }, { status: 404 });
|
|
}
|
|
|
|
const body = await req.json();
|
|
const scope = body?.scope as ClientAccessScope;
|
|
const result = setUserClientAccessScope(userId, scope);
|
|
if (!result.success) {
|
|
return Response.json({ error: result.error }, { status: 400 });
|
|
}
|
|
|
|
const ip = server.requestIP(req)?.address || "unknown";
|
|
logAudit({
|
|
timestamp: Date.now(),
|
|
username: authedUser.username,
|
|
ip,
|
|
action: AuditAction.COMMAND,
|
|
details: `Updated client access scope for ${targetUser.username} to ${scope}`,
|
|
success: true,
|
|
});
|
|
|
|
return Response.json({ success: true, scope });
|
|
}
|
|
|
|
if (req.method === "POST" && url.pathname.match(/^\/api\/users\/\d+\/client-access\/rules$/)) {
|
|
const authedUser = requirePermission(user, "users:manage");
|
|
const userId = parseInt(url.pathname.split("/")[3]);
|
|
const targetUser = getUserById(userId);
|
|
if (!targetUser) {
|
|
return Response.json({ error: "User not found" }, { status: 404 });
|
|
}
|
|
|
|
const body = await req.json();
|
|
const clientId = String(body?.clientId || "").trim();
|
|
const access = body?.access as ClientAccessRuleKind;
|
|
const result = setUserClientAccessRule(userId, clientId, access);
|
|
if (!result.success) {
|
|
return Response.json({ error: result.error }, { status: 400 });
|
|
}
|
|
|
|
if (access === "allow") {
|
|
const currentScope = getUserClientAccessScope(userId);
|
|
if (currentScope === "none") {
|
|
setUserClientAccessScope(userId, "allowlist");
|
|
}
|
|
}
|
|
|
|
const ip = server.requestIP(req)?.address || "unknown";
|
|
logAudit({
|
|
timestamp: Date.now(),
|
|
username: authedUser.username,
|
|
ip,
|
|
action: AuditAction.COMMAND,
|
|
details: `Set client access rule for ${targetUser.username}: ${access} ${clientId}`,
|
|
success: true,
|
|
});
|
|
|
|
return Response.json({ success: true });
|
|
}
|
|
|
|
if (req.method === "DELETE" && url.pathname.match(/^\/api\/users\/\d+\/client-access\/rules$/)) {
|
|
const authedUser = requirePermission(user, "users:manage");
|
|
const userId = parseInt(url.pathname.split("/")[3]);
|
|
const targetUser = getUserById(userId);
|
|
if (!targetUser) {
|
|
return Response.json({ error: "User not found" }, { status: 404 });
|
|
}
|
|
|
|
const clientId = String(url.searchParams.get("clientId") || "").trim();
|
|
if (!clientId) {
|
|
return Response.json({ error: "clientId is required" }, { status: 400 });
|
|
}
|
|
|
|
const result = removeUserClientAccessRule(userId, clientId);
|
|
if (!result.success) {
|
|
return Response.json({ error: result.error }, { status: 400 });
|
|
}
|
|
|
|
const ip = server.requestIP(req)?.address || "unknown";
|
|
logAudit({
|
|
timestamp: Date.now(),
|
|
username: authedUser.username,
|
|
ip,
|
|
action: AuditAction.COMMAND,
|
|
details: `Removed client access rule for ${targetUser.username}: ${clientId}`,
|
|
success: true,
|
|
});
|
|
|
|
return Response.json({ success: true });
|
|
}
|
|
|
|
if (req.method === "DELETE" && url.pathname.match(/^\/api\/users\/\d+$/)) {
|
|
const authedUser = requirePermission(user, "users:manage");
|
|
const userId = parseInt(url.pathname.split("/")[3]);
|
|
|
|
if (userId === authedUser.userId) {
|
|
return Response.json(
|
|
{ error: "Cannot delete your own account" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const targetUser = getUserById(userId);
|
|
const result = deleteUser(userId);
|
|
|
|
if (result.success) {
|
|
const ip = server.requestIP(req)?.address || "unknown";
|
|
logAudit({
|
|
timestamp: Date.now(),
|
|
username: authedUser.username,
|
|
ip,
|
|
action: AuditAction.COMMAND,
|
|
details: `Deleted user: ${targetUser?.username}`,
|
|
success: true,
|
|
});
|
|
|
|
return Response.json({ success: true });
|
|
}
|
|
return Response.json({ error: result.error }, { status: 400 });
|
|
}
|
|
|
|
if (req.method === "PUT" && url.pathname.match(/^\/api\/users\/\d+\/can-build$/)) {
|
|
const authedUser = requirePermission(user, "users:manage");
|
|
const userId = parseInt(url.pathname.split("/")[3]);
|
|
const targetUser = getUserById(userId);
|
|
if (!targetUser) {
|
|
return Response.json({ error: "User not found" }, { status: 404 });
|
|
}
|
|
|
|
const body = await req.json();
|
|
const canBuild = !!body?.canBuild;
|
|
const result = setUserCanBuild(userId, canBuild);
|
|
if (!result.success) {
|
|
return Response.json({ error: result.error }, { status: 400 });
|
|
}
|
|
|
|
const ip = server.requestIP(req)?.address || "unknown";
|
|
logAudit({
|
|
timestamp: Date.now(),
|
|
username: authedUser.username,
|
|
ip,
|
|
action: AuditAction.COMMAND,
|
|
details: `${canBuild ? "Granted" : "Revoked"} build permission for ${targetUser.username}`,
|
|
success: true,
|
|
});
|
|
|
|
return Response.json({ success: true, canBuild });
|
|
}
|
|
|
|
if (req.method === "PUT" && url.pathname.match(/^\/api\/users\/\d+\/can-upload-files$/)) {
|
|
const authedUser = requirePermission(user, "users:manage");
|
|
const userId = parseInt(url.pathname.split("/")[3]);
|
|
const targetUser = getUserById(userId);
|
|
if (!targetUser) {
|
|
return Response.json({ error: "User not found" }, { status: 404 });
|
|
}
|
|
|
|
const body = await req.json();
|
|
const canUploadFiles = !!body?.canUploadFiles;
|
|
const result = setUserCanUploadFiles(userId, canUploadFiles);
|
|
if (!result.success) {
|
|
return Response.json({ error: result.error }, { status: 400 });
|
|
}
|
|
|
|
const ip = server.requestIP(req)?.address || "unknown";
|
|
logAudit({
|
|
timestamp: Date.now(),
|
|
username: authedUser.username,
|
|
ip,
|
|
action: AuditAction.COMMAND,
|
|
details: `${canUploadFiles ? "Granted" : "Revoked"} file upload permission for ${targetUser.username}`,
|
|
success: true,
|
|
});
|
|
|
|
return Response.json({ success: true, canUploadFiles });
|
|
}
|
|
|
|
return new Response("Not found", { status: 404 });
|
|
} catch (error) {
|
|
if (error instanceof Response) {
|
|
return error;
|
|
}
|
|
logger.error("[users] API error:", error);
|
|
return Response.json({ error: "Internal server error" }, { status: 500 });
|
|
}
|
|
}
|