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) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-29 03:13:19 +02:00
parent 765bdf9f9e
commit a1c293028a

View File

@@ -177,14 +177,38 @@ export const projectRouter = router({
] ]
} }
// Jury members can only see assigned projects (but not if they also have admin roles) // Per-role visibility filters. Admin / Observer / Award master see all
if ( // (these roles are designed for cross-program oversight). Other roles
userHasRole(ctx.user, 'JURY_MEMBER') && // are scoped to projects they have a relationship with.
!userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN') const isAdmin = userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN')
) { const isObserverLevel = userHasRole(ctx.user, 'OBSERVER', 'AWARD_MASTER')
where.assignments = { if (!isAdmin && !isObserverLevel) {
...((where.assignments as Record<string, unknown>) || {}), const orClauses: Array<Record<string, unknown>> = []
some: { userId: ctx.user.id }, 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<Record<string, unknown>> | 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 // ProjectTag table may not exist yet
} }
// Check access for jury members (but not if they also have admin roles) // Per-role access check. Admin / Observer / Award master can read any
if (userHasRole(ctx.user, 'JURY_MEMBER') && !userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN')) { // project. Jury / Mentor / Applicant must have a relationship to it.
const assignment = await ctx.prisma.assignment.findFirst({ const isAdmin = userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN')
where: { const isObserverLevel = userHasRole(ctx.user, 'OBSERVER', 'AWARD_MASTER')
projectId: input.id, if (!isAdmin && !isObserverLevel) {
userId: ctx.user.id, const checks: Array<Promise<unknown>> = []
}, if (userHasRole(ctx.user, 'JURY_MEMBER')) {
}) checks.push(
ctx.prisma.assignment.findFirst({
if (!assignment) { 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({ throw new TRPCError({
code: 'FORBIDDEN', code: 'FORBIDDEN',
message: 'You are not assigned to this project', message: 'You do not have access to this project',
}) })
} }
} }