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) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-29 03:13:06 +02:00
parent 90dcb47c25
commit 48d29d4a6b

View File

@@ -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 getCOIStatus: protectedProcedure
.input(z.object({ assignmentId: z.string() })) .input(z.object({ assignmentId: z.string() }))
.query(async ({ ctx, input }) => { .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({ return ctx.prisma.conflictOfInterest.findUnique({
where: { assignmentId: input.assignmentId }, 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 getDiscussion: juryProcedure
.input( .input(
@@ -1095,6 +1109,24 @@ export const evaluationRouter = router({
}) })
) )
.query(async ({ ctx, input }) => { .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 // Get or create discussion
let discussion = await ctx.prisma.evaluationDiscussion.findUnique({ let discussion = await ctx.prisma.evaluationDiscussion.findUnique({
where: { 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 addComment: juryProcedure
.input( .input(
@@ -1182,6 +1215,24 @@ export const evaluationRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .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 // Check max comment length from round settings
const round = await ctx.prisma.round.findUniqueOrThrow({ const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId }, where: { id: input.roundId },