fix(security): require jury membership for liveVoting.vote

Prevents non-jury authenticated users from casting votes that get
counted in the jury aggregate. Admins are still allowed; everyone else
must be a JuryGroupMember of the round's jury group. Also explicitly
sets isAudienceVote=false on the upsert so audience votes can't be
laundered into jury votes via this path. Audience voting continues to
flow through the existing castAudienceVote publicProcedure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-29 03:12:54 +02:00
parent e0f6b7e741
commit 35f46c3e34

View File

@@ -432,6 +432,11 @@ export const liveVotingRouter = router({
/**
* Submit a vote (supports both simple and criteria modes)
*
* SECURITY: This endpoint records JURY votes only (isAudienceVote=false).
* Audience voters use `castAudienceVote` (publicProcedure with separate
* AudienceVoter records). Caller must be either an admin or a member of
* the round's jury group; otherwise FORBIDDEN.
*/
vote: protectedProcedure
.input(
@@ -448,8 +453,42 @@ export const liveVotingRouter = router({
// Verify session is in progress
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
include: {
round: {
select: { id: true, juryGroupId: true },
},
},
})
// Authorization: admins always allowed; otherwise the caller must be a
// JuryGroupMember of the round's jury group. JURY_MEMBER role alone is
// not enough — we verify the actual membership row.
const isAdmin =
ctx.user.role === 'SUPER_ADMIN' || ctx.user.role === 'PROGRAM_ADMIN'
if (!isAdmin) {
const juryGroupId = session.round?.juryGroupId
if (!juryGroupId) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'No jury group is associated with this voting session',
})
}
const membership = await ctx.prisma.juryGroupMember.findUnique({
where: {
juryGroupId_userId: {
juryGroupId,
userId: ctx.user.id,
},
},
})
if (!membership) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not a member of this jury group',
})
}
}
if (session.status !== 'IN_PROGRESS') {
throw new TRPCError({
code: 'BAD_REQUEST',
@@ -512,7 +551,8 @@ export const liveVotingRouter = router({
criterionScoresJson = input.criterionScores
}
// Upsert vote (allow vote change during window)
// Upsert vote (allow vote change during window). Explicitly recorded
// as a jury vote — audience votes go through castAudienceVote.
const vote = await ctx.prisma.liveVote.upsert({
where: {
sessionId_projectId_userId: {
@@ -526,10 +566,12 @@ export const liveVotingRouter = router({
projectId: input.projectId,
userId: ctx.user.id,
score: finalScore,
isAudienceVote: false,
criterionScoresJson: criterionScoresJson ?? undefined,
},
update: {
score: finalScore,
isAudienceVote: false,
criterionScoresJson: criterionScoresJson ?? undefined,
votedAt: new Date(),
},