e01074926e
CI / Architecture Guardrails (pull_request) Successful in 6m31s
CI / Typecheck (pull_request) Failing after 6m9s
CI / Build (pull_request) Has been skipped
CI / E2E Tests (pull_request) Has been skipped
CI / Fresh-Linux Docker Deploy (pull_request) Has been skipped
CI / Assistant Split Regression (pull_request) Successful in 7m23s
CI / Lint (pull_request) Successful in 6m54s
CI / Unit Tests (pull_request) Successful in 9m28s
CI / Release Images (pull_request) Has been skipped
Adds a synchronous policy check that blocks (1) the curated >=12-char common-password list (rockyou top, predictable seasonal, admin defaults), (2) trivial patterns (single-char repeat, short-pattern repeat, keyboard or numeric sequences), and (3) passwords containing the user's email local-part or any name component. Wired into all five password-mutation sites: first-admin setup, admin createUser/setUserPassword, invite acceptance, and password-reset. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
647 lines
18 KiB
TypeScript
647 lines
18 KiB
TypeScript
import { Prisma } from "@capakraken/db";
|
|
import {
|
|
PASSWORD_MAX_LENGTH,
|
|
PASSWORD_MIN_LENGTH,
|
|
PASSWORD_POLICY_MESSAGE,
|
|
checkPasswordPolicy,
|
|
} from "@capakraken/shared";
|
|
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";
|
|
import { invalidateRoleDefaultsCache } from "../trpc.js";
|
|
|
|
export const CreateUserInputSchema = z.object({
|
|
email: z.string().email().max(320),
|
|
name: z.string().min(1).max(200),
|
|
systemRole: z.nativeEnum(SystemRole).default(SystemRole.USER),
|
|
password: z.string().min(PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE).max(PASSWORD_MAX_LENGTH),
|
|
});
|
|
|
|
export const SetUserPasswordInputSchema = z.object({
|
|
userId: z.string().max(64),
|
|
password: z.string().min(PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE).max(PASSWORD_MAX_LENGTH),
|
|
});
|
|
|
|
export const UpdateUserRoleInputSchema = z.object({
|
|
id: z.string().max(64),
|
|
systemRole: z.nativeEnum(SystemRole),
|
|
});
|
|
|
|
export const UpdateUserNameInputSchema = z.object({
|
|
id: z.string().max(64),
|
|
name: z.string().min(1, "Name is required").max(200),
|
|
});
|
|
|
|
export const LinkUserResourceInputSchema = z.object({
|
|
userId: z.string().max(64),
|
|
resourceId: z.string().max(64).nullable(),
|
|
});
|
|
|
|
export const SetUserPermissionsInputSchema = z.object({
|
|
userId: z.string().max(64),
|
|
overrides: z
|
|
.object({
|
|
granted: z.array(z.string().max(128)).max(500).optional(),
|
|
denied: z.array(z.string().max(128)).max(500).optional(),
|
|
chapterIds: z.array(z.string().max(64)).max(500).optional(),
|
|
})
|
|
.nullable(),
|
|
});
|
|
|
|
export const UserIdInputSchema = z.object({
|
|
userId: z.string().max(64),
|
|
});
|
|
|
|
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 policy = checkPasswordPolicy(input.password, { email: input.email, name: input.name });
|
|
if (!policy.ok) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: policy.reason });
|
|
}
|
|
|
|
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 policy = checkPasswordPolicy(input.password, { email: user.email, name: user.name });
|
|
if (!policy.ok) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: policy.reason });
|
|
}
|
|
|
|
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 },
|
|
});
|
|
|
|
// Force re-login: a role change (especially a demotion) must revoke
|
|
// currently-issued JWTs. Our JWT middleware checks the jti against
|
|
// ActiveSession on every tRPC call, so wiping these rows invalidates
|
|
// every outstanding session for this user on the next request.
|
|
if (before.systemRole !== updated.systemRole) {
|
|
await ctx.db.activeSession.deleteMany({ where: { userId: updated.id } });
|
|
// Also nuke the per-instance role-defaults cache (cross-node via pub/sub).
|
|
invalidateRoleDefaultsCache();
|
|
}
|
|
|
|
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 },
|
|
});
|
|
|
|
// Permission overrides can remove access — force affected sessions to
|
|
// re-authenticate so the new override set is applied immediately rather
|
|
// than waiting for the TTL. Cross-node cache invalidation via pub/sub.
|
|
await ctx.db.activeSession.deleteMany({ where: { userId: input.userId } });
|
|
invalidateRoleDefaultsCache();
|
|
|
|
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 },
|
|
});
|
|
|
|
// Reset may remove privileges that were `granted` via override — force
|
|
// re-login so the regression applies on the next request.
|
|
await ctx.db.activeSession.deleteMany({ where: { userId: input.userId } });
|
|
invalidateRoleDefaultsCache();
|
|
|
|
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 };
|
|
}
|