Skip to content

Commit b63de0a

Browse files
committed
Updates for project-manager roles
1 parent 4eda48d commit b63de0a

4 files changed

Lines changed: 211 additions & 23 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@
2626
- Guards for Roles (e.g., `Administrator`) and M2M Scopes are provided.
2727
- Billing-account management endpoints accept `administrator`, `Talent Manager`,
2828
and `Topcoder Talent Manager` JWT roles; read-only billing-account lookups
29-
also continue to allow `copilot`. Role checks are case-insensitive so mixed
30-
token casing does not block Talent Manager access.
29+
also continue to allow `copilot`, `Project Manager`, and `Topcoder Project Manager`.
30+
Project Managers are restricted to billing accounts granted to their own
31+
user id on `GET /billing-accounts` and `GET /billing-accounts/:billingAccountId`.
32+
Role checks are case-insensitive so mixed token casing does not block access.
3133
- Configure env: `AUTH_SECRET` or `AUTH0_URL/AUDIENCE/ISSUER` as needed.
3234

3335
## Quickstart

src/auth/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,7 @@ export const SCOPES = {
1313

1414
export const ADMIN_ROLE = "administrator";
1515
export const COPILOT_ROLE = "copilot";
16+
export const PROJECT_MANAGER_ROLE = "Project Manager";
17+
export const TOPCODER_PROJECT_MANAGER_ROLE = "Topcoder Project Manager";
1618
export const TALENT_MANAGER_ROLE = "Talent Manager";
1719
export const TOPCODER_TALENT_MANAGER_ROLE = "Topcoder Talent Manager";

src/billing-accounts/billing-accounts.controller.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
Body,
33
Controller,
4+
Req,
45
Get,
56
Param,
67
Patch,
@@ -11,6 +12,7 @@ import {
1112
Delete,
1213
} from "@nestjs/common";
1314
import { BillingAccountsService } from "./billing-accounts.service";
15+
import type { BillingAccountsAuthUser } from "./billing-accounts.service";
1416
import { QueryBillingAccountsDto } from "./dto/query-billing-accounts.dto";
1517
import { CreateBillingAccountDto } from "./dto/create-billing-account.dto";
1618
import { UpdateBillingAccountDto } from "./dto/update-billing-account.dto";
@@ -24,9 +26,12 @@ import {
2426
SCOPES,
2527
ADMIN_ROLE,
2628
COPILOT_ROLE,
29+
PROJECT_MANAGER_ROLE,
2730
TALENT_MANAGER_ROLE,
31+
TOPCODER_PROJECT_MANAGER_ROLE,
2832
TOPCODER_TALENT_MANAGER_ROLE,
2933
} from "../auth/constants";
34+
import type { Request } from "express";
3035
import { buildOperationDoc } from "../common/swagger/swagger-auth.util";
3136
import {
3237
ApiBearerAuth,
@@ -45,12 +50,22 @@ const BILLING_ACCOUNT_READ_ROLES = [
4550
TOPCODER_TALENT_MANAGER_ROLE,
4651
];
4752

53+
const BILLING_ACCOUNT_PROJECT_READ_ROLES = [
54+
...BILLING_ACCOUNT_READ_ROLES,
55+
PROJECT_MANAGER_ROLE,
56+
TOPCODER_PROJECT_MANAGER_ROLE,
57+
];
58+
4859
const BILLING_ACCOUNT_MANAGE_ROLES = [
4960
ADMIN_ROLE,
5061
TALENT_MANAGER_ROLE,
5162
TOPCODER_TALENT_MANAGER_ROLE,
5263
];
5364

65+
interface BillingAccountsRequest extends Request {
66+
authUser?: BillingAccountsAuthUser;
67+
}
68+
5469
@ApiTags("Billing Accounts")
5570
@ApiBearerAuth("JWT")
5671
@ApiBearerAuth("M2M")
@@ -60,14 +75,14 @@ export class BillingAccountsController {
6075

6176
@Get()
6277
@UseGuards(RolesGuard, ScopesGuard)
63-
@Roles(...BILLING_ACCOUNT_READ_ROLES)
78+
@Roles(...BILLING_ACCOUNT_PROJECT_READ_ROLES)
6479
@Scopes(SCOPES.READ_BA, SCOPES.ALL_BA)
6580
@ApiOperation(
6681
buildOperationDoc({
6782
summary: "List billing accounts",
6883
description:
69-
"Retrieve billing accounts with optional filters, sorting, and pagination.",
70-
jwtRoles: BILLING_ACCOUNT_READ_ROLES,
84+
"Retrieve billing accounts with optional filters, sorting, and pagination. Project Managers are limited to billing accounts granted to their own user id.",
85+
jwtRoles: BILLING_ACCOUNT_PROJECT_READ_ROLES,
7186
m2mScopes: [SCOPES.READ_BA, SCOPES.ALL_BA],
7287
}),
7388
)
@@ -99,8 +114,11 @@ export class BillingAccountsController {
99114
@ApiQuery({ name: "sortOrder", required: false, enum: ["asc", "desc"] })
100115
@ApiQuery({ name: "page", required: false, type: Number })
101116
@ApiQuery({ name: "perPage", required: false, type: Number })
102-
async list(@Query() q: QueryBillingAccountsDto) {
103-
return this.service.list(q);
117+
async list(
118+
@Query() q: QueryBillingAccountsDto,
119+
@Req() req: BillingAccountsRequest,
120+
) {
121+
return this.service.list(q, req.authUser);
104122
}
105123

106124
@Post()
@@ -147,14 +165,14 @@ export class BillingAccountsController {
147165

148166
@Get(":billingAccountId")
149167
@UseGuards(RolesGuard, ScopesGuard)
150-
@Roles(...BILLING_ACCOUNT_READ_ROLES)
168+
@Roles(...BILLING_ACCOUNT_PROJECT_READ_ROLES)
151169
@Scopes(SCOPES.READ_BA, SCOPES.ALL_BA)
152170
@ApiOperation(
153171
buildOperationDoc({
154172
summary: "Get a billing account",
155173
description:
156-
"Fetch a billing account by its identifier, including budget and client data.",
157-
jwtRoles: BILLING_ACCOUNT_READ_ROLES,
174+
"Fetch a billing account by its identifier, including budget and client data. Project Managers can read only billing accounts granted to them.",
175+
jwtRoles: BILLING_ACCOUNT_PROJECT_READ_ROLES,
158176
m2mScopes: [SCOPES.READ_BA, SCOPES.ALL_BA],
159177
}),
160178
)
@@ -164,8 +182,11 @@ export class BillingAccountsController {
164182
description: "Billing Account ID",
165183
type: Number,
166184
})
167-
async get(@Param("billingAccountId", ParseIntPipe) id: number) {
168-
return this.service.get(id);
185+
async get(
186+
@Param("billingAccountId", ParseIntPipe) id: number,
187+
@Req() req: BillingAccountsRequest,
188+
) {
189+
return this.service.get(id, req.authUser);
169190
}
170191

171192
@Patch(":billingAccountId")

src/billing-accounts/billing-accounts.service.ts

Lines changed: 174 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,114 @@ import { LockAmountDto } from "./dto/lock-amount.dto";
88
import { ConsumeAmountDto } from "./dto/consume-amount.dto";
99
import { MembersLookupService } from "../common/members-lookup.service";
1010
import SalesforceService from "../common/salesforce.service";
11+
import {
12+
ADMIN_ROLE,
13+
COPILOT_ROLE,
14+
PROJECT_MANAGER_ROLE,
15+
TALENT_MANAGER_ROLE,
16+
TOPCODER_PROJECT_MANAGER_ROLE,
17+
TOPCODER_TALENT_MANAGER_ROLE,
18+
} from "../auth/constants";
19+
20+
export interface BillingAccountsAuthUser {
21+
role?: string;
22+
roles?: string[] | string;
23+
userId?: number | string;
24+
}
25+
26+
const UNRESTRICTED_BILLING_ACCOUNT_READ_ROLES = [
27+
ADMIN_ROLE,
28+
COPILOT_ROLE,
29+
TALENT_MANAGER_ROLE,
30+
TOPCODER_TALENT_MANAGER_ROLE,
31+
];
32+
33+
const RESTRICTED_PROJECT_MANAGER_READ_ROLES = [
34+
PROJECT_MANAGER_ROLE,
35+
TOPCODER_PROJECT_MANAGER_ROLE,
36+
];
37+
38+
/**
39+
* Normalizes authenticated caller roles for case-insensitive comparisons.
40+
*
41+
* Accepts either array-backed `roles` or the legacy comma-delimited `role`
42+
* payload shapes emitted by different token issuers.
43+
*
44+
* @param authUser Authenticated caller context from `req.authUser`.
45+
* @returns Lower-cased role names.
46+
*/
47+
function getNormalizedAuthUserRoles(
48+
authUser?: BillingAccountsAuthUser,
49+
): string[] {
50+
const roles = Array.isArray(authUser?.roles)
51+
? authUser.roles
52+
: String(authUser?.roles || authUser?.role || "")
53+
.split(",")
54+
.map((role) => role.trim())
55+
.filter(Boolean);
56+
57+
return roles.map((role) => role.toLowerCase());
58+
}
59+
60+
/**
61+
* Resolves the caller user id as a trimmed string when present.
62+
*
63+
* @param authUser Authenticated caller context from `req.authUser`.
64+
* @returns Normalized user id or `undefined` when missing.
65+
*/
66+
function getNormalizedAuthUserId(
67+
authUser?: BillingAccountsAuthUser,
68+
): string | undefined {
69+
if (
70+
typeof authUser?.userId === "number" &&
71+
Number.isFinite(authUser.userId)
72+
) {
73+
return String(authUser.userId);
74+
}
75+
76+
if (typeof authUser?.userId !== "string") {
77+
return undefined;
78+
}
79+
80+
const normalizedUserId = authUser.userId.trim();
81+
82+
return normalizedUserId || undefined;
83+
}
84+
85+
/**
86+
* Returns the enforced access-grant user id for restricted Project Manager
87+
* billing-account reads.
88+
*
89+
* `undefined` means the caller keeps unrestricted read behavior. `null`
90+
* indicates a restricted Project Manager caller without a usable `userId`,
91+
* which should be treated as no accessible accounts.
92+
*
93+
* @param authUser Authenticated caller context from `req.authUser`.
94+
* @returns Enforced user id, `null`, or `undefined`.
95+
*/
96+
function resolveRestrictedProjectManagerUserId(
97+
authUser?: BillingAccountsAuthUser,
98+
): string | null | undefined {
99+
const normalizedRoles = getNormalizedAuthUserRoles(authUser);
100+
const hasRestrictedProjectManagerRole =
101+
RESTRICTED_PROJECT_MANAGER_READ_ROLES.some((role) =>
102+
normalizedRoles.includes(role.toLowerCase()),
103+
);
104+
105+
if (!hasRestrictedProjectManagerRole) {
106+
return undefined;
107+
}
108+
109+
const hasUnrestrictedReadRole = UNRESTRICTED_BILLING_ACCOUNT_READ_ROLES.some(
110+
(role) => normalizedRoles.includes(role.toLowerCase()),
111+
);
112+
113+
if (hasUnrestrictedReadRole) {
114+
return undefined;
115+
}
116+
117+
return getNormalizedAuthUserId(authUser) ?? null;
118+
}
11119

12120
@Injectable()
13121
export class BillingAccountsService {
@@ -38,7 +146,17 @@ export class BillingAccountsService {
38146
return res;
39147
}
40148

41-
async list(q: QueryBillingAccountsDto) {
149+
/**
150+
* Lists billing accounts with optional filtering, sorting, and pagination.
151+
*
152+
* Project Manager callers are constrained to billing accounts granted to
153+
* their own `userId`, regardless of an explicit `userId` query override.
154+
*
155+
* @param q Query filters and pagination controls.
156+
* @param authUser Authenticated caller context from `req.authUser`.
157+
* @returns Paginated billing-account result set.
158+
*/
159+
async list(q: QueryBillingAccountsDto, authUser?: BillingAccountsAuthUser) {
42160
const {
43161
clientId,
44162
userId,
@@ -53,13 +171,27 @@ export class BillingAccountsService {
53171
sortBy,
54172
sortOrder = "asc",
55173
} = q;
174+
const restrictedProjectManagerUserId =
175+
resolveRestrictedProjectManagerUserId(authUser);
176+
177+
if (restrictedProjectManagerUserId === null) {
178+
return {
179+
page,
180+
perPage,
181+
total: 0,
182+
totalPages: 0,
183+
data: [],
184+
};
185+
}
56186

57187
const where: Prisma.BillingAccountWhereInput = {
58188
...(clientId ? { clientId } : {}),
59189
...(status ? { status } : {}),
60190
};
61191

62-
if (userId) {
192+
if (restrictedProjectManagerUserId) {
193+
where.accessGrants = { some: { userId: restrictedProjectManagerUserId } };
194+
} else if (userId) {
63195
where.accessGrants = { some: { userId } };
64196
}
65197

@@ -175,15 +307,46 @@ export class BillingAccountsService {
175307
});
176308
}
177309

178-
async get(billingAccountId: number) {
179-
const ba = await this.prisma.billingAccount.findUnique({
180-
where: { id: billingAccountId },
181-
include: {
182-
client: true,
183-
lockedAmounts: true,
184-
consumedAmounts: true,
185-
},
186-
});
310+
/**
311+
* Fetches a single billing account and its budget aggregates.
312+
*
313+
* Project Manager callers can read only billing accounts granted to their own
314+
* `userId`. Missing access is surfaced as not found to avoid leaking account
315+
* existence.
316+
*
317+
* @param billingAccountId Billing-account identifier.
318+
* @param authUser Authenticated caller context from `req.authUser`.
319+
* @returns Billing-account details with locked, consumed, and remaining
320+
* budget totals.
321+
* @throws NotFoundException When the billing account does not exist or the
322+
* caller does not have access to it.
323+
*/
324+
async get(billingAccountId: number, authUser?: BillingAccountsAuthUser) {
325+
const restrictedProjectManagerUserId =
326+
resolveRestrictedProjectManagerUserId(authUser);
327+
const include = {
328+
client: true,
329+
lockedAmounts: true,
330+
consumedAmounts: true,
331+
};
332+
const ba =
333+
restrictedProjectManagerUserId === undefined
334+
? await this.prisma.billingAccount.findUnique({
335+
where: { id: billingAccountId },
336+
include,
337+
})
338+
: restrictedProjectManagerUserId === null
339+
? null
340+
: await this.prisma.billingAccount.findFirst({
341+
where: {
342+
id: billingAccountId,
343+
accessGrants: {
344+
some: { userId: restrictedProjectManagerUserId },
345+
},
346+
},
347+
include,
348+
});
349+
187350
if (!ba)
188351
throw new NotFoundException(
189352
`Billing account with ID ${billingAccountId} not found`,

0 commit comments

Comments
 (0)