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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,6 @@ CHALLENGES_DB_URL=""
MEMBERS_DB_URL=""
RESOURCES_DB_URL=""
SKILLS_DB_URL=""

# Comma-separated internal billing account IDs, require active project membership for Talent Manager access.
INTERNAL_BILLING_ACCOUNT_IDS=""
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -521,3 +521,9 @@ Migration runbook (phased rollout, rollback, monitoring): `docs/MIGRATION_RUNBOO
| `docs/MIGRATION_RUNBOOK.md` | Phased rollout, rollback procedures, monitoring alerts |
| `docs/DEPENDENCIES.md` | Dependency security audit, outdated packages, overrides |
| `docs/timeline-milestone-migration.md` | Guidance for future timeline/milestone migration |

### 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.

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**.
135 changes: 119 additions & 16 deletions src/api/project/project.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,10 +270,26 @@ describe('ProjectService', () => {
);
});

it.each([
['project manager', UserRole.PROJECT_MANAGER],
['talent manager', UserRole.TALENT_MANAGER],
])(
it.each([UserRole.TALENT_MANAGER, UserRole.TOPCODER_TALENT_MANAGER])(
'lists non-member projects for %s',
async (role) => {
permissionServiceMock.hasIntersection.mockImplementation(
(roles: string[], allowed: string[]) =>
roles.some((value) => allowed.includes(value)),
);
prismaMock.project.count.mockResolvedValue(0);
prismaMock.project.findMany.mockResolvedValue([]);
await service.listProjects(
{ page: 1, perPage: 20 },
{ isMachine: false, userId: '999', roles: [role] },
);
expect(prismaMock.project.count).toHaveBeenCalledWith({
where: { deletedAt: null },
});
},
);

it.each([['project manager', UserRole.PROJECT_MANAGER]])(
'scopes %s project listings to project membership',
async (_label: string, role: UserRole) => {
permissionServiceMock.hasNamedPermission.mockImplementation(
Expand Down Expand Up @@ -522,11 +538,12 @@ describe('ProjectService', () => {
});

it.each([
['project manager', UserRole.PROJECT_MANAGER],
['talent manager', UserRole.TALENT_MANAGER],
['project manager', UserRole.PROJECT_MANAGER, false],
['Talent Manager', UserRole.TALENT_MANAGER, true],
['Topcoder Talent Manager', UserRole.TOPCODER_TALENT_MANAGER, true],
])(
'rejects direct project access for %s callers who are not on the project',
async (_label: string, role: UserRole) => {
'checks non-member direct access for %s',
async (_label: string, role: UserRole, canView: boolean) => {
const now = new Date();

prismaMock.project.findFirst.mockResolvedValue({
Expand Down Expand Up @@ -570,18 +587,104 @@ describe('ProjectService', () => {
permission === Permission.VIEW_PROJECT ||
permission === Permission.READ_PROJECT_ANY,
);
permissionServiceMock.hasIntersection.mockReturnValue(false);
permissionServiceMock.hasIntersection.mockImplementation(
(roles: string[], allowed: string[]) =>
roles.some((value) => allowed.includes(value)),
);

await expect(
service.getProject('1001', undefined, {
userId: '999',
roles: [role],
isMachine: false,
}),
).rejects.toBeInstanceOf(ForbiddenException);
const result = service.getProject('1001', undefined, {
userId: '999',
roles: [role],
isMachine: false,
});
if (canView)
await expect(result).resolves.toMatchObject({ name: 'Demo' });
else await expect(result).rejects.toBeInstanceOf(ForbiddenException);
},
);

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

beforeEach(() => {
process.env.INTERNAL_BILLING_ACCOUNT_IDS = '123';
billingAccountServiceMock.getBillingAccountsByIds.mockResolvedValue({});
permissionServiceMock.hasNamedPermission.mockReturnValue(true);
});

afterEach(() => {
if (originalIds === undefined)
delete process.env.INTERNAL_BILLING_ACCOUNT_IDS;
else process.env.INTERNAL_BILLING_ACCOUNT_IDS = originalIds;
});

it.each([
[UserRole.TALENT_MANAGER, 'manager', null, '42', true],
[UserRole.TOPCODER_TALENT_MANAGER, 'read', null, '42', true],
[UserRole.TALENT_MANAGER, 'customer', null, '42', true],
[UserRole.TALENT_MANAGER, 'manager', new Date(), '42', false],
[UserRole.TALENT_MANAGER, 'manager', null, '99', false],
])(
'checks %s with project role %s, deletedAt %s, and user %s',
async (role, memberRole, deletedAt, userId, allowed) => {
prismaMock.project.findFirst.mockResolvedValue({
id: 1001n,
name: 'Internal',
billingAccountId: 123n,
members: [{ userId: 42n, role: memberRole, deletedAt }],
invites: [
{ userId: BigInt(userId), status: 'pending', deletedAt: null },
],
});
const result = service.getProject('1001', 'id,name', {
userId,
roles: [role],
isMachine: false,
});
if (allowed)
await expect(result).resolves.toMatchObject({ name: 'Internal' });
else await expect(result).rejects.toBeInstanceOf(ForbiddenException);
},
);

it.each([false, true])(
'uses the membership override for both list results and totals with memberOnly=%s',
async (memberOnly) => {
prismaMock.project.count.mockResolvedValue(1);
prismaMock.project.findMany.mockResolvedValue([
{
id: 1001n,
name: 'Internal',
billingAccountId: 123n,
members: [{ userId: 42n, role: 'read', deletedAt: null }],
invites: [],
},
]);
const result = await service.listProjects(
{ memberOnly },
{
userId: '42',
roles: [UserRole.TALENT_MANAGER],
isMachine: false,
},
);
expect(result.total).toBe(1);
expect(result.data).toEqual([
expect.objectContaining({ name: 'Internal' }),
]);
const where = prismaMock.project.findMany.mock.calls[0][0].where;
expect(prismaMock.project.count).toHaveBeenCalledWith({ where });
expect(where.AND).toContainEqual({
OR: [
{ billingAccountId: null },
{ billingAccountId: { notIn: [123n] } },
{ members: { some: { userId: 42n, deletedAt: null } } },
],
});
},
);
});

it('lists billing accounts for project id', async () => {
billingAccountServiceMock.getBillingAccountsForProject.mockResolvedValue([
{
Expand Down
37 changes: 31 additions & 6 deletions src/api/project/project.service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {
activeProjectMembershipWhere,
internalBillingAccountIds,
isRestrictedTalentManager,
} from '../../shared/utils/internal-project.utils';
import { normalizeShowcaseProjectMetadata } from 'src/shared/utils/showcase-metadata.utils';
import {
BadRequestException,
Expand Down Expand Up @@ -135,8 +140,8 @@ export class ProjectService {
* Returns a paginated project list for the caller.
*
* Builds query clauses from shared utilities, scopes non-admin callers to
* their memberships, enriches member/invite handles, and hydrates billing
* account names.
* their memberships, grants Talent Managers non-internal projects and internal
* projects with active membership, enriches handles, and hydrates billing account names.
*
* @param criteria List filters, paging, sort, and field selection.
* @param user Authenticated caller context.
Expand Down Expand Up @@ -217,9 +222,11 @@ export class ProjectService {
*
* Members and invites are always loaded for permission evaluation regardless
* of requested `fields`, then relation visibility is filtered by caller
* permissions before response serialization. Human PM/TM-style callers must
* still be a project member or pending invitee; only admins, legacy manager
* roles, and authorized machine principals bypass membership scoping.
* permissions before response serialization. Talent Managers can read non-internal
* projects without membership, but internal projects require active membership.
* Other human callers need membership or a pending invite unless they have an
* administrator or legacy manager role; authorized machine principals also bypass
* membership scoping.
*
* @param projectId Project id path parameter.
* @param fieldsParam Optional CSV list of relation fields.
Expand Down Expand Up @@ -256,6 +263,21 @@ export class ProjectService {
);
}

if (
isRestrictedTalentManager(user) &&
project.billingAccountId !== null &&
internalBillingAccountIds().includes(project.billingAccountId) &&
!(project.members || []).some(
(member) =>
member.userId === activeProjectMembershipWhere(user).userId &&
member.deletedAt === null,
)
) {
throw new ForbiddenException(
'Talent Managers must be active members to access internal projects',
);
}

const [projectWithMemberHandles] =
await this.enrichProjectsWithMemberHandles([project]);
const projectWithRelations = projectWithMemberHandles || project;
Expand Down Expand Up @@ -1466,7 +1488,8 @@ export class ProjectService {
/**
* Returns whether the caller may bypass project membership visibility checks.
*
* Human callers only retain global access for admin or legacy manager roles.
* Human callers retain global access for admins, legacy managers, and Talent Managers.
* Talent Manager internal-account exclusions and active-membership overrides are enforced separately.
* Machine principals continue to rely on the named permission so scoped
* service tokens can read any project when authorized.
*
Expand All @@ -1490,6 +1513,8 @@ export class ProjectService {
...ADMIN_ROLES,
UserRole.MANAGER,
UserRole.TOPCODER_MANAGER,
UserRole.TALENT_MANAGER,
UserRole.TOPCODER_TALENT_MANAGER,
]);
}

Expand Down
83 changes: 83 additions & 0 deletions src/shared/interceptors/projectContext.interceptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import {
BadRequestException,
CallHandler,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
import { of } from 'rxjs';
import { ProjectContextInterceptor } from './projectContext.interceptor';

describe('ProjectContextInterceptor', () => {
const prismaServiceMock = {
project: { findFirst: jest.fn() },
projectMember: {
findMany: jest.fn(),
},
Expand All @@ -31,6 +33,87 @@ describe('ProjectContextInterceptor', () => {
}),
}) as ExecutionContext;

it('rejects internal project routes when cached membership is no longer active', async () => {
const previous = process.env.INTERNAL_BILLING_ACCOUNT_IDS;
process.env.INTERNAL_BILLING_ACCOUNT_IDS = '123';
try {
prismaServiceMock.project.findFirst.mockResolvedValue({
id: 1001n,
members: [],
});
const request = {
params: { projectId: '1001' },
user: { userId: '42', roles: ['Talent Manager'] },
projectContext: {
projectId: '1001',
projectMembers: [{ userId: '42', role: 'manager' }],
},
};
await expect(
interceptor.intercept(createExecutionContext(request), next),
).rejects.toBeInstanceOf(ForbiddenException);
expect(prismaServiceMock.project.findFirst).toHaveBeenCalledWith({
where: { id: 1001n, deletedAt: null, billingAccountId: { in: [123n] } },
select: {
id: true,
members: {
where: { userId: 42n, deletedAt: null },
select: { id: true },
},
},
});
} finally {
if (previous === undefined)
delete process.env.INTERNAL_BILLING_ACCOUNT_IDS;
else process.env.INTERNAL_BILLING_ACCOUNT_IDS = previous;
}
});

it.each(['Talent Manager', 'Topcoder Talent Manager'])(
'allows %s internal project routes with active membership',
async (role) => {
const previous = process.env.INTERNAL_BILLING_ACCOUNT_IDS;
process.env.INTERNAL_BILLING_ACCOUNT_IDS = '123';
try {
prismaServiceMock.project.findFirst.mockResolvedValue({
id: 1001n,
members: [{ id: 1n }],
});
prismaServiceMock.projectMember.findMany.mockResolvedValue([
{ id: 1n, userId: 42n, role: 'read', deletedAt: null },
]);
const request: any = {
params: { projectId: '1001' },
user: { userId: '42', roles: [role] },
};
await expect(
interceptor.intercept(createExecutionContext(request), next),
).resolves.toBeDefined();
expect(request.projectContext.projectMembers).toEqual([
expect.objectContaining({ userId: 42n, role: 'read' }),
]);
expect(prismaServiceMock.project.findFirst).toHaveBeenCalledWith({
where: {
id: 1001n,
deletedAt: null,
billingAccountId: { in: [123n] },
},
select: {
id: true,
members: {
where: { userId: 42n, deletedAt: null },
select: { id: true },
},
},
});
} finally {
if (previous === undefined)
delete process.env.INTERNAL_BILLING_ACCOUNT_IDS;
else process.env.INTERNAL_BILLING_ACCOUNT_IDS = previous;
}
},
);

it('loads project members when projectId exists', async () => {
const request: any = {
params: {
Expand Down
Loading
Loading