Files
doktainer-upstream/src/server/routes/users.ts
T

784 lines
22 KiB
TypeScript

import { FastifyInstance } from "fastify";
import bcrypt from "bcryptjs";
import crypto from "crypto";
import { z } from "zod";
import { resolvePublicAppUrl } from "../lib/public-url";
import prisma from "../lib/prisma";
import { requireRole } from "../middleware/auth";
import { auditLog } from "../services/audit.service";
const ManageableRoleSchema = z.enum(["OPERATOR", "DEVELOPER", "VIEWER"]);
const ServerAccessSchema = z.object({
allServersAccess: z.boolean().default(true),
serverIds: z.array(z.string()).default([]),
});
const UserCreateSchema = z.object({
email: z.string().email(),
name: z.string().min(2),
password: z.string().min(8),
role: ManageableRoleSchema.default("DEVELOPER"),
allServersAccess: z.boolean().default(true),
serverIds: z.array(z.string()).default([]),
});
const UserInvitationCreateSchema = z.object({
email: z.string().email(),
name: z.string().min(2),
role: ManageableRoleSchema.default("DEVELOPER"),
allServersAccess: z.boolean().default(true),
serverIds: z.array(z.string()).default([]),
expiresInDays: z.number().int().min(1).max(30).default(7),
});
const UserUpdateSchema = z.object({
email: z.string().trim().email(),
name: z.string().trim().min(2).max(120),
role: ManageableRoleSchema,
allServersAccess: z.boolean(),
serverIds: z.array(z.string()),
password: z.string().min(8).max(128).optional(),
});
function createInviteToken() {
const raw = crypto.randomBytes(32).toString("hex");
const hash = crypto.createHash("sha256").update(raw).digest("hex");
return { raw, hash };
}
async function resolveServerIds(serverIds: string[], organizationId: string) {
const uniqueServerIds = [...new Set(serverIds)];
if (uniqueServerIds.length === 0) return [];
const servers = await prisma.server.findMany({
where: { id: { in: uniqueServerIds }, organizationId },
select: { id: true },
});
if (servers.length !== uniqueServerIds.length) {
throw new Error("One or more selected servers do not exist");
}
return servers.map((server) => server.id);
}
export async function usersRoutes(app: FastifyInstance) {
app.get(
"/",
{ preHandler: [requireRole("OPERATOR")] },
async (req, reply) => {
const users = await prisma.user.findMany({
where: {
organizationMemberships: {
some: { organizationId: req.organizationId! },
},
},
select: {
id: true,
name: true,
email: true,
role: true,
isActive: true,
allServersAccess: true,
lastLogin: true,
createdAt: true,
serverAssignments: {
where: {
server: { organizationId: req.organizationId! },
},
select: {
serverId: true,
server: { select: { id: true, name: true } },
},
orderBy: { createdAt: "asc" },
},
},
orderBy: { createdAt: "asc" },
});
return reply.send({ success: true, data: users });
},
);
app.get(
"/invitations",
{ preHandler: [requireRole("OPERATOR")] },
async (req, reply) => {
const invitations = await prisma.userInvitation.findMany({
where: {
organizationId: req.organizationId!,
acceptedAt: null,
revokedAt: null,
},
select: {
id: true,
email: true,
name: true,
role: true,
allServersAccess: true,
serverIds: true,
expiresAt: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
});
const referencedServerIds = [
...new Set(invitations.flatMap((invitation) => invitation.serverIds)),
];
const referencedServers =
referencedServerIds.length > 0
? await prisma.server.findMany({
where: {
id: { in: referencedServerIds },
organizationId: req.organizationId!,
},
select: { id: true, name: true },
})
: [];
const serverMap = new Map(
referencedServers.map((server) => [server.id, server]),
);
return reply.send({
success: true,
data: invitations.map((invitation) => ({
...invitation,
servers: invitation.serverIds
.map((serverId) => serverMap.get(serverId))
.filter(Boolean),
})),
});
},
);
app.post(
"/invitations",
{ preHandler: [requireRole("OPERATOR")] },
async (req, reply) => {
const body = UserInvitationCreateSchema.safeParse(req.body);
if (!body.success)
return reply
.status(400)
.send({ success: false, error: body.error.flatten() });
const existingUser = await prisma.user.findUnique({
where: { email: body.data.email },
});
if (existingUser) {
return reply
.status(409)
.send({ success: false, error: "Email already in use" });
}
let validServerIds: string[] = [];
try {
validServerIds = await resolveServerIds(
body.data.serverIds,
req.organizationId!,
);
} catch (error) {
return reply.status(400).send({
success: false,
error:
error instanceof Error ? error.message : "Invalid server selection",
});
}
const { raw, hash } = createInviteToken();
const expiresAt = new Date(
Date.now() + body.data.expiresInDays * 86400000,
);
const invitation = await prisma.$transaction(async (tx) => {
await tx.userInvitation.updateMany({
where: {
email: body.data.email,
organizationId: req.organizationId!,
acceptedAt: null,
revokedAt: null,
},
data: { revokedAt: new Date() },
});
return tx.userInvitation.create({
data: {
organizationId: req.organizationId!,
email: body.data.email,
name: body.data.name,
role: body.data.role,
allServersAccess: body.data.allServersAccess,
serverIds: body.data.allServersAccess ? [] : validServerIds,
invitedById: req.userId,
tokenHash: hash,
expiresAt,
},
});
});
await auditLog({
userId: req.userId,
organizationId: req.organizationId,
action: "USER_INVITE_CREATE",
category: "AUTH",
level: "INFO",
message: `Invitation created for "${invitation.email}" with role ${invitation.role}`,
meta: { invitationId: invitation.id },
});
return reply.status(201).send({
success: true,
data: {
id: invitation.id,
email: invitation.email,
name: invitation.name,
role: invitation.role,
allServersAccess: invitation.allServersAccess,
serverIds: invitation.serverIds,
expiresAt: invitation.expiresAt,
inviteUrl: resolvePublicAppUrl(`/invite/${raw}`, {
headers: req.headers,
protocol: req.protocol,
}),
},
});
},
);
app.post(
"/invitations/:id/regenerate",
{ preHandler: [requireRole("OPERATOR")] },
async (req, reply) => {
const { id } = req.params as { id: string };
const invitation = await prisma.userInvitation.findFirst({
where: {
id,
organizationId: req.organizationId!,
acceptedAt: null,
revokedAt: null,
},
select: { id: true, email: true },
});
if (!invitation) {
return reply
.status(404)
.send({ success: false, error: "Invitation not found" });
}
const { raw, hash } = createInviteToken();
const expiresAt = new Date(Date.now() + 7 * 86400000);
await prisma.userInvitation.update({
where: { id },
data: { tokenHash: hash, expiresAt },
});
await auditLog({
userId: req.userId,
organizationId: req.organizationId,
action: "USER_INVITE_REGENERATE",
category: "AUTH",
level: "INFO",
message: `Invitation link regenerated for "${invitation.email}"`,
meta: { invitationId: id },
});
return reply.send({
success: true,
data: {
inviteUrl: resolvePublicAppUrl(`/invite/${raw}`, {
headers: req.headers,
protocol: req.protocol,
}),
expiresAt,
},
});
},
);
app.delete(
"/invitations/:id",
{ preHandler: [requireRole("OPERATOR")] },
async (req, reply) => {
const { id } = req.params as { id: string };
const invitation = await prisma.userInvitation.findFirst({
where: {
id,
organizationId: req.organizationId!,
acceptedAt: null,
revokedAt: null,
},
select: { id: true, email: true },
});
if (!invitation) {
return reply
.status(404)
.send({ success: false, error: "Invitation not found" });
}
await prisma.userInvitation.update({
where: { id },
data: { revokedAt: new Date() },
});
await auditLog({
userId: req.userId,
organizationId: req.organizationId,
action: "USER_INVITE_REVOKE",
category: "AUTH",
level: "WARNING",
message: `Invitation revoked for "${invitation.email}"`,
meta: { invitationId: id },
});
return reply.send({ success: true, message: "Invitation revoked" });
},
);
app.post(
"/",
{ preHandler: [requireRole("OPERATOR")] },
async (req, reply) => {
const body = UserCreateSchema.safeParse(req.body);
if (!body.success)
return reply
.status(400)
.send({ success: false, error: body.error.flatten() });
const existing = await prisma.user.findUnique({
where: { email: body.data.email },
});
if (existing)
return reply
.status(409)
.send({ success: false, error: "Email already in use" });
let validServerIds: string[] = [];
try {
validServerIds = await resolveServerIds(
body.data.serverIds,
req.organizationId!,
);
} catch (error) {
return reply.status(400).send({
success: false,
error:
error instanceof Error ? error.message : "Invalid server selection",
});
}
const passwordHash = await bcrypt.hash(body.data.password, 12);
const user = await prisma.$transaction(async (tx) => {
await tx.userInvitation.updateMany({
where: {
email: body.data.email,
organizationId: req.organizationId!,
acceptedAt: null,
revokedAt: null,
},
data: { revokedAt: new Date() },
});
return tx.user.create({
data: {
email: body.data.email,
name: body.data.name,
passwordHash,
role: body.data.role,
activeOrganizationId: req.organizationId!,
allServersAccess: body.data.allServersAccess,
organizationMemberships: {
create: {
organizationId: req.organizationId!,
isDefault: true,
},
},
serverAssignments:
body.data.allServersAccess || validServerIds.length === 0
? undefined
: {
create: validServerIds.map((serverId) => ({ serverId })),
},
},
select: { id: true, name: true, email: true, role: true },
});
});
await auditLog({
userId: req.userId,
organizationId: req.organizationId,
action: "USER_CREATE",
category: "AUTH",
level: "INFO",
message: `User "${user.email}" created with role ${user.role}`,
});
return reply.status(201).send({ success: true, data: user });
},
);
app.patch(
"/:id",
{ preHandler: [requireRole("SUPER_ADMIN")] },
async (req, reply) => {
const { id } = req.params as { id: string };
const body = UserUpdateSchema.safeParse(req.body);
if (!body.success) {
return reply
.status(400)
.send({ success: false, error: body.error.flatten() });
}
const targetUser = await prisma.user.findUnique({
where: { id },
select: {
id: true,
email: true,
name: true,
role: true,
allServersAccess: true,
organizationMemberships: { select: { organizationId: true } },
},
});
if (!targetUser) {
return reply
.status(404)
.send({ success: false, error: "User not found" });
}
if (targetUser.role === "SUPER_ADMIN") {
return reply.status(400).send({
success: false,
error: "Super Admin accounts are managed separately",
});
}
const membershipIds = targetUser.organizationMemberships.map(
(membership) => membership.organizationId,
);
if (
membershipIds.length !== 1 ||
membershipIds[0] !== req.organizationId
) {
return reply.status(409).send({
success: false,
error: "Organization user account membership is invalid",
});
}
const existingEmailOwner = await prisma.user.findUnique({
where: { email: body.data.email },
select: { id: true },
});
if (existingEmailOwner && existingEmailOwner.id !== id) {
return reply
.status(409)
.send({ success: false, error: "Email already in use" });
}
let validServerIds: string[] = [];
try {
validServerIds = await resolveServerIds(
body.data.serverIds,
req.organizationId!,
);
} catch (error) {
return reply.status(400).send({
success: false,
error:
error instanceof Error ? error.message : "Invalid server selection",
});
}
const passwordHash = body.data.password
? await bcrypt.hash(body.data.password, 12)
: undefined;
const updated = await prisma.$transaction(async (tx) => {
await tx.userServerAccess.deleteMany({ where: { userId: id } });
return tx.user.update({
where: { id },
data: {
name: body.data.name,
email: body.data.email,
role: body.data.role,
allServersAccess: body.data.allServersAccess,
...(passwordHash ? { passwordHash } : {}),
serverAssignments:
body.data.allServersAccess || validServerIds.length === 0
? undefined
: {
create: validServerIds.map((serverId) => ({ serverId })),
},
},
select: {
id: true,
name: true,
email: true,
role: true,
isActive: true,
allServersAccess: true,
lastLogin: true,
createdAt: true,
serverAssignments: {
where: { server: { organizationId: req.organizationId! } },
select: {
serverId: true,
server: { select: { id: true, name: true } },
},
orderBy: { createdAt: "asc" },
},
},
});
});
await auditLog({
userId: req.userId,
organizationId: req.organizationId,
action: "USER_UPDATE",
category: "AUTH",
level: "INFO",
message: `User "${updated.email}" updated`,
meta: {
targetUserId: id,
changed: {
name: targetUser.name !== updated.name,
email: targetUser.email !== updated.email,
role: targetUser.role !== updated.role,
password: Boolean(body.data.password),
serverAccess:
targetUser.allServersAccess !== updated.allServersAccess ||
validServerIds.length > 0,
},
},
});
return reply.send({ success: true, data: updated });
},
);
app.patch(
"/:id/server-access",
{ preHandler: [requireRole("SUPER_ADMIN")] },
async (req, reply) => {
const { id } = req.params as { id: string };
const body = ServerAccessSchema.safeParse(req.body);
if (!body.success)
return reply
.status(400)
.send({ success: false, error: body.error.flatten() });
const targetUser = await prisma.user.findUnique({
where: { id },
select: { id: true, role: true, email: true },
});
if (!targetUser) {
return reply
.status(404)
.send({ success: false, error: "User not found" });
}
if (targetUser.role === "SUPER_ADMIN" && req.userRole !== "SUPER_ADMIN") {
return reply.status(403).send({
success: false,
error: "Only Super Admin can modify another Super Admin",
});
}
const isOrganizationMember =
await prisma.organizationMembership.findUnique({
where: {
organizationId_userId: {
organizationId: req.organizationId!,
userId: id,
},
},
select: { userId: true },
});
if (!isOrganizationMember) {
return reply
.status(404)
.send({ success: false, error: "User not found" });
}
let validServerIds: string[] = [];
try {
validServerIds = await resolveServerIds(
body.data.serverIds,
req.organizationId!,
);
} catch (error) {
return reply.status(400).send({
success: false,
error:
error instanceof Error ? error.message : "Invalid server selection",
});
}
const updated = await prisma.$transaction(async (tx) => {
await tx.userServerAccess.deleteMany({ where: { userId: id } });
const allServersAccess =
targetUser.role === "SUPER_ADMIN" ? true : body.data.allServersAccess;
return tx.user.update({
where: { id },
data: {
allServersAccess,
serverAssignments:
allServersAccess || validServerIds.length === 0
? undefined
: {
create: validServerIds.map((serverId) => ({ serverId })),
},
},
select: {
id: true,
allServersAccess: true,
serverAssignments: {
select: {
serverId: true,
server: { select: { id: true, name: true } },
},
where: {
server: { organizationId: req.organizationId! },
},
orderBy: { createdAt: "asc" },
},
},
});
});
await auditLog({
userId: req.userId,
organizationId: req.organizationId,
action: "USER_SERVER_ACCESS_UPDATE",
category: "AUTH",
level: "INFO",
message: `Server access updated for user "${targetUser.email}"`,
meta: {
allServersAccess: updated.allServersAccess,
serverIds: updated.serverAssignments.map(
(assignment) => assignment.serverId,
),
},
});
return reply.send({ success: true, data: updated });
},
);
app.delete(
"/:id",
{ preHandler: [requireRole("SUPER_ADMIN")] },
async (req, reply) => {
const { id } = req.params as { id: string };
if (id === req.userId)
return reply
.status(400)
.send({ success: false, error: "Cannot delete your own account" });
const targetUser = await prisma.user.findUnique({
where: { id },
select: { id: true, email: true, role: true },
});
if (!targetUser) {
return reply
.status(404)
.send({ success: false, error: "User not found" });
}
const isOrganizationMember =
await prisma.organizationMembership.findUnique({
where: {
organizationId_userId: {
organizationId: req.organizationId!,
userId: id,
},
},
select: { userId: true },
});
if (!isOrganizationMember) {
return reply
.status(404)
.send({ success: false, error: "User not found" });
}
if (targetUser.role === "SUPER_ADMIN") {
return reply.status(400).send({
success: false,
error: "Super Admin accounts cannot be deleted",
});
}
await prisma.$transaction(async (tx) => {
await tx.auditLog.updateMany({
where: { userId: id },
data: { userId: null },
});
await tx.user.delete({ where: { id } });
});
await auditLog({
userId: req.userId,
organizationId: req.organizationId,
action: "USER_DELETE",
category: "AUTH",
level: "WARNING",
message: `User "${targetUser.email}" deleted`,
meta: { deletedUserId: id },
});
return reply.send({ success: true, message: "User deleted" });
},
);
app.patch(
"/:id/role",
{ preHandler: [requireRole("SUPER_ADMIN")] },
async (req, reply) => {
const { id } = req.params as { id: string };
const body = z.object({ role: ManageableRoleSchema }).safeParse(req.body);
if (!body.success)
return reply
.status(400)
.send({ success: false, error: body.error.flatten() });
const membership = await prisma.organizationMembership.findUnique({
where: {
organizationId_userId: {
organizationId: req.organizationId!,
userId: id,
},
},
select: { userId: true },
});
if (!membership) {
return reply
.status(404)
.send({ success: false, error: "User not found" });
}
const user = await prisma.user.update({
where: { id },
data: { role: body.data.role },
select: { id: true, name: true, role: true },
});
await auditLog({
userId: req.userId,
organizationId: req.organizationId,
action: "USER_ROLE_CHANGE",
category: "AUTH",
level: "INFO",
message: `User "${user.name}" role changed to ${body.data.role}`,
});
return reply.send({ success: true, data: user });
},
);
}