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)
|
* 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
|
vote: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -448,8 +453,42 @@ export const liveVotingRouter = router({
|
|||||||
// Verify session is in progress
|
// Verify session is in progress
|
||||||
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
||||||
where: { id: input.sessionId },
|
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') {
|
if (session.status !== 'IN_PROGRESS') {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
@@ -512,7 +551,8 @@ export const liveVotingRouter = router({
|
|||||||
criterionScoresJson = input.criterionScores
|
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({
|
const vote = await ctx.prisma.liveVote.upsert({
|
||||||
where: {
|
where: {
|
||||||
sessionId_projectId_userId: {
|
sessionId_projectId_userId: {
|
||||||
@@ -526,10 +566,12 @@ export const liveVotingRouter = router({
|
|||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
score: finalScore,
|
score: finalScore,
|
||||||
|
isAudienceVote: false,
|
||||||
criterionScoresJson: criterionScoresJson ?? undefined,
|
criterionScoresJson: criterionScoresJson ?? undefined,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
score: finalScore,
|
score: finalScore,
|
||||||
|
isAudienceVote: false,
|
||||||
criterionScoresJson: criterionScoresJson ?? undefined,
|
criterionScoresJson: criterionScoresJson ?? undefined,
|
||||||
votedAt: new Date(),
|
votedAt: new Date(),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user