From 48d29d4a6bdf70de21c166a7a50b00878a1e9f70 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 29 Apr 2026 03:13:06 +0200 Subject: [PATCH] fix(security): assignment check on getDiscussion/addComment/getCOIStatus evaluation.getDiscussion and evaluation.addComment were juryProcedure that took projectId+roundId from input but never verified the caller had an Assignment for that project+round. A juror could read foreign deliberations and inject comments into them. evaluation.getCOIStatus was protectedProcedure with no ownership check, returning the full ConflictOfInterest record (including the free-text description that captures personal/financial relationships) for any assignmentId. Both now check that admins are allowed always and otherwise require assignment ownership. getCOIStatus loads the assignment to verify caller ownership before returning the COI record. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/routers/evaluation.ts | 57 ++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/src/server/routers/evaluation.ts b/src/server/routers/evaluation.ts index 2381f5a..7748ee8 100644 --- a/src/server/routers/evaluation.ts +++ b/src/server/routers/evaluation.ts @@ -670,11 +670,24 @@ export const evaluationRouter = router({ }), /** - * Get COI status for an assignment + * Get COI status for an assignment. + * Caller must own the assignment (or be admin). The COI description can + * contain confidential personal/financial relationships. */ getCOIStatus: protectedProcedure .input(z.object({ assignmentId: z.string() })) .query(async ({ ctx, input }) => { + const assignment = await ctx.prisma.assignment.findUnique({ + where: { id: input.assignmentId }, + select: { userId: true }, + }) + if (!assignment) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Assignment not found' }) + } + const isAdmin = userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN') + if (!isAdmin && assignment.userId !== ctx.user.id) { + throw new TRPCError({ code: 'FORBIDDEN' }) + } return ctx.prisma.conflictOfInterest.findUnique({ where: { assignmentId: input.assignmentId }, }) @@ -1085,7 +1098,8 @@ export const evaluationRouter = router({ }), /** - * Get or create a discussion for a project evaluation + * Get or create a discussion for a project evaluation. + * Caller must have an Assignment for this project+round (or be admin). */ getDiscussion: juryProcedure .input( @@ -1095,6 +1109,24 @@ export const evaluationRouter = router({ }) ) .query(async ({ ctx, input }) => { + // Authorization: admins always allowed; jurors must have an Assignment. + if (!userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN')) { + const assignment = await ctx.prisma.assignment.findFirst({ + where: { + userId: ctx.user.id, + projectId: input.projectId, + roundId: input.roundId, + }, + select: { id: true }, + }) + if (!assignment) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You are not assigned to this project', + }) + } + } + // Get or create discussion let discussion = await ctx.prisma.evaluationDiscussion.findUnique({ where: { @@ -1171,7 +1203,8 @@ export const evaluationRouter = router({ }), /** - * Add a comment to a project evaluation discussion + * Add a comment to a project evaluation discussion. + * Caller must have an Assignment for this project+round (or be admin). */ addComment: juryProcedure .input( @@ -1182,6 +1215,24 @@ export const evaluationRouter = router({ }) ) .mutation(async ({ ctx, input }) => { + // Authorization: admins always allowed; jurors must have an Assignment. + if (!userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN')) { + const assignment = await ctx.prisma.assignment.findFirst({ + where: { + userId: ctx.user.id, + projectId: input.projectId, + roundId: input.roundId, + }, + select: { id: true }, + }) + if (!assignment) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You are not assigned to this project', + }) + } + } + // Check max comment length from round settings const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId },