admin / Synapse-Sonar
publicAttack Surface Simulation
Synapse-Sonar / synapse-sonar / app / api / users / route.ts
3828 B · main
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 | import { NextRequest, NextResponse } from "next/server"; import crypto from "crypto"; import { z } from "zod"; import { requireTenantContext, HttpError } from "@/lib/auth"; import { assertCan } from "@/lib/rbac"; import { prisma } from "@/lib/prisma"; export const runtime = "nodejs"; /** GET — list users in the caller's tenant. */ export async function GET() { try { const ctx = await requireTenantContext(); assertCan(ctx.role, "user:read"); const users = await prisma.user.findMany({ where: { tenantId: ctx.tenantId }, select: { id: true, username: true, email: true, name: true, role: true, status: true, mfaEnabled: true, lastLoginAt: true, }, orderBy: { createdAt: "asc" }, }); return NextResponse.json({ users }); } catch (err) { return errorResponse(err); } } const InviteSchema = z.object({ username: z .string() .min(3) .max(40) .regex(/^[a-zA-Z0-9._-]+$/, "Letters, numbers, dot, underscore and hyphen only"), name: z.string().optional(), email: z.string().email().optional(), role: z.enum(["TENANT_ADMIN", "SECURITY_ANALYST", "READ_ONLY_VIEWER"]), }); /** POST — add a new user to the tenant (admin only). They set their password * via the returned invite link. */ export async function POST(req: NextRequest) { try { const ctx = await requireTenantContext(); assertCan(ctx.role, "user:manage"); const body = InviteSchema.parse(await req.json()); // Usernames are global; reject duplicates with a clear message. const clash = await prisma.user.findUnique({ where: { username: body.username } }); if (clash) { return NextResponse.json( { error: "That username is already taken." }, { status: 409 } ); } const inviteToken = crypto.randomBytes(24).toString("hex"); const user = await prisma.user.create({ data: { tenantId: ctx.tenantId, // strictly scoped — cannot invite into other tenants username: body.username, email: body.email, name: body.name, role: body.role, status: "INVITED", inviteToken, }, select: { id: true, username: true, role: true, status: true }, }); return NextResponse.json({ user, inviteToken }); } catch (err) { return errorResponse(err); } } const UpdateSchema = z.object({ userId: z.string(), role: z.enum(["TENANT_ADMIN", "SECURITY_ANALYST", "READ_ONLY_VIEWER"]).optional(), status: z.enum(["ACTIVE", "DISABLED"]).optional(), }); /** PATCH — change a user's role or status (admin only), tenant-scoped. */ export async function PATCH(req: NextRequest) { try { const ctx = await requireTenantContext(); assertCan(ctx.role, "user:manage"); const body = UpdateSchema.parse(await req.json()); // updateMany with tenantId guard guarantees no cross-tenant write. const result = await prisma.user.updateMany({ where: { id: body.userId, tenantId: ctx.tenantId }, data: { ...(body.role ? { role: body.role } : {}), ...(body.status ? { status: body.status } : {}), }, }); if (result.count === 0) { return NextResponse.json({ error: "User not found" }, { status: 404 }); } return NextResponse.json({ ok: true }); } catch (err) { return errorResponse(err); } } function errorResponse(err: unknown) { if (err instanceof HttpError) { return NextResponse.json({ error: err.message }, { status: err.status }); } if (err instanceof z.ZodError) { return NextResponse.json({ error: "Invalid input", details: err.issues }, { status: 422 }); } const status = (err as { status?: number }).status ?? 500; return NextResponse.json({ error: (err as Error).message }, { status }); } |