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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@
Project-scoped billing-account readers are restricted to billing accounts
granted to their own user id on `GET /billing-accounts`. On
`GET /billing-accounts/:billingAccountId`, Project Manager callers can read
billing accounts granted to their own user id or assigned to active projects
they belong to. Plain `Topcoder User` project-member callers need a
billing accounts granted to their own user id or assigned to non-deleted
projects. Plain `Topcoder User` project-member callers need a
management or copilot project role for the project fallback.
Copilot-only callers receive billing-account responses with `markup` omitted
and `memberPaymentsRemaining` derived server-side as total remaining minus
Expand Down
2 changes: 1 addition & 1 deletion src/billing-accounts/billing-accounts.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ export class BillingAccountsController {
buildOperationDoc({
summary: "Get a billing account",
description:
"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 actively belong to; plain Topcoder User project members need an allowed project billing-account role. Copilot, Project Manager, Topcoder User, 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.",
"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 non-deleted projects; plain Topcoder User project members need an allowed project billing-account role. Copilot, Project Manager, Topcoder User, 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.",
jwtRoles: BILLING_ACCOUNT_DETAIL_READ_ROLES,
m2mScopes: [SCOPES.READ_BA, SCOPES.ALL_BA],
}),
Expand Down
52 changes: 35 additions & 17 deletions src/billing-accounts/billing-accounts.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,28 +229,39 @@ function resolveProjectScopedBillingAccountReadUserId(
}

/**
* Returns whether project fallback access should enforce a management/copilot
* project member role.
* Returns whether the caller has a Topcoder role that can read project billing
* account details in Projects API.
*
* Global Project Manager tokens already satisfy Projects API billing-account
* detail permission. They still need active membership on a project using the
* billing account, but the specific project member role does not need to carry
* the detail permission. Plain `Topcoder User` tokens must keep the stricter
* project-role check.
* These global roles can read a project billing account when the account is
* assigned to a non-deleted project. Plain `Topcoder User` project members
* must keep the stricter project membership role check.
*
* @param authUser Authenticated caller context from `req.authUser`.
* @returns `true` when the fallback must require an allowed project role.
* @returns `true` when the caller can use project-assigned billing-account access.
*/
function shouldRequireProjectBillingAccountRoleForFallback(
function hasProjectBillingAccountTopcoderDetailRole(
authUser?: BillingAccountsAuthUser,
): boolean {
const normalizedRoles = getNormalizedAuthUserRoles(authUser);

return !PROJECT_BILLING_ACCOUNT_TOPCODER_DETAIL_ROLES.some((role) =>
return PROJECT_BILLING_ACCOUNT_TOPCODER_DETAIL_ROLES.some((role) =>
normalizedRoles.includes(role.toLowerCase()),
);
}

/**
* Returns whether project fallback access should enforce a management/copilot
* project member role.
*
* @param authUser Authenticated caller context from `req.authUser`.
* @returns `true` when the fallback must require an allowed project role.
*/
function shouldRequireProjectBillingAccountRoleForFallback(
authUser?: BillingAccountsAuthUser,
): boolean {
return !hasProjectBillingAccountTopcoderDetailRole(authUser);
}

/**
* Returns the user id to use for project-level line-item filtering.
*
Expand Down Expand Up @@ -510,11 +521,11 @@ export class BillingAccountsService {
* budget aggregates.
*
* Project-scoped callers can read billing accounts granted to their own
* `userId`, or billing accounts assigned to an active project they belong to.
* Plain `Topcoder User` callers need an allowed management/copilot project
* role for that project fallback, while global Project Manager callers only
* need active project membership. Missing access is surfaced as not found to
* avoid leaking account existence. Locked and consumed line items expose
* `userId`, or billing accounts assigned to a non-deleted project. Plain
* `Topcoder User` callers need an allowed management/copilot project role
* for that project fallback, while global Project Manager callers can use
* the project assignment directly. Missing access is surfaced as not found
* to avoid leaking account existence. Locked and consumed line items expose
* `amount`, `date`, `externalId`, `externalType`, and `externalName`;
* challenge rows also expose the deprecated `challengeId` compatibility
* alias. Copilot-only callers also receive `memberPaymentAmount` on each
Expand All @@ -533,6 +544,8 @@ export class BillingAccountsService {
async get(billingAccountId: number, authUser?: BillingAccountsAuthUser) {
const projectScopedBillingAccountReadUserId =
resolveProjectScopedBillingAccountReadUserId(authUser);
const hasTopcoderProjectBillingDetailRole =
hasProjectBillingAccountTopcoderDetailRole(authUser);
const include = {
client: true,
lockedAmounts: true,
Expand All @@ -556,12 +569,17 @@ export class BillingAccountsService {
include,
});

if (!ba && projectScopedBillingAccountReadUserId) {
if (
!ba &&
(projectScopedBillingAccountReadUserId ||
hasTopcoderProjectBillingDetailRole)
) {
const hasProjectBillingAccountAccess =
await this.externalBudgetEntryLookup.hasProjectBillingAccountAccess(
billingAccountId,
projectScopedBillingAccountReadUserId,
projectScopedBillingAccountReadUserId ?? undefined,
{
allowAnyAssignedProject: hasTopcoderProjectBillingDetailRole,
requireAllowedProjectRole:
shouldRequireProjectBillingAccountRoleForFallback(authUser),
},
Expand Down
68 changes: 60 additions & 8 deletions src/billing-accounts/external-budget-entry-lookup.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ interface ProjectBillingAccountAccessRow {
}

interface ProjectBillingAccountAccessOptions {
allowAnyAssignedProject?: boolean;
requireAllowedProjectRole?: boolean;
}

Expand Down Expand Up @@ -210,26 +211,34 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy {
* Project-scoped Work users may open billing-account details from project
* pages even when the legacy billing-account resource grant was not imported
* for that account. This lookup validates access against non-deleted
* projects and non-deleted project membership in projects-api-v6. Callers
* without a billing-account Topcoder role can require the membership to be a
* management/copilot project role.
* projects in projects-api-v6. Global billing-account Topcoder roles can
* allow any non-deleted project assigned to the account, while plain project
* members require non-deleted membership. Callers without a billing-account
* Topcoder role can require the membership to be a management/copilot
* project role.
*
* @param billingAccountId Topcoder billing-account id from the detail route.
* @param userId Topcoder user id from the authenticated caller.
* @param userId Topcoder user id from the authenticated caller; optional
* when global role access allows any assigned project.
* @param options Access options for project-role enforcement.
* @returns `true` when the user has non-deleted project membership for a
* project assigned to the billing account; otherwise `false`.
* project assigned to the billing account, or when any assigned project is
* allowed and exists; otherwise `false`.
*/
async hasProjectBillingAccountAccess(
billingAccountId: number,
userId: string,
userId?: string,
options: ProjectBillingAccountAccessOptions = {},
): Promise<boolean> {
const normalizedBillingAccountId =
this.normalizeNumericTextId(billingAccountId);
const normalizedUserId = this.normalizeNumericTextId(userId);
const allowAnyAssignedProject = options.allowAnyAssignedProject === true;

if (!normalizedBillingAccountId || !normalizedUserId) {
if (
!normalizedBillingAccountId ||
(!allowAnyAssignedProject && !normalizedUserId)
) {
return false;
}

Expand All @@ -239,6 +248,18 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy {
return false;
}

if (allowAnyAssignedProject) {
return this.hasBillingAccountAssignedProject(
client,
normalizedBillingAccountId,
);
}

const membershipUserId = normalizedUserId;
if (!membershipUserId) {
return false;
}

const projectRoleFilter =
options.requireAllowedProjectRole === false
? Prisma.empty
Expand All @@ -257,7 +278,7 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy {
normalizedBillingAccountId,
)}
AND project."deletedAt" IS NULL
AND project_member."userId" = ${BigInt(normalizedUserId)}
AND project_member."userId" = ${BigInt(membershipUserId)}
${projectRoleFilter}
AND project_member."deletedAt" IS NULL
LIMIT 1
Expand All @@ -273,6 +294,37 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy {
}
}

/**
* Checks whether a billing account is assigned to any non-deleted project.
*
* @param client Projects API Prisma client.
* @param billingAccountId Normalized numeric billing-account id.
* @returns `true` when at least one non-deleted project uses the account.
*/
private async hasBillingAccountAssignedProject(
client: PrismaClient,
billingAccountId: string,
): Promise<boolean> {
try {
const rows = await client.$queryRaw<ProjectBillingAccountAccessRow[]>(
Prisma.sql`
SELECT true AS "hasAccess"
FROM projects project
WHERE project."billingAccountId" = ${BigInt(billingAccountId)}
AND project."deletedAt" IS NULL
LIMIT 1
`,
);

return rows.some((row) => row.hasAccess);
} catch (error) {
this.logger.warn(
`Failed to resolve project billing-account assignment: ${this.getErrorMessage(error)}`,
);
return false;
}
}

/**
* Disconnects optional lookup clients created for external stores.
*
Expand Down
Loading