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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -95,5 +95,5 @@ MEMBERS_DB_URL=""
RESOURCES_DB_URL=""
SKILLS_DB_URL=""

# Comma-separated internal billing account IDs, excluded from Talent Manager access.
# Comma-separated internal billing account IDs, require active project membership for Talent Manager access.
INTERNAL_BILLING_ACCOUNT_IDS=""
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -526,4 +526,4 @@ Migration runbook (phased rollout, rollback, monitoring): `docs/MIGRATION_RUNBOO

`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, even when the Talent Manager has membership or a pending invite. Administrators and machine tokens keep their existing authorization policies. `memberOnly=true` narrows the visible projects to existing membership/pending-invite associations and still excludes internal projects; Work exposes this as **My Projects**.
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**.
82 changes: 82 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,88 @@ describe('ProjectService', () => {
},
);

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
24 changes: 16 additions & 8 deletions src/api/project/project.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
activeProjectMembershipWhere,
internalBillingAccountIds,
isRestrictedTalentManager,
} from '../../shared/utils/internal-project.utils';
Expand Down Expand Up @@ -139,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, grants Talent Managers non-internal projects, enriches 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 @@ -221,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 @@ -263,10 +266,15 @@ export class ProjectService {
if (
isRestrictedTalentManager(user) &&
project.billingAccountId !== null &&
internalBillingAccountIds().includes(project.billingAccountId)
internalBillingAccountIds().includes(project.billingAccountId) &&
!(project.members || []).some(
(member) =>
member.userId === activeProjectMembershipWhere(user).userId &&
member.deletedAt === null,
)
) {
throw new ForbiddenException(
'Talent Managers cannot access internal projects',
'Talent Managers must be active members to access internal projects',
);
}

Expand Down Expand Up @@ -1481,7 +1489,7 @@ export class ProjectService {
* Returns whether the caller may bypass project membership visibility checks.
*
* Human callers retain global access for admins, legacy managers, and Talent Managers.
* Talent Manager internal-account exclusions are enforced separately.
* 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 Down
60 changes: 57 additions & 3 deletions src/shared/interceptors/projectContext.interceptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,14 @@ describe('ProjectContextInterceptor', () => {
}),
}) as ExecutionContext;

it('rejects internal project routes even when membership was already cached', async () => {
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 });
prismaServiceMock.project.findFirst.mockResolvedValue({
id: 1001n,
members: [],
});
const request = {
params: { projectId: '1001' },
user: { userId: '42', roles: ['Talent Manager'] },
Expand All @@ -51,7 +54,13 @@ describe('ProjectContextInterceptor', () => {
).rejects.toBeInstanceOf(ForbiddenException);
expect(prismaServiceMock.project.findFirst).toHaveBeenCalledWith({
where: { id: 1001n, deletedAt: null, billingAccountId: { in: [123n] } },
select: { id: true },
select: {
id: true,
members: {
where: { userId: 42n, deletedAt: null },
select: { id: true },
},
},
});
} finally {
if (previous === undefined)
Expand All @@ -60,6 +69,51 @@ describe('ProjectContextInterceptor', () => {
}
});

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
17 changes: 12 additions & 5 deletions src/shared/interceptors/projectContext.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from '@nestjs/common';
import { Observable } from 'rxjs';
import {
activeProjectMembershipWhere,
internalBillingAccountIds,
isRestrictedTalentManager,
} from '../utils/internal-project.utils';
Expand Down Expand Up @@ -41,15 +42,15 @@ export class ProjectContextInterceptor implements NestInterceptor {
* `projectId` route param is available.
*
* Behavior:
* - Rejects Talent Manager access to configured internal projects before cache hits.
* - Rejects Talent Manager access to configured internal projects without active membership, before cache hits.
* - Initializes `request.projectContext` if absent.
* - Short-circuits when no project id is present.
* - Short-circuits on cache hits where project id already matches.
* - Throws `BadRequestException` when `projectId` is present but not numeric.
* - Queries active project members and maps `role` to plain strings.
* - On query error, logs a warning and stores `projectMembers = []`.
*
* @throws ForbiddenException for internal projects accessed by a Talent Manager.
* @throws ForbiddenException for internal projects accessed by a Talent Manager without active membership.
* @throws Database/configuration errors during internal-project checks propagate.
* @todo Member query + mapping logic is duplicated in multiple guards.
* Introduce a shared `ProjectContextService` to centralize loading behavior.
Expand Down Expand Up @@ -83,11 +84,17 @@ export class ProjectContextInterceptor implements NestInterceptor {
deletedAt: null,
billingAccountId: { in: internalIds },
},
select: { id: true },
select: {
id: true,
members: {
where: activeProjectMembershipWhere(request.user),
select: { id: true },
},
},
});
if (internalProject) {
if (internalProject && !internalProject.members.length) {
throw new ForbiddenException(
'Talent Managers cannot access internal projects',
'Talent Managers must be active members to access internal projects',
);
}
}
Expand Down
28 changes: 27 additions & 1 deletion src/shared/utils/internal-project.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
activeProjectMembershipWhere,
internalBillingAccountIds,
isRestrictedTalentManager,
} from './internal-project.utils';
Expand All @@ -25,6 +26,30 @@ describe('Talent Manager internal project visibility', () => {
},
);

