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
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
- Endpoints:
- `GET /billing-accounts`
- `POST /billing-accounts`
- `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; copilot-only callers receive `memberPaymentsRemaining` and per-line-item `memberPaymentAmount` instead of raw `markup`)
- `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-scoped, and Talent Manager callers only receive line items for projects they belong to; copilot-only callers receive `memberPaymentsRemaining` and per-line-item `memberPaymentAmount` instead of raw `markup`)
- `GET /billing-accounts/users/:userId` (list billing accounts accessible by the given Topcoder user ID — resolved via Salesforce resource object)
- `PATCH /billing-accounts/:billingAccountId`
- `PATCH /billing-accounts/:billingAccountId/lock-amount` (challenge-only typed reference; non-negative amount; 0 amount = unlock; rejects insufficient remaining funds)
Expand All @@ -27,16 +27,20 @@
- Billing-account management endpoints accept `administrator`, `Talent Manager`,
and `Topcoder Talent Manager` JWT roles; read-only billing-account lookups
also continue to allow `copilot`, `Project Manager`, and `Topcoder Project Manager`.
Project Managers are restricted to billing accounts granted to their own
user id on `GET /billing-accounts`. On `GET /billing-accounts/:billingAccountId`,
they can read billing accounts granted to their own user id or assigned to
projects they belong to.
The detail endpoint additionally accepts `Topcoder User` so project-member
callers from Work can be authorized by project membership.
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
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
the total remaining markup amount. Billing-account detail
responses for copilots also include per-line-item `memberPaymentAmount` values
that remove the markup fee from locked and consumed amounts. On detail
responses, copilot, Project Manager, and Talent
responses, copilot, project-scoped, and Talent
Manager callers only receive locked/consumed line items whose challenge or
engagement project maps to an active `project_members` row for their user id.
Line items with unresolved project access are omitted for those callers.
Expand Down
1 change: 1 addition & 0 deletions src/auth/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ export const ADMIN_ROLE = "administrator";
export const COPILOT_ROLE = "copilot";
export const PROJECT_MANAGER_ROLE = "Project Manager";
export const TOPCODER_PROJECT_MANAGER_ROLE = "Topcoder Project Manager";
export const TOPCODER_USER_ROLE = "Topcoder User";
export const TALENT_MANAGER_ROLE = "Talent Manager";
export const TOPCODER_TALENT_MANAGER_ROLE = "Topcoder Talent Manager";
12 changes: 9 additions & 3 deletions src/billing-accounts/billing-accounts.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
PROJECT_MANAGER_ROLE,
TALENT_MANAGER_ROLE,
TOPCODER_PROJECT_MANAGER_ROLE,
TOPCODER_USER_ROLE,
TOPCODER_TALENT_MANAGER_ROLE,
} from "../auth/constants";
import type { Request } from "express";
Expand Down Expand Up @@ -58,6 +59,11 @@ const BILLING_ACCOUNT_PROJECT_READ_ROLES = [
TOPCODER_PROJECT_MANAGER_ROLE,
];

const BILLING_ACCOUNT_DETAIL_READ_ROLES = [
...BILLING_ACCOUNT_PROJECT_READ_ROLES,
TOPCODER_USER_ROLE,
];

const BILLING_ACCOUNT_MANAGE_ROLES = [
ADMIN_ROLE,
TALENT_MANAGER_ROLE,
Expand Down Expand Up @@ -167,14 +173,14 @@ export class BillingAccountsController {

@Get(":billingAccountId")
@UseGuards(RolesGuard, ScopesGuard)
@Roles(...BILLING_ACCOUNT_PROJECT_READ_ROLES)
@Roles(...BILLING_ACCOUNT_DETAIL_READ_ROLES)
@Scopes(SCOPES.READ_BA, SCOPES.ALL_BA)
@ApiOperation(
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 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.",
jwtRoles: BILLING_ACCOUNT_PROJECT_READ_ROLES,
"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.",
jwtRoles: BILLING_ACCOUNT_DETAIL_READ_ROLES,
m2mScopes: [SCOPES.READ_BA, SCOPES.ALL_BA],
}),
)
Expand Down
109 changes: 74 additions & 35 deletions src/billing-accounts/billing-accounts.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
PROJECT_MANAGER_ROLE,
TALENT_MANAGER_ROLE,
TOPCODER_PROJECT_MANAGER_ROLE,
TOPCODER_USER_ROLE,
TOPCODER_TALENT_MANAGER_ROLE,
} from "../auth/constants";

Expand All @@ -50,7 +51,13 @@ const UNRESTRICTED_BILLING_ACCOUNT_READ_ROLES = [
TOPCODER_TALENT_MANAGER_ROLE,
];

const RESTRICTED_PROJECT_MANAGER_READ_ROLES = [
const PROJECT_SCOPED_BILLING_ACCOUNT_READ_ROLES = [
PROJECT_MANAGER_ROLE,
TOPCODER_PROJECT_MANAGER_ROLE,
TOPCODER_USER_ROLE,
];

const PROJECT_BILLING_ACCOUNT_TOPCODER_DETAIL_ROLES = [
PROJECT_MANAGER_ROLE,
TOPCODER_PROJECT_MANAGER_ROLE,
];
Expand All @@ -59,6 +66,7 @@ const PROJECT_ACCESS_FILTERED_LINE_ITEM_ROLES = [
COPILOT_ROLE,
PROJECT_MANAGER_ROLE,
TOPCODER_PROJECT_MANAGER_ROLE,
TOPCODER_USER_ROLE,
TALENT_MANAGER_ROLE,
TOPCODER_TALENT_MANAGER_ROLE,
];
Expand Down Expand Up @@ -187,26 +195,25 @@ function getNormalizedAuthUserId(
}

/**
* Returns the enforced user id for restricted Project Manager billing-account
* reads.
* Returns the enforced user id for project-scoped billing-account reads.
*
* `undefined` means the caller keeps unrestricted read behavior. `null`
* indicates a restricted Project Manager caller without a usable `userId`,
* indicates a restricted project-scoped caller without a usable `userId`,
* which should be treated as no accessible accounts.
*
* @param authUser Authenticated caller context from `req.authUser`.
* @returns Enforced user id, `null`, or `undefined`.
*/
function resolveRestrictedProjectManagerUserId(
function resolveProjectScopedBillingAccountReadUserId(
authUser?: BillingAccountsAuthUser,
): string | null | undefined {
const normalizedRoles = getNormalizedAuthUserRoles(authUser);
const hasRestrictedProjectManagerRole =
RESTRICTED_PROJECT_MANAGER_READ_ROLES.some((role) =>
const hasProjectScopedBillingAccountReadRole =
PROJECT_SCOPED_BILLING_ACCOUNT_READ_ROLES.some((role) =>
normalizedRoles.includes(role.toLowerCase()),
);

if (!hasRestrictedProjectManagerRole) {
if (!hasProjectScopedBillingAccountReadRole) {
return undefined;
}

Expand All @@ -221,13 +228,36 @@ function resolveRestrictedProjectManagerUserId(
return getNormalizedAuthUserId(authUser) ?? null;
}

/**
* Returns whether project fallback access should enforce a management/copilot
* project member role.
*
* 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.
*
* @param authUser Authenticated caller context from `req.authUser`.
* @returns `true` when the fallback must require an allowed project role.
*/
function shouldRequireProjectBillingAccountRoleForFallback(
authUser?: BillingAccountsAuthUser,
): boolean {
const normalizedRoles = getNormalizedAuthUserRoles(authUser);

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

/**
* Returns the user id to use for project-level line-item filtering.
*
* Administrators keep full line-item visibility. Copilots, Project Managers,
* and Talent Managers only receive line items whose underlying challenge or
* engagement project can be resolved to an active project membership for their
* user id.
* Administrators keep full line-item visibility. Copilots, project-scoped
* billing-account readers, and Talent Managers only receive line items whose
* underlying challenge or engagement project can be resolved to an active
* project membership for their user id.
*
* @param authUser Authenticated caller context from `req.authUser`.
* @returns User id to filter by, `null` when missing, or `undefined` when no
Expand Down Expand Up @@ -313,7 +343,7 @@ export class BillingAccountsService {
/**
* Lists billing accounts with optional filtering, sorting, and pagination.
*
* Project Manager callers are constrained to billing accounts granted to
* Project-scoped callers are constrained to billing accounts granted to
* their own `userId`, regardless of an explicit `userId` query override.
*
* @param q Query filters and pagination controls.
Expand All @@ -335,10 +365,10 @@ export class BillingAccountsService {
sortBy,
sortOrder = "asc",
} = q;
const restrictedProjectManagerUserId =
resolveRestrictedProjectManagerUserId(authUser);
const projectScopedBillingAccountReadUserId =
resolveProjectScopedBillingAccountReadUserId(authUser);

if (restrictedProjectManagerUserId === null) {
if (projectScopedBillingAccountReadUserId === null) {
return {
page,
perPage,
Expand All @@ -353,8 +383,10 @@ export class BillingAccountsService {
...(status ? { status } : {}),
};

if (restrictedProjectManagerUserId) {
where.accessGrants = { some: { userId: restrictedProjectManagerUserId } };
if (projectScopedBillingAccountReadUserId) {
where.accessGrants = {
some: { userId: projectScopedBillingAccountReadUserId },
};
} else if (userId) {
where.accessGrants = { some: { userId } };
}
Expand Down Expand Up @@ -477,16 +509,19 @@ export class BillingAccountsService {
* Fetches a single billing account, its normalized budget line items, and
* budget aggregates.
*
* Project Manager callers can read billing accounts granted to their own
* `userId`, or billing accounts assigned to a project they belong to. 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 line item so the UI can show the
* payment value without exposing markup. Copilot, Project Manager, and
* Talent Manager callers only receive line items for projects they belong to;
* unresolved project access hides the line item.
* 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
* `amount`, `date`, `externalId`, `externalType`, and `externalName`;
* challenge rows also expose the deprecated `challengeId` compatibility
* alias. Copilot-only callers also receive `memberPaymentAmount` on each
* line item so the UI can show the payment value without exposing markup.
* Copilot, project-scoped, and Talent Manager callers only receive line
* items for projects they belong to; unresolved project access hides the
* line item.
*
* @param billingAccountId Billing-account identifier.
* @param authUser Authenticated caller context from `req.authUser`.
Expand All @@ -496,36 +531,40 @@ export class BillingAccountsService {
* caller does not have access to it.
*/
async get(billingAccountId: number, authUser?: BillingAccountsAuthUser) {
const restrictedProjectManagerUserId =
resolveRestrictedProjectManagerUserId(authUser);
const projectScopedBillingAccountReadUserId =
resolveProjectScopedBillingAccountReadUserId(authUser);
const include = {
client: true,
lockedAmounts: true,
consumedAmounts: true,
};
let ba =
restrictedProjectManagerUserId === undefined
projectScopedBillingAccountReadUserId === undefined
? await this.prisma.billingAccount.findUnique({
where: { id: billingAccountId },
include,
})
: restrictedProjectManagerUserId === null
: projectScopedBillingAccountReadUserId === null
? null
: await this.prisma.billingAccount.findFirst({
where: {
id: billingAccountId,
accessGrants: {
some: { userId: restrictedProjectManagerUserId },
some: { userId: projectScopedBillingAccountReadUserId },
},
},
include,
});

if (!ba && restrictedProjectManagerUserId) {
if (!ba && projectScopedBillingAccountReadUserId) {
const hasProjectBillingAccountAccess =
await this.externalBudgetEntryLookup.hasProjectBillingAccountAccess(
billingAccountId,
restrictedProjectManagerUserId,
projectScopedBillingAccountReadUserId,
{
requireAllowedProjectRole:
shouldRequireProjectBillingAccountRoleForFallback(authUser),
},
);

if (hasProjectBillingAccountAccess) {
Expand Down
34 changes: 30 additions & 4 deletions src/billing-accounts/external-budget-entry-lookup.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ interface ProjectBillingAccountAccessRow {
hasAccess: boolean;
}

interface ProjectBillingAccountAccessOptions {
requireAllowedProjectRole?: boolean;
}

const PROJECT_BILLING_ACCOUNT_DETAIL_ROLES = [
"manager",
"account_manager",
"account_executive",
"project_manager",
"program_manager",
"solution_architect",
"copilot",
];

/**
* Resolves budget-entry external metadata from service-owned persistence stores.
*
Expand Down Expand Up @@ -193,19 +207,23 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy {
/**
* Checks whether a user belongs to a project using a billing account.
*
* Project Managers may open billing-account details from Work 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.
* 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.
*
* @param billingAccountId Topcoder billing-account id from the detail route.
* @param userId Topcoder user id from the authenticated caller.
* @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`.
*/
async hasProjectBillingAccountAccess(
billingAccountId: number,
userId: string,
options: ProjectBillingAccountAccessOptions = {},
): Promise<boolean> {
const normalizedBillingAccountId =
this.normalizeNumericTextId(billingAccountId);
Expand All @@ -221,6 +239,13 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy {
return false;
}

const projectRoleFilter =
options.requireAllowedProjectRole === false
? Prisma.empty
: Prisma.sql`AND project_member."role"::text IN (${Prisma.join(
PROJECT_BILLING_ACCOUNT_DETAIL_ROLES,
)})`;

try {
const rows = await client.$queryRaw<ProjectBillingAccountAccessRow[]>(
Prisma.sql`
Expand All @@ -233,6 +258,7 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy {
)}
AND project."deletedAt" IS NULL
AND project_member."userId" = ${BigInt(normalizedUserId)}
${projectRoleFilter}
AND project_member."deletedAt" IS NULL
LIMIT 1
`,
Expand Down
Loading