Skip to content

Commit 1a744f1

Browse files
authored
Merge pull request #13 from topcoder-platform/PM-4952
PM-4952: Allow project managers to open project billing accounts
2 parents d0e1c39 + 760120d commit 1a744f1

4 files changed

Lines changed: 92 additions & 13 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@
2828
and `Topcoder Talent Manager` JWT roles; read-only billing-account lookups
2929
also continue to allow `copilot`, `Project Manager`, and `Topcoder Project Manager`.
3030
Project Managers are restricted to billing accounts granted to their own
31-
user id on `GET /billing-accounts` and `GET /billing-accounts/:billingAccountId`.
31+
user id on `GET /billing-accounts`. On `GET /billing-accounts/:billingAccountId`,
32+
they can read billing accounts granted to their own user id or assigned to
33+
projects they belong to.
3234
Copilot-only callers receive billing-account responses with `markup` omitted
3335
and `memberPaymentsRemaining` derived server-side as total remaining minus
3436
the total remaining markup amount. Billing-account detail

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. Copilot, Project Manager, and Talent Manager callers only receive locked/consumed line items for projects they belong to. Copilot-only callers receive copilot-safe budget data without the raw markup field, including memberPaymentsRemaining and per-line-item memberPaymentAmount values.",
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 billing accounts granted to them or assigned to projects they belong to. Copilot, Project Manager, and Talent Manager callers only receive locked/consumed line items for projects they belong to. Copilot-only callers receive copilot-safe budget data without the raw markup field, including memberPaymentsRemaining and per-line-item memberPaymentAmount values.",
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: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,8 @@ function getNormalizedAuthUserId(
187187
}
188188

189189
/**
190-
* Returns the enforced access-grant user id for restricted Project Manager
191-
* billing-account reads.
190+
* Returns the enforced user id for restricted Project Manager billing-account
191+
* reads.
192192
*
193193
* `undefined` means the caller keeps unrestricted read behavior. `null`
194194
* indicates a restricted Project Manager caller without a usable `userId`,
@@ -477,14 +477,15 @@ export class BillingAccountsService {
477477
* Fetches a single billing account, its normalized budget line items, and
478478
* budget aggregates.
479479
*
480-
* Project Manager callers can read only billing accounts granted to their own
481-
* `userId`. Missing access is surfaced as not found to avoid leaking account
482-
* existence. Locked and consumed line items expose `amount`, `date`,
483-
* `externalId`, `externalType`, and `externalName`; challenge rows also expose
484-
* the deprecated `challengeId` compatibility alias. Copilot-only callers also
480+
* Project Manager callers can read billing accounts granted to their own
481+
* `userId`, or billing accounts assigned to a project they belong to. Missing
482+
* access is surfaced as not found to avoid leaking account existence. Locked
483+
* and consumed line items expose `amount`, `date`, `externalId`,
484+
* `externalType`, and `externalName`; challenge rows also expose the
485+
* deprecated `challengeId` compatibility alias. Copilot-only callers also
485486
* receive `memberPaymentAmount` on each line item so the UI can show the
486-
* payment value without exposing markup. Copilot, Project Manager, and Talent
487-
* Manager callers only receive line items for projects they belong to;
487+
* payment value without exposing markup. Copilot, Project Manager, and
488+
* Talent Manager callers only receive line items for projects they belong to;
488489
* unresolved project access hides the line item.
489490
*
490491
* @param billingAccountId Billing-account identifier.
@@ -502,7 +503,7 @@ export class BillingAccountsService {
502503
lockedAmounts: true,
503504
consumedAmounts: true,
504505
};
505-
const ba =
506+
let ba =
506507
restrictedProjectManagerUserId === undefined
507508
? await this.prisma.billingAccount.findUnique({
508509
where: { id: billingAccountId },
@@ -520,6 +521,21 @@ export class BillingAccountsService {
520521
include,
521522
});
522523

524+
if (!ba && restrictedProjectManagerUserId) {
525+
const hasProjectBillingAccountAccess =
526+
await this.externalBudgetEntryLookup.hasProjectBillingAccountAccess(
527+
billingAccountId,
528+
restrictedProjectManagerUserId,
529+
);
530+
531+
if (hasProjectBillingAccountAccess) {
532+
ba = await this.prisma.billingAccount.findUnique({
533+
where: { id: billingAccountId },
534+
include,
535+
});
536+
}
537+
}
538+
523539
if (!ba)
524540
throw new NotFoundException(
525541
`Billing account with ID ${billingAccountId} not found`,

src/billing-accounts/external-budget-entry-lookup.service.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ interface ProjectAccessRow {
3232
projectId: string;
3333
}
3434

35+
interface ProjectBillingAccountAccessRow {
36+
hasAccess: boolean;
37+
}
38+
3539
/**
3640
* Resolves budget-entry external metadata from service-owned persistence stores.
3741
*
@@ -186,6 +190,63 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy {
186190
return accessibleReferenceKeys;
187191
}
188192

193+
/**
194+
* Checks whether a user belongs to a project using a billing account.
195+
*
196+
* Project Managers may open billing-account details from Work project pages
197+
* even when the legacy billing-account resource grant was not imported for
198+
* that account. This lookup validates access against non-deleted projects
199+
* and non-deleted project membership in projects-api-v6.
200+
*
201+
* @param billingAccountId Topcoder billing-account id from the detail route.
202+
* @param userId Topcoder user id from the authenticated caller.
203+
* @returns `true` when the user has non-deleted project membership for a
204+
* project assigned to the billing account; otherwise `false`.
205+
*/
206+
async hasProjectBillingAccountAccess(
207+
billingAccountId: number,
208+
userId: string,
209+
): Promise<boolean> {
210+
const normalizedBillingAccountId =
211+
this.normalizeNumericTextId(billingAccountId);
212+
const normalizedUserId = this.normalizeNumericTextId(userId);
213+
214+
if (!normalizedBillingAccountId || !normalizedUserId) {
215+
return false;
216+
}
217+
218+
const client = this.getProjectsClient();
219+
220+
if (!client) {
221+
return false;
222+
}
223+
224+
try {
225+
const rows = await client.$queryRaw<ProjectBillingAccountAccessRow[]>(
226+
Prisma.sql`
227+
SELECT true AS "hasAccess"
228+
FROM projects project
229+
INNER JOIN project_members project_member
230+
ON project_member."projectId" = project."id"
231+
WHERE project."billingAccountId" = ${BigInt(
232+
normalizedBillingAccountId,
233+
)}
234+
AND project."deletedAt" IS NULL
235+
AND project_member."userId" = ${BigInt(normalizedUserId)}
236+
AND project_member."deletedAt" IS NULL
237+
LIMIT 1
238+
`,
239+
);
240+
241+
return rows.some((row) => row.hasAccess);
242+
} catch (error) {
243+
this.logger.warn(
244+
`Failed to resolve project billing-account access: ${this.getErrorMessage(error)}`,
245+
);
246+
return false;
247+
}
248+
}
249+
189250
/**
190251
* Disconnects optional lookup clients created for external stores.
191252
*
@@ -555,7 +616,7 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy {
555616
this.projectsClient = this.createOptionalClient(
556617
process.env.PROJECTS_DB_URL || process.env.PROJECT_DB_URL,
557618
"PROJECTS_DB_URL or PROJECT_DB_URL",
558-
"project-access filtering will hide line items whose access cannot be resolved.",
619+
"project-access checks will hide line items and deny project-based billing-account access when access cannot be resolved.",
559620
);
560621

561622
return this.projectsClient;

0 commit comments

Comments
 (0)