From ba5e0136d4f20494286ad4c8da0d50803f232640 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 19 Mar 2026 14:53:03 +1100 Subject: [PATCH 1/3] Fixes for PM vs TM handling --- README.md | 1 + docs/PERMISSIONS.md | 1 + .../project-member.service.spec.ts | 148 ++++++++++++++++++ src/shared/constants/permissions.constants.ts | 2 + .../services/permission.service.spec.ts | 19 +++ src/shared/utils/member.utils.spec.ts | 15 ++ src/shared/utils/member.utils.ts | 12 +- 7 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 src/shared/utils/member.utils.spec.ts diff --git a/README.md b/README.md index 18113a3..7d7b1dc 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ For the full v5 -> v6 mapping table, see `docs/api-usage-analysis.md`. Talent Manager note: - `Talent Manager` and `Topcoder Talent Manager` callers create projects as primary `manager` members. +- `Talent Manager` and `Topcoder Talent Manager` users can also be assigned the `manager` (`Full Access`) project role through member add/update/invite flows. - Updating `billingAccountId` is restricted to human administrators and project members whose role on that project is `manager` (`Full Access`). ### Members diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index ee234f3..974dd44 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -31,6 +31,7 @@ Swagger auth notes: ## Talent Manager Behavior - `Talent Manager` and `Topcoder Talent Manager` satisfy `CREATE_PROJECT_AS_MANAGER`, so project creation persists them as the primary `manager` project member. +- The same Talent Manager roles also satisfy the `manager` project-role validation used by member add/update/invite flows, so they can be granted `Full Access` from Work Manager's Users tab. - That primary `manager` membership then unlocks the standard manager-level project-owner paths, such as edit and delete checks that rely on project-member context. ## Billing Account Editing diff --git a/src/api/project-member/project-member.service.spec.ts b/src/api/project-member/project-member.service.spec.ts index 2ef87cf..db70231 100644 --- a/src/api/project-member/project-member.service.spec.ts +++ b/src/api/project-member/project-member.service.spec.ts @@ -2,6 +2,7 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { ProjectMemberRole } from '@prisma/client'; import { Permission } from 'src/shared/constants/permissions'; import { KAFKA_TOPIC } from 'src/shared/config/kafka.config'; +import { UserRole } from 'src/shared/enums/userRole.enum'; import { MemberService } from 'src/shared/services/member.service'; import { PermissionService } from 'src/shared/services/permission.service'; import { ProjectMemberService } from './project-member.service'; @@ -177,6 +178,73 @@ describe('ProjectMemberService', () => { }); }); + [UserRole.TALENT_MANAGER, UserRole.TOPCODER_TALENT_MANAGER].forEach( + (topcoderRole) => { + it(`allows ${topcoderRole} users to be added as manager project members`, async () => { + prismaMock.project.findFirst.mockResolvedValue({ + id: BigInt(1001), + members: [], + }); + + const txMock = { + projectMember: { + create: jest.fn().mockResolvedValue({ + id: BigInt(3), + projectId: BigInt(1001), + userId: BigInt(456), + role: ProjectMemberRole.manager, + isPrimary: false, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 123, + updatedBy: 123, + deletedAt: null, + deletedBy: null, + }), + }, + projectMemberInvite: { + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + }; + + prismaMock.$transaction.mockImplementation( + (callback: (tx: unknown) => Promise) => callback(txMock), + ); + + permissionServiceMock.hasNamedPermission.mockImplementation( + (permission: Permission): boolean => + permission === Permission.CREATE_PROJECT_MEMBER_NOT_OWN, + ); + memberServiceMock.getUserRoles.mockResolvedValue([topcoderRole]); + memberServiceMock.getMemberDetailsByUserIds.mockResolvedValue([]); + + await service.addMember( + '1001', + { + userId: '456', + role: ProjectMemberRole.manager, + }, + { + userId: '123', + roles: [UserRole.TOPCODER_ADMIN], + isMachine: false, + }, + undefined, + ); + + expect(txMock.projectMember.create).toHaveBeenCalledWith({ + data: { + projectId: BigInt(1001), + userId: BigInt(456), + role: ProjectMemberRole.manager, + createdBy: 123, + updatedBy: 123, + }, + }); + }); + }, + ); + it('rejects invalid target user ids before querying the project', async () => { await expect( service.addMember( @@ -297,6 +365,86 @@ describe('ProjectMemberService', () => { }); }); + [UserRole.TALENT_MANAGER, UserRole.TOPCODER_TALENT_MANAGER].forEach( + (topcoderRole) => { + it(`allows ${topcoderRole} users to be promoted to manager project members`, async () => { + prismaMock.project.findFirst.mockResolvedValue({ + id: BigInt(1001), + members: [ + { + id: BigInt(2), + projectId: BigInt(1001), + userId: BigInt(456), + role: ProjectMemberRole.customer, + isPrimary: false, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 1, + updatedBy: 1, + deletedAt: null, + deletedBy: null, + }, + ], + }); + + const txMock = { + projectMember: { + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + update: jest.fn().mockResolvedValue({ + id: BigInt(2), + projectId: BigInt(1001), + userId: BigInt(456), + role: ProjectMemberRole.manager, + isPrimary: false, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 1, + updatedBy: 123, + deletedAt: null, + deletedBy: null, + }), + }, + }; + + prismaMock.$transaction.mockImplementation( + (callback: (tx: unknown) => Promise) => callback(txMock), + ); + + permissionServiceMock.hasNamedPermission.mockImplementation( + (permission: Permission): boolean => + permission === Permission.UPDATE_PROJECT_MEMBER_NON_CUSTOMER, + ); + memberServiceMock.getUserRoles.mockResolvedValue([topcoderRole]); + memberServiceMock.getMemberDetailsByUserIds.mockResolvedValue([]); + + await service.updateMember( + '1001', + '2', + { + role: ProjectMemberRole.manager, + }, + { + userId: '123', + roles: [UserRole.TOPCODER_ADMIN], + isMachine: false, + }, + undefined, + ); + + expect(txMock.projectMember.update).toHaveBeenCalledWith({ + where: { + id: BigInt(2), + }, + data: { + role: ProjectMemberRole.manager, + isPrimary: undefined, + updatedBy: 123, + }, + }); + }); + }, + ); + it('deletes a project member for machine principals inferred from token claims', async () => { prismaMock.project.findFirst.mockResolvedValue({ id: BigInt(1001), diff --git a/src/shared/constants/permissions.constants.ts b/src/shared/constants/permissions.constants.ts index e335c7f..0dfff45 100644 --- a/src/shared/constants/permissions.constants.ts +++ b/src/shared/constants/permissions.constants.ts @@ -1102,6 +1102,8 @@ export const PROJECT_TO_TOPCODER_ROLES_MATRIX = { USER_ROLE.PROGRAM_MANAGER, USER_ROLE.SOLUTION_ARCHITECT, USER_ROLE.PROJECT_MANAGER, + USER_ROLE.TALENT_MANAGER, + USER_ROLE.TOPCODER_TALENT_MANAGER, USER_ROLE.COPILOT_MANAGER, ], [PROJECT_MEMBER_ROLE.COPILOT]: [USER_ROLE.COPILOT, 'copilot'], diff --git a/src/shared/services/permission.service.spec.ts b/src/shared/services/permission.service.spec.ts index ba7a35f..11bdf0e 100644 --- a/src/shared/services/permission.service.spec.ts +++ b/src/shared/services/permission.service.spec.ts @@ -535,6 +535,25 @@ describe('PermissionService', () => { expect(allowed).toBe(false); }); + it('allows Project Manager role to edit projects when they have full-access membership', () => { + const allowed = service.hasNamedPermission( + Permission.EDIT_PROJECT, + { + userId: '3001', + roles: [UserRole.PROJECT_MANAGER], + isMachine: false, + }, + [ + { + userId: '3001', + role: ProjectMemberRole.MANAGER, + }, + ], + ); + + expect(allowed).toBe(true); + }); + it('allows deleting project for machine token with project write scope', () => { const allowed = service.hasNamedPermission(Permission.DELETE_PROJECT, { scopes: [Scope.PROJECTS_ALL], diff --git a/src/shared/utils/member.utils.spec.ts b/src/shared/utils/member.utils.spec.ts new file mode 100644 index 0000000..e39f576 --- /dev/null +++ b/src/shared/utils/member.utils.spec.ts @@ -0,0 +1,15 @@ +import { ProjectMemberRole } from '@prisma/client'; +import { UserRole } from 'src/shared/enums/userRole.enum'; +import { validateUserHasProjectRole } from './member.utils'; + +describe('validateUserHasProjectRole', () => { + [UserRole.TALENT_MANAGER, UserRole.TOPCODER_TALENT_MANAGER].forEach( + (topcoderRole) => { + it(`accepts ${topcoderRole} for manager project role validation`, () => { + expect( + validateUserHasProjectRole(ProjectMemberRole.manager, [topcoderRole]), + ).toBe(true); + }); + }, + ); +}); diff --git a/src/shared/utils/member.utils.ts b/src/shared/utils/member.utils.ts index 0b4fd95..8e67d45 100644 --- a/src/shared/utils/member.utils.ts +++ b/src/shared/utils/member.utils.ts @@ -48,8 +48,10 @@ export type ProjectInviteLike = { /** * Manager-tier Topcoder roles allowed to hold management project roles. * - * @todo Duplicates `MANAGER_ROLES` from `userRole.enum.ts`. Import the shared - * constant instead of maintaining a local copy. + * This intentionally overlaps with, but is not identical to, + * `MANAGER_ROLES` from `userRole.enum.ts`: Talent Manager roles may hold + * management project memberships without broadening unrelated global + * manager-only route access. */ const MANAGER_TOPCODER_ROLES: string[] = [ UserRole.TOPCODER_ADMIN, @@ -62,6 +64,8 @@ const MANAGER_TOPCODER_ROLES: string[] = [ UserRole.PROGRAM_MANAGER, UserRole.SOLUTION_ARCHITECT, UserRole.PROJECT_MANAGER, + UserRole.TALENT_MANAGER, + UserRole.TOPCODER_TALENT_MANAGER, UserRole.COPILOT_MANAGER, ]; @@ -182,7 +186,9 @@ export function getDefaultProjectRole( /** * Validates that user Topcoder roles permit the requested project role. * - * `customer` and `observer` are unrestricted by design. + * `customer` and `observer` are unrestricted by design. Full-access + * `manager` membership accepts the same manager-tier Topcoder roles used for + * project creation, including Talent Manager variants. */ export function validateUserHasProjectRole( role: ProjectMemberRole, From 0413dd904d2f491e944f16647991c67620f33e8f Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 20 Mar 2026 10:11:44 +1100 Subject: [PATCH 2/3] Allow talent managers to get project permissions - used when launching challenges --- README.md | 3 ++- docs/PERMISSIONS.md | 1 + src/api/project/project.controller.ts | 15 ++++++++++----- src/api/project/project.service.spec.ts | 16 ++++++++++++++++ src/api/project/project.service.ts | 16 +++++++++------- 5 files changed, 38 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7d7b1dc..039ed77 100644 --- a/README.md +++ b/README.md @@ -96,11 +96,12 @@ For the full v5 -> v6 mapping table, see `docs/api-usage-analysis.md`. | `DELETE` | `/v6/projects/:projectId` | Admin only | Soft-delete project | | `GET` | `/v6/projects/:projectId/billingAccount` | JWT / M2M | Default billing account (Salesforce) | | `GET` | `/v6/projects/:projectId/billingAccounts` | JWT / M2M | All billing accounts for project | -| `GET` | `/v6/projects/:projectId/permissions` | JWT / M2M | Regular human JWT: caller work-management policy map. M2M, admins, project managers, and project copilots on the project: per-member permission matrix with project permissions and template policies | +| `GET` | `/v6/projects/:projectId/permissions` | JWT / M2M | Regular human JWT: caller work-management policy map. M2M, admins, project managers, talent managers, and project copilots on the project: per-member permission matrix with project permissions and template policies | Talent Manager note: - `Talent Manager` and `Topcoder Talent Manager` callers create projects as primary `manager` members. - `Talent Manager` and `Topcoder Talent Manager` users can also be assigned the `manager` (`Full Access`) project role through member add/update/invite flows. +- `Talent Manager` and `Topcoder Talent Manager` callers also receive the elevated per-member response from `GET /v6/projects/:projectId/permissions`, which is used to provision challenge-related actions in Work Manager. - Updating `billingAccountId` is restricted to human administrators and project members whose role on that project is `manager` (`Full Access`). ### Members diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index 974dd44..7a1d4cb 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -33,6 +33,7 @@ Swagger auth notes: - `Talent Manager` and `Topcoder Talent Manager` satisfy `CREATE_PROJECT_AS_MANAGER`, so project creation persists them as the primary `manager` project member. - The same Talent Manager roles also satisfy the `manager` project-role validation used by member add/update/invite flows, so they can be granted `Full Access` from Work Manager's Users tab. - That primary `manager` membership then unlocks the standard manager-level project-owner paths, such as edit and delete checks that rely on project-member context. +- `Talent Manager` and `Topcoder Talent Manager` also qualify for the elevated `GET /v6/projects/:projectId/permissions` response, which keeps Work Manager's challenge-provisioning matrix aligned with project-manager access. ## Billing Account Editing diff --git a/src/api/project/project.controller.ts b/src/api/project/project.controller.ts index 7a46170..8ea95fc 100644 --- a/src/api/project/project.controller.ts +++ b/src/api/project/project.controller.ts @@ -316,9 +316,10 @@ export class ProjectController { * @param projectId Project id path parameter. * @param user Authenticated caller context. * @returns Non-privileged human callers receive a caller policy map. M2M, - * admins, global project managers, and project copilots on the requested - * project receive a per-user matrix containing memberships, Topcoder roles, - * named project permissions, and template work-management policies. + * admins, global project managers, global talent managers, and project + * copilots on the requested project receive a per-user matrix containing + * memberships, Topcoder roles, named project permissions, and template + * work-management policies. * @throws BadRequestException When `projectId` is not numeric. * @throws UnauthorizedException When the caller is unauthenticated. * @throws ForbiddenException When caller cannot access the project. @@ -334,7 +335,11 @@ export class ProjectController { Scope.CONNECT_PROJECT_ADMIN, ) @RequirePermission(Permission.VIEW_PROJECT, { - topcoderRoles: [UserRole.PROJECT_MANAGER], + topcoderRoles: [ + UserRole.PROJECT_MANAGER, + UserRole.TALENT_MANAGER, + UserRole.TOPCODER_TALENT_MANAGER, + ], }) @ApiOperation({ summary: 'Get user permissions for project' }) @ApiParam({ @@ -345,7 +350,7 @@ export class ProjectController { @ApiResponse({ status: 200, description: - 'Caller policy map for regular human JWTs, or a per-user permission matrix for M2M/admin/project-manager/project-copilot callers', + 'Caller policy map for regular human JWTs, or a per-user permission matrix for M2M/admin/project-manager/talent-manager/project-copilot callers', schema: { oneOf: [ { diff --git a/src/api/project/project.service.spec.ts b/src/api/project/project.service.spec.ts index 5066c2d..a20d0c1 100644 --- a/src/api/project/project.service.spec.ts +++ b/src/api/project/project.service.spec.ts @@ -832,6 +832,22 @@ describe('ProjectService', () => { isMachine: false, }, ], + [ + 'talent manager', + { + userId: '999', + roles: [UserRole.TALENT_MANAGER], + isMachine: false, + }, + ], + [ + 'topcoder talent manager', + { + userId: '999', + roles: [UserRole.TOPCODER_TALENT_MANAGER], + isMachine: false, + }, + ], ])( 'returns a per-user permission matrix for %s callers on all projects', async ( diff --git a/src/api/project/project.service.ts b/src/api/project/project.service.ts index 6bc5236..abb7fe1 100644 --- a/src/api/project/project.service.ts +++ b/src/api/project/project.service.ts @@ -878,8 +878,8 @@ export class ProjectService { * * Human JWT callers keep the legacy v5/v6 behavior and receive the * work-management policy map allowed for the authenticated caller unless - * they are an admin, a global `Project Manager`, or a `copilot` member on - * the requested project. + * they are an admin, a global `Project Manager`/`Talent Manager`, or a + * `copilot` member on the requested project. * * M2M callers receive a per-user matrix built from active project members. * Each entry includes the user's active memberships, fetched Topcoder roles, @@ -1754,7 +1754,7 @@ export class ProjectService { * Matrix access is granted to: * - machine principals * - admins - * - global `Project Manager` role holders + * - global `Project Manager` and `Talent Manager` role holders * - callers who are a `copilot` member on the current project * * @param user Authenticated caller context. @@ -1774,10 +1774,12 @@ export class ProjectService { .map((role) => String(role).trim().toLowerCase()) .filter((role) => role.length > 0), ); - const hasGlobalMatrixRole = [...ADMIN_ROLES, UserRole.PROJECT_MANAGER].some( - (role) => - normalizedUserRoles.has(String(role).trim().toLowerCase()), - ); + const hasGlobalMatrixRole = [ + ...ADMIN_ROLES, + UserRole.PROJECT_MANAGER, + UserRole.TALENT_MANAGER, + UserRole.TOPCODER_TALENT_MANAGER, + ].some((role) => normalizedUserRoles.has(String(role).trim().toLowerCase())); if (hasGlobalMatrixRole) { return true; From 784b70326bad646131b163783dad0a9401800bb4 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 26 Mar 2026 07:57:53 +1100 Subject: [PATCH 3/3] Better handling of BA details for M2M tokens - fix for markup not getting applied to new challenges --- src/api/project/project.service.spec.ts | 26 +++++++++++++++++++++++++ src/api/project/project.service.ts | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/api/project/project.service.spec.ts b/src/api/project/project.service.spec.ts index a20d0c1..aa548cf 100644 --- a/src/api/project/project.service.spec.ts +++ b/src/api/project/project.service.spec.ts @@ -521,6 +521,32 @@ describe('ProjectService', () => { }); }); + it('returns project billing account markup for machine principals inferred from token claims', async () => { + prismaMock.project.findFirst.mockResolvedValue({ + id: BigInt(1001), + billingAccountId: BigInt(12), + }); + billingAccountServiceMock.getDefaultBillingAccount.mockResolvedValue({ + tcBillingAccountId: '12', + markup: 0.58, + active: true, + }); + + const result = await service.getProjectBillingAccount('1001', { + isMachine: false, + scopes: [], + tokenPayload: { + gty: 'client-credentials', + }, + }); + + expect(result).toEqual({ + tcBillingAccountId: '12', + markup: 0.58, + active: true, + }); + }); + it('falls back to project billingAccountId when Salesforce billing lookup is empty', async () => { prismaMock.project.findFirst.mockResolvedValue({ id: BigInt(1001), diff --git a/src/api/project/project.service.ts b/src/api/project/project.service.ts index abb7fe1..c088146 100644 --- a/src/api/project/project.service.ts +++ b/src/api/project/project.service.ts @@ -1067,7 +1067,7 @@ export class ProjectService { tcBillingAccountId: projectBillingAccountId, }; - if (user.isMachine) { + if (this.isMachinePrincipal(user)) { return billingAccount; }