Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
9d4e7c6
Check BA service before SF for markup details
jmgasper Mar 30, 2026
f0af295
PM-4211: add M2M project member write regression tests
jmgasper Apr 2, 2026
81af7f1
PM-3764: restore legacy project read role parity
jmgasper Apr 2, 2026
a99163c
Merge pull request #10 from topcoder-platform/PM-4211
jmgasper Apr 2, 2026
fb052e6
Merge pull request #11 from topcoder-platform/PM-3764
jmgasper Apr 2, 2026
e749f86
PM-3764: allow legacy topcoder_manager through route guards
jmgasper Apr 3, 2026
4f3c8c1
PM-4211: merge M2M member scopes across auth layers
jmgasper Apr 3, 2026
6ab8c1e
Merge pull request #13 from topcoder-platform/PM-4211-1
jmgasper Apr 5, 2026
4330dc2
Merge pull request #12 from topcoder-platform/PM-3764-1
jmgasper Apr 5, 2026
a4ce199
PM-4720 Make copilot read endpoints public
himaniraghav3 Apr 6, 2026
fd0f5d4
PM-4211: cover Auth0 M2M member token shape
jmgasper Apr 6, 2026
82b568b
PM-3764: restore work-layer read parity
jmgasper Apr 6, 2026
5ff0844
Merge pull request #15 from topcoder-platform/PM-3764-2
jmgasper Apr 7, 2026
141a53a
Merge pull request #14 from topcoder-platform/PM-4211-2
jmgasper Apr 7, 2026
286d60b
Merge pull request #16 from topcoder-platform/PM-4720
himaniraghav3 Apr 7, 2026
d11bde4
Handle public user on Public copilot routes
himaniraghav3 Apr 7, 2026
3d0add9
Merge pull request #17 from topcoder-platform/PM-4720
himaniraghav3 Apr 7, 2026
b2c4d54
PM-4847: scope PM/TM project access to memberships
jmgasper Apr 13, 2026
196911e
Merge pull request #19 from topcoder-platform/PM-4847
jmgasper Apr 14, 2026
8e1bd2a
Updates for M2M
jmgasper Apr 14, 2026
93df63f
Merge pull request #20 from topcoder-platform/PM-4847
jmgasper Apr 14, 2026
b6f5f9e
Update Trivy action to version 0.35.0
kkartunov Apr 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/trivy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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 |
Expand Down
7 changes: 7 additions & 0 deletions docs/PERMISSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions src/api/copilot/copilot-opportunity.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -59,6 +60,7 @@ export class CopilotOpportunityController {
* @returns Opportunity page data.
*/
@Get('copilots/opportunities')
@Public()
@Roles(...Object.values(UserRole))
@Scopes(
Scope.PROJECTS_READ,
Expand All @@ -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<CopilotOpportunityResponseDto[]> {
const result = await this.service.listOpportunities(query, user);

Expand All @@ -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(
Expand All @@ -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<CopilotOpportunityResponseDto> {
return this.service.getOpportunity(id, user);
}
Expand Down
14 changes: 7 additions & 7 deletions src/api/copilot/copilot-opportunity.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PaginatedOpportunityResponse> {
// TODO [SECURITY]: No permission check is applied here; this is intentional for authenticated browsing and should remain explicitly documented.
const [sortField, sortDirection] = parseSortExpression(
Expand Down Expand Up @@ -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<CopilotOpportunityResponseDto> {
// TODO [SECURITY]: No permission check is applied; any authenticated user can access any opportunity by id.
const parsedOpportunityId = parseNumericId(opportunityId, 'Opportunity');
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -622,9 +622,9 @@ export class CopilotOpportunityService {
*/
private async getMembershipProjectIds(
opportunities: CopilotOpportunity[],
user: JwtUser,
user: JwtUser | undefined,
): Promise<Set<string>> {
if (!user.userId || !/^\d+$/.test(user.userId)) {
if (!user?.userId || !/^\d+$/.test(user.userId)) {
return new Set<string>();
}

Expand Down
14 changes: 10 additions & 4 deletions src/api/copilot/copilot.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand All @@ -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 [
Expand Down
131 changes: 131 additions & 0 deletions src/api/project/project.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 =>
Expand Down Expand Up @@ -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([
{
Expand Down
Loading
Loading