Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 docs/PERMISSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/api/workstream/workstream.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand Down
16 changes: 6 additions & 10 deletions src/shared/constants/roles.ts
Original file line number Diff line number Diff line change
@@ -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);
44 changes: 44 additions & 0 deletions src/shared/guards/tokenRoles.guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, any> = {
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<string, any> = {
headers: {
Expand Down
52 changes: 52 additions & 0 deletions src/shared/services/permission.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
6 changes: 5 additions & 1 deletion src/shared/services/permission.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 5 additions & 2 deletions src/shared/utils/permission-docs.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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:
Expand Down
Loading