Skip to content

Commit f193ac7

Browse files
authored
Merge pull request #12 from topcoder-platform/develop
Per project filtering of BA line items
2 parents 4ff34f5 + f5a36a6 commit f193ac7

5 files changed

Lines changed: 474 additions & 17 deletions

File tree

.env.example

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/topcoder-services?sc
44
# Example: postgresql://postgres:postgres@localhost:5432/topcoder-services?schema=members
55
MEMBER_DB_URL=""
66

7-
# Optional lookup stores used to resolve billing-account line-item names.
8-
# If unset, line items are still returned with externalName omitted.
7+
# Optional lookup stores used to resolve billing-account line-item names and
8+
# project access for line-item filtering.
9+
# If name lookups are unset, line items are still returned with externalName omitted.
10+
# If PROJECTS_DB_URL or the relevant source DB is unset for a project-filtered
11+
# caller, line items whose access cannot be resolved are omitted.
912
CHALLENGE_DB_URL="postgresql://postgres:postgres@localhost:5432/topcoder-services?schema=challenges"
1013
ENGAGEMENTS_DB_URL="postgresql://postgres:postgres@localhost:5432/engagements"
1114

1215
# Historical engagement-payment backfill sources.
13-
# FINANCE_DB_URL points to tc-finance-api, PROJECTS_DB_URL points to projects-api-v6.
16+
# FINANCE_DB_URL points to tc-finance-api. PROJECTS_DB_URL points to
17+
# projects-api-v6 and is also used by detail line-item access filtering.
1418
FINANCE_DB_URL="postgresql://postgres:postgres@localhost:5432/topcoder-services?schema=finance"
1519
PROJECTS_DB_URL="postgresql://postgres:postgres@localhost:5432/topcoder-services?schema=projects"
1620

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
- Endpoints:
88
- `GET /billing-accounts`
99
- `POST /billing-accounts`
10-
- `GET /billing-accounts/:billingAccountId` (includes locked/consumed arrays + budget totals; line items expose `amount`, `date`, `externalId`, `externalType`, `externalName`, and `challengeId` only for challenge compatibility)
10+
- `GET /billing-accounts/:billingAccountId` (includes locked/consumed arrays + budget totals; line items expose `amount`, `date`, `externalId`, `externalType`, `externalName`, and `challengeId` only for challenge compatibility; copilot, Project Manager, and Talent Manager callers only receive line items for projects they belong to)
1111
- `GET /billing-accounts/users/:userId` (list billing accounts accessible by the given Topcoder user ID — resolved via Salesforce resource object)
1212
- `PATCH /billing-accounts/:billingAccountId`
1313
- `PATCH /billing-accounts/:billingAccountId/lock-amount` (challenge-only typed reference; non-negative amount; 0 amount = unlock; rejects insufficient remaining funds)
@@ -29,6 +29,10 @@
2929
also continue to allow `copilot`, `Project Manager`, and `Topcoder Project Manager`.
3030
Project Managers are restricted to billing accounts granted to their own
3131
user id on `GET /billing-accounts` and `GET /billing-accounts/:billingAccountId`.
32+
On billing-account detail responses, copilot, Project Manager, and Talent
33+
Manager callers only receive locked/consumed line items whose challenge or
34+
engagement project maps to an active `project_members` row for their user id.
35+
Line items with unresolved project access are omitted for those callers.
3236
Role checks are case-insensitive so mixed token casing does not block access.
3337
- Configure env: `AUTH_SECRET` or `AUTH0_URL/AUDIENCE/ISSUER` as needed.
3438

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ export class BillingAccountsController {
173173
buildOperationDoc({
174174
summary: "Get a billing account",
175175
description:
176-
"Fetch a billing account by its identifier, including budget, client data, and normalized locked/consumed line items. Line items include amount, date, externalId, externalType, externalName, and challengeId only for legacy challenge compatibility. Project Managers can read only billing accounts granted to them.",
176+
"Fetch a billing account by its identifier, including budget, client data, and normalized locked/consumed line items. Line items include amount, date, externalId, externalType, externalName, and challengeId only for legacy challenge compatibility. Project Managers can read only billing accounts granted to them. Copilot, Project Manager, and Talent Manager callers only receive locked/consumed line items for projects they belong to.",
177177
jwtRoles: BILLING_ACCOUNT_PROJECT_READ_ROLES,
178178
m2mScopes: [SCOPES.READ_BA, SCOPES.ALL_BA],
179179
}),

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

Lines changed: 113 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ const RESTRICTED_PROJECT_MANAGER_READ_ROLES = [
5555
TOPCODER_PROJECT_MANAGER_ROLE,
5656
];
5757

58+
const PROJECT_ACCESS_FILTERED_LINE_ITEM_ROLES = [
59+
COPILOT_ROLE,
60+
PROJECT_MANAGER_ROLE,
61+
TOPCODER_PROJECT_MANAGER_ROLE,
62+
TALENT_MANAGER_ROLE,
63+
TOPCODER_TALENT_MANAGER_ROLE,
64+
];
65+
5866
const BUDGET_AMOUNT_DECIMAL_PLACES = 4;
5967

6068
interface BudgetAmountLineItem {
@@ -92,6 +100,11 @@ interface NormalizedEngagementConsume {
92100
externalType: "ENGAGEMENT";
93101
}
94102

103+
interface ProjectAccessFilteredLineItems {
104+
lockedAmounts: BudgetAmountLineItem[];
105+
consumedAmounts: BudgetAmountLineItem[];
106+
}
107+
95108
/**
96109
* Normalizes authenticated caller roles for case-insensitive comparisons.
97110
*
@@ -183,6 +196,40 @@ function resolveRestrictedProjectManagerUserId(
183196
return getNormalizedAuthUserId(authUser) ?? null;
184197
}
185198

199+
/**
200+
* Returns the user id to use for project-level line-item filtering.
201+
*
202+
* Administrators keep full line-item visibility. Copilots, Project Managers,
203+
* and Talent Managers only receive line items whose underlying challenge or
204+
* engagement project can be resolved to an active project membership for their
205+
* user id.
206+
*
207+
* @param authUser Authenticated caller context from `req.authUser`.
208+
* @returns User id to filter by, `null` when missing, or `undefined` when no
209+
* filtering is required.
210+
*/
211+
function resolveProjectAccessFilteredLineItemUserId(
212+
authUser?: BillingAccountsAuthUser,
213+
): string | null | undefined {
214+
const normalizedRoles = getNormalizedAuthUserRoles(authUser);
215+
const hasAdminRole = normalizedRoles.includes(ADMIN_ROLE.toLowerCase());
216+
217+
if (hasAdminRole) {
218+
return undefined;
219+
}
220+
221+
const requiresProjectAccessFiltering =
222+
PROJECT_ACCESS_FILTERED_LINE_ITEM_ROLES.some((role) =>
223+
normalizedRoles.includes(role.toLowerCase()),
224+
);
225+
226+
if (!requiresProjectAccessFiltering) {
227+
return undefined;
228+
}
229+
230+
return getNormalizedAuthUserId(authUser) ?? null;
231+
}
232+
186233
@Injectable()
187234
export class BillingAccountsService {
188235
constructor(
@@ -382,7 +429,9 @@ export class BillingAccountsService {
382429
* `userId`. Missing access is surfaced as not found to avoid leaking account
383430
* existence. Locked and consumed line items expose `amount`, `date`,
384431
* `externalId`, `externalType`, and `externalName`; challenge rows also expose
385-
* the deprecated `challengeId` compatibility alias.
432+
* the deprecated `challengeId` compatibility alias. Copilot, Project Manager,
433+
* and Talent Manager callers only receive line items for projects they belong
434+
* to; unresolved project access hides the line item.
386435
*
387436
* @param billingAccountId Billing-account identifier.
388437
* @param authUser Authenticated caller context from `req.authUser`.
@@ -431,23 +480,31 @@ export class BillingAccountsService {
431480
0,
432481
);
433482
const remaining = Number(ba.budget) - consumed - locked;
483+
const { lockedAmounts, consumedAmounts } =
484+
await this.filterBudgetLineItemsForProjectAccess(
485+
{
486+
lockedAmounts: ba.lockedAmounts,
487+
consumedAmounts: ba.consumedAmounts,
488+
},
489+
authUser,
490+
);
434491
const externalNames = await this.externalBudgetEntryLookup.getExternalNames(
435492
[
436-
...ba.lockedAmounts.map((lineItem) =>
493+
...lockedAmounts.map((lineItem) =>
437494
this.toBudgetEntryReference(lineItem),
438495
),
439-
...ba.consumedAmounts.map((lineItem) =>
496+
...consumedAmounts.map((lineItem) =>
440497
this.toBudgetEntryReference(lineItem),
441498
),
442499
],
443500
);
444501

445502
return {
446503
...ba,
447-
lockedAmounts: ba.lockedAmounts.map((lineItem) =>
504+
lockedAmounts: lockedAmounts.map((lineItem) =>
448505
this.serializeBudgetLineItem(lineItem, externalNames),
449506
),
450-
consumedAmounts: ba.consumedAmounts.map((lineItem) =>
507+
consumedAmounts: consumedAmounts.map((lineItem) =>
451508
this.serializeBudgetLineItem(lineItem, externalNames),
452509
),
453510
lockedBudget: locked,
@@ -983,6 +1040,57 @@ export class BillingAccountsService {
9831040
};
9841041
}
9851042

1043+
/**
1044+
* Filters locked and consumed detail rows by project access when required.
1045+
*
1046+
* Caller roles that can view billing accounts across multiple projects still
1047+
* need project-level protection on individual payment rows. This helper keeps
1048+
* unrestricted callers unchanged, hides all rows when a restricted caller has
1049+
* no usable user id, and otherwise keeps only references whose project access
1050+
* is proven by the external lookup service.
1051+
*
1052+
* @param lineItems Locked and consumed budget rows from one billing account.
1053+
* @param authUser Authenticated caller context from `req.authUser`.
1054+
* @returns Filtered locked and consumed rows for the response detail arrays.
1055+
*/
1056+
private async filterBudgetLineItemsForProjectAccess(
1057+
lineItems: ProjectAccessFilteredLineItems,
1058+
authUser?: BillingAccountsAuthUser,
1059+
): Promise<ProjectAccessFilteredLineItems> {
1060+
const userId = resolveProjectAccessFilteredLineItemUserId(authUser);
1061+
1062+
if (userId === undefined) {
1063+
return lineItems;
1064+
}
1065+
1066+
if (userId === null) {
1067+
return { lockedAmounts: [], consumedAmounts: [] };
1068+
}
1069+
1070+
const accessibleReferenceKeys =
1071+
await this.externalBudgetEntryLookup.getProjectAccessibleReferenceKeys(
1072+
[
1073+
...lineItems.lockedAmounts.map((lineItem) =>
1074+
this.toBudgetEntryReference(lineItem),
1075+
),
1076+
...lineItems.consumedAmounts.map((lineItem) =>
1077+
this.toBudgetEntryReference(lineItem),
1078+
),
1079+
],
1080+
userId,
1081+
);
1082+
1083+
const canViewLineItem = (lineItem: BudgetAmountLineItem) =>
1084+
accessibleReferenceKeys.has(
1085+
getBudgetEntryReferenceKey(this.toBudgetEntryReference(lineItem)),
1086+
);
1087+
1088+
return {
1089+
lockedAmounts: lineItems.lockedAmounts.filter(canViewLineItem),
1090+
consumedAmounts: lineItems.consumedAmounts.filter(canViewLineItem),
1091+
};
1092+
}
1093+
9861094
/**
9871095
* Shapes a budget row for API details responses.
9881096
*

0 commit comments

Comments
 (0)