From d504eadb7fc394a54df3ab057c8594451bb49fd9 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Thu, 13 Aug 2026 14:10:52 +1000 Subject: [PATCH] Fix resource list filters for opportunities --- ReadMe.md | 29 ++++ docs/swagger.yaml | 20 ++- src/services/ResourceService.ts | 55 +++++--- test/unit/getResources.test.js | 229 ++++++++++++++++++++++++++++++++ 4 files changed, 309 insertions(+), 24 deletions(-) diff --git a/ReadMe.md b/ReadMe.md index 3c183c3..1af44d6 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -48,6 +48,35 @@ Useful commands are: The API listens on port 3000 by default. Swagger UI is available at `/v6/resources/api-docs`, and the source definition is `docs/swagger.yaml`. +## Resource list filters and visibility + +`GET /v6/resources` first establishes the caller's authorized resource set and +then intersects any supplied `memberId`, `memberHandle`, and exact `roleId` +filters with that set. These filters are applied before `X-Total`, ordering, and +pagination are calculated. + +- Anonymous challenge reads expose only assignments with the configured + Submitter role. +- Ordinary authenticated members can see challenge Submitters plus their own + assignments for other roles. They may restrict by member only when the + requested ID or handle resolves to their own account; cross-member requests + return `403`. +- Administrators, machine callers, resource managers, members assigned the + challenge's Copilot resource role, and members with another challenge-wide + full-access resource retain their existing visibility, with the same exact + filters applied to their result candidates. + +For a paginated registrant list, send the challenge UUID and canonical +Submitter role UUID together, for example: + +```text +GET /v6/resources?challengeId=&roleId=&page=1&perPage=20 +``` + +For a signed-in member's registration check, also provide that caller's own +member ID. The response pagination headers then describe only that exact +challenge/member/role combination. + ## Configuration compatibility The TypeScript conversion retains the existing environment-variable names and defaults. No deployment parameter rename is required. diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 67a8a12..9cbeafd 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -60,6 +60,18 @@ paths: description: | Retrieve resources assigned to a challenge with optional filtering and sorting. + Results are filtered in two stages: the API first determines the + caller's visible resource assignments, then intersects the optional + `memberId`, `memberHandle`, and exact `roleId` filters with that set. + Filtering happens before the total, sort order, and page are computed. + + Anonymous callers can list only Submitter assignments for a challenge. + Ordinary authenticated members can list challenge Submitters and their + own other assignments. An ordinary member may use `memberId` or + `memberHandle` only for their own account; requesting another member is + rejected with `403`. Administrators, M2M callers, and users with + challenge-wide resource access retain their broader visibility. + ### Authentication - JWT roles: `administrator`, `copilot`, `Connect Manager`, `Topcoder User` - M2M scopes: `read:resources`, `all:resources` @@ -79,15 +91,15 @@ paths: required: true - name: memberId type: integer - description: The member id + description: Exact member id. Ordinary authenticated members may request only their own id. in: query - name: memberHandle type: string - description: The member handle + description: Member handle resolved to an exact member filter. Ordinary authenticated members may request only their own handle. in: query - name: roleId type: string - description: role id to filter on + description: Exact resource-role UUID intersected with the caller's visible assignments before pagination. format: UUID in: query required: false @@ -103,7 +115,7 @@ paths: required: false responses: '200': - description: OK - the request was successful + description: OK - the request was successful. Pagination metadata is returned in X-Page, X-Per-Page, X-Total, and X-Total-Pages headers. schema: type: array items: diff --git a/src/services/ResourceService.ts b/src/services/ResourceService.ts index 69d05b0..4ac873d 100644 --- a/src/services/ResourceService.ts +++ b/src/services/ResourceService.ts @@ -121,17 +121,29 @@ async function checkAccess (currentUser, currentUserResources) { } /** - * Get resources with given challenge id. + * Get resources that match the requested filters within the caller's visible set. + * + * Anonymous callers can see only challenge submitters. Ordinary authenticated + * callers can see challenge submitters plus their own non-submitter roles; + * requesting another member remains forbidden. Administrators, machine users, + * and callers with challenge-wide access retain their existing visibility. + * Every supplied role or member filter is intersected with that visible set + * before count, ordering, and pagination are calculated. + * * @param {Object} currentUser the current user * @param {String} challengeId the challenge id - * @param {String} roleId the role id to filter on - * @param {String} memberId the member id - * @param {String} memberHandle the member handle + * @param {String} roleId the exact resource role id to filter on + * @param {String} memberId the exact member id to filter on + * @param {String} memberHandle the member handle to resolve and filter on * @param {Number} page The page number * @param {Number} perPage The number of items to list per page - * @param {Number} sortBy The field that becomes the sorting criteria - * @param {Number} sortOrder The sort order - * @returns {Object} the search result + * @param {String} sortBy The field that becomes the sorting criteria + * @param {String} sortOrder The sort order + * @returns {Promise} the filtered page and its pagination metadata + * @throws {BadRequestError} when no supported lookup key is supplied + * @throws {ForbiddenError} when an ordinary caller requests another member or + * the caller cannot access the requested challenge + * @throws {NotFoundError} when the requested challenge does not exist */ async function getResources (currentUser, challengeId, roleId, memberId, memberHandle, page, perPage, sortBy, sortOrder) { page = page || 1 @@ -229,15 +241,18 @@ async function getResources (currentUser, challengeId, roleId, memberId, memberH ] } ] }) - } else { - if (roleId) { - prismaFilter.where.AND.push({ roleId }) - } - if (resolvedMemberId) { - prismaFilter.where.AND.push({ memberId: resolvedMemberId }) - } else if (memberHandle) { - prismaFilter.where.AND.push({ memberId: '__no_match__' }) - } + } + + // Query filters always narrow the caller's authorized candidate set. Keeping + // these predicates outside the access branches ensures count and pagination + // describe the exact role/member result for every caller type. + if (roleId) { + prismaFilter.where.AND.push({ roleId }) + } + if (resolvedMemberId) { + prismaFilter.where.AND.push({ memberId: resolvedMemberId }) + } else if (memberHandle) { + prismaFilter.where.AND.push({ memberId: '__no_match__' }) } const orderBy = [{ [sortBy]: sortOrder }] @@ -312,10 +327,10 @@ async function getResources (currentUser, challengeId, roleId, memberId, memberH getResources.schema = { currentUser: Joi.any(), - challengeId: Joi.optionalId(), - roleId: Joi.optionalId(), - memberId: Joi.string(), - memberHandle: Joi.string(), + challengeId: Joi.optionalId().description('Challenge UUID used to scope visible resources'), + roleId: Joi.optionalId().description('Exact resource-role UUID used to narrow visible resources'), + memberId: Joi.string().description('Exact member ID used to narrow visible resources'), + memberHandle: Joi.string().description('Member handle resolved to an exact member filter'), page: Joi.page().default(1), perPage: Joi.perPage().default(config.DEFAULT_PAGE_SIZE), sortBy: Joi.string().valid('memberHandle', 'created').default('created'), diff --git a/test/unit/getResources.test.js b/test/unit/getResources.test.js index e890024..79ed4e0 100644 --- a/test/unit/getResources.test.js +++ b/test/unit/getResources.test.js @@ -3,7 +3,10 @@ */ const should = require('should') +const config = require('config') +const { v4: uuid } = require('uuid') const service = require('../../src/services/ResourceService') +const controller = require('../../src/controllers/ResourceController') const helper = require('../../src/common/helper') const prisma = require('../../src/common/prisma').getClient() const { user } = require('../common/testData') @@ -222,6 +225,232 @@ module.exports = describe('Get resources', () => { should.exist(result.data[0].roleName) }) + it('intersects an ordinary caller own member and exact role filters', async () => { + const ownSubmitter = await service.getResources( + user.diazz, + challengeId, + submitterRoleId, + user.diazz.userId + ) + should.equal(ownSubmitter.total, 1) + should.equal(ownSubmitter.data.length, 1) + should.equal(ownSubmitter.data[0].memberId, user.diazz.userId) + should.equal(ownSubmitter.data[0].roleId, submitterRoleId) + + const ownReviewer = await service.getResources( + user.diazz, + challengeId, + reviewerRoleId, + user.diazz.userId + ) + should.equal(ownReviewer.total, 0) + should.equal(ownReviewer.data.length, 0) + }) + + it('resolves an ordinary caller own handle before applying the exact role', async () => { + const result = await service.getResources( + user.diazz, + challengeId, + reviewerRoleId, + undefined, + user.diazz.handle + ) + + should.equal(result.total, 0) + should.equal(result.data.length, 0) + }) + + it('rejects an ordinary caller cross-member filter without expanding visibility', async () => { + try { + await service.getResources( + user.diazz, + challengeId, + submitterRoleId, + user.phead.userId + ) + throw new Error('should not throw error here') + } catch (err) { + should.equal(err.name, 'ForbiddenError') + err.message.should.containEql('You are not allowed to perform this operation!') + } + + try { + await service.getResources( + user.diazz, + challengeId, + submitterRoleId, + undefined, + user.phead.handle + ) + throw new Error('should not throw error here') + } catch (err) { + should.equal(err.name, 'ForbiddenError') + err.message.should.containEql('You are not allowed to perform this operation!') + } + }) + + it('applies a global Submitter role filter before count, order, and pagination', async () => { + const resourceIds = [uuid(), uuid(), uuid()] + await prisma.resource.createMany({ + data: [ + { + id: resourceIds[0], + challengeId, + memberId: user.diazz.userId, + memberHandle: user.diazz.handle, + roleId: config.SUBMITTER_RESOURCE_ROLE_ID, + createdAt: new Date('2030-01-01T00:00:00.000Z'), + createdBy: 'testdata' + }, + { + id: resourceIds[1], + challengeId, + memberId: '990001', + memberHandle: 'pagination-one', + roleId: config.SUBMITTER_RESOURCE_ROLE_ID, + createdAt: new Date('2030-01-02T00:00:00.000Z'), + createdBy: 'testdata' + }, + { + id: resourceIds[2], + challengeId, + memberId: '990002', + memberHandle: 'pagination-two', + roleId: config.SUBMITTER_RESOURCE_ROLE_ID, + createdAt: new Date('2030-01-03T00:00:00.000Z'), + createdBy: 'testdata' + } + ] + }) + + try { + const result = await service.getResources( + user.diazz, + challengeId, + config.SUBMITTER_RESOURCE_ROLE_ID, + undefined, + undefined, + 2, + 1, + 'created', + 'asc' + ) + + should.equal(result.total, 3) + should.equal(result.page, 2) + should.equal(result.perPage, 1) + should.equal(result.data.length, 1) + should.equal(result.data[0].id, resourceIds[1]) + should.equal(result.data[0].roleId, config.SUBMITTER_RESOURCE_ROLE_ID) + + const publicResult = await service.getResources( + null, + challengeId, + config.SUBMITTER_RESOURCE_ROLE_ID, + undefined, + undefined, + 2, + 1, + 'created', + 'asc' + ) + should.equal(publicResult.total, 3) + should.equal(publicResult.data.length, 1) + should.equal(publicResult.data[0].id, resourceIds[1]) + + const hiddenRole = await service.getResources( + null, + challengeId, + reviewerRoleId + ) + should.equal(hiddenRole.total, 0) + should.equal(hiddenRole.data.length, 0) + } finally { + await prisma.resource.deleteMany({ + where: { id: { in: resourceIds } } + }) + } + }) + + it('combines privileged member and role filters without changing broad access', async () => { + const result = await service.getResources( + user.admin, + challengeId, + reviewerRoleId, + user.phead.userId + ) + + should.equal(result.total, 1) + should.equal(result.data.length, 1) + should.equal(result.data[0].memberId, user.phead.userId) + should.equal(result.data[0].roleId, reviewerRoleId) + }) + + it('forwards exact filters and exposes filtered pagination through response headers', async () => { + const originalGetResources = service.getResources + const responseHeaders = {} + let responseBody + let forwardedArguments + const req = { + authUser: user.diazz, + path: '/v6/resources', + query: { + challengeId, + roleId: submitterRoleId, + memberId: user.diazz.userId, + page: '2', + perPage: '1', + sortBy: 'created', + sortOrder: 'asc' + } + } + const res = { + set: (name, value) => { + responseHeaders[name] = value + }, + send: (body) => { + responseBody = body + } + } + + service.getResources = async (...args) => { + forwardedArguments = args + return { + data: [{ id: 'filtered-resource' }], + total: 3, + page: 2, + perPage: 1 + } + } + + try { + await controller.getResources(req, res) + } finally { + service.getResources = originalGetResources + } + + should.deepEqual(forwardedArguments, [ + user.diazz, + challengeId, + submitterRoleId, + user.diazz.userId, + undefined, + '2', + '1', + 'created', + 'asc' + ]) + should.deepEqual(responseBody, [{ id: 'filtered-resource' }]) + should.equal(responseHeaders['X-Prev-Page'], 1) + should.equal(responseHeaders['X-Next-Page'], 3) + should.equal(responseHeaders['X-Page'], 2) + should.equal(responseHeaders['X-Per-Page'], 1) + should.equal(responseHeaders['X-Total'], 3) + should.equal(responseHeaders['X-Total-Pages'], 3) + responseHeaders.Link.should.containEql(`roleId=${submitterRoleId}`) + responseHeaders.Link.should.containEql(`memberId=${user.diazz.userId}`) + }) + it('get resources using m2m token', async () => { hasCopilotRole = false hasReviewerRole = false