diff --git a/.env.example b/.env.example index 787a95b..964e9c3 100644 --- a/.env.example +++ b/.env.example @@ -4,13 +4,17 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/topcoder-services?sc # Example: postgresql://postgres:postgres@localhost:5432/topcoder-services?schema=members MEMBER_DB_URL="" -# Optional lookup stores used to resolve billing-account line-item names. -# If unset, line items are still returned with externalName omitted. +# Optional lookup stores used to resolve billing-account line-item names and +# project access for line-item filtering. +# If name lookups are unset, line items are still returned with externalName omitted. +# If PROJECTS_DB_URL or the relevant source DB is unset for a project-filtered +# caller, line items whose access cannot be resolved are omitted. CHALLENGE_DB_URL="postgresql://postgres:postgres@localhost:5432/topcoder-services?schema=challenges" ENGAGEMENTS_DB_URL="postgresql://postgres:postgres@localhost:5432/engagements" # Historical engagement-payment backfill sources. -# FINANCE_DB_URL points to tc-finance-api, PROJECTS_DB_URL points to projects-api-v6. +# FINANCE_DB_URL points to tc-finance-api. PROJECTS_DB_URL points to +# projects-api-v6 and is also used by detail line-item access filtering. FINANCE_DB_URL="postgresql://postgres:postgres@localhost:5432/topcoder-services?schema=finance" PROJECTS_DB_URL="postgresql://postgres:postgres@localhost:5432/topcoder-services?schema=projects" diff --git a/README.md b/README.md index 3dc95c1..3ae057a 100644 --- a/README.md +++ b/README.md @@ -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) + - `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) - `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) @@ -29,6 +29,10 @@ 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` and `GET /billing-accounts/:billingAccountId`. + On billing-account detail responses, copilot, Project Manager, 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. Role checks are case-insensitive so mixed token casing does not block access. - Configure env: `AUTH_SECRET` or `AUTH0_URL/AUDIENCE/ISSUER` as needed. diff --git a/src/billing-accounts/billing-accounts.controller.ts b/src/billing-accounts/billing-accounts.controller.ts index 2f99809..63d623f 100644 --- a/src/billing-accounts/billing-accounts.controller.ts +++ b/src/billing-accounts/billing-accounts.controller.ts @@ -173,7 +173,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 only billing accounts granted to them.", + "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.", jwtRoles: BILLING_ACCOUNT_PROJECT_READ_ROLES, m2mScopes: [SCOPES.READ_BA, SCOPES.ALL_BA], }), diff --git a/src/billing-accounts/billing-accounts.service.ts b/src/billing-accounts/billing-accounts.service.ts index 8254997..1d16109 100644 --- a/src/billing-accounts/billing-accounts.service.ts +++ b/src/billing-accounts/billing-accounts.service.ts @@ -55,6 +55,14 @@ const RESTRICTED_PROJECT_MANAGER_READ_ROLES = [ TOPCODER_PROJECT_MANAGER_ROLE, ]; +const PROJECT_ACCESS_FILTERED_LINE_ITEM_ROLES = [ + COPILOT_ROLE, + PROJECT_MANAGER_ROLE, + TOPCODER_PROJECT_MANAGER_ROLE, + TALENT_MANAGER_ROLE, + TOPCODER_TALENT_MANAGER_ROLE, +]; + const BUDGET_AMOUNT_DECIMAL_PLACES = 4; interface BudgetAmountLineItem { @@ -92,6 +100,11 @@ interface NormalizedEngagementConsume { externalType: "ENGAGEMENT"; } +interface ProjectAccessFilteredLineItems { + lockedAmounts: BudgetAmountLineItem[]; + consumedAmounts: BudgetAmountLineItem[]; +} + /** * Normalizes authenticated caller roles for case-insensitive comparisons. * @@ -183,6 +196,40 @@ function resolveRestrictedProjectManagerUserId( return getNormalizedAuthUserId(authUser) ?? null; } +/** + * 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. + * + * @param authUser Authenticated caller context from `req.authUser`. + * @returns User id to filter by, `null` when missing, or `undefined` when no + * filtering is required. + */ +function resolveProjectAccessFilteredLineItemUserId( + authUser?: BillingAccountsAuthUser, +): string | null | undefined { + const normalizedRoles = getNormalizedAuthUserRoles(authUser); + const hasAdminRole = normalizedRoles.includes(ADMIN_ROLE.toLowerCase()); + + if (hasAdminRole) { + return undefined; + } + + const requiresProjectAccessFiltering = + PROJECT_ACCESS_FILTERED_LINE_ITEM_ROLES.some((role) => + normalizedRoles.includes(role.toLowerCase()), + ); + + if (!requiresProjectAccessFiltering) { + return undefined; + } + + return getNormalizedAuthUserId(authUser) ?? null; +} + @Injectable() export class BillingAccountsService { constructor( @@ -382,7 +429,9 @@ export class BillingAccountsService { * `userId`. 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. + * the deprecated `challengeId` compatibility alias. Copilot, Project Manager, + * 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`. @@ -431,12 +480,20 @@ export class BillingAccountsService { 0, ); const remaining = Number(ba.budget) - consumed - locked; + const { lockedAmounts, consumedAmounts } = + await this.filterBudgetLineItemsForProjectAccess( + { + lockedAmounts: ba.lockedAmounts, + consumedAmounts: ba.consumedAmounts, + }, + authUser, + ); const externalNames = await this.externalBudgetEntryLookup.getExternalNames( [ - ...ba.lockedAmounts.map((lineItem) => + ...lockedAmounts.map((lineItem) => this.toBudgetEntryReference(lineItem), ), - ...ba.consumedAmounts.map((lineItem) => + ...consumedAmounts.map((lineItem) => this.toBudgetEntryReference(lineItem), ), ], @@ -444,10 +501,10 @@ export class BillingAccountsService { return { ...ba, - lockedAmounts: ba.lockedAmounts.map((lineItem) => + lockedAmounts: lockedAmounts.map((lineItem) => this.serializeBudgetLineItem(lineItem, externalNames), ), - consumedAmounts: ba.consumedAmounts.map((lineItem) => + consumedAmounts: consumedAmounts.map((lineItem) => this.serializeBudgetLineItem(lineItem, externalNames), ), lockedBudget: locked, @@ -983,6 +1040,57 @@ export class BillingAccountsService { }; } + /** + * Filters locked and consumed detail rows by project access when required. + * + * Caller roles that can view billing accounts across multiple projects still + * need project-level protection on individual payment rows. This helper keeps + * unrestricted callers unchanged, hides all rows when a restricted caller has + * no usable user id, and otherwise keeps only references whose project access + * is proven by the external lookup service. + * + * @param lineItems Locked and consumed budget rows from one billing account. + * @param authUser Authenticated caller context from `req.authUser`. + * @returns Filtered locked and consumed rows for the response detail arrays. + */ + private async filterBudgetLineItemsForProjectAccess( + lineItems: ProjectAccessFilteredLineItems, + authUser?: BillingAccountsAuthUser, + ): Promise { + const userId = resolveProjectAccessFilteredLineItemUserId(authUser); + + if (userId === undefined) { + return lineItems; + } + + if (userId === null) { + return { lockedAmounts: [], consumedAmounts: [] }; + } + + const accessibleReferenceKeys = + await this.externalBudgetEntryLookup.getProjectAccessibleReferenceKeys( + [ + ...lineItems.lockedAmounts.map((lineItem) => + this.toBudgetEntryReference(lineItem), + ), + ...lineItems.consumedAmounts.map((lineItem) => + this.toBudgetEntryReference(lineItem), + ), + ], + userId, + ); + + const canViewLineItem = (lineItem: BudgetAmountLineItem) => + accessibleReferenceKeys.has( + getBudgetEntryReferenceKey(this.toBudgetEntryReference(lineItem)), + ); + + return { + lockedAmounts: lineItems.lockedAmounts.filter(canViewLineItem), + consumedAmounts: lineItems.consumedAmounts.filter(canViewLineItem), + }; + } + /** * Shapes a budget row for API details responses. * diff --git a/src/billing-accounts/external-budget-entry-lookup.service.ts b/src/billing-accounts/external-budget-entry-lookup.service.ts index 2e2213b..96f4c3b 100644 --- a/src/billing-accounts/external-budget-entry-lookup.service.ts +++ b/src/billing-accounts/external-budget-entry-lookup.service.ts @@ -12,26 +12,44 @@ interface ChallengeNameRow { name: string | null; } +interface ChallengeProjectRow { + id: string; + legacyId: number | null; + projectId: number | bigint | string | null; +} + interface EngagementNameRow { id: string; title: string | null; } +interface EngagementProjectRow { + id: string; + projectId: string | null; +} + +interface ProjectAccessRow { + projectId: string; +} + /** - * Resolves budget-entry external names from service-owned persistence stores. + * Resolves budget-entry external metadata from service-owned persistence stores. * * The service uses raw batched lookups so billing-account detail responses do - * not make one network/database call per line item. Missing DB URLs or missing - * referenced rows produce empty name mappings rather than failing the billing - * account response. + * not make one network/database call per line item. It resolves display names + * for response rows and project access for role-filtered line-item visibility. + * Missing DB URLs or missing referenced rows produce empty lookup mappings + * rather than failing the billing account response. */ @Injectable() export class ExternalBudgetEntryLookupService implements OnModuleDestroy { private readonly logger = new Logger(ExternalBudgetEntryLookupService.name); private challengeClient?: PrismaClient; private engagementsClient?: PrismaClient; + private projectsClient?: PrismaClient; private challengeClientInitialized = false; private engagementsClientInitialized = false; + private projectsClientInitialized = false; /** * Resolve external display names for mixed budget-entry references. @@ -87,6 +105,87 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { return result; } + /** + * Resolve which budget-entry references belong to projects accessible by a user. + * + * Challenge rows are mapped through challenge-api persistence and engagement + * rows are mapped through engagement assignments. The resulting project ids + * are checked against active `project_members` rows from projects-api-v6. + * References whose project or membership cannot be resolved are excluded so + * callers only see line items with proven project access. + * + * @param references Typed budget-entry references from locked/consumed rows. + * @param userId Topcoder user id from the authenticated caller. + * @returns Set of reference keys visible to the user. + */ + async getProjectAccessibleReferenceKeys( + references: BudgetEntryReference[], + userId: string, + ): Promise> { + const accessibleReferenceKeys = new Set(); + const normalizedUserId = this.normalizeNumericTextId(userId); + + if (!normalizedUserId || references.length === 0) { + return accessibleReferenceKeys; + } + + const referencesByType = new Map< + BudgetEntryExternalTypeValue, + Set + >(); + + for (const reference of references) { + const externalIds = + referencesByType.get(reference.externalType) ?? new Set(); + externalIds.add(reference.externalId); + referencesByType.set(reference.externalType, externalIds); + } + + const [challengeProjectIds, engagementProjectIds] = await Promise.all([ + this.getChallengeProjectIdsByIds([ + ...(referencesByType.get("CHALLENGE") ?? []), + ]), + this.getEngagementProjectIdsByAssignmentIds([ + ...(referencesByType.get("ENGAGEMENT") ?? []), + ]), + ]); + + const projectIdByReferenceKey = new Map(); + + for (const [externalId, projectId] of challengeProjectIds.entries()) { + projectIdByReferenceKey.set( + getBudgetEntryReferenceKey({ + externalType: "CHALLENGE", + externalId, + }), + projectId, + ); + } + + for (const [externalId, projectId] of engagementProjectIds.entries()) { + projectIdByReferenceKey.set( + getBudgetEntryReferenceKey({ + externalType: "ENGAGEMENT", + externalId, + }), + projectId, + ); + } + + const accessibleProjectIds = await this.getAccessibleProjectIdsForUser( + normalizedUserId, + [...projectIdByReferenceKey.values()], + ); + + for (const [referenceKey, projectId] of projectIdByReferenceKey.entries()) { + if (accessibleProjectIds.has(projectId)) { + accessibleReferenceKeys.add(referenceKey); + } + } + + return accessibleReferenceKeys; + } + /** * Disconnects optional lookup clients created for external stores. * @@ -96,6 +195,7 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { await Promise.all([ this.challengeClient?.$disconnect(), this.engagementsClient?.$disconnect(), + this.projectsClient?.$disconnect(), ]); } @@ -153,6 +253,64 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { return result; } + /** + * Resolve project ids for challenge budget-entry ids. + * + * @param externalIds Challenge ids or numeric legacy ids stored on budget entries. + * @returns Map of matched challenge external id to project id. + */ + private async getChallengeProjectIdsByIds( + externalIds: string[], + ): Promise> { + const result = new Map(); + const uniqueExternalIds = [...new Set(externalIds.filter(Boolean))]; + + if (uniqueExternalIds.length === 0) { + return result; + } + + const client = this.getChallengeClient(); + + if (!client) { + return result; + } + + try { + const idRows = await client.$queryRaw( + Prisma.sql` + SELECT "id", "legacyId", "projectId" + FROM "Challenge" + WHERE "id" IN (${Prisma.join(uniqueExternalIds)}) + `, + ); + + for (const row of idRows) { + this.addChallengeProjectId(result, row); + } + + const legacyIds = this.getNumericLegacyIds(uniqueExternalIds); + if (legacyIds.length > 0) { + const legacyRows = await client.$queryRaw( + Prisma.sql` + SELECT "id", "legacyId", "projectId" + FROM "Challenge" + WHERE "legacyId" IN (${Prisma.join(legacyIds)}) + `, + ); + + for (const row of legacyRows) { + this.addChallengeProjectId(result, row); + } + } + } catch (error) { + this.logger.warn( + `Failed to resolve challenge projects for billing-account entries: ${this.getErrorMessage(error)}`, + ); + } + + return result; + } + /** * Resolve engagement titles by assignment ids. * @@ -200,6 +358,137 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { return result; } + /** + * Resolve project ids for engagement assignment budget-entry ids. + * + * @param assignmentIds Engagement assignment ids stored on budget entries. + * @returns Map of assignment id to project id. + */ + private async getEngagementProjectIdsByAssignmentIds( + assignmentIds: string[], + ): Promise> { + const result = new Map(); + const uniqueAssignmentIds = [...new Set(assignmentIds.filter(Boolean))]; + + if (uniqueAssignmentIds.length === 0) { + return result; + } + + const client = this.getEngagementsClient(); + + if (!client) { + return result; + } + + try { + const rows = await client.$queryRaw( + Prisma.sql` + SELECT assignment."id", engagement."projectId" + FROM "EngagementAssignment" assignment + INNER JOIN "Engagement" engagement + ON engagement."id" = assignment."engagementId" + WHERE assignment."id" IN (${Prisma.join(uniqueAssignmentIds)}) + `, + ); + + for (const row of rows) { + const projectId = this.normalizeNumericTextId(row.projectId); + + if (projectId) { + result.set(row.id, projectId); + } + } + } catch (error) { + this.logger.warn( + `Failed to resolve engagement projects for billing-account entries: ${this.getErrorMessage(error)}`, + ); + } + + return result; + } + + /** + * Loads active project ids that a user belongs to. + * + * @param userId Normalized numeric Topcoder user id. + * @param projectIds Candidate project ids from line-item references. + * @returns Set of candidate project ids with an active project member row. + */ + private async getAccessibleProjectIdsForUser( + userId: string, + projectIds: string[], + ): Promise> { + const result = new Set(); + const uniqueProjectIds = [ + ...new Set( + projectIds + .map((projectId) => this.normalizeNumericTextId(projectId)) + .filter((projectId): projectId is string => Boolean(projectId)), + ), + ]; + + if (uniqueProjectIds.length === 0) { + return result; + } + + const client = this.getProjectsClient(); + + if (!client) { + return result; + } + + try { + const rows = await client.$queryRaw( + Prisma.sql` + SELECT DISTINCT "projectId"::text AS "projectId" + FROM project_members + WHERE "userId" = ${BigInt(userId)} + AND "projectId" IN (${Prisma.join( + uniqueProjectIds.map((projectId) => BigInt(projectId)), + )}) + AND "deletedAt" IS NULL + `, + ); + + for (const row of rows) { + const projectId = this.normalizeNumericTextId(row.projectId); + + if (projectId) { + result.add(projectId); + } + } + } catch (error) { + this.logger.warn( + `Failed to resolve project access for billing-account entries: ${this.getErrorMessage(error)}`, + ); + } + + return result; + } + + /** + * Adds current and legacy challenge ids to a challenge-project lookup map. + * + * @param result Mutable lookup map being populated. + * @param row Challenge row with current id, legacy id, and project id. + */ + private addChallengeProjectId( + result: Map, + row: ChallengeProjectRow, + ): void { + const projectId = this.normalizeNumericTextId(row.projectId); + + if (!projectId) { + return; + } + + result.set(row.id, projectId); + + if (row.legacyId !== null) { + result.set(String(row.legacyId), projectId); + } + } + /** * Converts string ids that can represent challenge legacy ids into numbers. * @@ -252,21 +541,41 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { return this.engagementsClient; } + /** + * Lazily initializes the projects lookup client. + * + * @returns Prisma client for the projects DB, or undefined when not configured. + */ + private getProjectsClient(): PrismaClient | undefined { + if (this.projectsClientInitialized) { + return this.projectsClient; + } + + this.projectsClientInitialized = true; + this.projectsClient = this.createOptionalClient( + process.env.PROJECTS_DB_URL || process.env.PROJECT_DB_URL, + "PROJECTS_DB_URL or PROJECT_DB_URL", + "project-access filtering will hide line items whose access cannot be resolved.", + ); + + return this.projectsClient; + } + /** * Creates a Prisma client for an optional external lookup database. * * @param databaseUrl External database connection string. * @param envDescription Human-readable environment variable description. + * @param disabledMessage Message describing behavior when the URL is missing. * @returns Prisma client when configured, otherwise undefined. */ private createOptionalClient( databaseUrl: string | undefined, envDescription: string, + disabledMessage = "billing-account external names will be omitted for that type.", ): PrismaClient | undefined { if (!databaseUrl) { - this.logger.warn( - `${envDescription} not set; billing-account external names will be omitted for that type.`, - ); + this.logger.warn(`${envDescription} not set; ${disabledMessage}`); return undefined; } @@ -280,6 +589,38 @@ export class ExternalBudgetEntryLookupService implements OnModuleDestroy { }); } + /** + * Normalizes a numeric id that may arrive as string, number, bigint, or null. + * + * @param value Id value to normalize. + * @returns Canonical decimal string, or undefined for non-numeric values. + */ + private normalizeNumericTextId( + value: bigint | number | string | null | undefined, + ): string | undefined { + if (typeof value === "bigint") { + return value >= 0n ? value.toString() : undefined; + } + + if (typeof value === "number") { + return Number.isSafeInteger(value) && value >= 0 + ? String(value) + : undefined; + } + + if (typeof value !== "string") { + return undefined; + } + + const normalizedValue = value.trim(); + + if (!/^\d+$/.test(normalizedValue)) { + return undefined; + } + + return BigInt(normalizedValue).toString(); + } + /** * Normalizes unknown errors for structured log messages. *