From 9d4e7c6b94e7a3d4815b9a764d756d72f5199ad1 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 31 Mar 2026 10:15:02 +1100 Subject: [PATCH 01/12] Check BA service before SF for markup details --- .env.example | 1 + README.md | 3 +- src/shared/config/service-endpoints.config.ts | 7 +- .../services/billingAccount.service.spec.ts | 125 ++++++++++++++++ src/shared/services/billingAccount.service.ts | 138 +++++++++++++++++- test/deployment-validation.e2e-spec.ts | 3 +- 6 files changed, 265 insertions(+), 12 deletions(-) create mode 100644 src/shared/services/billingAccount.service.spec.ts 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/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/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/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/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) { From f0af2955316e7bf1631a2f67849af263817384bf Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 3 Apr 2026 04:46:34 +1100 Subject: [PATCH 02/12] PM-4211: add M2M project member write regression tests What was broken QA reported that M2M POST, PATCH, and DELETE requests against /v6/projects/{projectId}/members were still returning 403 responses after the earlier permission changes. Root cause (if identifiable) The earlier fix path added M2M write-scope handling in the permission layer, but the exact guarded HTTP flows for the project-member write endpoints were not covered by regression tests, so the QA scenario was not exercised directly. What was changed Added explicit project-member e2e coverage for client-credentials tokens with project-member write scope on POST, PATCH, and DELETE routes. Verified the attached M2M token shape still resolves to the expected write permissions on the current dev baseline before adding the regression coverage. Any added/updated tests Updated test/project-member.e2e-spec.ts with M2M POST/PATCH/DELETE coverage. Validated the updated spec with pnpm test:e2e -- --runInBand test/project-member.e2e-spec.ts. --- test/project-member.e2e-spec.ts | 80 +++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/test/project-member.e2e-spec.ts b/test/project-member.e2e-spec.ts index 2bcd9db..568fecc 100644 --- a/test/project-member.e2e-spec.ts +++ b/test/project-member.e2e-spec.ts @@ -225,6 +225,86 @@ 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('lists members for m2m token with project-member read scope', async () => { (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce({ scopes: [Scope.PROJECT_MEMBERS_READ], From 81af7f1d5f1f17430fc3342b27ed96c79df164d9 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 3 Apr 2026 05:01:09 +1100 Subject: [PATCH 03/12] 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. * From e749f865ad32887768bfdbe272710badae706bd4 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sat, 4 Apr 2026 03:00:08 +1100 Subject: [PATCH 04/12] PM-3764: allow legacy topcoder_manager through route guards What was broken The previous PM-3764 fix restored legacy read parity inside PermissionService, but some QA users could still be denied before those permission checks ran. Tokens carrying the legacy `topcoder_manager` role were excluded from controller-level `@Roles(...Object.values(UserRole))` allowlists, so the read-parity logic never executed for them. Root cause (if identifiable) PermissionService and the Swagger permission documentation still knew about the legacy `topcoder_manager` role, but the shared UserRole enum did not. Route-level role gates derive their allowlists from `Object.values(UserRole)`, which left the coarse auth layer out of sync with the PM-3764 compatibility logic. What was changed Added `UserRole.TOPCODER_MANAGER` for the legacy JWT role and updated the permission/documentation helpers to use the enum-backed value. Kept the existing PM-3764 read-parity behavior intact while extending regression coverage for legacy `topcoder_manager` access through the route guard and read-permission paths. Documented that `topcoder_manager` is accepted by both route guards and PermissionService. Any added/updated tests Added a TokenRolesGuard regression test covering `topcoder_manager` against `Object.values(UserRole)` route allowlists. Expanded PermissionService regression coverage for `topcoder_manager` project, member, invite, and attachment read access. Validated with `pnpm lint`, targeted auth regression tests, and `pnpm build`. The full `pnpm test` suite still has the existing unrelated metadata event-bus failures on the current `dev` baseline. --- docs/PERMISSIONS.md | 1 + src/shared/enums/userRole.enum.ts | 4 ++ src/shared/guards/tokenRoles.guard.spec.ts | 44 ++++++++++++ .../services/permission.service.spec.ts | 70 +++++++++++-------- src/shared/services/permission.service.ts | 11 ++- src/shared/utils/permission-docs.utils.ts | 4 +- 6 files changed, 95 insertions(+), 39 deletions(-) diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index 8150d65..2580a8c 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. +- 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/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..5a53d0a 100644 --- a/src/shared/guards/tokenRoles.guard.spec.ts +++ b/src/shared/guards/tokenRoles.guard.spec.ts @@ -6,6 +6,7 @@ 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 { JwtService } from '../modules/global/jwt.service'; import { M2MService } from '../modules/global/m2m.service'; import { ADMIN_ONLY_KEY } from './auth-metadata.constants'; @@ -219,6 +220,49 @@ 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 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 a203341..2f82b4b 100644 --- a/src/shared/services/permission.service.spec.ts +++ b/src/shared/services/permission.service.spec.ts @@ -347,6 +347,7 @@ describe('PermissionService', () => { }); it.each([ + UserRole.TOPCODER_MANAGER, UserRole.PROJECT_MANAGER, UserRole.TASK_MANAGER, UserRole.TOPCODER_TASK_MANAGER, @@ -392,41 +393,50 @@ 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, - { + 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: [UserRole.PROGRAM_MANAGER], + roles: [role], isMachine: false, - }, - ); + }); - expect(allowed).toBe(true); - }); + 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, - }, - ); + 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); - }); + expect(allowed).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( diff --git a/src/shared/services/permission.service.ts b/src/shared/services/permission.service.ts index 0537428..6143fb6 100644 --- a/src/shared/services/permission.service.ts +++ b/src/shared/services/permission.service.ts @@ -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,11 +160,10 @@ 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); @@ -833,7 +832,7 @@ export class PermissionService { UserRole.TOPCODER_TASK_MANAGER, UserRole.TALENT_MANAGER, UserRole.TOPCODER_TALENT_MANAGER, - 'topcoder_manager', + UserRole.TOPCODER_MANAGER, ]); } @@ -847,7 +846,7 @@ export class PermissionService { private hasManagerTopcoderRole(user: JwtUser): boolean { return this.hasIntersection(user.roles || [], [ ...MANAGER_ROLES, - 'topcoder_manager', + UserRole.TOPCODER_MANAGER, ]); } diff --git a/src/shared/utils/permission-docs.utils.ts b/src/shared/utils/permission-docs.utils.ts index 950afaf..0ff7d88 100644 --- a/src/shared/utils/permission-docs.utils.ts +++ b/src/shared/utils/permission-docs.utils.ts @@ -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]; From 4f3c8c13e0a4dbe79ae7f1fc38153268e543a8d8 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sat, 4 Apr 2026 03:15:18 +1100 Subject: [PATCH 05/12] PM-4211: merge M2M member scopes across auth layers What was broken M2M POST, PATCH, and DELETE requests for project members could still return 403 even when the raw token payload carried project-member write scope. Root cause (if identifiable) The permission layer preferred user.scopes when they were present, while the route guard evaluated scopes from the raw token payload. If those two scope sources drifted, the guard could admit the request and the project-member service could still reject it. What was changed Merged normalized scopes from both user.scopes and the raw token payload in PermissionService so downstream permission checks use the same effective M2M grants that the auth guard sees. Added a regression that covers create, update, and delete project-member permissions when the raw token payload is broader than user.scopes. Any added/updated tests Updated src/shared/services/permission.service.spec.ts with create, update, and delete project-member M2M regression coverage for mismatched scope sources. --- .../services/permission.service.spec.ts | 21 +++++++++++++++++++ src/shared/services/permission.service.ts | 18 ++++++++++++---- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/shared/services/permission.service.spec.ts b/src/shared/services/permission.service.spec.ts index a203341..e286c67 100644 --- a/src/shared/services/permission.service.spec.ts +++ b/src/shared/services/permission.service.spec.ts @@ -488,6 +488,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 0537428..d7a6bb2 100644 --- a/src/shared/services/permission.service.ts +++ b/src/shared/services/permission.service.ts @@ -633,6 +633,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 */ @@ -643,13 +648,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, }; } From a4ce199f7b6afc17521c0e8ca0fd152318beb91a Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Mon, 6 Apr 2026 14:35:26 +0530 Subject: [PATCH 06/12] PM-4720 Make copilot read endpoints public --- src/api/copilot/copilot-opportunity.controller.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/api/copilot/copilot-opportunity.controller.ts b/src/api/copilot/copilot-opportunity.controller.ts index dd3ec74..ca6c849 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, @@ -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( From fd0f5d404203cf328b1c5ea85f76f563e71ca345 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 7 Apr 2026 01:10:03 +1000 Subject: [PATCH 07/12] PM-4211: cover Auth0 M2M member token shape What was broken The earlier PM-4211 runtime fix handled M2M project-member writes, but the exact Auth0 client-credentials token shape from QA was still not covered by automated regression. That left the POST, PATCH, and DELETE member flows vulnerable to future regressions without a failing test. Root cause (if identifiable) The existing route e2e coverage mocked a simplified machine user, and JwtService coverage did not assert the non-numeric @clients subject plus project-member write scope combination from the QA token. What was changed Added JwtService coverage for an Auth0 client-credentials subject carrying project-member write scope. Added guarded project-member POST, PATCH, and DELETE regression coverage for an Auth0-shaped M2M principal whose raw token payload is broader than user.scopes. Any added/updated tests Updated src/shared/modules/global/jwt.service.spec.ts. Updated test/project-member.e2e-spec.ts. --- src/shared/modules/global/jwt.service.spec.ts | 28 ++++++ test/project-member.e2e-spec.ts | 94 +++++++++++++++++++ 2 files changed, 122 insertions(+) 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/test/project-member.e2e-spec.ts b/test/project-member.e2e-spec.ts index 568fecc..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') @@ -305,6 +319,86 @@ describe('Project Member endpoints (e2e)', () => { ); }); + 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], From 82b568be13d1548d8e63924da914ee8d82bf997d Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 7 Apr 2026 01:22:04 +1000 Subject: [PATCH 08/12] PM-3764: restore work-layer read parity What was broken Work stream, work, and work item read routes could still reject legitimate Projects API users before or during permission evaluation. The work-layer controllers only admitted a narrow role subset, and the named work-view permissions still excluded manager-tier legacy project-view access. Root cause (if identifiable) The earlier PM-3764 follow-ups restored legacy read parity for projects, members, invites, and attachments, but the work-layer endpoints were still using narrower auth rules than the legacy v5 projectView policy. What was changed Broadened the work-layer coarse role gate to accept all known human roles so project-member access reaches PermissionGuard. Updated work-layer view permissions to allow manager-tier legacy project-view access and machine connect_project_admin scope, and aligned the Swagger/docs output with that behavior. Documented the remaining PM-3764 work-layer read parity in the permissions docs. Any added/updated tests Added a TokenRolesGuard regression for Topcoder User tokens on work-layer routes. Expanded PermissionService regression coverage for manager-tier work-layer reads and machine connect_project_admin scope. Validated with pnpm lint, targeted auth/work regressions, and pnpm build. Full pnpm test still has the existing unrelated metadata event-bus failures on the current dev baseline. --- docs/PERMISSIONS.md | 1 + src/api/workstream/workstream.controller.ts | 6 ++- src/shared/constants/roles.ts | 16 +++--- src/shared/guards/tokenRoles.guard.spec.ts | 44 ++++++++++++++++ .../services/permission.service.spec.ts | 52 +++++++++++++++++++ src/shared/services/permission.service.ts | 6 ++- src/shared/utils/permission-docs.utils.ts | 7 ++- 7 files changed, 117 insertions(+), 15 deletions(-) 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: From d11bde41782bc2f7c1fe8e999f34e04f4fd76684 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Tue, 7 Apr 2026 10:22:26 +0530 Subject: [PATCH 09/12] Handle public user on Public copilot routes --- src/api/copilot/copilot-opportunity.controller.ts | 4 ++-- src/api/copilot/copilot-opportunity.service.ts | 14 +++++++------- src/api/copilot/copilot.utils.ts | 14 ++++++++++---- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/api/copilot/copilot-opportunity.controller.ts b/src/api/copilot/copilot-opportunity.controller.ts index ca6c849..9c154f5 100644 --- a/src/api/copilot/copilot-opportunity.controller.ts +++ b/src/api/copilot/copilot-opportunity.controller.ts @@ -84,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); @@ -131,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 [ From b2c4d54c1f8c5a47ef29588b3cfa2b44123c1eb4 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 13 Apr 2026 17:54:08 +1000 Subject: [PATCH 10/12] PM-4847: scope PM/TM project access to memberships What was broken PM and TM users could list every project in Work Manager and open project records they were not part of. Root cause ProjectService treated PM/TM-style roles as global project readers when building the projects query and when resolving a project by id, so membership scoping was bypassed unless the client explicitly requested memberOnly filtering. What was changed Added a service-level global-read check that only bypasses membership scoping for admins, legacy manager roles, and authorized machine principals. Applied that check to project listing, direct project fetches, and project response filtering so PM/TM callers must now be project members or pending invitees. Any added/updated tests Added ProjectService coverage for PM/TM project list scoping and for rejecting direct project access when the caller is not on the project. Validated with pnpm test -- src/api/project/project.service.spec.ts, pnpm lint, and pnpm build. The repo-wide pnpm test run still fails in unrelated metadata event-publishing specs. --- src/api/project/project.service.spec.ts | 131 ++++++++++++++++++++++ src/api/project/project.service.ts | 141 +++++++++++++++++++++--- 2 files changed, 256 insertions(+), 16 deletions(-) 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. * From 8e1bd2a3cf7d22073dbb7d89729ee1e717accdc8 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 14 Apr 2026 18:20:49 +1000 Subject: [PATCH 11/12] Updates for M2M --- .../services/permission.service.spec.ts | 22 +++++++++++++++++++ src/shared/services/permission.service.ts | 2 +- src/shared/utils/permission-docs.utils.ts | 1 + src/shared/utils/swagger.utils.spec.ts | 21 ++++++++++++++++++ 4 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/shared/services/permission.service.spec.ts b/src/shared/services/permission.service.spec.ts index daa5fc4..747ffcb 100644 --- a/src/shared/services/permission.service.spec.ts +++ b/src/shared/services/permission.service.spec.ts @@ -346,6 +346,28 @@ 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, diff --git a/src/shared/services/permission.service.ts b/src/shared/services/permission.service.ts index c1fc53e..b2df6c1 100644 --- a/src/shared/services/permission.service.ts +++ b/src/shared/services/permission.service.ts @@ -254,7 +254,7 @@ export class PermissionService { switch (permission) { // Project read/write lifecycle permissions. case NamedPermission.READ_PROJECT_ANY: - return hasProjectReadTopcoderRole; + return hasProjectReadTopcoderRole || hasProjectReadScope; case NamedPermission.VIEW_PROJECT: return ( diff --git a/src/shared/utils/permission-docs.utils.ts b/src/shared/utils/permission-docs.utils.ts index 4820bc0..76202d0 100644 --- a/src/shared/utils/permission-docs.utils.ts +++ b/src/shared/utils/permission-docs.utils.ts @@ -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: 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.', From b6f5f9ea104043370ef9e18b7c8126f803867626 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Tue, 14 Apr 2026 11:54:18 +0300 Subject: [PATCH 12/12] Update Trivy action to version 0.35.0 --- .github/workflows/trivy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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