diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index 2580a8c..b8a56e1 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. +- 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. ## Billing Account Editing diff --git a/src/api/workstream/workstream.controller.ts b/src/api/workstream/workstream.controller.ts index 7d3bd9d..af7af8c 100644 --- a/src/api/workstream/workstream.controller.ts +++ b/src/api/workstream/workstream.controller.ts @@ -42,8 +42,10 @@ import { WorkStreamService } from './workstream.service'; /** * REST controller for work streams under `/projects/:projectId/workstreams`. * Work streams are containers for works (project phases) linked via the - * `phase_work_streams` join table. Access is restricted to - * admin/manager/copilot roles. Used by the platform-ui Work app. + * `phase_work_streams` join table. Route-level auth accepts any known human + * role and defers the final allow/deny decision to `PermissionGuard`, which + * preserves legacy project-view access for project members and manager-tier + * roles. Used by the platform-ui Work app. */ export class WorkStreamController { constructor(private readonly service: WorkStreamService) {} diff --git a/src/shared/constants/roles.ts b/src/shared/constants/roles.ts index 819dc82..c80abbd 100644 --- a/src/shared/constants/roles.ts +++ b/src/shared/constants/roles.ts @@ -1,14 +1,10 @@ import { UserRole } from 'src/shared/enums/userRole.enum'; /** - * Roles allowed for workstream/work/workitem endpoints. + * Coarse auth pass-through for workstream/work/workitem endpoints. + * + * Fine-grained access is still enforced by `PermissionGuard`, which needs to + * see all authenticated human roles so project-member and manager-tier + * read-parity checks can run. */ -export const WORK_LAYER_ALLOWED_ROLES = [ - UserRole.TOPCODER_ADMIN, - UserRole.CONNECT_ADMIN, - UserRole.TG_ADMIN, - UserRole.MANAGER, - UserRole.COPILOT, - UserRole.TC_COPILOT, - UserRole.COPILOT_MANAGER, -] as const; +export const WORK_LAYER_ALLOWED_ROLES = Object.values(UserRole); diff --git a/src/shared/guards/tokenRoles.guard.spec.ts b/src/shared/guards/tokenRoles.guard.spec.ts index 5a53d0a..04c0572 100644 --- a/src/shared/guards/tokenRoles.guard.spec.ts +++ b/src/shared/guards/tokenRoles.guard.spec.ts @@ -7,6 +7,7 @@ import { Reflector } from '@nestjs/core'; import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; import { SCOPES_KEY } from '../decorators/scopes.decorator'; import { UserRole } from '../enums/userRole.enum'; +import { WORK_LAYER_ALLOWED_ROLES } from '../constants/roles'; import { JwtService } from '../modules/global/jwt.service'; import { M2MService } from '../modules/global/m2m.service'; import { ADMIN_ONLY_KEY } from './auth-metadata.constants'; @@ -263,6 +264,49 @@ describe('TokenRolesGuard', () => { ); }); + it('allows Topcoder User tokens on work-layer routes so project-member permissions run downstream', async () => { + const request: Record = { + headers: { + authorization: 'Bearer human-token', + }, + }; + + reflectorMock.getAllAndOverride.mockImplementation((key: string) => { + if (key === IS_PUBLIC_KEY) { + return false; + } + if (key === ROLES_KEY) { + return WORK_LAYER_ALLOWED_ROLES; + } + if (key === SCOPES_KEY) { + return []; + } + return undefined; + }); + + jwtServiceMock.validateToken.mockResolvedValue({ + roles: [UserRole.TOPCODER_USER], + scopes: [], + isMachine: false, + tokenPayload: { + sub: '123', + }, + }); + m2mServiceMock.validateMachineToken.mockReturnValue({ + isMachine: false, + scopes: [], + }); + + const result = await guard.canActivate(createExecutionContext(request)); + + expect(result).toBe(true); + expect(request.user).toEqual( + expect.objectContaining({ + roles: [UserRole.TOPCODER_USER], + }), + ); + }); + it('allows human token when required scope is present', async () => { const request: Record = { headers: { diff --git a/src/shared/services/permission.service.spec.ts b/src/shared/services/permission.service.spec.ts index 8a49b05..daa5fc4 100644 --- a/src/shared/services/permission.service.spec.ts +++ b/src/shared/services/permission.service.spec.ts @@ -422,6 +422,58 @@ describe('PermissionService', () => { }, ); + it.each([UserRole.PROJECT_MANAGER, UserRole.PROGRAM_MANAGER])( + 'allows manager-tier role %s to view work-layer resources without membership', + (role) => { + expect( + service.hasNamedPermission(Permission.WORKSTREAM_VIEW, { + userId: '555', + roles: [role], + isMachine: false, + }), + ).toBe(true); + + expect( + service.hasNamedPermission(Permission.WORK_VIEW, { + userId: '555', + roles: [role], + isMachine: false, + }), + ).toBe(true); + + expect( + service.hasNamedPermission(Permission.WORKITEM_VIEW, { + userId: '555', + roles: [role], + isMachine: false, + }), + ).toBe(true); + }, + ); + + it('allows machine admin scope to view work-layer resources', () => { + expect( + service.hasNamedPermission(Permission.WORKSTREAM_VIEW, { + scopes: [Scope.CONNECT_PROJECT_ADMIN], + isMachine: true, + }), + ).toBe(true); + + expect( + service.hasNamedPermission(Permission.WORK_VIEW, { + scopes: [Scope.CONNECT_PROJECT_ADMIN], + isMachine: true, + }), + ).toBe(true); + + expect( + service.hasNamedPermission(Permission.WORKITEM_VIEW, { + scopes: [Scope.CONNECT_PROJECT_ADMIN], + isMachine: true, + }), + ).toBe(true); + }); + it.each([UserRole.PROGRAM_MANAGER, UserRole.TOPCODER_MANAGER])( 'allows manager-tier role %s to view project attachments without membership', (role) => { diff --git a/src/shared/services/permission.service.ts b/src/shared/services/permission.service.ts index fbf486f..c1fc53e 100644 --- a/src/shared/services/permission.service.ts +++ b/src/shared/services/permission.service.ts @@ -474,7 +474,11 @@ export class PermissionService { case NamedPermission.WORKSTREAM_VIEW: case NamedPermission.WORK_VIEW: case NamedPermission.WORKITEM_VIEW: - return isAdmin || hasProjectMembership; + return ( + hasManagerTopcoderRole || + hasProjectMembership || + hasStrictAdminAccess + ); case NamedPermission.WORKSTREAM_DELETE: case NamedPermission.WORK_DELETE: diff --git a/src/shared/utils/permission-docs.utils.ts b/src/shared/utils/permission-docs.utils.ts index 0ff7d88..4820bc0 100644 --- a/src/shared/utils/permission-docs.utils.ts +++ b/src/shared/utils/permission-docs.utils.ts @@ -10,7 +10,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 as PermissionPolicy, PermissionRule, @@ -83,6 +83,8 @@ const PROJECT_CREATOR_MANAGER_USER_ROLES = [ ...TALENT_MANAGER_ROLES, ]; +const PROJECT_VIEW_USER_ROLES = [...MANAGER_ROLES, UserRole.TOPCODER_MANAGER]; + const PROJECT_MEMBER_MANAGEMENT_ROLES = [...PROJECT_MEMBER_MANAGER_ROLES]; const PROJECT_MEMBER_MANAGEMENT_AND_COPILOT_ROLES = [ @@ -446,8 +448,9 @@ function getNamedPermissionDocumentation( case NamedPermission.WORK_VIEW: case NamedPermission.WORKITEM_VIEW: return createSummary({ - userRoles: ADMIN_AND_MANAGER_ROLES, + userRoles: PROJECT_VIEW_USER_ROLES, allowAnyProjectMember: true, + scopes: STRICT_ADMIN_SCOPES, }); case NamedPermission.WORKSTREAM_DELETE: