From a1c293028a91d330f20f796f15cb1c4d07ea77d9 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 29 Apr 2026 03:13:19 +0200 Subject: [PATCH] fix(security): per-role visibility on project.list and project.get project.list previously gated only JURY_MEMBER to assigned projects; APPLICANT, MENTOR, OBSERVER, AUDIENCE, AWARD_MASTER fell through with full access to every project across every program (team-member PII, files, mentor identities). project.get had the same flaw. Now: SUPER_ADMIN/PROGRAM_ADMIN see all (existing); OBSERVER/AWARD_MASTER see all (these roles exist for cross-program oversight); JURY_MEMBER sees only their assignments; MENTOR sees only their mentorAssignments; APPLICANT sees only their team's projects; AUDIENCE sees nothing. For users holding multiple roles, the access check uses an OR over the applicable relationships (e.g. a mentor who is also an applicant sees both their mentor projects and their team projects). Existing admin/jury/mentor UIs continue to work because their access paths are still satisfied. Audience users were not expected to use project.list in the first place; they now correctly receive an empty list rather than the full database. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/routers/project.ts | 85 +++++++++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 19 deletions(-) diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts index 7e48465..b725c36 100644 --- a/src/server/routers/project.ts +++ b/src/server/routers/project.ts @@ -177,14 +177,38 @@ export const projectRouter = router({ ] } - // Jury members can only see assigned projects (but not if they also have admin roles) - if ( - userHasRole(ctx.user, 'JURY_MEMBER') && - !userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN') - ) { - where.assignments = { - ...((where.assignments as Record) || {}), - some: { userId: ctx.user.id }, + // Per-role visibility filters. Admin / Observer / Award master see all + // (these roles are designed for cross-program oversight). Other roles + // are scoped to projects they have a relationship with. + const isAdmin = userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN') + const isObserverLevel = userHasRole(ctx.user, 'OBSERVER', 'AWARD_MASTER') + if (!isAdmin && !isObserverLevel) { + const orClauses: Array> = [] + if (userHasRole(ctx.user, 'JURY_MEMBER')) { + orClauses.push({ assignments: { some: { userId: ctx.user.id } } }) + } + if (userHasRole(ctx.user, 'MENTOR')) { + orClauses.push({ mentorAssignment: { mentorId: ctx.user.id } }) + } + if (userHasRole(ctx.user, 'APPLICANT')) { + orClauses.push({ teamMembers: { some: { userId: ctx.user.id } } }) + orClauses.push({ submittedByUserId: ctx.user.id }) + } + if (orClauses.length === 0) { + // No relationship-based access (e.g. AUDIENCE) — return nothing. + where.id = { in: [] } + } else if (orClauses.length === 1) { + Object.assign(where, orClauses[0]) + } else { + // Multiple roles — combine with the existing search OR if any. + // Compose with existing `where.OR` (from `search`) by AND-ing. + const existingOr = where.OR as Array> | undefined + if (existingOr) { + where.AND = [{ OR: existingOr }, { OR: orClauses }] + delete where.OR + } else { + where.OR = orClauses + } } } @@ -510,19 +534,42 @@ export const projectRouter = router({ // ProjectTag table may not exist yet } - // Check access for jury members (but not if they also have admin roles) - if (userHasRole(ctx.user, 'JURY_MEMBER') && !userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN')) { - const assignment = await ctx.prisma.assignment.findFirst({ - where: { - projectId: input.id, - userId: ctx.user.id, - }, - }) - - if (!assignment) { + // Per-role access check. Admin / Observer / Award master can read any + // project. Jury / Mentor / Applicant must have a relationship to it. + const isAdmin = userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN') + const isObserverLevel = userHasRole(ctx.user, 'OBSERVER', 'AWARD_MASTER') + if (!isAdmin && !isObserverLevel) { + const checks: Array> = [] + if (userHasRole(ctx.user, 'JURY_MEMBER')) { + checks.push( + ctx.prisma.assignment.findFirst({ + where: { projectId: input.id, userId: ctx.user.id }, + select: { id: true }, + }), + ) + } + if (userHasRole(ctx.user, 'MENTOR')) { + checks.push( + ctx.prisma.mentorAssignment.findFirst({ + where: { projectId: input.id, mentorId: ctx.user.id }, + select: { id: true }, + }), + ) + } + if (userHasRole(ctx.user, 'APPLICANT')) { + checks.push( + ctx.prisma.teamMember.findFirst({ + where: { projectId: input.id, userId: ctx.user.id }, + select: { id: true }, + }), + ) + } + const results = await Promise.all(checks) + const hasAccess = results.some((r) => r !== null && r !== undefined) + if (!hasAccess) { throw new TRPCError({ code: 'FORBIDDEN', - message: 'You are not assigned to this project', + message: 'You do not have access to this project', }) } }