Files
CapaKraken/packages/api/src/router/user-procedure-support.ts
T
Hartmut 97cfd0ed90 fix(security): raise password minimum to 12 chars, hide raw error messages, add audit script
- Password validation: min(8) → min(12) across auth.ts, user-procedure-support.ts,
  and invite.ts (aligns with NIST SP 800-63B modern recommendations)
- Error boundary: stop rendering raw error.message which could leak internal
  details; always show the generic fallback text
- Add `pnpm audit` script (--audit-level=high) for dependency vulnerability scanning

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 21:48:51 +02:00

597 lines
16 KiB
TypeScript

import { Prisma } from "@capakraken/db";
import { PermissionOverrides, SystemRole, resolvePermissions } from "@capakraken/shared/types";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { makeAuditLogger } from "../lib/audit-helpers.js";
import type { TRPCContext } from "../trpc.js";
export const CreateUserInputSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
systemRole: z.nativeEnum(SystemRole).default(SystemRole.USER),
password: z.string().min(12),
});
export const SetUserPasswordInputSchema = z.object({
userId: z.string(),
password: z.string().min(12, "Password must be at least 12 characters"),
});
export const UpdateUserRoleInputSchema = z.object({
id: z.string(),
systemRole: z.nativeEnum(SystemRole),
});
export const UpdateUserNameInputSchema = z.object({
id: z.string(),
name: z.string().min(1, "Name is required").max(200),
});
export const LinkUserResourceInputSchema = z.object({
userId: z.string(),
resourceId: z.string().nullable(),
});
export const SetUserPermissionsInputSchema = z.object({
userId: z.string(),
overrides: z
.object({
granted: z.array(z.string()).optional(),
denied: z.array(z.string()).optional(),
chapterIds: z.array(z.string()).optional(),
})
.nullable(),
});
export const UserIdInputSchema = z.object({
userId: z.string(),
});
type UserReadContext = Pick<TRPCContext, "db" | "dbUser">;
type UserMutationContext = UserReadContext;
export async function listAssignableUsers(ctx: UserReadContext) {
return ctx.db.user.findMany({
select: {
id: true,
name: true,
email: true,
},
orderBy: { name: "asc" },
});
}
export async function listUsers(ctx: UserReadContext) {
return ctx.db.user.findMany({
select: {
id: true,
name: true,
email: true,
systemRole: true,
createdAt: true,
lastLoginAt: true,
lastActiveAt: true,
permissionOverrides: true,
totpEnabled: true,
isActive: true,
},
orderBy: { name: "asc" },
});
}
export async function countActiveUsers(ctx: UserReadContext) {
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000);
const count = await ctx.db.user.count({
where: { lastActiveAt: { gte: fiveMinAgo } },
});
return { count };
}
export async function getCurrentUserProfile(ctx: UserReadContext) {
return findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: ctx.dbUser!.id },
select: {
id: true,
name: true,
email: true,
systemRole: true,
permissionOverrides: true,
createdAt: true,
},
}),
"User",
);
}
export async function createUser(
ctx: UserMutationContext,
input: z.infer<typeof CreateUserInputSchema>,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const existing = await ctx.db.user.findUnique({ where: { email: input.email } });
if (existing) {
throw new TRPCError({ code: "CONFLICT", message: "User with this email already exists" });
}
const { hash } = await import("@node-rs/argon2");
const passwordHash = await hash(input.password);
const user = await ctx.db.user.create({
data: {
email: input.email,
name: input.name,
systemRole: input.systemRole,
passwordHash,
},
select: { id: true, name: true, email: true, systemRole: true },
});
const matchingResource = await ctx.db.resource.findFirst({
where: { email: input.email, userId: null },
select: { id: true },
});
if (matchingResource) {
await ctx.db.resource.update({
where: { id: matchingResource.id },
data: { userId: user.id },
});
}
audit({
entityType: "User",
entityId: user.id,
entityName: `${user.name} (${user.email})`,
action: "CREATE",
after: user as unknown as Record<string, unknown>,
});
return user;
}
export async function setUserPassword(
ctx: UserMutationContext,
input: z.infer<typeof SetUserPasswordInputSchema>,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
select: { id: true, name: true, email: true },
}),
"User",
);
const { hash } = await import("@node-rs/argon2");
const passwordHash = await hash(input.password);
await ctx.db.user.update({
where: { id: input.userId },
data: { passwordHash },
});
// Invalidate all active sessions so any compromised session cannot be
// reused after the password is changed (CWE-613).
await ctx.db.activeSession.deleteMany({ where: { userId: input.userId } });
audit({
entityType: "User",
entityId: user.id,
entityName: `${user.name} (${user.email})`,
action: "UPDATE",
summary: "Password reset by admin",
});
return { success: true };
}
export async function updateUserRole(
ctx: UserMutationContext,
input: z.infer<typeof UpdateUserRoleInputSchema>,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const before = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.id },
select: { id: true, name: true, email: true, systemRole: true },
}),
"User",
);
const updated = await ctx.db.user.update({
where: { id: input.id },
data: { systemRole: input.systemRole },
select: { id: true, name: true, email: true, systemRole: true },
});
audit({
entityType: "User",
entityId: updated.id,
entityName: `${updated.name} (${updated.email})`,
action: "UPDATE",
before: before as unknown as Record<string, unknown>,
after: updated as unknown as Record<string, unknown>,
summary: `Changed role from ${before.systemRole} to ${updated.systemRole}`,
});
return updated;
}
export async function updateUserName(
ctx: UserMutationContext,
input: z.infer<typeof UpdateUserNameInputSchema>,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const before = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.id },
select: { id: true, name: true, email: true },
}),
"User",
);
const updated = await ctx.db.user.update({
where: { id: input.id },
data: { name: input.name },
select: { id: true, name: true, email: true },
});
audit({
entityType: "User",
entityId: updated.id,
entityName: `${updated.name} (${updated.email})`,
action: "UPDATE",
before: before as unknown as Record<string, unknown>,
after: updated as unknown as Record<string, unknown>,
summary: `Changed name from "${before.name}" to "${updated.name}"`,
});
return updated;
}
export async function linkUserResource(
ctx: UserMutationContext,
input: z.infer<typeof LinkUserResourceInputSchema>,
) {
await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
select: { id: true },
}),
"User",
);
if (input.resourceId) {
const resource = await findUniqueOrThrow(
ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: { id: true, userId: true },
}),
"Resource",
);
if (resource.userId && resource.userId !== input.userId) {
throw new TRPCError({
code: "CONFLICT",
message: "Resource is already linked to another user",
});
}
await ctx.db.resource.updateMany({
where: {
userId: input.userId,
NOT: { id: input.resourceId },
},
data: { userId: null },
});
const linkResult = await ctx.db.resource.updateMany({
where: {
id: input.resourceId,
OR: [
{ userId: null },
{ userId: input.userId },
],
},
data: { userId: input.userId },
});
if (linkResult.count !== 1) {
const [userStillExists, resourceStillExists] = await Promise.all([
ctx.db.user.findUnique({
where: { id: input.userId },
select: { id: true },
}),
ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: { id: true, userId: true },
}),
]);
if (!userStillExists) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
if (!resourceStillExists) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Resource not found",
});
}
if (resourceStillExists.userId && resourceStillExists.userId !== input.userId) {
throw new TRPCError({
code: "CONFLICT",
message: "Resource is already linked to another user",
});
}
throw new TRPCError({
code: "CONFLICT",
message: "Resource link changed during update. Please retry.",
});
}
} else {
await ctx.db.resource.updateMany({
where: { userId: input.userId },
data: { userId: null },
});
}
return { success: true };
}
export async function autoLinkUsersByEmail(ctx: UserMutationContext) {
const unlinkedUsers = await ctx.db.user.findMany({
where: { resource: null },
select: { id: true, email: true },
});
let linked = 0;
for (const user of unlinkedUsers) {
const resource = await ctx.db.resource.findFirst({
where: { email: user.email, userId: null },
select: { id: true },
});
if (resource) {
await ctx.db.resource.update({
where: { id: resource.id },
data: { userId: user.id },
});
linked++;
}
}
return { linked, checked: unlinkedUsers.length };
}
export async function setUserPermissions(
ctx: UserMutationContext,
input: z.infer<typeof SetUserPermissionsInputSchema>,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const before = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
select: { id: true, name: true, email: true, permissionOverrides: true },
}),
"User",
);
const user = await ctx.db.user.update({
where: { id: input.userId },
data: { permissionOverrides: input.overrides ?? Prisma.DbNull },
select: { id: true, name: true, email: true, permissionOverrides: true },
});
audit({
entityType: "User",
entityId: input.userId,
entityName: `${before.name} (${before.email})`,
action: "UPDATE",
before: { permissionOverrides: before.permissionOverrides } as unknown as Record<string, unknown>,
after: { permissionOverrides: input.overrides } as unknown as Record<string, unknown>,
summary: input.overrides
? `Set permission overrides (granted: ${input.overrides.granted?.length ?? 0}, denied: ${input.overrides.denied?.length ?? 0})`
: "Cleared permission overrides",
});
return user;
}
export async function resetUserPermissions(
ctx: UserMutationContext,
input: z.infer<typeof UserIdInputSchema>,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const before = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
select: { id: true, name: true, email: true, permissionOverrides: true },
}),
"User",
);
const updated = await ctx.db.user.update({
where: { id: input.userId },
data: { permissionOverrides: Prisma.DbNull },
select: { id: true, name: true, email: true, permissionOverrides: true },
});
audit({
entityType: "User",
entityId: input.userId,
entityName: `${before.name} (${before.email})`,
action: "UPDATE",
before: { permissionOverrides: before.permissionOverrides } as unknown as Record<string, unknown>,
after: { permissionOverrides: null } as unknown as Record<string, unknown>,
summary: "Reset permission overrides to role defaults",
});
return updated;
}
export async function getEffectiveUserPermissions(
ctx: UserMutationContext,
input: z.infer<typeof UserIdInputSchema>,
) {
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
select: { systemRole: true, permissionOverrides: true },
}),
"User",
);
const permissions = resolvePermissions(
user.systemRole as SystemRole,
user.permissionOverrides as PermissionOverrides | null,
);
return {
systemRole: user.systemRole,
effectivePermissions: Array.from(permissions),
overrides: user.permissionOverrides as PermissionOverrides | null,
};
}
export async function deactivateUser(
ctx: UserMutationContext,
input: z.infer<typeof UserIdInputSchema>,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
if (ctx.dbUser!.id === input.userId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "You cannot deactivate your own account." });
}
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
select: { id: true, name: true, email: true, isActive: true },
}),
"User",
);
if (!user.isActive) {
throw new TRPCError({ code: "BAD_REQUEST", message: "User is already inactive." });
}
await ctx.db.user.update({ where: { id: input.userId }, data: { isActive: false, deletedAt: new Date() } });
// Invalidate all existing sessions so the user is logged out immediately
await ctx.db.activeSession.deleteMany({ where: { userId: input.userId } });
audit({
entityType: "User",
entityId: user.id,
entityName: `${user.name} (${user.email})`,
action: "UPDATE",
summary: "User deactivated",
});
return { success: true };
}
export async function reactivateUser(
ctx: UserMutationContext,
input: z.infer<typeof UserIdInputSchema>,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
select: { id: true, name: true, email: true, isActive: true },
}),
"User",
);
if (user.isActive) {
throw new TRPCError({ code: "BAD_REQUEST", message: "User is already active." });
}
await ctx.db.user.update({ where: { id: input.userId }, data: { isActive: true, deletedAt: null } });
audit({
entityType: "User",
entityId: user.id,
entityName: `${user.name} (${user.email})`,
action: "UPDATE",
summary: "User reactivated",
});
return { success: true };
}
export async function deleteUser(
ctx: UserMutationContext,
input: z.infer<typeof UserIdInputSchema>,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
if (ctx.dbUser!.id === input.userId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "You cannot delete your own account." });
}
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
select: { id: true, name: true, email: true },
}),
"User",
);
// These tables have required (non-nullable) FKs to User — must be removed first
await ctx.db.vacation.deleteMany({ where: { requestedById: input.userId } });
await ctx.db.notificationBroadcast.deleteMany({ where: { senderId: input.userId } });
await ctx.db.inviteToken.deleteMany({ where: { createdById: input.userId } });
// Unlink resource (nullable FK — belt-and-suspenders)
await ctx.db.resource.updateMany({ where: { userId: input.userId }, data: { userId: null } });
audit({
entityType: "User",
entityId: user.id,
entityName: `${user.name} (${user.email})`,
action: "DELETE",
summary: "User account permanently deleted",
});
// Delete user — Prisma cascade covers: Account, Session, ActiveSession,
// Notification, ReportTemplate, AssistantApproval, Comment.
// Nullable FKs (AuditLog.userId, etc.) become NULL via Prisma SET NULL default.
await ctx.db.user.delete({ where: { id: input.userId } });
return { success: true };
}
export async function disableTotp(
ctx: UserMutationContext,
input: z.infer<typeof UserIdInputSchema>,
) {
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: input.userId },
select: { id: true, name: true, email: true, totpEnabled: true },
}),
"User",
);
await ctx.db.user.update({
where: { id: input.userId },
data: { totpEnabled: false, totpSecret: null },
});
audit({
entityType: "User",
entityId: user.id,
entityName: `${user.name} (${user.email})`,
action: "UPDATE",
summary: "Disabled TOTP MFA (admin override)",
});
return { disabled: true };
}