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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,6 @@ Migration runbook (phased rollout, rollback, monitoring): `docs/MIGRATION_RUNBOO

### Talent Manager project visibility

`Talent Manager` and `Topcoder Talent Manager` users can list and open all non-internal projects without membership. Set `INTERNAL_BILLING_ACCOUNT_IDS` to the comma-separated positive billing account IDs used for internal projects before deploying this policy (for example, `123,456`); an empty value excludes no accounts, and invalid values reject requests rather than applying a partial list. Projects without a billing account remain visible.
`Talent Manager` and `Topcoder Talent Manager` users can list and open all non-internal projects without membership, including read-only access to their members, invites, and attachments. Set `INTERNAL_BILLING_ACCOUNT_IDS` to the comma-separated positive billing account IDs used for internal projects before deploying this policy (for example, `123,456`); an empty value excludes no accounts, and invalid values reject requests rather than applying a partial list. Projects without a billing account remain visible.

Internal projects are excluded from list results and totals and rejected on direct project and nested project routes unless the Talent Manager is an active project member. Explicit membership in any project role overrides the internal billing account exclusion; deleted memberships and pending invites alone do not. Administrators and machine tokens keep their existing authorization policies. `memberOnly=true` narrows the visible projects to existing membership/pending-invite associations, including internal projects with active membership; Work exposes this as **My Projects**.
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.
- `Talent Manager` and `Topcoder Talent Manager` also read project members, invites, and attachments without membership (`READ_PROJECT_MEMBER`, `READ_PROJECT_INVITE_NOT_OWN`, `VIEW_PROJECT_ATTACHMENT`), so Work Manager's Users and Assets Library tabs load for non-internal projects. Internal projects still require active membership, and member, invite, and attachment changes keep their existing project-role checks.
- 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.

Expand Down
48 changes: 48 additions & 0 deletions src/api/project/project.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,54 @@ describe('ProjectService', () => {
},
);

it.each([UserRole.TALENT_MANAGER, UserRole.TOPCODER_TALENT_MANAGER])(
'returns members, invites, and attachments of a non-member project to %s',
async (role) => {
const realPermissionService = new PermissionService({
hasRequiredScopes: () => false,
validateMachineToken: () => ({ isMachine: false, scopes: [] }),
} as any);
const serviceWithRealPermissions = new ProjectService(
prismaMock as any,
realPermissionService,
billingAccountServiceMock as any,
memberServiceMock as any,
);
prismaMock.project.findFirst.mockResolvedValue({
id: 1001n,
name: 'Non-internal',
billingAccountId: null,
members: [{ id: 1n, userId: 100n, role: 'manager', deletedAt: null }],
invites: [
{
id: 2n,
userId: 200n,
email: null,
status: 'pending',
deletedAt: null,
},
],
attachments: [
{ id: 3n, createdBy: 100, allowedUsers: [], deletedAt: null },
],
});

const result = await serviceWithRealPermissions.getProject(
'1001',
'members,invites,attachments',
{ userId: '999', roles: [role], isMachine: false },
);

expect(result.members).toEqual([
expect.objectContaining({ userId: '100', role: 'manager' }),
]);
expect(result.invites).toEqual([
expect.objectContaining({ userId: '200', status: 'pending' }),
]);
expect(result.attachments).toHaveLength(1);
},
);

describe('Talent Manager internal project membership override', () => {
const originalIds = process.env.INTERNAL_BILLING_ACCOUNT_IDS;

Expand Down
62 changes: 62 additions & 0 deletions src/shared/services/permission.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,68 @@ describe('PermissionService', () => {
},
);

it.each([UserRole.TALENT_MANAGER, UserRole.TOPCODER_TALENT_MANAGER])(
'allows %s to read project members, invites, and attachments without membership',
(role) => {
const user = {
userId: '555',
roles: [role],
isMachine: false,
};
const projectMembers = [
{
userId: '999',
role: ProjectMemberRole.MANAGER,
},
];

expect(
[
Permission.READ_PROJECT_MEMBER,
Permission.READ_PROJECT_INVITE_NOT_OWN,
Permission.VIEW_PROJECT_ATTACHMENT,
].map((permission) =>
service.hasNamedPermission(permission, user, projectMembers),
),
).toEqual([true, true, true]);

expect(
[
Permission.CREATE_PROJECT_MEMBER_NOT_OWN,
Permission.UPDATE_PROJECT_MEMBER_NON_CUSTOMER,
Permission.DELETE_PROJECT_MEMBER_TOPCODER,
Permission.CREATE_PROJECT_INVITE_TOPCODER,
Permission.UPDATE_PROJECT_INVITE_NOT_OWN,
Permission.CREATE_PROJECT_ATTACHMENT,
Permission.EDIT_PROJECT_ATTACHMENT,
Permission.DELETE_PROJECT_ATTACHMENT,
].map((permission) =>
service.hasNamedPermission(permission, user, projectMembers),
),
).toEqual([false, false, false, false, false, false, false, false]);
},
);

it('requires membership for non-manager roles to read project members, invites, and attachments', () => {
const user = {
userId: '555',
roles: [UserRole.TOPCODER_USER],
isMachine: false,
};

expect(
[
Permission.READ_PROJECT_MEMBER,
Permission.READ_PROJECT_INVITE_NOT_OWN,
Permission.VIEW_PROJECT_ATTACHMENT,
].map((permission) =>
service.hasNamedPermission(permission, user, [
{ userId: '999', role: ProjectMemberRole.MANAGER },
]),
),
).toEqual([false, false, false]);
});

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
15 changes: 14 additions & 1 deletion src/shared/services/permission.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ export class PermissionService {
* check: any non-empty `userId`, any role, any scope, or `isMachine`.
* @security Legacy manager JWTs use `UserRole.TOPCODER_MANAGER`, which is
* broader than strict admin access and retained for v5 compatibility.
* @security Talent Manager roles can read project members, invites, and
* attachments without membership so Work Manager can show non-internal
* project workspaces. Internal-project membership is enforced separately by
* `ProjectContextInterceptor` and `ProjectService.getProject`.
*/
hasNamedPermission(
permission: NamedPermission,
Expand Down Expand Up @@ -167,6 +171,8 @@ export class PermissionService {
]);
const hasProjectReadTopcoderRole = this.hasProjectReadTopcoderRole(user);
const hasManagerTopcoderRole = this.hasManagerTopcoderRole(user);
// Internal-project membership for Talent Managers is enforced upstream.
const hasTalentManagerRole = this.hasTalentManagerRole(user);
const hasStrictAdminAccess =
this.hasIntersection(user.roles || [], ADMIN_ROLES) ||
this.m2mService.hasRequiredScopes(effectiveScopes, [
Expand Down Expand Up @@ -299,6 +305,7 @@ export class PermissionService {
case NamedPermission.READ_PROJECT_MEMBER:
return (
hasManagerTopcoderRole ||
hasTalentManagerRole ||
hasProjectMembership ||
hasProjectMemberReadScope
);
Expand Down Expand Up @@ -335,6 +342,7 @@ export class PermissionService {
case NamedPermission.READ_PROJECT_INVITE_NOT_OWN:
return (
hasManagerTopcoderRole ||
hasTalentManagerRole ||
hasProjectMembership ||
hasProjectInviteReadScope
);
Expand Down Expand Up @@ -449,7 +457,9 @@ export class PermissionService {

// Project attachment permissions.
case NamedPermission.VIEW_PROJECT_ATTACHMENT:
return hasManagerTopcoderRole || hasProjectMembership;
return (
hasManagerTopcoderRole || hasTalentManagerRole || hasProjectMembership
);

case NamedPermission.CREATE_PROJECT_ATTACHMENT:
case NamedPermission.EDIT_PROJECT_ATTACHMENT:
Expand Down Expand Up @@ -891,6 +901,9 @@ export class PermissionService {
/**
* Checks whether user has one of the Talent Manager Topcoder roles.
*
* Used by {@link hasNamedPermission} to grant read-only access to project
* members, invites, and attachments without project membership.
*
* @param user authenticated JWT user context
* @returns `true` when user has Talent Manager access
*/
Expand Down
6 changes: 3 additions & 3 deletions src/shared/utils/permission-docs.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ function getNamedPermissionDocumentation(

case NamedPermission.READ_PROJECT_MEMBER:
return createSummary({
userRoles: ADMIN_AND_MANAGER_ROLES,
userRoles: [...ADMIN_AND_MANAGER_ROLES, ...TALENT_MANAGER_ROLES],
allowAnyProjectMember: true,
scopes: PROJECT_MEMBER_READ_SCOPES,
});
Expand Down Expand Up @@ -312,7 +312,7 @@ function getNamedPermissionDocumentation(

case NamedPermission.READ_PROJECT_INVITE_NOT_OWN:
return createSummary({
userRoles: ADMIN_AND_MANAGER_ROLES,
userRoles: [...ADMIN_AND_MANAGER_ROLES, ...TALENT_MANAGER_ROLES],
allowAnyProjectMember: true,
scopes: PROJECT_INVITE_READ_SCOPES,
});
Expand Down Expand Up @@ -418,7 +418,7 @@ function getNamedPermissionDocumentation(

case NamedPermission.VIEW_PROJECT_ATTACHMENT:
return createSummary({
userRoles: ADMIN_AND_MANAGER_ROLES,
userRoles: [...ADMIN_AND_MANAGER_ROLES, ...TALENT_MANAGER_ROLES],
allowAnyProjectMember: true,
});

Expand Down
Loading