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:
@@ -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(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user