From 78fe4678165753b11a8e4929694e7608d7659dac Mon Sep 17 00:00:00 2001 From: jmgasper Date: Mon, 27 Apr 2026 15:45:46 +1000 Subject: [PATCH 1/4] Challenge whitelist handling --- prisma/challenge-schema.prisma | 11 ++ src/common/helper.js | 85 +++++++++++++- src/controllers/ResourceController.js | 4 +- src/services/ResourceService.js | 71 +++++++----- test/unit/createResource.test.js | 38 +++++++ test/unit/deleteResource.test.js | 36 ++++++ test/unit/getResources.test.js | 104 ++++++++++++++++++ test/unit/listChallengesByMember.test.js | 46 ++++++++ .../updatePhaseChangeNotifications.test.js | 32 ++++++ 9 files changed, 392 insertions(+), 35 deletions(-) diff --git a/prisma/challenge-schema.prisma b/prisma/challenge-schema.prisma index 8bf4fa1..2f57e94 100644 --- a/prisma/challenge-schema.prisma +++ b/prisma/challenge-schema.prisma @@ -11,5 +11,16 @@ datasource db { model Challenge { id String @id @default(uuid()) numOfRegistrants Int @default(0) + userWhitelist ChallengeUserWhitelist[] } +model ChallengeUserWhitelist { + challengeId String + userId String + + challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade) + + @@id([challengeId, userId]) + @@index([challengeId]) + @@index([userId]) +} diff --git a/src/common/helper.js b/src/common/helper.js index beb8360..5e30a2c 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -231,28 +231,98 @@ async function getMemberDetailsById (memberId) { } /** - * Fetch challenge information from the challenge database and optionally from the Challenge API. - * Uses the Prisma challenge client to ensure the challenge exists before pulling additional details. + * Fetch challenge information by challenge id. + * + * When includeDetails is false, the challenge database is used for a lightweight + * existence check and a local NotFoundError is raised if the record is missing. + * When includeDetails is true, Challenge API remains authoritative for the full + * payload and its error contract, including the 404 response shape exposed by + * resource create/delete flows. * * @param {String} challengeId the challenge id * @param {Object} [options] optional parameters * @param {Boolean} [options.includeDetails=false] whether to fetch full challenge details from the API * @returns {Promise} the challenge record or detailed Challenge API payload + * @throws {NotFoundError} when includeDetails is false and the challenge database record is missing */ async function getChallengeById (challengeId, options = {}) { const { includeDetails = false } = options + + if (includeDetails) { + const response = await getRequest(`${config.CHALLENGE_API_URL}/${challengeId}`) + return _.get(response, 'body', null) + } + const challengeRecord = await prismaChallenge.challenge.findUnique({ where: { id: challengeId } }) if (!challengeRecord) { throw new errors.NotFoundError(`Challenge ID ${challengeId} not found`) } - if (!includeDetails) { - return challengeRecord + return challengeRecord +} + +/** + * Determine whether challenge whitelist checks apply for a request. + * Interactive users, including admins and anonymous callers, must be evaluated; + * M2M callers are allowed to bypass this user-facing access control. + * + * @param {Object} currentUser the user who performs the operation + * @returns {Boolean} true when whitelist rules should be applied + */ +function shouldApplyChallengeWhitelist (currentUser) { + return !_.get(currentUser, 'isMachine', false) +} + +/** + * Filter challenge ids by the current challenge user whitelist state. + * Challenges with no whitelist rows stay visible. Evaluation failures fail + * closed and return an empty list for interactive callers. + * + * @param {Object} currentUser the user who performs the operation + * @param {Array} challengeIds challenge ids to filter + * @returns {Promise>} challenge ids visible to the caller + */ +async function filterChallengeIdsByWhitelist (currentUser, challengeIds) { + const ids = _.uniq((challengeIds || []).map(id => _.toString(id).trim()).filter(Boolean)) + if (ids.length === 0 || !shouldApplyChallengeWhitelist(currentUser)) { + return ids + } + + const userId = _.toString(_.get(currentUser, 'userId', '')).trim() + + try { + const rows = await prismaChallenge.challengeUserWhitelist.findMany({ + where: { challengeId: { in: ids } }, + select: { challengeId: true, userId: true } + }) + const restrictedIds = new Set(rows.map(row => row.challengeId)) + const allowedRestrictedIds = new Set( + rows + .filter(row => userId && _.toString(row.userId) === userId) + .map(row => row.challengeId) + ) + + return ids.filter(id => !restrictedIds.has(id) || allowedRestrictedIds.has(id)) + } catch (err) { + logger.warn(`filterChallengeIdsByWhitelist failed: ${err.message}`) + return [] } +} - const response = await getRequest(`${config.CHALLENGE_API_URL}/${challengeId}`) - return _.get(response, 'body', null) +/** + * Ensure an interactive caller is allowed by the challenge whitelist. + * + * @param {Object} currentUser the user who performs the operation + * @param {String} challengeId the challenge id to evaluate + * @returns {Promise} + * @throws {ForbiddenError} when the whitelist blocks the caller or evaluation fails + */ +async function ensureChallengeWhitelistAccess (currentUser, challengeId) { + const visibleIds = await filterChallengeIdsByWhitelist(currentUser, [challengeId]) + if (!visibleIds.includes(challengeId)) { + throw new errors.ForbiddenError(`You don't have access to view this challenge`) + } } async function getMemberDetailsByHandleFromV3Members (handle) { @@ -573,6 +643,9 @@ module.exports = { setResHeaders, getAllPages, checkChallengeGroupAccess, + shouldApplyChallengeWhitelist, + filterChallengeIdsByWhitelist, + ensureChallengeWhitelistAccess, checkAgreedTerms, postRequest, advanceChallengePhase, diff --git a/src/controllers/ResourceController.js b/src/controllers/ResourceController.js index 67820f5..dc6e875 100644 --- a/src/controllers/ResourceController.js +++ b/src/controllers/ResourceController.js @@ -52,7 +52,7 @@ async function updatePhaseChangeNotifications (req, res) { * @param {Object} res the response */ async function listChallengesByMember (req, res) { - const result = await service.listChallengesByMember(req.params.memberId, req.query) + const result = await service.listChallengesByMember(req.params.memberId, req.query, req.authUser) helper.setResHeaders(req, res, result) res.send(result.data) } @@ -63,7 +63,7 @@ async function listChallengesByMember (req, res) { * @param {Object} res the response */ async function getResourceCount (req, res) { - const result = await service.getResourceCount(req.query.challengeId, req.query.roleId) + const result = await service.getResourceCount(req.query.challengeId, req.query.roleId, req.authUser) res.send(result) } diff --git a/src/services/ResourceService.js b/src/services/ResourceService.js index 6305168..8ddadc1 100644 --- a/src/services/ResourceService.js +++ b/src/services/ResourceService.js @@ -125,6 +125,7 @@ async function getResources (currentUser, challengeId, roleId, memberId, memberH throw new errors.BadRequestError(`Challenge ID ${challengeId} must be a valid v5 Challenge Id (UUID)`) } if (challengeId) { + await helper.ensureChallengeWhitelistAccess(currentUser, challengeId) try { // Verify that the challenge exists await helper.getChallengeById(challengeId) @@ -215,12 +216,10 @@ async function getResources (currentUser, challengeId, roleId, memberId, memberH } const orderBy = [{ [sortBy]: sortOrder }] - const total = await prisma.resource.count(prismaFilter) + let total const prismaQuery = { ...prismaFilter, orderBy, - skip: (page - 1) * perPage, - take: perPage, include: { resourceRole: { select: { @@ -229,7 +228,23 @@ async function getResources (currentUser, challengeId, roleId, memberId, memberH } } } - let resources = await prisma.resource.findMany(prismaQuery) + let resources + if (!challengeId && helper.shouldApplyChallengeWhitelist(currentUser)) { + const allResources = await prisma.resource.findMany(prismaQuery) + const visibleChallengeIds = new Set( + await helper.filterChallengeIdsByWhitelist(currentUser, _.map(allResources, 'challengeId')) + ) + resources = allResources.filter(resource => visibleChallengeIds.has(resource.challengeId)) + total = resources.length + resources = resources.slice((page - 1) * perPage, page * perPage) + } else { + total = await prisma.resource.count(prismaFilter) + resources = await prisma.resource.findMany({ + ...prismaQuery, + skip: (page - 1) * perPage, + take: perPage + }) + } resources = _.map(resources, item => { const ret = _.omit(item, 'updatedBy', 'updatedAt', 'createdAt', 'resourceRole') ret.created = item.createdAt @@ -316,6 +331,8 @@ async function getResourceRole (roleId, isCreated) { * @returns {Promise} the resource entities and member information. */ async function init (currentUser, challengeId, resource, isCreated) { + await helper.ensureChallengeWhitelistAccess(currentUser, challengeId) + // Verify that the challenge exists const challenge = await helper.getChallengeById(challengeId, { includeDetails: true }) @@ -700,6 +717,7 @@ async function updatePhaseChangeNotifications (currentUser, resourceId, payload) const isMachineUser = Boolean(currentUser && currentUser.isMachine) const isAdminUser = Boolean(currentUser && helper.hasAdminRole(currentUser)) + await helper.ensureChallengeWhitelistAccess(currentUser, resource.challengeId) if (!isMachineUser && !isAdminUser) { if (!currentUser || _.toString(resource.memberId) !== _.toString(currentUser.userId)) { @@ -739,9 +757,10 @@ updatePhaseChangeNotifications.schema = { * List all challenge ids that given member has access to. * @param {Number} memberId the member id * @param {Object} criteria the criteria: {resourceRoleId, page, perPage} + * @param {Object} currentUser the user who performs the operation * @returns {Array} an array of challenge ids represents challenges that given member has access to. */ -async function listChallengesByMember (memberId, criteria) { +async function listChallengesByMember (memberId, criteria, currentUser) { const perPage = criteria.perPage || config.DEFAULT_PAGE_SIZE const page = criteria.page || 1 @@ -753,28 +772,22 @@ async function listChallengesByMember (memberId, criteria) { if (criteria.resourceRoleId) { prismaFilter.where.AND.push({ roleId: criteria.resourceRoleId }) } - // TODO: total count is total resource count, not distinct challengeId count - const total = await prisma.resource.count(prismaFilter) - - let records = [] - if (criteria.useScroll) { - records = await prisma.resource.findMany({ - ...selectClause, - ...prismaFilter - }) - } else { - records = await prisma.resource.findMany({ - ...selectClause, - ...prismaFilter, - skip: (page - 1) * perPage, - take: perPage - }) - } // convert to challengeId array and remove duplicated - const arr = _.uniq(_.map(records, 'challengeId')) + const records = await prisma.resource.findMany({ + ...selectClause, + ...prismaFilter, + orderBy: [{ challengeId: 'asc' }] + }) + const visibleChallengeIds = await helper.filterChallengeIdsByWhitelist( + currentUser, + _.uniq(_.map(records, 'challengeId')) + ) + const arr = criteria.useScroll + ? visibleChallengeIds + : visibleChallengeIds.slice((page - 1) * perPage, page * perPage) return { data: arr, - total, + total: visibleChallengeIds.length, page, perPage } @@ -787,17 +800,20 @@ listChallengesByMember.schema = { page: Joi.page().default(1), perPage: Joi.perPage().default(config.DEFAULT_PAGE_SIZE), useScroll: Joi.boolean().default(false) - }).required() + }).required(), + currentUser: Joi.any() } /** * Get resource count of a challenge. * @param {String} challengeId the challenge id * @param {String} roleId the role id to filter on + * @param {Object} currentUser the user who performs the operation * @returns {Object} the search result */ -async function getResourceCount (challengeId, roleId) { +async function getResourceCount (challengeId, roleId, currentUser) { logger.debug(`getResourceCount ${JSON.stringify([challengeId, roleId])}`) + await helper.ensureChallengeWhitelistAccess(currentUser, challengeId) const whereClause = { where: { AND: [] } } whereClause.where.AND.push({ challengeId }) if (roleId) { @@ -818,7 +834,8 @@ async function getResourceCount (challengeId, roleId) { getResourceCount.schema = { challengeId: Joi.id(), - roleId: Joi.optionalId() + roleId: Joi.optionalId(), + currentUser: Joi.any() } module.exports = { diff --git a/test/unit/createResource.test.js b/test/unit/createResource.test.js index 8b52055..a73cef7 100644 --- a/test/unit/createResource.test.js +++ b/test/unit/createResource.test.js @@ -216,6 +216,44 @@ module.exports = describe('Create resource', () => { await assertResource(ret.id, ret) }) + it('enforces challenge user whitelist for interactive resource creation', async () => { + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId: challengeId3 } + }) + await helper.prismaChallenge.challengeUserWhitelist.create({ + data: { + challengeId: challengeId3, + userId: user.phead.userId + } + }) + + let createdResourceId + try { + const blockedEntity = resources.createBody('diazz', submitterRoleId, challengeId3) + try { + await service.createResource(user.diazz, blockedEntity) + throw new Error('should not throw error here') + } catch (err) { + should.equal(err.name, 'ForbiddenError') + } + + const machineEntity = resources.createBody('machinewhitelistuser', submitterRoleId, challengeId3) + const ret = await service.createResource(user.m2m, machineEntity) + createdResourceId = ret.id + should.equal(ret.roleId, machineEntity.roleId) + should.equal(ret.memberHandle.toLowerCase(), machineEntity.memberHandle.toLowerCase()) + } finally { + if (createdResourceId) { + await prisma.resource.deleteMany({ + where: { id: createdResourceId } + }) + } + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId: challengeId3 } + }) + } + }) + it('failure - create self obtainable resource for other user by normal user forbidden', async () => { const entity = resources.createBody('lunarkid', config.SUBMITTER_RESOURCE_ROLE_ID, challengeId3) try { diff --git a/test/unit/deleteResource.test.js b/test/unit/deleteResource.test.js index bc83120..a8dfa85 100644 --- a/test/unit/deleteResource.test.js +++ b/test/unit/deleteResource.test.js @@ -6,6 +6,7 @@ const _ = require('lodash') const should = require('should') const service = require('../../src/services/ResourceService') const helper = require('../../src/common/helper') +const prisma = require('../../src/common/prisma').getClient() const { requestBody, user } = require('../common/testData') const { assertValidationError, assertError, getRoleIds } = require('../common/testHelper') @@ -119,6 +120,41 @@ module.exports = describe('Delete resource', () => { } }) + it('enforces challenge user whitelist for interactive resource deletion', async () => { + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId } + }) + await helper.prismaChallenge.challengeUserWhitelist.create({ + data: { + challengeId, + userId: user.phead.userId + } + }) + + try { + const entity = resources.createBody('diazz', submitterRoleId, challengeId) + try { + await service.deleteResource(user.admin, entity) + throw new Error('should not throw error here') + } catch (err) { + should.equal(err.name, 'ForbiddenError') + } + + const existing = await prisma.resource.findFirst({ + where: { + challengeId, + memberHandle: 'diazz', + roleId: submitterRoleId + } + }) + should.exist(existing) + } finally { + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId } + }) + } + }) + it('failure - delete resource for non-existed challenge', async () => { const entity = resources.createBody('ghostar', observerRoleId, challengeNotFoundId) try { diff --git a/test/unit/getResources.test.js b/test/unit/getResources.test.js index 0dd39eb..a80183b 100644 --- a/test/unit/getResources.test.js +++ b/test/unit/getResources.test.js @@ -10,6 +10,8 @@ const { user } = require('../common/testData') const { assertValidationError, assertError, getRoleIds } = require('../common/testHelper') const challengeId = 'fe6d0a58-ce7d-4521-8501-b8132b1c0391' +const challengeId2 = 'fe6d0a58-ce7d-4521-8501-b8132b1c0392' +const challengeId3 = 'fe6d0a58-ce7d-4521-8501-b8132b1c0393' const challengeNotFoundId = '11111111-ce7d-4521-8501-b8132b1c0391' module.exports = describe('Get resources', () => { @@ -77,6 +79,108 @@ module.exports = describe('Get resources', () => { should.equal(hasReviewerRole, true) }) + it('enforces challenge user whitelist for interactive resource reads', async () => { + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId } + }) + await helper.prismaChallenge.challengeUserWhitelist.create({ + data: { + challengeId, + userId: user.phead.userId + } + }) + + try { + const allowed = await service.getResources(user.phead, challengeId) + should.equal(allowed.total, 5) + + const machine = await service.getResources(user.m2m, challengeId) + should.equal(machine.total, 5) + + try { + await service.getResources(user.admin, challengeId) + throw new Error('should not throw error here') + } catch (err) { + should.equal(err.name, 'ForbiddenError') + } + } finally { + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId } + }) + } + }) + + it('filters resource lists before pagination metadata is computed', async () => { + await prisma.resource.updateMany({ + where: { challengeId, memberId: '151743' }, + data: { createdAt: new Date('2020-01-01T00:00:00.000Z') } + }) + await prisma.resource.updateMany({ + where: { challengeId: challengeId2, memberId: '151743' }, + data: { createdAt: new Date('2020-01-02T00:00:00.000Z') } + }) + await prisma.resource.updateMany({ + where: { challengeId: challengeId3, memberId: '151743' }, + data: { createdAt: new Date('2020-01-03T00:00:00.000Z') } + }) + + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId: challengeId2 } + }) + await helper.prismaChallenge.challengeUserWhitelist.create({ + data: { + challengeId: challengeId2, + userId: user.phead.userId + } + }) + + try { + const blocked = await service.getResources(user.admin, null, null, '151743', null, 2, 1, 'created', 'asc') + should.equal(blocked.total, 2) + should.equal(blocked.data.length, 1) + should.equal(blocked.data[0].challengeId, challengeId3) + + const machine = await service.getResources(user.m2m, null, null, '151743', null, 2, 1, 'created', 'asc') + should.equal(machine.total, 3) + should.equal(machine.data.length, 1) + should.equal(machine.data[0].challengeId, challengeId2) + } finally { + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId: challengeId2 } + }) + } + }) + + it('enforces challenge user whitelist for resource count reads', async () => { + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId } + }) + await helper.prismaChallenge.challengeUserWhitelist.create({ + data: { + challengeId, + userId: user.phead.userId + } + }) + + try { + try { + await service.getResourceCount(challengeId, null, user.admin) + throw new Error('should not throw error here') + } catch (err) { + should.equal(err.name, 'ForbiddenError') + } + + const machine = await service.getResourceCount(challengeId, null, user.m2m) + should.equal(machine[submitterRoleId], 3) + should.equal(machine[copilotRoleId], 1) + should.equal(machine[reviewerRoleId], 1) + } finally { + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId } + }) + } + }) + it('get resources by user has full-access permission', async () => { hasCopilotRole = false hasReviewerRole = false diff --git a/test/unit/listChallengesByMember.test.js b/test/unit/listChallengesByMember.test.js index 8c4ceff..f2e749a 100644 --- a/test/unit/listChallengesByMember.test.js +++ b/test/unit/listChallengesByMember.test.js @@ -4,6 +4,7 @@ const should = require('should') const service = require('../../src/services/ResourceService') +const helper = require('../../src/common/helper') const { assertValidationError, getRoleIds } = require('../common/testHelper') const challengeId1 = 'fe6d0a58-ce7d-4521-8501-b8132b1c0391' @@ -36,6 +37,51 @@ module.exports = describe('List challenges by member', () => { should.equal(ret.data.includes(challengeId3), true) }) + it('filters member challenge lists by challenge user whitelist', async () => { + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId: challengeId2 } + }) + await helper.prismaChallenge.challengeUserWhitelist.create({ + data: { + challengeId: challengeId2, + userId: 'allowed-user' + } + }) + + try { + const blocked = await service.listChallengesByMember( + '151743', + {}, + { userId: 'blocked-user', roles: ['administrator'] } + ) + should.equal(blocked.data.length, 2) + should.equal(blocked.data.includes(challengeId1), true) + should.equal(blocked.data.includes(challengeId2), false) + should.equal(blocked.data.includes(challengeId3), true) + + const blockedSecondPage = await service.listChallengesByMember( + '151743', + { page: 2, perPage: 1 }, + { userId: 'blocked-user', roles: ['administrator'] } + ) + should.equal(blockedSecondPage.total, 2) + should.equal(blockedSecondPage.data.length, 1) + should.equal(blockedSecondPage.data[0], challengeId3) + + const allowed = await service.listChallengesByMember( + '151743', + {}, + { userId: 'allowed-user', roles: ['administrator'] } + ) + should.equal(allowed.data.length, 3) + should.equal(allowed.data.includes(challengeId2), true) + } finally { + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId: challengeId2 } + }) + } + }) + it('get challenges ghostar can access with filter 1', async () => { const ret = await service.listChallengesByMember('151743', { resourceRoleId: submitterRoleId }) should.equal(ret.data.length, 1) diff --git a/test/unit/updatePhaseChangeNotifications.test.js b/test/unit/updatePhaseChangeNotifications.test.js index 6a487fd..87e283c 100644 --- a/test/unit/updatePhaseChangeNotifications.test.js +++ b/test/unit/updatePhaseChangeNotifications.test.js @@ -5,10 +5,13 @@ const should = require('should') const { v4: uuid } = require('uuid') const service = require('../../src/services/ResourceService') +const helper = require('../../src/common/helper') const prisma = require('../../src/common/prisma').getClient() const { user } = require('../common/testData') const { assertValidationError, assertError, getRoleIds } = require('../common/testHelper') +const challengeId = 'fe6d0a58-ce7d-4521-8501-b8132b1c0391' + module.exports = describe('Update phase change notifications', () => { let submitterRoleId let createdResourceIds = [] @@ -82,6 +85,35 @@ module.exports = describe('Update phase change notifications', () => { should.equal(result.phaseChangeNotifications, false) }) + it('enforces challenge user whitelist for interactive notification updates', async () => { + const record = await createResourceForUser(user.diazz, { challengeId }) + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId } + }) + await helper.prismaChallenge.challengeUserWhitelist.create({ + data: { + challengeId, + userId: user.phead.userId + } + }) + + try { + try { + await service.updatePhaseChangeNotifications(user.admin, record.id, { phaseChangeNotifications: false }) + throw new Error('should not throw error here') + } catch (err) { + should.equal(err.name, 'ForbiddenError') + } + + const updated = await service.updatePhaseChangeNotifications(user.m2m, record.id, { phaseChangeNotifications: false }) + should.equal(updated.phaseChangeNotifications, false) + } finally { + await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ + where: { challengeId } + }) + } + }) + it('allows machine-to-machine tokens with update scope to update any resource', async () => { const record = await createResourceForUser(user.diazz) From 448cb673782a0d40d597cc54b077ee8c7ea2c26e Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Mon, 4 May 2026 10:23:26 +0300 Subject: [PATCH 2/4] Delete .github/workflows/code_reviewer.yml --- .github/workflows/code_reviewer.yml | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 .github/workflows/code_reviewer.yml diff --git a/.github/workflows/code_reviewer.yml b/.github/workflows/code_reviewer.yml deleted file mode 100644 index 82c7862..0000000 --- a/.github/workflows/code_reviewer.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: AI PR Reviewer - -on: - pull_request: - types: - - opened - - synchronize -permissions: - pull-requests: write -jobs: - tc-ai-pr-review: - runs-on: ubuntu-latest - steps: - - name: Checkout Repo - uses: actions/checkout@v3 - - - name: TC AI PR Reviewer - uses: topcoder-platform/tc-ai-pr-reviewer@master - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # The GITHUB_TOKEN is there by default so you just need to keep it like it is and not necessarily need to add it as secret as it will throw an error. [More Details](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#about-the-github_token-secret) - LAB45_API_KEY: ${{ secrets.LAB45_API_KEY }} - exclude: '**/*.json, **/*.md, **/*.jpg, **/*.png, **/*.jpeg, **/*.bmp, **/*.webp' # Optional: exclude patterns separated by commas \ No newline at end of file From cb4162091bb7c56661a15c545bb74d163ee4b300 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 12 May 2026 11:49:07 +1000 Subject: [PATCH 3/4] PM-5012: allow Work managers to load challenge resources What was broken TM/PM users opening Work challenge drafts could not load saved copilot and reviewer assignments because resource reads only returned submitters and the caller's own resources unless the caller had admin, copilot, or challenge-level full-access resource permissions. Root cause resource-api-v6 route access allowed manager users, but the service-level resource access check did not treat Work project/talent manager roles as resource managers. What was changed Added Work manager role recognition to the resource access check so project manager, topcoder project manager, talent manager, topcoder talent manager, and Connect Manager users can read the challenge resource rows the Work app uses to hydrate saved copilot and reviewer fields. Any added/updated tests Added getResources unit coverage for project manager and talent manager roles returning the full challenge resource list. --- src/services/ResourceService.js | 27 ++++++++++++++++++++++++--- test/common/testData.js | 20 ++++++++++++++++++++ test/unit/getResources.test.js | 18 ++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/services/ResourceService.js b/src/services/ResourceService.js index 8ddadc1..04e17a3 100644 --- a/src/services/ResourceService.js +++ b/src/services/ResourceService.js @@ -27,6 +27,13 @@ const RESTRICTED_ROLE_NAMES = [ 'checkpoint reviewer', 'approver' ] +const RESOURCE_MANAGER_ROLE_NAMES = new Set([ + _.toLower(constants.UserRoles.Manager), + 'project manager', + 'topcoder project manager', + 'talent manager', + 'topcoder talent manager' +]) let copilotResourceRoleIdsCache let restrictedRoleIdsCache @@ -72,11 +79,25 @@ async function getRestrictedRoleIds () { return restrictedRoleIdsCache } +/** + * Check whether the current user has a Work manager role that can manage challenge resources. + * @param {Object} currentUser the current user + * @returns {Boolean} true when the user has a resource manager role + */ +function hasResourceManagerRole (currentUser) { + return _.some(_.get(currentUser, 'roles', []), role => RESOURCE_MANAGER_ROLE_NAMES.has(_.toLower(role))) +} + /** * Check whether the user can access resources + * @param {Object} currentUser the current user * @param {Array} resources resources of current user for specified challenge id */ -async function checkAccess (currentUserResources) { +async function checkAccess (currentUser, currentUserResources) { + if (hasResourceManagerRole(currentUser)) { + return + } + const copilotRoleIds = await getCopilotResourceRoleIds() const hasCopilotRole = _.some(currentUserResources, r => copilotRoleIds.includes(r.roleId)) if (hasCopilotRole) { @@ -166,7 +187,7 @@ async function getResources (currentUser, challengeId, roleId, memberId, memberH } }) try { - await checkAccess(resources) + await checkAccess(currentUser, resources) hasFullAccess = true } catch (e) { hasFullAccess = false @@ -453,7 +474,7 @@ async function init (currentUser, challengeId, resource, isCreated) { if (!resourceRole.selfObtainable || _.toString(memberId) !== _.toString(currentUser.userId)) { // if user is not creating/deleting a self obtainable resource for itself // we need to perform check access first - await checkAccess(currentUserResources) + await checkAccess(currentUser, currentUserResources) } } } diff --git a/test/common/testData.js b/test/common/testData.js index e21d6d7..c37aeb6 100644 --- a/test/common/testData.js +++ b/test/common/testData.js @@ -55,6 +55,26 @@ const user = { email: 'email@domain.com.z', jti: 'f1e613be-d5b9-4231-baae-ee9f2d227234' }, + projectManager: { + roles: [ 'Topcoder User', 'project manager' ], + iss: 'https://api.topcoder-dev.com', + handle: 'projectmanager', + exp: 1561792370, + userId: '8123456', + iat: 1549791770, + email: 'email@domain.com.z', + jti: 'f1e613be-d5b9-4231-baae-ee9f2d227234' + }, + talentManager: { + roles: [ 'Topcoder User', 'talent manager' ], + iss: 'https://api.topcoder-dev.com', + handle: 'talentmanager', + exp: 1561792370, + userId: '8123457', + iat: 1549791770, + email: 'email@domain.com.z', + jti: 'f1e613be-d5b9-4231-baae-ee9f2d227234' + }, lunarkid: { roles: [ 'Topcoder User' ], iss: 'https://api.topcoder-dev.com', diff --git a/test/unit/getResources.test.js b/test/unit/getResources.test.js index a80183b..ee6b8d2 100644 --- a/test/unit/getResources.test.js +++ b/test/unit/getResources.test.js @@ -79,6 +79,24 @@ module.exports = describe('Get resources', () => { should.equal(hasReviewerRole, true) }) + it('get resources by project manager role', async () => { + const result = await service.getResources(user.projectManager, challengeId) + should.equal(result.total, 5) + for (const record of result.data) { + await assertResource(record.id, record) + should.exist(record.roleName) + } + }) + + it('get resources by talent manager role', async () => { + const result = await service.getResources(user.talentManager, challengeId) + should.equal(result.total, 5) + for (const record of result.data) { + await assertResource(record.id, record) + should.exist(record.roleName) + } + }) + it('enforces challenge user whitelist for interactive resource reads', async () => { await helper.prismaChallenge.challengeUserWhitelist.deleteMany({ where: { challengeId } From a7229f84b4e22596574c42ce0cab8cbb8092f09c Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 12 May 2026 13:18:44 +1000 Subject: [PATCH 4/4] Build fix --- .dockerignore | 6 ++++++ docker/Dockerfile | 4 ++-- package.json | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..861ea06 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.git +coverage +.nyc_output +npm-debug.log* +pnpm-debug.log* diff --git a/docker/Dockerfile b/docker/Dockerfile index 11dd61c..75bbf0f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -8,9 +8,9 @@ COPY . /resources-api # Set working directory for future use WORKDIR /resources-api -RUN npm install pnpm -g +RUN npm install -g pnpm@10.33.2 # Install the dependencies from package.json -RUN pnpm install +RUN pnpm install --frozen-lockfile RUN pnpm lint RUN pnpm lint:fix diff --git a/package.json b/package.json index c3a605b..dd3ce32 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "description": "TopCoder Challenge Resources V5 API", "main": "app.js", + "packageManager": "pnpm@10.33.2", "scripts": { "start": "node app.js", "start:dev": "nodemon app.js", @@ -61,6 +62,19 @@ "prisma": { "schema": "./prisma/schema.prisma" }, + "pnpm": { + "onlyBuiltDependencies": [ + "@prisma/client", + "@prisma/engines", + "prisma" + ], + "ignoredBuiltDependencies": [ + "@scarf/scarf", + "aws-sdk", + "core-js", + "dtrace-provider" + ] + }, "engines": { "node": ">=18 <23" },