it.each([undefined, '', 'handle', '42broken'])(
'does not grant membership for an unresolved identity %s',
(userId) => {
expect(
activeProjectMembershipWhere({ userId, isMachine: false }),
).toEqual({
userId: -1n,
deletedAt: null,
});
},
);

it('normalizes numeric membership identities without precision loss', () => {
expect(
activeProjectMembershipWhere({
userId: ' 9007199254740993 ',
isMachine: false,
}),
).toEqual({
userId: 9007199254740993n,
deletedAt: null,
});
});

it('retains administrator and machine policies', () => {
expect(
isRestrictedTalentManager({
Expand Down Expand Up @@ -55,7 +80,7 @@ describe('Talent Manager internal project visibility', () => {
});

it.each([false, true])(
'excludes internal projects with memberOnly=%s',
'allows active membership to override internal exclusions with memberOnly=%s',
(memberOnly) => {
process.env.INTERNAL_BILLING_ACCOUNT_IDS = '123,456';
const where = buildProjectWhereClause(
Expand All @@ -69,6 +94,7 @@ describe('Talent Manager internal project visibility', () => {
OR: [
{ billingAccountId: null },
{ billingAccountId: { notIn: [123n, 456n] } },
{ members: { some: { userId: 42n, deletedAt: null } } },
],
},
]),
Expand Down
20 changes: 19 additions & 1 deletion src/shared/utils/internal-project.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { JwtUser } from '../modules/global/jwt.service';
* Identifies human Talent Managers subject to internal-project restrictions.
* Used by project queries and the global project-context interceptor.
* @param user Authenticated caller; administrators and machine tokens retain their policies.
* @returns Whether the caller must be excluded from internal projects.
* @returns Whether the caller needs active membership to access internal projects.
* @throws Does not throw.
*/
export function isRestrictedTalentManager(user?: JwtUser): boolean {
Expand Down Expand Up @@ -40,3 +40,21 @@ export function internalBillingAccountIds(
});
return [...new Set(ids)];
}

/**
* Builds the active membership filter used to override internal-project exclusions.
* Used by project list queries, direct reads, and the project-context interceptor.
* @param user Authenticated caller whose numeric user ID identifies membership.
* @returns Member criteria; missing or nonnumeric IDs use an impossible user ID.
* @throws Does not throw.
*/
export function activeProjectMembershipWhere(user?: JwtUser): {
userId: bigint;
deletedAt: null;
} {
const userId = String(user?.userId ?? '').trim();
return {
userId: /^\d+$/.test(userId) ? BigInt(userId) : -1n,
deletedAt: null,
};
}
Loading
Loading