Skip to content
Open
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.
- 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
4 changes: 4 additions & 0 deletions src/shared/enums/userRole.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
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 @@ -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';
Expand Down Expand Up @@ -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<string, any> = {
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<string, any> = {
headers: {
Expand Down
70 changes: 40 additions & 30 deletions src/shared/services/permission.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ describe('PermissionService', () => {
});

it.each([
UserRole.TOPCODER_MANAGER,
UserRole.PROJECT_MANAGER,
UserRole.TASK_MANAGER,
UserRole.TOPCODER_TASK_MANAGER,
Expand Down Expand Up @@ -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(
Expand Down
11 changes: 5 additions & 6 deletions src/shared/services/permission.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -833,7 +832,7 @@ export class PermissionService {
UserRole.TOPCODER_TASK_MANAGER,
UserRole.TALENT_MANAGER,
UserRole.TOPCODER_TALENT_MANAGER,
'topcoder_manager',
UserRole.TOPCODER_MANAGER,
]);
}

Expand All @@ -847,7 +846,7 @@ export class PermissionService {
private hasManagerTopcoderRole(user: JwtUser): boolean {
return this.hasIntersection(user.roles || [], [
...MANAGER_ROLES,
'topcoder_manager',
UserRole.TOPCODER_MANAGER,
]);
}

Expand Down
4 changes: 1 addition & 3 deletions src/shared/utils/permission-docs.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
Loading