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
5 changes: 5 additions & 0 deletions docs/PERMISSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
63 changes: 63 additions & 0 deletions src/shared/services/permission.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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,
Expand Down
59 changes: 53 additions & 6 deletions src/shared/services/permission.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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, [
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
*
Expand Down
Loading