diff --git a/README.md b/README.md index 478bded..274c5fa 100644 --- a/README.md +++ b/README.md @@ -524,6 +524,6 @@ Migration runbook (phased rollout, rollback, monitoring): `docs/MIGRATION_RUNBOO ### Talent Manager project visibility -`Talent Manager` and `Topcoder Talent Manager` users can list and open all non-internal projects without membership. Set `INTERNAL_BILLING_ACCOUNT_IDS` to the comma-separated positive billing account IDs used for internal projects before deploying this policy (for example, `123,456`); an empty value excludes no accounts, and invalid values reject requests rather than applying a partial list. Projects without a billing account remain visible. +`Talent Manager` and `Topcoder Talent Manager` users can list and open all non-internal projects without membership, including read-only access to their members, invites, and attachments. Set `INTERNAL_BILLING_ACCOUNT_IDS` to the comma-separated positive billing account IDs used for internal projects before deploying this policy (for example, `123,456`); an empty value excludes no accounts, and invalid values reject requests rather than applying a partial list. Projects without a billing account remain visible. Internal projects are excluded from list results and totals and rejected on direct project and nested project routes unless the Talent Manager is an active project member. Explicit membership in any project role overrides the internal billing account exclusion; deleted memberships and pending invites alone do not. Administrators and machine tokens keep their existing authorization policies. `memberOnly=true` narrows the visible projects to existing membership/pending-invite associations, including internal projects with active membership; Work exposes this as **My Projects**. diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index b8a56e1..06f3b4d 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -39,6 +39,7 @@ Swagger auth notes: - `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. +- `Talent Manager` and `Topcoder Talent Manager` also read project members, invites, and attachments without membership (`READ_PROJECT_MEMBER`, `READ_PROJECT_INVITE_NOT_OWN`, `VIEW_PROJECT_ATTACHMENT`), so Work Manager's Users and Assets Library tabs load for non-internal projects. Internal projects still require active membership, and member, invite, and attachment changes keep their existing project-role checks. - Work streams, works, and work items now follow the same legacy project-view read path: manager-tier roles can read them without membership, and any current project member can reach those endpoints because the work-layer route guard no longer blocks non-manager human roles before `PermissionGuard` runs. - The legacy JWT role `topcoder_manager` is accepted end-to-end by both route-level role guards and `PermissionService`, so those users are not blocked before the PM-3764 read-parity checks run. diff --git a/src/api/project/project.service.spec.ts b/src/api/project/project.service.spec.ts index a07b4c4..d11bc90 100644 --- a/src/api/project/project.service.spec.ts +++ b/src/api/project/project.service.spec.ts @@ -603,6 +603,54 @@ describe('ProjectService', () => { }, ); + it.each([UserRole.TALENT_MANAGER, UserRole.TOPCODER_TALENT_MANAGER])( + 'returns members, invites, and attachments of a non-member project to %s', + async (role) => { + const realPermissionService = new PermissionService({ + hasRequiredScopes: () => false, + validateMachineToken: () => ({ isMachine: false, scopes: [] }), + } as any); + const serviceWithRealPermissions = new ProjectService( + prismaMock as any, + realPermissionService, + billingAccountServiceMock as any, + memberServiceMock as any, + ); + prismaMock.project.findFirst.mockResolvedValue({ + id: 1001n, + name: 'Non-internal', + billingAccountId: null, + members: [{ id: 1n, userId: 100n, role: 'manager', deletedAt: null }], + invites: [ + { + id: 2n, + userId: 200n, + email: null, + status: 'pending', + deletedAt: null, + }, + ], + attachments: [ + { id: 3n, createdBy: 100, allowedUsers: [], deletedAt: null }, + ], + }); + + const result = await serviceWithRealPermissions.getProject( + '1001', + 'members,invites,attachments', + { userId: '999', roles: [role], isMachine: false }, + ); + + expect(result.members).toEqual([ + expect.objectContaining({ userId: '100', role: 'manager' }), + ]); + expect(result.invites).toEqual([ + expect.objectContaining({ userId: '200', status: 'pending' }), + ]); + expect(result.attachments).toHaveLength(1); + }, + ); + describe('Talent Manager internal project membership override', () => { const originalIds = process.env.INTERNAL_BILLING_ACCOUNT_IDS; diff --git a/src/shared/services/permission.service.spec.ts b/src/shared/services/permission.service.spec.ts index 3151d20..63d4544 100644 --- a/src/shared/services/permission.service.spec.ts +++ b/src/shared/services/permission.service.spec.ts @@ -513,6 +513,68 @@ describe('PermissionService', () => { }, ); + it.each([UserRole.TALENT_MANAGER, UserRole.TOPCODER_TALENT_MANAGER])( + 'allows %s to read project members, invites, and attachments without membership', + (role) => { + const user = { + userId: '555', + roles: [role], + isMachine: false, + }; + const projectMembers = [ + { + userId: '999', + role: ProjectMemberRole.MANAGER, + }, + ]; + + expect( + [ + Permission.READ_PROJECT_MEMBER, + Permission.READ_PROJECT_INVITE_NOT_OWN, + Permission.VIEW_PROJECT_ATTACHMENT, + ].map((permission) => + service.hasNamedPermission(permission, user, projectMembers), + ), + ).toEqual([true, true, true]); + + expect( + [ + Permission.CREATE_PROJECT_MEMBER_NOT_OWN, + Permission.UPDATE_PROJECT_MEMBER_NON_CUSTOMER, + Permission.DELETE_PROJECT_MEMBER_TOPCODER, + Permission.CREATE_PROJECT_INVITE_TOPCODER, + Permission.UPDATE_PROJECT_INVITE_NOT_OWN, + Permission.CREATE_PROJECT_ATTACHMENT, + Permission.EDIT_PROJECT_ATTACHMENT, + Permission.DELETE_PROJECT_ATTACHMENT, + ].map((permission) => + service.hasNamedPermission(permission, user, projectMembers), + ), + ).toEqual([false, false, false, false, false, false, false, false]); + }, + ); + + it('requires membership for non-manager roles to read project members, invites, and attachments', () => { + const user = { + userId: '555', + roles: [UserRole.TOPCODER_USER], + isMachine: false, + }; + + expect( + [ + Permission.READ_PROJECT_MEMBER, + Permission.READ_PROJECT_INVITE_NOT_OWN, + Permission.VIEW_PROJECT_ATTACHMENT, + ].map((permission) => + service.hasNamedPermission(permission, user, [ + { userId: '999', role: ProjectMemberRole.MANAGER }, + ]), + ), + ).toEqual([false, false, false]); + }); + 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 b55061a..094cc4d 100644 --- a/src/shared/services/permission.service.ts +++ b/src/shared/services/permission.service.ts @@ -140,6 +140,10 @@ export class PermissionService { * check: any non-empty `userId`, any role, any scope, or `isMachine`. * @security Legacy manager JWTs use `UserRole.TOPCODER_MANAGER`, which is * broader than strict admin access and retained for v5 compatibility. + * @security Talent Manager roles can read project members, invites, and + * attachments without membership so Work Manager can show non-internal + * project workspaces. Internal-project membership is enforced separately by + * `ProjectContextInterceptor` and `ProjectService.getProject`. */ hasNamedPermission( permission: NamedPermission, @@ -167,6 +171,8 @@ export class PermissionService { ]); const hasProjectReadTopcoderRole = this.hasProjectReadTopcoderRole(user); const hasManagerTopcoderRole = this.hasManagerTopcoderRole(user); + // Internal-project membership for Talent Managers is enforced upstream. + const hasTalentManagerRole = this.hasTalentManagerRole(user); const hasStrictAdminAccess = this.hasIntersection(user.roles || [], ADMIN_ROLES) || this.m2mService.hasRequiredScopes(effectiveScopes, [ @@ -299,6 +305,7 @@ export class PermissionService { case NamedPermission.READ_PROJECT_MEMBER: return ( hasManagerTopcoderRole || + hasTalentManagerRole || hasProjectMembership || hasProjectMemberReadScope ); @@ -335,6 +342,7 @@ export class PermissionService { case NamedPermission.READ_PROJECT_INVITE_NOT_OWN: return ( hasManagerTopcoderRole || + hasTalentManagerRole || hasProjectMembership || hasProjectInviteReadScope ); @@ -449,7 +457,9 @@ export class PermissionService { // Project attachment permissions. case NamedPermission.VIEW_PROJECT_ATTACHMENT: - return hasManagerTopcoderRole || hasProjectMembership; + return ( + hasManagerTopcoderRole || hasTalentManagerRole || hasProjectMembership + ); case NamedPermission.CREATE_PROJECT_ATTACHMENT: case NamedPermission.EDIT_PROJECT_ATTACHMENT: @@ -891,6 +901,9 @@ export class PermissionService { /** * Checks whether user has one of the Talent Manager Topcoder roles. * + * Used by {@link hasNamedPermission} to grant read-only access to project + * members, invites, and attachments without project membership. + * * @param user authenticated JWT user context * @returns `true` when user has Talent Manager access */ diff --git a/src/shared/utils/permission-docs.utils.ts b/src/shared/utils/permission-docs.utils.ts index e2d3056..21fdd79 100644 --- a/src/shared/utils/permission-docs.utils.ts +++ b/src/shared/utils/permission-docs.utils.ts @@ -275,7 +275,7 @@ function getNamedPermissionDocumentation( case NamedPermission.READ_PROJECT_MEMBER: return createSummary({ - userRoles: ADMIN_AND_MANAGER_ROLES, + userRoles: [...ADMIN_AND_MANAGER_ROLES, ...TALENT_MANAGER_ROLES], allowAnyProjectMember: true, scopes: PROJECT_MEMBER_READ_SCOPES, }); @@ -312,7 +312,7 @@ function getNamedPermissionDocumentation( case NamedPermission.READ_PROJECT_INVITE_NOT_OWN: return createSummary({ - userRoles: ADMIN_AND_MANAGER_ROLES, + userRoles: [...ADMIN_AND_MANAGER_ROLES, ...TALENT_MANAGER_ROLES], allowAnyProjectMember: true, scopes: PROJECT_INVITE_READ_SCOPES, }); @@ -418,7 +418,7 @@ function getNamedPermissionDocumentation( case NamedPermission.VIEW_PROJECT_ATTACHMENT: return createSummary({ - userRoles: ADMIN_AND_MANAGER_ROLES, + userRoles: [...ADMIN_AND_MANAGER_ROLES, ...TALENT_MANAGER_ROLES], allowAnyProjectMember: true, });