From 81af7f1d5f1f17430fc3342b27ed96c79df164d9 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 3 Apr 2026 05:01:09 +1100 Subject: [PATCH] PM-3764: restore legacy project read role parity What was broken The v6 named-permission path no longer matched tc-project-service for several project read flows. Project Manager, Task Manager, Talent Manager, and related manager-tier roles could be blocked from listing projects, viewing projects, or reading project members, invites, and attachments unless they were explicit project members. Root cause (if identifiable) The earlier PM-3764 compatibility work restored many v5 response and M2M behaviors, but the Nest named-permission checks were narrower than the legacy v5 permission constants. QA therefore still hit access failures even after the broader compatibility and deployment fixes. What was changed Restored the legacy v5 project-read Topcoder role allowlist inside PermissionService for READ_PROJECT_ANY and VIEW_PROJECT. Restored manager-tier read access for project members, invites, and attachments so the named-permission path matches the legacy service more closely. Documented the restored legacy read-access behavior in docs/PERMISSIONS.md. Any added/updated tests Expanded PermissionService regression coverage for legacy project-read roles and manager-tier read access to project members, invites, and attachments. Verified the affected project controller and service unit suites still pass. --- docs/PERMISSIONS.md | 5 ++ .../services/permission.service.spec.ts | 63 +++++++++++++++++++ src/shared/services/permission.service.ts | 59 +++++++++++++++-- 3 files changed, 121 insertions(+), 6 deletions(-) diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index 7a1d4cb..8150d65 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -35,6 +35,11 @@ Swagger auth notes: - 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. +## Legacy Read Access + +- `Project Manager`, `Task Manager`, `Topcoder Task Manager`, `Talent Manager`, and `Topcoder Talent Manager` retain the legacy v5 ability to view projects without being explicit project members. +- Manager-tier platform roles also retain legacy read access to project members, invites, and attachments on those projects. + ## Billing Account Editing - `MANAGE_PROJECT_BILLING_ACCOUNT_ID` is intentionally narrower than general project edit access. diff --git a/src/shared/services/permission.service.spec.ts b/src/shared/services/permission.service.spec.ts index 11bdf0e..a203341 100644 --- a/src/shared/services/permission.service.spec.ts +++ b/src/shared/services/permission.service.spec.ts @@ -346,6 +346,33 @@ describe('PermissionService', () => { expect(allowed).toBe(true); }); + it.each([ + UserRole.PROJECT_MANAGER, + UserRole.TASK_MANAGER, + UserRole.TOPCODER_TASK_MANAGER, + UserRole.TALENT_MANAGER, + UserRole.TOPCODER_TALENT_MANAGER, + ])( + 'allows %s to view projects without membership', + (role) => { + expect( + service.hasNamedPermission(Permission.VIEW_PROJECT, { + userId: '555', + roles: [role], + isMachine: false, + }), + ).toBe(true); + + expect( + service.hasNamedPermission(Permission.READ_PROJECT_ANY, { + userId: '555', + roles: [role], + isMachine: false, + }), + ).toBe(true); + }, + ); + it('allows creating projects for Project Manager role', () => { const allowed = service.hasNamedPermission(Permission.CREATE_PROJECT, { userId: '555', @@ -365,6 +392,42 @@ describe('PermissionService', () => { expect(allowed).toBe(true); }); + it('allows manager-tier roles to read project members without membership', () => { + const allowed = service.hasNamedPermission(Permission.READ_PROJECT_MEMBER, { + userId: '555', + roles: [UserRole.PROGRAM_MANAGER], + isMachine: false, + }); + + expect(allowed).toBe(true); + }); + + it('allows manager-tier roles to read project invites without membership', () => { + const allowed = service.hasNamedPermission( + Permission.READ_PROJECT_INVITE_NOT_OWN, + { + userId: '555', + roles: [UserRole.PROGRAM_MANAGER], + isMachine: false, + }, + ); + + expect(allowed).toBe(true); + }); + + it('allows manager-tier roles to view project attachments without membership', () => { + const allowed = service.hasNamedPermission( + Permission.VIEW_PROJECT_ATTACHMENT, + { + userId: '555', + roles: [UserRole.PROGRAM_MANAGER], + isMachine: false, + }, + ); + + expect(allowed).toBe(true); + }); + it('allows creating other project members for machine token with project-member write scope', () => { const allowed = service.hasNamedPermission( Permission.CREATE_PROJECT_MEMBER_NOT_OWN, diff --git a/src/shared/services/permission.service.ts b/src/shared/services/permission.service.ts index 4eb3fc0..0537428 100644 --- a/src/shared/services/permission.service.ts +++ b/src/shared/services/permission.service.ts @@ -6,7 +6,7 @@ import { PROJECT_MEMBER_MANAGER_ROLES, } from '../enums/projectMemberRole.enum'; import { Scope } from '../enums/scopes.enum'; -import { ADMIN_ROLES, UserRole } from '../enums/userRole.enum'; +import { ADMIN_ROLES, MANAGER_ROLES, UserRole } from '../enums/userRole.enum'; import { Permission, PermissionRule, @@ -166,6 +166,8 @@ export class PermissionService { UserRole.MANAGER, 'topcoder_manager', ]); + const hasProjectReadTopcoderRole = this.hasProjectReadTopcoderRole(user); + const hasManagerTopcoderRole = this.hasManagerTopcoderRole(user); const hasStrictAdminAccess = this.hasIntersection(user.roles || [], ADMIN_ROLES) || this.m2mService.hasRequiredScopes(effectiveScopes, [ @@ -253,11 +255,11 @@ export class PermissionService { switch (permission) { // Project read/write lifecycle permissions. case NamedPermission.READ_PROJECT_ANY: - return isAdmin; + return hasProjectReadTopcoderRole; case NamedPermission.VIEW_PROJECT: return ( - isAdmin || + hasProjectReadTopcoderRole || hasProjectMembership || hasPendingInvite || hasProjectReadScope @@ -288,7 +290,11 @@ export class PermissionService { // Project member management permissions. case NamedPermission.READ_PROJECT_MEMBER: - return isAdmin || hasProjectMembership || hasProjectMemberReadScope; + return ( + hasManagerTopcoderRole || + hasProjectMembership || + hasProjectMemberReadScope + ); case NamedPermission.CREATE_PROJECT_MEMBER_OWN: return isAuthenticated; @@ -320,7 +326,11 @@ export class PermissionService { return isAuthenticated; case NamedPermission.READ_PROJECT_INVITE_NOT_OWN: - return isAdmin || hasProjectMembership || hasProjectInviteReadScope; + return ( + hasManagerTopcoderRole || + hasProjectMembership || + hasProjectInviteReadScope + ); case NamedPermission.CREATE_PROJECT_INVITE_TOPCODER: return isAdmin || isManagementMember || hasProjectInviteWriteScope; @@ -432,7 +442,7 @@ export class PermissionService { // Project attachment permissions. case NamedPermission.VIEW_PROJECT_ATTACHMENT: - return isAdmin || hasProjectMembership; + return hasManagerTopcoderRole || hasProjectMembership; case NamedPermission.CREATE_PROJECT_ATTACHMENT: case NamedPermission.EDIT_PROJECT_ATTACHMENT: @@ -804,6 +814,43 @@ export class PermissionService { return this.hasIntersection(user.roles || [], [UserRole.COPILOT_MANAGER]); } + /** + * Checks Topcoder roles allowed to view project records without membership. + * + * Mirrors the legacy `tc-project-service` `READ_PROJECT` / + * `READ_PROJECT_ANY` role allowlist used by Work Manager and other + * project-management consumers. + * + * @param user authenticated JWT user context + * @returns `true` when user has a project-read legacy Topcoder role + */ + private hasProjectReadTopcoderRole(user: JwtUser): boolean { + return this.hasIntersection(user.roles || [], [ + ...ADMIN_ROLES, + UserRole.MANAGER, + UserRole.PROJECT_MANAGER, + UserRole.TASK_MANAGER, + UserRole.TOPCODER_TASK_MANAGER, + UserRole.TALENT_MANAGER, + UserRole.TOPCODER_TALENT_MANAGER, + 'topcoder_manager', + ]); + } + + /** + * Checks manager-tier Topcoder roles that retain legacy read access to + * project members, invites, and attachments. + * + * @param user authenticated JWT user context + * @returns `true` when user has one of the manager-tier roles + */ + private hasManagerTopcoderRole(user: JwtUser): boolean { + return this.hasIntersection(user.roles || [], [ + ...MANAGER_ROLES, + 'topcoder_manager', + ]); + } + /** * Checks Topcoder roles allowed to view billing-account data. *