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. *