diff --git a/.env.example b/.env.example index 41bf42a..e9c0006 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,7 @@ ENABLE_FILE_UPLOAD=true # External services MEMBER_API_URL="" IDENTITY_API_URL="" +BILLING_ACCOUNTS_API_URL="" # Salesforce Billing Account integration SALESFORCE_CLIENT_ID="" diff --git a/.github/workflows/trivy.yaml b/.github/workflows/trivy.yaml index a99d8b8..5dc5051 100644 --- a/.github/workflows/trivy.yaml +++ b/.github/workflows/trivy.yaml @@ -19,7 +19,7 @@ jobs: uses: actions/checkout@v4 - name: Run Trivy scanner in repo mode - uses: aquasecurity/trivy-action@0.34.0 + uses: aquasecurity/trivy-action@0.35.0 with: scan-type: fs ignore-unfixed: true diff --git a/README.md b/README.md index 039ed77..80af4dc 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ For the full v5 -> v6 mapping table, see `docs/api-usage-analysis.md`. | `GET` | `/v6/projects/:projectId` | JWT / M2M | Get project by ID (includes `members`, `invites`) | | `PATCH` | `/v6/projects/:projectId` | JWT / M2M | Update project | | `DELETE` | `/v6/projects/:projectId` | Admin only | Soft-delete project | -| `GET` | `/v6/projects/:projectId/billingAccount` | JWT / M2M | Default billing account (Salesforce) | +| `GET` | `/v6/projects/:projectId/billingAccount` | JWT / M2M | Default billing account (Billing Accounts API with Salesforce fallback) | | `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, talent managers, and project copilots on the project: per-member permission matrix with project permissions and template policies | @@ -321,6 +321,7 @@ Reference source: `.env.example`. | `ENABLE_FILE_UPLOAD` | - | `true` | Toggle S3 file upload | | `MEMBER_API_URL` | ✅ | - | Member API base URL | | `IDENTITY_API_URL` | ✅ | - | Identity API base URL | +| `BILLING_ACCOUNTS_API_URL` | - | - | Billing Accounts API base URL used for default billing-account lookup before Salesforce fallback | | `SALESFORCE_CLIENT_ID` | ✅ | - | Salesforce JWT client ID | | `SALESFORCE_CLIENT_AUDIENCE` | ✅ | `https://login.salesforce.com` | Salesforce audience | | `SALESFORCE_SUBJECT` | ✅ | - | Salesforce JWT subject | diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index 7a1d4cb..b8a56e1 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -35,6 +35,13 @@ 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. +- 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 - `MANAGE_PROJECT_BILLING_ACCOUNT_ID` is intentionally narrower than general project edit access. diff --git a/src/api/copilot/copilot-opportunity.controller.ts b/src/api/copilot/copilot-opportunity.controller.ts index dd3ec74..9c154f5 100644 --- a/src/api/copilot/copilot-opportunity.controller.ts +++ b/src/api/copilot/copilot-opportunity.controller.ts @@ -23,6 +23,7 @@ import { import { Request, Response } from 'express'; import { Permission } from 'src/shared/constants/permissions'; import { CurrentUser } from 'src/shared/decorators/currentUser.decorator'; +import { Public } from 'src/shared/decorators/public.decorator'; import { RequirePermission } from 'src/shared/decorators/requirePermission.decorator'; import { Scopes } from 'src/shared/decorators/scopes.decorator'; import { Scope } from 'src/shared/enums/scopes.enum'; @@ -59,6 +60,7 @@ export class CopilotOpportunityController { * @returns Opportunity page data. */ @Get('copilots/opportunities') + @Public() @Roles(...Object.values(UserRole)) @Scopes( Scope.PROJECTS_READ, @@ -82,7 +84,7 @@ export class CopilotOpportunityController { @Req() req: Request, @Res({ passthrough: true }) res: Response, @Query() query: ListOpportunitiesQueryDto, - @CurrentUser() user: JwtUser, + @CurrentUser() user: JwtUser | undefined, ): Promise { const result = await this.service.listOpportunities(query, user); @@ -108,6 +110,7 @@ export class CopilotOpportunityController { */ @Get('copilot/opportunity/:id') @Get('copilots/opportunity/:id') + @Public() // TODO [QUALITY]: Two route decorators (singular/plural) map to the same handler for legacy compatibility; document which route is canonical. @Roles(...Object.values(UserRole)) @Scopes( @@ -128,7 +131,7 @@ export class CopilotOpportunityController { @ApiResponse({ status: 404, description: 'Not found' }) async getOpportunity( @Param('id') id: string, - @CurrentUser() user: JwtUser, + @CurrentUser() user: JwtUser | undefined, ): Promise { return this.service.getOpportunity(id, user); } diff --git a/src/api/copilot/copilot-opportunity.service.ts b/src/api/copilot/copilot-opportunity.service.ts index 3d8c7ba..bfcbe96 100644 --- a/src/api/copilot/copilot-opportunity.service.ts +++ b/src/api/copilot/copilot-opportunity.service.ts @@ -85,12 +85,12 @@ export class CopilotOpportunityService { * Admin/manager responses also include minimal project metadata for v5 compatibility. * * @param query Pagination, sort, and noGrouping parameters. - * @param user Authenticated JWT user. + * @param user Authenticated JWT user, or undefined for anonymous `@Public()` callers. * @returns Paginated opportunity response payload. */ async listOpportunities( query: ListOpportunitiesQueryDto, - user: JwtUser, + user: JwtUser | undefined, ): Promise { // TODO [SECURITY]: No permission check is applied here; this is intentional for authenticated browsing and should remain explicitly documented. const [sortField, sortDirection] = parseSortExpression( @@ -160,14 +160,14 @@ export class CopilotOpportunityService { * Admin/manager responses also include minimal project metadata for v5 compatibility. * * @param opportunityId Opportunity id path value. - * @param user Authenticated JWT user. + * @param user Authenticated JWT user, or undefined for anonymous `@Public()` callers. * @returns One formatted opportunity response. * @throws BadRequestException If id is non-numeric. * @throws NotFoundException If opportunity does not exist. */ async getOpportunity( opportunityId: string, - user: JwtUser, + user: JwtUser | undefined, ): Promise { // TODO [SECURITY]: No permission check is applied; any authenticated user can access any opportunity by id. const parsedOpportunityId = parseNumericId(opportunityId, 'Opportunity'); @@ -208,7 +208,7 @@ export class CopilotOpportunityService { ); const canApplyAsCopilot = - user.userId && user.userId.trim().length > 0 + user?.userId && user.userId.trim().length > 0 ? !members.includes(user.userId) : true; @@ -622,9 +622,9 @@ export class CopilotOpportunityService { */ private async getMembershipProjectIds( opportunities: CopilotOpportunity[], - user: JwtUser, + user: JwtUser | undefined, ): Promise> { - if (!user.userId || !/^\d+$/.test(user.userId)) { + if (!user?.userId || !/^\d+$/.test(user.userId)) { return new Set(); } diff --git a/src/api/copilot/copilot.utils.ts b/src/api/copilot/copilot.utils.ts index 68eb382..2d25c45 100644 --- a/src/api/copilot/copilot.utils.ts +++ b/src/api/copilot/copilot.utils.ts @@ -81,10 +81,13 @@ export function getCopilotTypeLabel(type: CopilotOpportunityType): string { /** * Returns true if user is admin, project manager, or manager. * - * @param user Authenticated JWT user. + * @param user Authenticated JWT user (undefined on anonymous `@Public()` routes). * @returns Whether the user is admin-or-manager scoped. */ -export function isAdminOrManager(user: JwtUser): boolean { +export function isAdminOrManager(user: JwtUser | undefined): boolean { + if (!user) { + return false; + } const userRoles = user.roles || []; return [ @@ -98,10 +101,13 @@ export function isAdminOrManager(user: JwtUser): boolean { /** * Returns true if user is admin or project manager. * - * @param user Authenticated JWT user. + * @param user Authenticated JWT user (undefined on anonymous `@Public()` routes). * @returns Whether the user is admin-or-pm scoped. */ -export function isAdminOrPm(user: JwtUser): boolean { +export function isAdminOrPm(user: JwtUser | undefined): boolean { + if (!user) { + return false; + } const userRoles = user.roles || []; return [ diff --git a/src/api/project/project.service.spec.ts b/src/api/project/project.service.spec.ts index aa548cf..c3be179 100644 --- a/src/api/project/project.service.spec.ts +++ b/src/api/project/project.service.spec.ts @@ -65,6 +65,16 @@ describe('ProjectService', () => { prismaMock.$queryRaw.mockResolvedValue([]); memberServiceMock.getMemberDetailsByUserIds.mockResolvedValue([]); memberServiceMock.getUserRoles.mockResolvedValue([]); + permissionServiceMock.hasIntersection.mockImplementation( + (userRoles: string[] = [], allowedRoles: string[] = []) => + userRoles.some((userRole) => + allowedRoles.some( + (allowedRole) => + String(userRole).trim().toLowerCase() === + String(allowedRole).trim().toLowerCase(), + ), + ), + ); service = new ProjectService( prismaMock as any, permissionServiceMock as unknown as PermissionService, @@ -259,6 +269,66 @@ describe('ProjectService', () => { 'JMGasper+devtest140@gmail.com', ); }); + + it.each([ + ['project manager', UserRole.PROJECT_MANAGER], + ['talent manager', UserRole.TALENT_MANAGER], + ])( + 'scopes %s project listings to project membership', + async (_label: string, role: UserRole) => { + permissionServiceMock.hasNamedPermission.mockImplementation( + (permission: Permission): boolean => + permission === Permission.READ_PROJECT_ANY || + permission === Permission.READ_PROJECT_MEMBER, + ); + permissionServiceMock.hasIntersection.mockReturnValue(false); + + prismaMock.project.count.mockResolvedValue(0); + prismaMock.project.findMany.mockResolvedValue([]); + + await service.listProjects( + { + page: 1, + perPage: 20, + }, + { + userId: '999', + roles: [role], + isMachine: false, + }, + ); + + expect(prismaMock.project.count).toHaveBeenCalledWith({ + where: { + deletedAt: null, + AND: [ + { + OR: [ + { + members: { + some: { + userId: BigInt(999), + deletedAt: null, + }, + }, + }, + { + invites: { + some: { + userId: BigInt(999), + status: 'pending', + deletedAt: null, + }, + }, + }, + ], + }, + ], + }, + }); + }, + ); + it('does not load relation payloads by default in project listing', async () => { permissionServiceMock.hasNamedPermission.mockImplementation( (permission: Permission): boolean => @@ -451,6 +521,67 @@ describe('ProjectService', () => { ).rejects.toBeInstanceOf(NotFoundException); }); + it.each([ + ['project manager', UserRole.PROJECT_MANAGER], + ['talent manager', UserRole.TALENT_MANAGER], + ])( + 'rejects direct project access for %s callers who are not on the project', + async (_label: string, role: UserRole) => { + const now = new Date(); + + prismaMock.project.findFirst.mockResolvedValue({ + id: BigInt(1001), + name: 'Demo', + description: null, + type: 'app', + status: 'active', + billingAccountId: null, + directProjectId: null, + estimatedPrice: null, + actualPrice: null, + terms: [], + groups: [], + external: null, + bookmarks: null, + utm: null, + details: null, + challengeEligibility: null, + cancelReason: null, + templateId: null, + version: 'v3', + lastActivityAt: now, + lastActivityUserId: '100', + createdAt: now, + updatedAt: now, + createdBy: 100, + updatedBy: 100, + members: [ + { + userId: BigInt(100), + role: 'manager', + deletedAt: null, + }, + ], + invites: [], + attachments: [], + }); + permissionServiceMock.hasNamedPermission.mockImplementation( + (permission: Permission): boolean => + permission === Permission.VIEW_PROJECT || + permission === Permission.READ_PROJECT_ANY, + ); + permissionServiceMock.hasIntersection.mockReturnValue(false); + + await expect( + service.getProject('1001', undefined, { + userId: '999', + roles: [role], + isMachine: false, + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }, + ); + it('lists billing accounts for project id', async () => { billingAccountServiceMock.getBillingAccountsForProject.mockResolvedValue([ { diff --git a/src/api/project/project.service.ts b/src/api/project/project.service.ts index c088146..e51db00 100644 --- a/src/api/project/project.service.ts +++ b/src/api/project/project.service.ts @@ -137,12 +137,13 @@ export class ProjectService { const perPage = criteria.perPage || 20; const skip = (page - 1) * perPage; - const isAdmin = this.permissionService.hasNamedPermission( - Permission.READ_PROJECT_ANY, + const hasGlobalProjectReadAccess = this.hasGlobalProjectReadAccess(user); + + const where = buildProjectWhereClause( + criteria, user, + hasGlobalProjectReadAccess, ); - - const where = buildProjectWhereClause(criteria, user, isAdmin); const requestedFields = this.resolveListFields(criteria.fields); const includeFields = this.resolveListIncludeFields(requestedFields); const include = buildProjectIncludeClause(includeFields); @@ -171,7 +172,7 @@ export class ProjectService { const filteredProject = this.filterProjectRelations( project, user, - isAdmin, + hasGlobalProjectReadAccess, ); const projectWithRequestedFields = this.filterProjectFields( filteredProject, @@ -200,14 +201,17 @@ export class ProjectService { * * Members and invites are always loaded for permission evaluation regardless * of requested `fields`, then relation visibility is filtered by caller - * permissions before response serialization. + * permissions before response serialization. Human PM/TM-style callers must + * still be a project member or pending invitee; only admins, legacy manager + * roles, and authorized machine principals bypass membership scoping. * * @param projectId Project id path parameter. * @param fieldsParam Optional CSV list of relation fields. * @param user Authenticated caller context. * @returns Project response DTO. * @throws NotFoundException When the project does not exist. - * @throws ForbiddenException When caller lacks `VIEW_PROJECT`. + * @throws ForbiddenException When caller lacks `VIEW_PROJECT` or does not + * have member/invite visibility for the requested project. */ async getProject( projectId: string, @@ -239,6 +243,10 @@ export class ProjectService { const [projectWithMemberHandles] = await this.enrichProjectsWithMemberHandles([project]); const projectWithRelations = projectWithMemberHandles || project; + const hasGlobalProjectReadAccess = this.hasGlobalProjectReadAccess( + user, + projectWithRelations.members || [], + ); const canViewProject = this.permissionService.hasNamedPermission( Permission.VIEW_PROJECT, @@ -254,16 +262,21 @@ export class ProjectService { throw new ForbiddenException('Insufficient permissions'); } - const isAdmin = this.permissionService.hasNamedPermission( - Permission.READ_PROJECT_ANY, - user, - projectWithRelations.members || [], - ); + if ( + !hasGlobalProjectReadAccess && + !this.hasProjectScopedVisibility( + user, + projectWithRelations.members || [], + projectWithRelations.invites || [], + ) + ) { + throw new ForbiddenException('Insufficient permissions'); + } const filteredProject = this.filterProjectRelations( projectWithRelations, user, - isAdmin, + hasGlobalProjectReadAccess, ); const projectWithRequestedFields = this.filterProjectFields( filteredProject, @@ -784,14 +797,17 @@ export class ProjectService { await this.enrichProjectsWithMemberHandles([project]); const projectWithRelations = projectWithMemberHandles || project; - const isAdmin = this.permissionService.hasNamedPermission( - Permission.READ_PROJECT_ANY, + const hasGlobalProjectReadAccess = this.hasGlobalProjectReadAccess( user, projectWithRelations.members || [], ); const response = this.toDto( - this.filterProjectRelations(projectWithRelations, user, isAdmin), + this.filterProjectRelations( + projectWithRelations, + user, + hasGlobalProjectReadAccess, + ), ); this.publishEvent(KAFKA_TOPIC.PROJECT_UPDATED, response); @@ -1413,6 +1429,99 @@ export class ProjectService { return undefined; } + /** + * Returns whether the caller may bypass project membership visibility checks. + * + * Human callers only retain global access for admin or legacy manager roles. + * Machine principals continue to rely on the named permission so scoped + * service tokens can read any project when authorized. + * + * @param user Authenticated caller context. + * @param projectMembers Optional project members for permission evaluation. + * @returns `true` when the caller can read projects without membership. + */ + private hasGlobalProjectReadAccess( + user: JwtUser, + projectMembers: ProjectMember[] = [], + ): boolean { + if (this.isMachinePrincipal(user)) { + return this.permissionService.hasNamedPermission( + Permission.READ_PROJECT_ANY, + user, + projectMembers, + ); + } + + return this.permissionService.hasIntersection(user.roles || [], [ + ...ADMIN_ROLES, + UserRole.MANAGER, + UserRole.TOPCODER_MANAGER, + ]); + } + + /** + * Returns whether the caller can see a project through membership or invite. + * + * @param user Authenticated caller context. + * @param projectMembers Active project members. + * @param projectInvites Pending or historical project invites. + * @returns `true` when the caller is a member or has a pending invite. + */ + private hasProjectScopedVisibility( + user: JwtUser, + projectMembers: ProjectMember[], + projectInvites: ProjectMemberInvite[], + ): boolean { + const parsedUserId = this.parseUserIdValue(user.userId); + + if ( + parsedUserId && + projectMembers.some((member) => { + const parsedMemberUserId = this.parseUserIdValue(member.userId); + + return Boolean( + parsedMemberUserId && + parsedMemberUserId === parsedUserId && + member.deletedAt === null, + ); + }) + ) { + return true; + } + + const normalizedEmail = String(user.email || '') + .trim() + .toLowerCase(); + + return projectInvites.some((invite) => { + if ( + invite.deletedAt !== null || + String(invite.status || '').trim().toLowerCase() !== 'pending' + ) { + return false; + } + + const parsedInviteUserId = this.parseUserIdValue(invite.userId); + + if ( + parsedUserId && + parsedInviteUserId && + parsedInviteUserId === parsedUserId + ) { + return true; + } + + if (!normalizedEmail) { + return false; + } + + return ( + typeof invite.email === 'string' && + invite.email.trim().toLowerCase() === normalizedEmail + ); + }); + } + /** * Normalizes a handle candidate to a trimmed non-empty string. * 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/config/service-endpoints.config.ts b/src/shared/config/service-endpoints.config.ts index c390212..6072262 100644 --- a/src/shared/config/service-endpoints.config.ts +++ b/src/shared/config/service-endpoints.config.ts @@ -2,6 +2,9 @@ * Runtime service endpoint configuration. */ export const SERVICE_ENDPOINTS = { - memberApiUrl: process.env.MEMBER_API_URL || '', - identityApiUrl: process.env.IDENTITY_API_URL || '', + billingAccountsApiUrl: process.env.BILLING_ACCOUNTS_API_URL || '', + memberApiUrl: + process.env.MEMBER_API_URL || process.env.MEMBER_SERVICE_ENDPOINT || '', + identityApiUrl: + process.env.IDENTITY_API_URL || process.env.IDENTITY_SERVICE_ENDPOINT || '', }; 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/enums/userRole.enum.ts b/src/shared/enums/userRole.enum.ts index 1d9b011..792ee82 100644 --- a/src/shared/enums/userRole.enum.ts +++ b/src/shared/enums/userRole.enum.ts @@ -10,6 +10,10 @@ export enum UserRole { * Connect manager role. */ MANAGER = 'Connect Manager', + /** + * Legacy manager role still emitted by some JWTs. + */ + TOPCODER_MANAGER = 'topcoder_manager', /** * Connect account manager role. */ diff --git a/src/shared/guards/tokenRoles.guard.spec.ts b/src/shared/guards/tokenRoles.guard.spec.ts index 015a4e6..04c0572 100644 --- a/src/shared/guards/tokenRoles.guard.spec.ts +++ b/src/shared/guards/tokenRoles.guard.spec.ts @@ -6,6 +6,8 @@ import { 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'; @@ -219,6 +221,92 @@ describe('TokenRolesGuard', () => { expect(request.user).toEqual(user); }); + it('allows legacy topcoder_manager human tokens on routes that accept all known user roles', 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 Object.values(UserRole); + } + if (key === SCOPES_KEY) { + return []; + } + return undefined; + }); + + jwtServiceMock.validateToken.mockResolvedValue({ + roles: [UserRole.TOPCODER_MANAGER], + 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_MANAGER], + }), + ); + }); + + 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/modules/global/jwt.service.spec.ts b/src/shared/modules/global/jwt.service.spec.ts index 18a0787..7ae355b 100644 --- a/src/shared/modules/global/jwt.service.spec.ts +++ b/src/shared/modules/global/jwt.service.spec.ts @@ -1,4 +1,5 @@ import * as jwt from 'jsonwebtoken'; +import { Scope } from 'src/shared/enums/scopes.enum'; import { JwtService } from './jwt.service'; function signToken(payload: Record): string { @@ -44,6 +45,33 @@ describe('JwtService', () => { expect(user.userId).toBe('auth0|abcd'); }); + it('extracts Auth0 client-credentials scopes for machine subjects', async () => { + const token = signToken({ + sub: 'VYWpLOVcDTMvUUlZmNaqhwxjqXWn0qu8@clients', + azp: 'VYWpLOVcDTMvUUlZmNaqhwxjqXWn0qu8', + gty: 'client-credentials', + scope: `${Scope.PROJECTS_READ} ${Scope.PROJECT_MEMBERS_WRITE}`, + }); + + const user = await service.validateToken(token); + + expect(user).toEqual( + expect.objectContaining({ + userId: 'VYWpLOVcDTMvUUlZmNaqhwxjqXWn0qu8@clients', + isMachine: true, + scopes: expect.arrayContaining([ + Scope.PROJECTS_READ, + Scope.PROJECT_MEMBERS_WRITE, + ]), + tokenPayload: expect.objectContaining({ + sub: 'VYWpLOVcDTMvUUlZmNaqhwxjqXWn0qu8@clients', + azp: 'VYWpLOVcDTMvUUlZmNaqhwxjqXWn0qu8', + gty: 'client-credentials', + }), + }), + ); + }); + it('extracts lower-cased email from namespaced email claim', async () => { const token = signToken({ sub: 'auth0|abcd', diff --git a/src/shared/services/billingAccount.service.spec.ts b/src/shared/services/billingAccount.service.spec.ts new file mode 100644 index 0000000..d4312b3 --- /dev/null +++ b/src/shared/services/billingAccount.service.spec.ts @@ -0,0 +1,125 @@ +import { HttpService } from '@nestjs/axios'; +import { of, throwError } from 'rxjs'; +import { M2MService } from 'src/shared/modules/global/m2m.service'; +import { BillingAccountService } from './billingAccount.service'; + +jest.mock('src/shared/config/service-endpoints.config', () => ({ + SERVICE_ENDPOINTS: { + billingAccountsApiUrl: 'https://billing-accounts.test/v6/billing-accounts/', + identityApiUrl: 'https://identity.test', + memberApiUrl: 'https://member.test', + }, +})); + +describe('BillingAccountService', () => { + const originalEnv = { ...process.env }; + + const httpServiceMock = { + get: jest.fn(), + post: jest.fn(), + }; + + const m2mServiceMock = { + getM2MToken: jest.fn().mockResolvedValue('m2m-token'), + }; + + let service: BillingAccountService; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { + ...originalEnv, + }; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('returns default billing account details from the Billing Accounts API when available', async () => { + httpServiceMock.get.mockReturnValueOnce( + of({ + data: { + id: 80001063, + markup: '0.33', + name: 'Acme Billing Account', + status: 'ACTIVE', + startDate: '2026-01-01', + endDate: '2026-12-31', + }, + }), + ); + + service = new BillingAccountService( + httpServiceMock as unknown as HttpService, + m2mServiceMock as unknown as M2MService, + ); + + const result = await service.getDefaultBillingAccount('80001063'); + + expect(m2mServiceMock.getM2MToken).toHaveBeenCalledTimes(1); + expect(httpServiceMock.get).toHaveBeenCalledWith( + 'https://billing-accounts.test/v6/billing-accounts/80001063', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer m2m-token', + }), + timeout: 5000, + }), + ); + expect(result).toEqual({ + tcBillingAccountId: '80001063', + markup: 0.33, + name: 'Acme Billing Account', + active: true, + startDate: '2026-01-01', + endDate: '2026-12-31', + }); + expect(httpServiceMock.post).not.toHaveBeenCalled(); + }); + + it('falls back to Salesforce when the Billing Accounts API lookup fails', async () => { + process.env.SALESFORCE_CLIENT_ID = 'salesforce-client-id'; + process.env.SALESFORCE_CLIENT_AUDIENCE = 'https://login.salesforce.com'; + process.env.SALESFORCE_SUBJECT = 'integration-user'; + process.env.SALESFORCE_CLIENT_KEY = 'private-key'; + + httpServiceMock.get.mockReturnValueOnce( + throwError(() => new Error('billing accounts api unavailable')), + ); + + service = new BillingAccountService( + httpServiceMock as unknown as HttpService, + m2mServiceMock as unknown as M2MService, + ); + + (service as any).authenticate = jest.fn().mockResolvedValue({ + accessToken: 'salesforce-token', + instanceUrl: 'https://salesforce.example.com', + }); + (service as any).queryBillingAccountRecords = jest.fn().mockResolvedValue([ + { + TopCoder_Billing_Account_Id__c: '80001063', + Mark_Up__c: 0.42, + Active__c: true, + Start_Date__c: '2026-01-01', + End_Date__c: '2026-12-31', + }, + ]); + + const result = await service.getDefaultBillingAccount('80001063'); + + expect(result).toEqual({ + tcBillingAccountId: '80001063', + markup: 0.42, + active: true, + startDate: '2026-01-01', + endDate: '2026-12-31', + }); + expect((service as any).authenticate).toHaveBeenCalledTimes(1); + expect((service as any).queryBillingAccountRecords).toHaveBeenCalledTimes( + 1, + ); + expect(httpServiceMock.get).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/shared/services/billingAccount.service.ts b/src/shared/services/billingAccount.service.ts index 553aaa5..b82fe8f 100644 --- a/src/shared/services/billingAccount.service.ts +++ b/src/shared/services/billingAccount.service.ts @@ -3,7 +3,9 @@ import { Injectable } from '@nestjs/common'; import { createPrivateKey, KeyObject } from 'crypto'; import { firstValueFrom } from 'rxjs'; import * as jwt from 'jsonwebtoken'; +import { SERVICE_ENDPOINTS } from 'src/shared/config/service-endpoints.config'; import { LoggerService } from 'src/shared/modules/global/logger.service'; +import { M2MService } from 'src/shared/modules/global/m2m.service'; export interface BillingAccount { tcBillingAccountId?: string; @@ -29,6 +31,8 @@ export interface BillingAccount { @Injectable() export class BillingAccountService { private readonly logger = LoggerService.forRoot('BillingAccountService'); + private readonly billingAccountsApiUrl = + SERVICE_ENDPOINTS.billingAccountsApiUrl.replace(/\/+$/, ''); private readonly salesforceAudience = process.env.SALESFORCE_CLIENT_AUDIENCE || process.env.SALESFORCE_AUDIENCE || @@ -47,7 +51,10 @@ export class BillingAccountService { private readonly sfdcBillingAccountActiveField = process.env.SFDC_BILLING_ACCOUNT_ACTIVE_FIELD || 'Active__c'; - constructor(private readonly httpService: HttpService) {} + constructor( + private readonly httpService: HttpService, + private readonly m2mService: M2MService, + ) {} /** * Returns billing accounts available to the current project user. @@ -123,8 +130,9 @@ export class BillingAccountService { /** * Returns the default billing-account record by Topcoder billing-account id. * - * Queries `Topcoder_Billing_Account__c` by - * `TopCoder_Billing_Account_Id__c`. + * Resolves the billing account from the Billing Accounts API first, then + * falls back to Salesforce `Topcoder_Billing_Account__c` when that lookup is + * unavailable or does not return a usable record. * * @param billingAccountId Topcoder billing-account id * @returns billing-account details, or `null` when not found/invalid @@ -132,11 +140,6 @@ export class BillingAccountService { async getDefaultBillingAccount( billingAccountId: string, ): Promise { - if (!this.isSalesforceConfigured()) { - this.logger.warn('Salesforce integration is not configured.'); - return null; - } - try { const normalizedBillingAccountId = this.parseIntStrictly(billingAccountId); @@ -147,6 +150,19 @@ export class BillingAccountService { return null; } + const billingAccountFromApi = + await this.getBillingAccountFromBillingAccountsApi( + normalizedBillingAccountId, + ); + if (billingAccountFromApi) { + return billingAccountFromApi; + } + + if (!this.isSalesforceConfigured()) { + this.logger.warn('Salesforce integration is not configured.'); + return null; + } + const { accessToken, instanceUrl } = await this.authenticate(); // SECURITY: SOQL injection mitigated by parseIntStrictly integer validation. If this validation is ever relaxed, parameterized queries must be used. const sql = `SELECT TopCoder_Billing_Account_Id__c, Mark_Up__c, Active__c, Start_Date__c, End_Date__c from Topcoder_Billing_Account__c tba where TopCoder_Billing_Account_Id__c='${normalizedBillingAccountId}'`; @@ -186,6 +202,66 @@ export class BillingAccountService { } } + /** + * Resolves a billing-account record from the Billing Accounts API. + * + * Uses an M2M token and maps the response into the legacy billing-account + * shape returned by Projects API. + * + * @param billingAccountId normalized Topcoder billing-account id + * @returns billing-account details or `null` when lookup fails + */ + private async getBillingAccountFromBillingAccountsApi( + billingAccountId: string, + ): Promise { + if (!this.billingAccountsApiUrl) { + return null; + } + + try { + const token = await this.m2mService.getM2MToken(); + const response = await firstValueFrom( + this.httpService.get(`${this.billingAccountsApiUrl}/${billingAccountId}`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + timeout: 5000, + }), + ); + + const payload = + response?.data && typeof response.data === 'object' + ? (response.data as Record) + : undefined; + const normalizedBillingAccountId = + this.readAsIdString(payload?.tcBillingAccountId) || + this.readAsIdString(payload?.id); + + if (!normalizedBillingAccountId) { + return null; + } + + return { + tcBillingAccountId: normalizedBillingAccountId, + name: this.readAsString(payload?.name), + startDate: this.readAsString(payload?.startDate), + endDate: this.readAsString(payload?.endDate), + active: + this.readAsBoolean(payload?.active) ?? + this.readActiveFlagFromStatus(payload?.status), + markup: this.readAsNumber(payload?.markup), + }; + } catch (error) { + this.logger.warn( + `Unable to fetch default billing account from Billing Accounts API for billingAccountId=${billingAccountId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return null; + } + } + /** * Returns a map of billing-account details keyed by Topcoder account id. * @@ -409,6 +485,28 @@ export class BillingAccountService { return trimmed.length > 0 ? trimmed : undefined; } + /** + * Coerces an API id field to a normalized integer string. + * + * @param value raw id value + * @returns normalized id or `undefined` + */ + private readAsIdString(value: unknown): string | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return this.parseIntStrictly(String(value)); + } + + if (typeof value === 'bigint') { + return this.parseIntStrictly(value.toString()); + } + + if (typeof value === 'string') { + return this.parseIntStrictly(value.trim()); + } + + return undefined; + } + /** * Coerces a Salesforce field value to a finite number. * @@ -455,6 +553,30 @@ export class BillingAccountService { return undefined; } + /** + * Maps common status values to an active boolean. + * + * @param value raw status value + * @returns active flag when a known status is supplied + */ + private readActiveFlagFromStatus(value: unknown): boolean | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim().toLowerCase(); + + if (normalized === 'active') { + return true; + } + + if (normalized === 'inactive') { + return false; + } + + return undefined; + } + /** * Normalizes private-key input from multiple secret-storage formats. * diff --git a/src/shared/services/permission.service.spec.ts b/src/shared/services/permission.service.spec.ts index 11bdf0e..747ffcb 100644 --- a/src/shared/services/permission.service.spec.ts +++ b/src/shared/services/permission.service.spec.ts @@ -346,6 +346,56 @@ describe('PermissionService', () => { expect(allowed).toBe(true); }); + it('allows reading any project for machine token with project read scope', () => { + const allowed = service.hasNamedPermission(Permission.READ_PROJECT_ANY, { + scopes: [Scope.PROJECTS_READ], + isMachine: true, + }); + + expect(allowed).toBe(true); + }); + + it('allows reading any project when machine scope is inferred from token claims', () => { + const allowed = service.hasNamedPermission(Permission.READ_PROJECT_ANY, { + scopes: [], + isMachine: false, + tokenPayload: { + gty: 'client-credentials', + scope: Scope.PROJECTS_READ, + }, + }); + + expect(allowed).toBe(true); + }); + + it.each([ + UserRole.TOPCODER_MANAGER, + 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 +415,103 @@ describe('PermissionService', () => { expect(allowed).toBe(true); }); + it.each([UserRole.PROGRAM_MANAGER, UserRole.TOPCODER_MANAGER])( + 'allows manager-tier role %s to read project members without membership', + (role) => { + const allowed = service.hasNamedPermission(Permission.READ_PROJECT_MEMBER, { + userId: '555', + roles: [role], + isMachine: false, + }); + + expect(allowed).toBe(true); + }, + ); + + it.each([UserRole.PROGRAM_MANAGER, UserRole.TOPCODER_MANAGER])( + 'allows manager-tier role %s to read project invites without membership', + (role) => { + const allowed = service.hasNamedPermission( + Permission.READ_PROJECT_INVITE_NOT_OWN, + { + userId: '555', + roles: [role], + isMachine: false, + }, + ); + + expect(allowed).toBe(true); + }, + ); + + 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) => { + const allowed = service.hasNamedPermission( + Permission.VIEW_PROJECT_ATTACHMENT, + { + userId: '555', + roles: [role], + 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, @@ -425,6 +572,27 @@ describe('PermissionService', () => { expect(allowed).toBe(true); }); + it.each([ + Permission.CREATE_PROJECT_MEMBER_NOT_OWN, + Permission.UPDATE_PROJECT_MEMBER_NON_CUSTOMER, + Permission.DELETE_PROJECT_MEMBER_TOPCODER, + ])( + 'allows %s when raw M2M token scopes are broader than user.scopes', + (permission) => { + const allowed = service.hasNamedPermission(permission, { + scopes: [Scope.PROJECTS_READ], + isMachine: false, + tokenPayload: { + gty: 'client-credentials', + scope: `${Scope.PROJECTS_READ} ${Scope.PROJECT_MEMBERS_WRITE}`, + sub: 'svc-projects@clients', + }, + }); + + expect(allowed).toBe(true); + }, + ); + it('allows reading other users project invites for machine token with invite read scope', () => { const allowed = service.hasNamedPermission( Permission.READ_PROJECT_INVITE_NOT_OWN, diff --git a/src/shared/services/permission.service.ts b/src/shared/services/permission.service.ts index 4eb3fc0..b2df6c1 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, @@ -138,8 +138,8 @@ export class PermissionService { * @returns `true` when the user satisfies the named permission rule * @security `CREATE_PROJECT` currently trusts a permissive `isAuthenticated` * check: any non-empty `userId`, any role, any scope, or `isMachine`. - * @security Admin detection currently includes the raw role string - * `'topcoder_manager'`, which can drift from enum-backed role names. + * @security Legacy manager JWTs use `UserRole.TOPCODER_MANAGER`, which is + * broader than strict admin access and retained for v5 compatibility. */ hasNamedPermission( permission: NamedPermission, @@ -160,12 +160,13 @@ export class PermissionService { machineContext.isMachine; // TODO: intentionally permissive authentication gate for CREATE_PROJECT; reassess whether any role/scope/machine token should qualify. - // TODO: replace 'topcoder_manager' string literal with UserRole enum value. const isAdmin = this.hasIntersection(user.roles || [], [ ...ADMIN_ROLES, UserRole.MANAGER, - 'topcoder_manager', + UserRole.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 +254,11 @@ export class PermissionService { switch (permission) { // Project read/write lifecycle permissions. case NamedPermission.READ_PROJECT_ANY: - return isAdmin; + return hasProjectReadTopcoderRole || hasProjectReadScope; case NamedPermission.VIEW_PROJECT: return ( - isAdmin || + hasProjectReadTopcoderRole || hasProjectMembership || hasPendingInvite || hasProjectReadScope @@ -288,7 +289,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 +325,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 +441,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: @@ -465,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: @@ -623,6 +636,11 @@ export class PermissionService { * Resolves machine-token status and effective scopes from the normalized user * and the raw token payload so guard and permission checks stay aligned. * + * Merges both scope sources because upstream auth middleware can populate + * `user.scopes` differently from the raw token payload used by + * `TokenRolesGuard`. Keeping the union here avoids false 403s when one source + * is stale or incomplete but the other still carries the granted M2M scopes. + * * @param user authenticated JWT user context * @returns machine classification and the scopes to evaluate */ @@ -633,13 +651,18 @@ export class PermissionService { const payloadMachineContext = this.m2mService.validateMachineToken( user.tokenPayload, ); + const userScopes = Array.isArray(user.scopes) + ? user.scopes + .map((scope) => String(scope).trim()) + .filter((scope) => scope.length > 0) + : []; + const mergedScopes = Array.from( + new Set([...userScopes, ...payloadMachineContext.scopes]), + ); return { isMachine: Boolean(user.isMachine || payloadMachineContext.isMachine), - scopes: - Array.isArray(user.scopes) && user.scopes.length > 0 - ? user.scopes - : payloadMachineContext.scopes, + scopes: mergedScopes, }; } @@ -804,6 +827,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, + UserRole.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, + UserRole.TOPCODER_MANAGER, + ]); + } + /** * Checks Topcoder roles allowed to view billing-account data. * diff --git a/src/shared/utils/permission-docs.utils.ts b/src/shared/utils/permission-docs.utils.ts index 950afaf..76202d0 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, @@ -48,12 +48,10 @@ export interface PermissionDocumentationSummary { scopes: string[]; } -const LEGACY_TOPCODER_MANAGER_ROLE = 'topcoder_manager'; - const ADMIN_AND_MANAGER_ROLES = [ ...ADMIN_ROLES, UserRole.MANAGER, - LEGACY_TOPCODER_MANAGER_ROLE, + UserRole.TOPCODER_MANAGER, ]; const STRICT_ADMIN_ACCESS_ROLES = [...ADMIN_ROLES]; @@ -85,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 = [ @@ -236,6 +236,7 @@ function getNamedPermissionDocumentation( case NamedPermission.READ_PROJECT_ANY: return createSummary({ userRoles: ADMIN_AND_MANAGER_ROLES, + scopes: PROJECT_READ_SCOPES, }); case NamedPermission.VIEW_PROJECT: @@ -448,8 +449,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: diff --git a/src/shared/utils/swagger.utils.spec.ts b/src/shared/utils/swagger.utils.spec.ts index 70164a9..bcc2c12 100644 --- a/src/shared/utils/swagger.utils.spec.ts +++ b/src/shared/utils/swagger.utils.spec.ts @@ -76,6 +76,27 @@ describe('enrichSwaggerAuthDocumentation', () => { ); }); + it('documents project-read machine scopes for global project access routes', () => { + const document = createDocument({ + description: 'List projects.', + [SWAGGER_REQUIRED_ROLES_KEY]: Object.values(UserRole), + [SWAGGER_REQUIRED_SCOPES_KEY]: [ + Scope.PROJECTS_READ, + Scope.PROJECTS_WRITE, + Scope.PROJECTS_ALL, + ], + [SWAGGER_REQUIRED_PERMISSIONS_KEY]: [Permission.READ_PROJECT_ANY], + }); + + enrichSwaggerAuthDocumentation(document); + + const description = document.paths['/test'].get?.description; + + expect(description).toContain( + 'Policy allows token scopes (any): all:connect_project, all:projects, read:projects, write:projects', + ); + }); + it('documents permission-specific machine scopes when policy narrows route access', () => { const document = createDocument({ description: 'Get project billing account details.', diff --git a/test/deployment-validation.e2e-spec.ts b/test/deployment-validation.e2e-spec.ts index a346bed..9b1f399 100644 --- a/test/deployment-validation.e2e-spec.ts +++ b/test/deployment-validation.e2e-spec.ts @@ -175,7 +175,8 @@ describe('Deployment validation', () => { const targets = [ process.env.IDENTITY_API_URL, process.env.MEMBER_API_URL, - process.env.BILLING_ACCOUNT_SERVICE_URL, + process.env.BILLING_ACCOUNTS_API_URL || + process.env.BILLING_ACCOUNT_SERVICE_URL, ].filter((value): value is string => Boolean(value)); for (const endpoint of targets) { diff --git a/test/project-member.e2e-spec.ts b/test/project-member.e2e-spec.ts index 2bcd9db..7550366 100644 --- a/test/project-member.e2e-spec.ts +++ b/test/project-member.e2e-spec.ts @@ -38,6 +38,20 @@ const tokenUsers: Record = { }, }; +function createAuth0MachineMemberWriteUser(): JwtUser { + return { + userId: 'VYWpLOVcDTMvUUlZmNaqhwxjqXWn0qu8@clients', + scopes: [Scope.PROJECTS_READ], + isMachine: true, + tokenPayload: { + sub: 'VYWpLOVcDTMvUUlZmNaqhwxjqXWn0qu8@clients', + azp: 'VYWpLOVcDTMvUUlZmNaqhwxjqXWn0qu8', + gty: 'client-credentials', + scope: `${Scope.PROJECTS_READ} ${Scope.PROJECT_MEMBERS_WRITE}`, + }, + }; +} + @Controller('/projects/project-member-test') class ProjectMemberTestController { @Get('/health') @@ -225,6 +239,166 @@ describe('Project Member endpoints (e2e)', () => { expect(projectMemberServiceMock.deleteMember).toHaveBeenCalled(); }); + it('creates members for m2m token with project-member write scope', async () => { + (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce({ + scopes: [Scope.PROJECT_MEMBERS_WRITE], + isMachine: true, + tokenPayload: { + gty: 'client-credentials', + scope: Scope.PROJECT_MEMBERS_WRITE, + }, + }); + + await request(app.getHttpServer()) + .post('/v6/projects/1001/members') + .set('Authorization', 'Bearer m2m-member-write') + .send({ userId: '101125', role: 'observer' }) + .expect(201); + + expect(projectMemberServiceMock.addMember).toHaveBeenCalledWith( + '1001', + expect.objectContaining({ userId: '101125', role: 'observer' }), + expect.objectContaining({ + scopes: [Scope.PROJECT_MEMBERS_WRITE], + isMachine: true, + }), + undefined, + ); + }); + + it('updates members for m2m token with project-member write scope', async () => { + (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce({ + scopes: [Scope.PROJECT_MEMBERS_WRITE], + isMachine: true, + tokenPayload: { + gty: 'client-credentials', + scope: Scope.PROJECT_MEMBERS_WRITE, + }, + }); + + await request(app.getHttpServer()) + .patch('/v6/projects/1001/members/11') + .set('Authorization', 'Bearer m2m-member-write') + .send({ role: 'observer' }) + .expect(200); + + expect(projectMemberServiceMock.updateMember).toHaveBeenCalledWith( + '1001', + '11', + expect.objectContaining({ role: 'observer' }), + expect.objectContaining({ + scopes: [Scope.PROJECT_MEMBERS_WRITE], + isMachine: true, + }), + undefined, + ); + }); + + it('deletes members for m2m token with project-member write scope', async () => { + (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce({ + scopes: [Scope.PROJECT_MEMBERS_WRITE], + isMachine: true, + tokenPayload: { + gty: 'client-credentials', + scope: Scope.PROJECT_MEMBERS_WRITE, + }, + }); + + await request(app.getHttpServer()) + .delete('/v6/projects/1001/members/11') + .set('Authorization', 'Bearer m2m-member-write') + .expect(204); + + expect(projectMemberServiceMock.deleteMember).toHaveBeenCalledWith( + '1001', + '11', + expect.objectContaining({ + scopes: [Scope.PROJECT_MEMBERS_WRITE], + isMachine: true, + }), + ); + }); + + it('creates members for Auth0-shaped m2m token when tokenPayload scope is broader than user.scopes', async () => { + (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce( + createAuth0MachineMemberWriteUser(), + ); + + await request(app.getHttpServer()) + .post('/v6/projects/1001/members') + .set('Authorization', 'Bearer m2m-member-write-auth0-shape') + .send({ userId: '101125', role: 'observer' }) + .expect(201); + + expect(projectMemberServiceMock.addMember).toHaveBeenCalledWith( + '1001', + expect.objectContaining({ userId: '101125', role: 'observer' }), + expect.objectContaining({ + userId: 'VYWpLOVcDTMvUUlZmNaqhwxjqXWn0qu8@clients', + scopes: [Scope.PROJECTS_READ], + isMachine: true, + tokenPayload: expect.objectContaining({ + gty: 'client-credentials', + scope: `${Scope.PROJECTS_READ} ${Scope.PROJECT_MEMBERS_WRITE}`, + }), + }), + undefined, + ); + }); + + it('updates members for Auth0-shaped m2m token when tokenPayload scope is broader than user.scopes', async () => { + (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce( + createAuth0MachineMemberWriteUser(), + ); + + await request(app.getHttpServer()) + .patch('/v6/projects/1001/members/11') + .set('Authorization', 'Bearer m2m-member-write-auth0-shape') + .send({ role: 'observer' }) + .expect(200); + + expect(projectMemberServiceMock.updateMember).toHaveBeenCalledWith( + '1001', + '11', + expect.objectContaining({ role: 'observer' }), + expect.objectContaining({ + userId: 'VYWpLOVcDTMvUUlZmNaqhwxjqXWn0qu8@clients', + scopes: [Scope.PROJECTS_READ], + isMachine: true, + tokenPayload: expect.objectContaining({ + gty: 'client-credentials', + scope: `${Scope.PROJECTS_READ} ${Scope.PROJECT_MEMBERS_WRITE}`, + }), + }), + undefined, + ); + }); + + it('deletes members for Auth0-shaped m2m token when tokenPayload scope is broader than user.scopes', async () => { + (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce( + createAuth0MachineMemberWriteUser(), + ); + + await request(app.getHttpServer()) + .delete('/v6/projects/1001/members/11') + .set('Authorization', 'Bearer m2m-member-write-auth0-shape') + .expect(204); + + expect(projectMemberServiceMock.deleteMember).toHaveBeenCalledWith( + '1001', + '11', + expect.objectContaining({ + userId: 'VYWpLOVcDTMvUUlZmNaqhwxjqXWn0qu8@clients', + scopes: [Scope.PROJECTS_READ], + isMachine: true, + tokenPayload: expect.objectContaining({ + gty: 'client-credentials', + scope: `${Scope.PROJECTS_READ} ${Scope.PROJECT_MEMBERS_WRITE}`, + }), + }), + ); + }); + it('lists members for m2m token with project-member read scope', async () => { (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce({ scopes: [Scope.PROJECT_MEMBERS_READ],