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