From 6d2de224157f72eeb928839158f3e696dde7d324 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 18 Mar 2026 07:31:13 +1100 Subject: [PATCH 1/2] Talent manager permissions for BAs (https://topcoder.atlassian.net/browse/PM-4376) --- README.md | 4 + src/auth/auth.middleware.ts | 5 +- src/auth/constants.ts | 2 + src/auth/guards/roles.guard.ts | 18 ++- src/auth/guards/scopes.guard.ts | 8 +- .../billing-accounts.controller.ts | 153 +++++++++++++----- .../billing-accounts.service.ts | 14 +- .../dto/create-billing-account.dto.ts | 12 +- .../dto/query-billing-accounts.dto.ts | 9 +- src/clients/clients.controller.ts | 23 ++- src/clients/dto/create-client.dto.ts | 8 +- src/common/members-lookup.service.ts | 18 ++- src/common/prisma.service.ts | 6 +- src/common/swagger/swagger-auth.util.ts | 6 +- src/health/health.controller.ts | 5 +- src/health/health.service.ts | 8 +- src/main.ts | 11 +- 17 files changed, 222 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index e7a95c1..b7a6fa7 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,10 @@ **Authorization** - JWT middleware via `tc-core-library-js` attaches `req.authUser` - Guards for Roles (e.g., `Administrator`) and M2M Scopes are provided. +- Billing-account management endpoints accept `administrator`, `Talent Manager`, + and `Topcoder Talent Manager` JWT roles; read-only billing-account lookups + also continue to allow `copilot`. Role checks are case-insensitive so mixed + token casing does not block Talent Manager access. - Configure env: `AUTH_SECRET` or `AUTH0_URL/AUDIENCE/ISSUER` as needed. ## Quickstart diff --git a/src/auth/auth.middleware.ts b/src/auth/auth.middleware.ts index 3e54901..9c83177 100644 --- a/src/auth/auth.middleware.ts +++ b/src/auth/auth.middleware.ts @@ -1,9 +1,6 @@ import { Injectable, NestMiddleware } from "@nestjs/common"; import { Request, Response, NextFunction } from "express"; - -// tc-core-library-js is CommonJS only, import via require - -const tcCore = require("tc-core-library-js"); +import * as tcCore from "tc-core-library-js"; @Injectable() export class AuthMiddleware implements NestMiddleware { diff --git a/src/auth/constants.ts b/src/auth/constants.ts index 0fdc504..beb7981 100644 --- a/src/auth/constants.ts +++ b/src/auth/constants.ts @@ -13,3 +13,5 @@ export const SCOPES = { export const ADMIN_ROLE = "administrator"; export const COPILOT_ROLE = "copilot"; +export const TALENT_MANAGER_ROLE = "Talent Manager"; +export const TOPCODER_TALENT_MANAGER_ROLE = "Topcoder Talent Manager"; diff --git a/src/auth/guards/roles.guard.ts b/src/auth/guards/roles.guard.ts index 32a1188..2d1b6f0 100644 --- a/src/auth/guards/roles.guard.ts +++ b/src/auth/guards/roles.guard.ts @@ -19,6 +19,9 @@ export class RolesGuard implements CanActivate { context.getClass(), ]); if (!required || required.length === 0) return true; + const normalizedRequiredRoles = required.map((role) => + role.trim().toLowerCase(), + ); const req = context.switchToHttp().getRequest(); const user = req.authUser; @@ -31,24 +34,33 @@ export class RolesGuard implements CanActivate { .split(",") .map((r: string) => r.trim()) .filter(Boolean); + const normalizedRoles = roles.map((role) => role.toLowerCase()); - const ok = roles.some((r: string) => required.includes(r)); + const ok = normalizedRoles.some((role: string) => + normalizedRequiredRoles.includes(role), + ); if (ok) return true; const fallbackScopes = this.reflector.getAllAndOverride( SCOPES_KEY, - [context.getHandler(), context.getClass()] + [context.getHandler(), context.getClass()], ); if (fallbackScopes && fallbackScopes.length > 0) { + const normalizedFallbackScopes = fallbackScopes.map((scope) => + scope.trim().toLowerCase(), + ); const scopes: string[] = Array.isArray(user.scopes) ? user.scopes : (user.scope || "") .split(" ") .map((s: string) => s.trim()) .filter(Boolean); + const normalizedScopes = scopes.map((scope) => scope.toLowerCase()); - const scopeOk = fallbackScopes.some((s) => scopes.includes(s)); + const scopeOk = normalizedScopes.some((scope) => + normalizedFallbackScopes.includes(scope), + ); if (scopeOk) return true; } diff --git a/src/auth/guards/scopes.guard.ts b/src/auth/guards/scopes.guard.ts index 2913325..7793f00 100644 --- a/src/auth/guards/scopes.guard.ts +++ b/src/auth/guards/scopes.guard.ts @@ -35,10 +35,10 @@ export class ScopesGuard implements CanActivate { const ok = required.some((s) => scopes.includes(s)); if (ok) return true; - const fallbackRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ - context.getHandler(), - context.getClass(), - ]); + const fallbackRoles = this.reflector.getAllAndOverride( + ROLES_KEY, + [context.getHandler(), context.getClass()], + ); if (fallbackRoles && fallbackRoles.length > 0) { const roles: string[] = Array.isArray(user.roles) diff --git a/src/billing-accounts/billing-accounts.controller.ts b/src/billing-accounts/billing-accounts.controller.ts index 533bbbd..cb9b53c 100644 --- a/src/billing-accounts/billing-accounts.controller.ts +++ b/src/billing-accounts/billing-accounts.controller.ts @@ -20,7 +20,13 @@ import { Roles } from "../auth/decorators/roles.decorator"; import { Scopes } from "../auth/decorators/scopes.decorator"; import { RolesGuard } from "../auth/guards/roles.guard"; import { ScopesGuard } from "../auth/guards/scopes.guard"; -import { SCOPES, ADMIN_ROLE, COPILOT_ROLE } from "../auth/constants"; +import { + SCOPES, + ADMIN_ROLE, + COPILOT_ROLE, + TALENT_MANAGER_ROLE, + TOPCODER_TALENT_MANAGER_ROLE, +} from "../auth/constants"; import { buildOperationDoc } from "../common/swagger/swagger-auth.util"; import { ApiBearerAuth, @@ -32,6 +38,19 @@ import { ApiBody, } from "@nestjs/swagger"; +const BILLING_ACCOUNT_READ_ROLES = [ + ADMIN_ROLE, + COPILOT_ROLE, + TALENT_MANAGER_ROLE, + TOPCODER_TALENT_MANAGER_ROLE, +]; + +const BILLING_ACCOUNT_MANAGE_ROLES = [ + ADMIN_ROLE, + TALENT_MANAGER_ROLE, + TOPCODER_TALENT_MANAGER_ROLE, +]; + @ApiTags("Billing Accounts") @ApiBearerAuth("JWT") @ApiBearerAuth("M2M") @@ -41,18 +60,23 @@ export class BillingAccountsController { @Get() @UseGuards(RolesGuard, ScopesGuard) - @Roles(ADMIN_ROLE, COPILOT_ROLE) + @Roles(...BILLING_ACCOUNT_READ_ROLES) @Scopes(SCOPES.READ_BA, SCOPES.ALL_BA) @ApiOperation( buildOperationDoc({ summary: "List billing accounts", - description: "Retrieve billing accounts with optional filters, sorting, and pagination.", - jwtRoles: [ADMIN_ROLE, COPILOT_ROLE], + description: + "Retrieve billing accounts with optional filters, sorting, and pagination.", + jwtRoles: BILLING_ACCOUNT_READ_ROLES, m2mScopes: [SCOPES.READ_BA, SCOPES.ALL_BA], }), ) @ApiOkResponse({ description: "Paginated list of billing accounts returned" }) - @ApiQuery({ name: "name", required: false, description: "Filter by name (contains, case-insensitive)" }) + @ApiQuery({ + name: "name", + required: false, + description: "Filter by name (contains, case-insensitive)", + }) @ApiQuery({ name: "clientId", required: false }) @ApiQuery({ name: "userId", required: false }) @ApiQuery({ name: "status", required: false, enum: ["ACTIVE", "INACTIVE"] }) @@ -60,7 +84,18 @@ export class BillingAccountsController { @ApiQuery({ name: "startDateTo", required: false, type: String }) @ApiQuery({ name: "endDateFrom", required: false, type: String }) @ApiQuery({ name: "endDateTo", required: false, type: String }) - @ApiQuery({ name: "sortBy", required: false, enum: ["endDate", "startDate", "id", "createdAt", "createdBy", "remainingBudget"] }) + @ApiQuery({ + name: "sortBy", + required: false, + enum: [ + "endDate", + "startDate", + "id", + "createdAt", + "createdBy", + "remainingBudget", + ], + }) @ApiQuery({ name: "sortOrder", required: false, enum: ["asc", "desc"] }) @ApiQuery({ name: "page", required: false, type: Number }) @ApiQuery({ name: "perPage", required: false, type: Number }) @@ -70,13 +105,14 @@ export class BillingAccountsController { @Post() @UseGuards(RolesGuard, ScopesGuard) - @Roles(ADMIN_ROLE) + @Roles(...BILLING_ACCOUNT_MANAGE_ROLES) @Scopes(SCOPES.CREATE_BA, SCOPES.ALL_BA) @ApiOperation( buildOperationDoc({ summary: "Create a billing account", - description: "Create a new billing account with the provided project and budget details.", - jwtRoles: [ADMIN_ROLE], + description: + "Create a new billing account with the provided project and budget details.", + jwtRoles: BILLING_ACCOUNT_MANAGE_ROLES, m2mScopes: [SCOPES.CREATE_BA, SCOPES.ALL_BA], }), ) @@ -88,36 +124,45 @@ export class BillingAccountsController { @Get(":billingAccountId") @UseGuards(RolesGuard, ScopesGuard) - @Roles(ADMIN_ROLE, COPILOT_ROLE) + @Roles(...BILLING_ACCOUNT_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 and client data.", - jwtRoles: [ADMIN_ROLE, COPILOT_ROLE], + description: + "Fetch a billing account by its identifier, including budget and client data.", + jwtRoles: BILLING_ACCOUNT_READ_ROLES, m2mScopes: [SCOPES.READ_BA, SCOPES.ALL_BA], }), ) @ApiOkResponse({ description: "Billing account returned" }) - @ApiParam({ name: "billingAccountId", description: "Billing Account ID", type: Number }) + @ApiParam({ + name: "billingAccountId", + description: "Billing Account ID", + type: Number, + }) async get(@Param("billingAccountId", ParseIntPipe) id: number) { return this.service.get(id); } @Patch(":billingAccountId") @UseGuards(RolesGuard, ScopesGuard) - @Roles(ADMIN_ROLE) + @Roles(...BILLING_ACCOUNT_MANAGE_ROLES) @Scopes(SCOPES.UPDATE_BA, SCOPES.ALL_BA) @ApiOperation( buildOperationDoc({ summary: "Update a billing account", description: "Update billing account metadata or budget details.", - jwtRoles: [ADMIN_ROLE], + jwtRoles: BILLING_ACCOUNT_MANAGE_ROLES, m2mScopes: [SCOPES.UPDATE_BA, SCOPES.ALL_BA], }), ) @ApiOkResponse({ description: "Billing account updated" }) - @ApiParam({ name: "billingAccountId", description: "Billing Account ID", type: Number }) + @ApiParam({ + name: "billingAccountId", + description: "Billing Account ID", + type: Number, + }) @ApiBody({ type: UpdateBillingAccountDto }) async update( @Param("billingAccountId", ParseIntPipe) id: number, @@ -133,13 +178,18 @@ export class BillingAccountsController { @ApiOperation( buildOperationDoc({ summary: "Lock funds for a challenge", - description: "Reserve an amount on a billing account for a specific challenge.", + description: + "Reserve an amount on a billing account for a specific challenge.", jwtRoles: [ADMIN_ROLE], m2mScopes: [SCOPES.UPDATE_BA, SCOPES.ALL_BA], }), ) @ApiOkResponse({ description: "Lock created/updated or unlocked" }) - @ApiParam({ name: "billingAccountId", description: "Billing Account ID", type: Number }) + @ApiParam({ + name: "billingAccountId", + description: "Billing Account ID", + type: Number, + }) @ApiBody({ type: LockAmountDto }) async lock( @Param("billingAccountId", ParseIntPipe) id: number, @@ -155,13 +205,18 @@ export class BillingAccountsController { @ApiOperation( buildOperationDoc({ summary: "Consume reserved funds", - description: "Consume a previously locked amount for a challenge and record the transaction.", + description: + "Consume a previously locked amount for a challenge and record the transaction.", jwtRoles: [ADMIN_ROLE], m2mScopes: [SCOPES.UPDATE_BA, SCOPES.ALL_BA], }), ) @ApiOkResponse({ description: "Consumed amount recorded" }) - @ApiParam({ name: "billingAccountId", description: "Billing Account ID", type: Number }) + @ApiParam({ + name: "billingAccountId", + description: "Billing Account ID", + type: Number, + }) @ApiBody({ type: ConsumeAmountDto }) async consume( @Param("billingAccountId", ParseIntPipe) id: number, @@ -174,48 +229,57 @@ export class BillingAccountsController { @Get(":billingAccountId/users") @UseGuards(RolesGuard, ScopesGuard) - @Roles(ADMIN_ROLE, COPILOT_ROLE) + @Roles(...BILLING_ACCOUNT_READ_ROLES) @Scopes(SCOPES.READ_BA, SCOPES.ALL_BA) @ApiOperation( buildOperationDoc({ summary: "List billing account resources", - description: "List users assigned to a billing account (includes member handle).", - jwtRoles: [ADMIN_ROLE, COPILOT_ROLE], + description: + "List users assigned to a billing account (includes member handle).", + jwtRoles: BILLING_ACCOUNT_READ_ROLES, m2mScopes: [SCOPES.READ_BA, SCOPES.ALL_BA], }), ) @ApiOkResponse({ description: "List of resources returned" }) - @ApiParam({ name: "billingAccountId", description: "Billing Account ID", type: Number }) + @ApiParam({ + name: "billingAccountId", + description: "Billing Account ID", + type: Number, + }) async listUsers(@Param("billingAccountId", ParseIntPipe) id: number) { return this.service.listUsers(id); } @Post(":billingAccountId/users") @UseGuards(RolesGuard, ScopesGuard) - @Roles(ADMIN_ROLE) + @Roles(...BILLING_ACCOUNT_MANAGE_ROLES) @Scopes(SCOPES.UPDATE_BA, SCOPES.ALL_BA) @ApiOperation( buildOperationDoc({ summary: "Add a user to billing account", description: "Grant resource access to a user on the billing account.", - jwtRoles: [ADMIN_ROLE], + jwtRoles: BILLING_ACCOUNT_MANAGE_ROLES, m2mScopes: [SCOPES.UPDATE_BA, SCOPES.ALL_BA], }), ) @ApiOkResponse({ description: "User added" }) - @ApiParam({ name: "billingAccountId", description: "Billing Account ID", type: Number }) + @ApiParam({ + name: "billingAccountId", + description: "Billing Account ID", + type: Number, + }) @ApiBody({ schema: { - type: 'object', + type: "object", properties: { param: { - type: 'object', - properties: { userId: { type: 'string' } }, - required: ['userId'], + type: "object", + properties: { userId: { type: "string" } }, + required: ["userId"], }, - userId: { type: 'string' }, + userId: { type: "string" }, }, - required: ['param'], + required: ["param"], example: { param: { userId: "12345678", @@ -234,18 +298,22 @@ export class BillingAccountsController { @Delete(":billingAccountId/users/:userId") @UseGuards(RolesGuard, ScopesGuard) - @Roles(ADMIN_ROLE) + @Roles(...BILLING_ACCOUNT_MANAGE_ROLES) @Scopes(SCOPES.UPDATE_BA, SCOPES.ALL_BA) @ApiOperation( buildOperationDoc({ summary: "Remove a user from billing account", description: "Revoke resource access for a user on this billing account.", - jwtRoles: [ADMIN_ROLE], + jwtRoles: BILLING_ACCOUNT_MANAGE_ROLES, m2mScopes: [SCOPES.UPDATE_BA, SCOPES.ALL_BA], }), ) @ApiOkResponse({ description: "User removed" }) - @ApiParam({ name: "billingAccountId", description: "Billing Account ID", type: Number }) + @ApiParam({ + name: "billingAccountId", + description: "Billing Account ID", + type: Number, + }) @ApiParam({ name: "userId", description: "User ID (string)" }) async removeUser( @Param("billingAccountId", ParseIntPipe) id: number, @@ -256,18 +324,23 @@ export class BillingAccountsController { @Get(":billingAccountId/users/:userId/access") @UseGuards(RolesGuard, ScopesGuard) - @Roles(ADMIN_ROLE, COPILOT_ROLE) + @Roles(...BILLING_ACCOUNT_READ_ROLES) @Scopes(SCOPES.READ_BA, SCOPES.ALL_BA) @ApiOperation( buildOperationDoc({ summary: "Check user access", - description: "Return whether a given user has access to the billing account.", - jwtRoles: [ADMIN_ROLE, COPILOT_ROLE], + description: + "Return whether a given user has access to the billing account.", + jwtRoles: BILLING_ACCOUNT_READ_ROLES, m2mScopes: [SCOPES.READ_BA, SCOPES.ALL_BA], }), ) @ApiOkResponse({ description: "Boolean access returned" }) - @ApiParam({ name: "billingAccountId", description: "Billing Account ID", type: Number }) + @ApiParam({ + name: "billingAccountId", + description: "Billing Account ID", + type: Number, + }) @ApiParam({ name: "userId", description: "User ID (string)" }) async hasAccess( @Param("billingAccountId", ParseIntPipe) id: number, diff --git a/src/billing-accounts/billing-accounts.service.ts b/src/billing-accounts/billing-accounts.service.ts index da0fddc..b8d3dbc 100644 --- a/src/billing-accounts/billing-accounts.service.ts +++ b/src/billing-accounts/billing-accounts.service.ts @@ -81,24 +81,23 @@ export class BillingAccountsService { by: ["billingAccountId"], where: { billingAccountId: { in: ids } }, _sum: { amount: true }, - orderBy: [], + orderBy: [], }), this.prisma.consumedAmount.groupBy({ by: ["billingAccountId"], where: { billingAccountId: { in: ids } }, _sum: { amount: true }, - orderBy: [], + orderBy: [], }), ]); const lockedMap = new Map( - lockedAgg.map((r) => [r.billingAccountId, r._sum?.amount || 0]) + lockedAgg.map((r) => [r.billingAccountId, r._sum?.amount || 0]), ); const consumedMap = new Map( - consumedAgg.map((r) => [r.billingAccountId, r._sum?.amount || 0]) + consumedAgg.map((r) => [r.billingAccountId, r._sum?.amount || 0]), ); - const data = items.map((i) => { const locked = Number(lockedMap.get(i.id) || 0); const consumed = Number(consumedMap.get(i.id) || 0); @@ -162,7 +161,10 @@ export class BillingAccountsService { consumedAmounts: true, }, }); - if (!ba) throw new NotFoundException(`Billing account with ID ${billingAccountId} not found`); + if (!ba) + throw new NotFoundException( + `Billing account with ID ${billingAccountId} not found`, + ); const locked = ba.lockedAmounts.reduce( (sum, r) => sum + Number(r.amount), diff --git a/src/billing-accounts/dto/create-billing-account.dto.ts b/src/billing-accounts/dto/create-billing-account.dto.ts index 25685ad..c465a31 100644 --- a/src/billing-accounts/dto/create-billing-account.dto.ts +++ b/src/billing-accounts/dto/create-billing-account.dto.ts @@ -1,5 +1,11 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString } from "class-validator"; +import { + IsBoolean, + IsDateString, + IsNumber, + IsOptional, + IsString, +} from "class-validator"; export class CreateBillingAccountDto { @ApiProperty({ example: "Acme Innovation Billing Account" }) @@ -11,7 +17,9 @@ export class CreateBillingAccountDto { @IsString() projectId?: string; - @ApiPropertyOptional({ example: "Primary billing account for Acme Innovation initiatives." }) + @ApiPropertyOptional({ + example: "Primary billing account for Acme Innovation initiatives.", + }) @IsOptional() @IsString() description?: string; diff --git a/src/billing-accounts/dto/query-billing-accounts.dto.ts b/src/billing-accounts/dto/query-billing-accounts.dto.ts index 67c91eb..d315b2f 100644 --- a/src/billing-accounts/dto/query-billing-accounts.dto.ts +++ b/src/billing-accounts/dto/query-billing-accounts.dto.ts @@ -1,4 +1,11 @@ -import { IsDateString, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; +import { + IsDateString, + IsIn, + IsInt, + IsOptional, + IsString, + Min, +} from "class-validator"; import { Transform } from "class-transformer"; export class QueryBillingAccountsDto { diff --git a/src/clients/clients.controller.ts b/src/clients/clients.controller.ts index bf41a44..9041919 100644 --- a/src/clients/clients.controller.ts +++ b/src/clients/clients.controller.ts @@ -11,7 +11,10 @@ import { import { ClientsService } from "./clients.service"; import { QueryClientsDto } from "./dto/query-clients.dto"; import { UpdateClientDto } from "./dto/update-client.dto"; -import { CreateClientDto, CreateClientRequestDto } from "./dto/create-client.dto"; +import { + CreateClientDto, + CreateClientRequestDto, +} from "./dto/create-client.dto"; import { Roles } from "../auth/decorators/roles.decorator"; import { Scopes } from "../auth/decorators/scopes.decorator"; import { RolesGuard } from "../auth/guards/roles.guard"; @@ -42,7 +45,8 @@ export class ClientsController { @ApiOperation( buildOperationDoc({ summary: "List clients", - description: "Retrieve clients with optional filters, sorting, and pagination.", + description: + "Retrieve clients with optional filters, sorting, and pagination.", jwtRoles: [ADMIN_ROLE], m2mScopes: [SCOPES.READ_CLIENT, SCOPES.ALL_CLIENT], }), @@ -55,7 +59,11 @@ export class ClientsController { @ApiQuery({ name: "startDateTo", required: false, type: String }) @ApiQuery({ name: "endDateFrom", required: false, type: String }) @ApiQuery({ name: "endDateTo", required: false, type: String }) - @ApiQuery({ name: "sortBy", required: false, enum: ["name", "startDate", "endDate", "status", "createdAt"] }) + @ApiQuery({ + name: "sortBy", + required: false, + enum: ["name", "startDate", "endDate", "status", "createdAt"], + }) @ApiQuery({ name: "sortOrder", required: false, enum: ["asc", "desc"] }) @ApiQuery({ name: "page", required: false, type: Number }) @ApiQuery({ name: "perPage", required: false, type: Number }) @@ -70,7 +78,8 @@ export class ClientsController { @ApiOperation( buildOperationDoc({ summary: "Get a client", - description: "Fetch a client by its identifier, including billing accounts and metadata.", + description: + "Fetch a client by its identifier, including billing accounts and metadata.", jwtRoles: [ADMIN_ROLE], m2mScopes: [SCOPES.READ_CLIENT, SCOPES.ALL_CLIENT], }), @@ -88,7 +97,8 @@ export class ClientsController { @ApiOperation( buildOperationDoc({ summary: "Create a client", - description: "Create a new client with optional code name, dates, and status.", + description: + "Create a new client with optional code name, dates, and status.", jwtRoles: [ADMIN_ROLE], m2mScopes: [SCOPES.CREATE_CLIENT, SCOPES.ALL_CLIENT], }), @@ -123,7 +133,8 @@ export class ClientsController { @ApiOperation( buildOperationDoc({ summary: "Update a client", - description: "Update client metadata, billing account associations, or status.", + description: + "Update client metadata, billing account associations, or status.", jwtRoles: [ADMIN_ROLE], m2mScopes: [SCOPES.UPDATE_CLIENT, SCOPES.ALL_CLIENT], }), diff --git a/src/clients/dto/create-client.dto.ts b/src/clients/dto/create-client.dto.ts index 70f9bd2..1c28946 100644 --- a/src/clients/dto/create-client.dto.ts +++ b/src/clients/dto/create-client.dto.ts @@ -1,5 +1,11 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { IsDateString, IsIn, IsOptional, IsString, ValidateNested } from "class-validator"; +import { + IsDateString, + IsIn, + IsOptional, + IsString, + ValidateNested, +} from "class-validator"; import { Type } from "class-transformer"; export class CreateClientDto { diff --git a/src/common/members-lookup.service.ts b/src/common/members-lookup.service.ts index 0432154..242250b 100644 --- a/src/common/members-lookup.service.ts +++ b/src/common/members-lookup.service.ts @@ -15,7 +15,9 @@ export class MembersLookupService { if (this.initialized) return; const url = process.env.MEMBER_DB_URL; if (!url) { - this.logger.warn("MEMBER_DB_URL not set; member handle lookups will be skipped."); + this.logger.warn( + "MEMBER_DB_URL not set; member handle lookups will be skipped.", + ); this.initialized = true; return; } @@ -26,7 +28,7 @@ export class MembersLookupService { ? parseInt(process.env.BA_SERVICE_PRISMA_TIMEOUT, 10) : 10000, }, - datasources: { db: { url } } + datasources: { db: { url } }, }); this.initialized = true; } @@ -43,14 +45,20 @@ export class MembersLookupService { // Convert to numeric values where possible (members.userId is BigInt) const numericIds = userIds .map((id) => { - try { return BigInt(id); } catch { return undefined; } + try { + return BigInt(id); + } catch { + return undefined; + } }) .filter((v): v is bigint => typeof v === "bigint"); if (!numericIds.length) return result; // Build an IN (...) clause safely using Prisma.sql and Prisma.join - const rows: Array<{ userId: bigint; handle: string }> = await (this.client as any).$queryRaw( - Prisma.sql`SELECT "userId", "handle" FROM "member" WHERE "userId" IN (${Prisma.join(numericIds)})` + const rows: Array<{ userId: bigint; handle: string }> = await ( + this.client as any + ).$queryRaw( + Prisma.sql`SELECT "userId", "handle" FROM "member" WHERE "userId" IN (${Prisma.join(numericIds)})`, ); for (const r of rows) { diff --git a/src/common/prisma.service.ts b/src/common/prisma.service.ts index 2e4f63a..d6aaf27 100644 --- a/src/common/prisma.service.ts +++ b/src/common/prisma.service.ts @@ -17,9 +17,9 @@ export class PrismaService extends PrismaClient implements OnModuleInit { await this.$connect(); } - async enableShutdownHooks(app: INestApplication) { - (this as any).$on("beforeExit", async () => { - await app.close(); + enableShutdownHooks(app: INestApplication) { + (this as any).$on("beforeExit", () => { + void app.close(); }); } } diff --git a/src/common/swagger/swagger-auth.util.ts b/src/common/swagger/swagger-auth.util.ts index c3bc569..30b5cbc 100644 --- a/src/common/swagger/swagger-auth.util.ts +++ b/src/common/swagger/swagger-auth.util.ts @@ -1,7 +1,9 @@ import { ApiOperationOptions } from "@nestjs/swagger"; -interface OperationDocOptions - extends Omit { +interface OperationDocOptions extends Omit< + ApiOperationOptions, + "summary" | "description" +> { summary: string; description?: string; jwtRoles?: string[]; diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts index 34233eb..7896927 100644 --- a/src/health/health.controller.ts +++ b/src/health/health.controller.ts @@ -12,13 +12,14 @@ export class HealthController { @ApiOperation( buildOperationDoc({ summary: "Check service health", - description: "Verify that the Billing Accounts API and its dependencies respond as expected.", + description: + "Verify that the Billing Accounts API and its dependencies respond as expected.", publicAccess: true, }), ) @ApiResponse({ status: 200, description: "Service is healthy." }) @ApiResponse({ status: 503, description: "Service is unhealthy." }) - check() : any { + check(): any { return this.healthService.check(); } } diff --git a/src/health/health.service.ts b/src/health/health.service.ts index 096221a..fd775a8 100644 --- a/src/health/health.service.ts +++ b/src/health/health.service.ts @@ -14,12 +14,8 @@ export class HealthService { // and it just searches a single challenge type, it should be quick operation checksRun += 1; const timestampMS = new Date().getTime(); - try { - await this.service.get('1'); - } catch (e) { - throw e; - } + await this.service.get("1"); // there is no error, and it is quick, then return checks run count - return ({checksRun, timestamp: timestampMS}) + return { checksRun, timestamp: timestampMS }; } } diff --git a/src/main.ts b/src/main.ts index 2fd55c3..82790af 100644 --- a/src/main.ts +++ b/src/main.ts @@ -63,7 +63,8 @@ async function bootstrap() { type: "http", scheme: "bearer", bearerFormat: "JWT", - description: "User JWT token with role claims (e.g. administrator, copilot).", + description: + "User JWT token with role claims (e.g. administrator, Talent Manager, Topcoder Talent Manager, copilot).", }, "JWT", ) @@ -72,7 +73,8 @@ async function bootstrap() { type: "http", scheme: "bearer", bearerFormat: "JWT", - description: "Machine-to-machine token that carries the required scopes in `scope`.", + description: + "Machine-to-machine token that carries the required scopes in `scope`.", }, "M2M", ) @@ -85,4 +87,7 @@ async function bootstrap() { console.log(`TC Billing Account Service listening on :${port}`); } -bootstrap(); +bootstrap().catch((error) => { + console.error("Failed to start Billing Accounts API", error); + process.exit(1); +}); From b63de0abca819e2d5ef50f1d12f9320000af14c0 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 7 Apr 2026 11:16:38 +1000 Subject: [PATCH 2/2] Updates for project-manager roles --- README.md | 6 +- src/auth/constants.ts | 2 + .../billing-accounts.controller.ts | 41 +++- .../billing-accounts.service.ts | 185 ++++++++++++++++-- 4 files changed, 211 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 8c70199..292d2c9 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,10 @@ - Guards for Roles (e.g., `Administrator`) and M2M Scopes are provided. - Billing-account management endpoints accept `administrator`, `Talent Manager`, and `Topcoder Talent Manager` JWT roles; read-only billing-account lookups - also continue to allow `copilot`. Role checks are case-insensitive so mixed - token casing does not block Talent Manager access. + 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`. + Role checks are case-insensitive so mixed token casing does not block access. - Configure env: `AUTH_SECRET` or `AUTH0_URL/AUDIENCE/ISSUER` as needed. ## Quickstart diff --git a/src/auth/constants.ts b/src/auth/constants.ts index beb7981..2edf9ea 100644 --- a/src/auth/constants.ts +++ b/src/auth/constants.ts @@ -13,5 +13,7 @@ export const SCOPES = { 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 TALENT_MANAGER_ROLE = "Talent Manager"; export const TOPCODER_TALENT_MANAGER_ROLE = "Topcoder Talent Manager"; diff --git a/src/billing-accounts/billing-accounts.controller.ts b/src/billing-accounts/billing-accounts.controller.ts index 8ef28e3..abb4e89 100644 --- a/src/billing-accounts/billing-accounts.controller.ts +++ b/src/billing-accounts/billing-accounts.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Req, Get, Param, Patch, @@ -11,6 +12,7 @@ import { Delete, } from "@nestjs/common"; import { BillingAccountsService } from "./billing-accounts.service"; +import type { BillingAccountsAuthUser } from "./billing-accounts.service"; import { QueryBillingAccountsDto } from "./dto/query-billing-accounts.dto"; import { CreateBillingAccountDto } from "./dto/create-billing-account.dto"; import { UpdateBillingAccountDto } from "./dto/update-billing-account.dto"; @@ -24,9 +26,12 @@ import { SCOPES, ADMIN_ROLE, COPILOT_ROLE, + PROJECT_MANAGER_ROLE, TALENT_MANAGER_ROLE, + TOPCODER_PROJECT_MANAGER_ROLE, TOPCODER_TALENT_MANAGER_ROLE, } from "../auth/constants"; +import type { Request } from "express"; import { buildOperationDoc } from "../common/swagger/swagger-auth.util"; import { ApiBearerAuth, @@ -45,12 +50,22 @@ const BILLING_ACCOUNT_READ_ROLES = [ TOPCODER_TALENT_MANAGER_ROLE, ]; +const BILLING_ACCOUNT_PROJECT_READ_ROLES = [ + ...BILLING_ACCOUNT_READ_ROLES, + PROJECT_MANAGER_ROLE, + TOPCODER_PROJECT_MANAGER_ROLE, +]; + const BILLING_ACCOUNT_MANAGE_ROLES = [ ADMIN_ROLE, TALENT_MANAGER_ROLE, TOPCODER_TALENT_MANAGER_ROLE, ]; +interface BillingAccountsRequest extends Request { + authUser?: BillingAccountsAuthUser; +} + @ApiTags("Billing Accounts") @ApiBearerAuth("JWT") @ApiBearerAuth("M2M") @@ -60,14 +75,14 @@ export class BillingAccountsController { @Get() @UseGuards(RolesGuard, ScopesGuard) - @Roles(...BILLING_ACCOUNT_READ_ROLES) + @Roles(...BILLING_ACCOUNT_PROJECT_READ_ROLES) @Scopes(SCOPES.READ_BA, SCOPES.ALL_BA) @ApiOperation( buildOperationDoc({ summary: "List billing accounts", description: - "Retrieve billing accounts with optional filters, sorting, and pagination.", - jwtRoles: BILLING_ACCOUNT_READ_ROLES, + "Retrieve billing accounts with optional filters, sorting, and pagination. Project Managers are limited to billing accounts granted to their own user id.", + jwtRoles: BILLING_ACCOUNT_PROJECT_READ_ROLES, m2mScopes: [SCOPES.READ_BA, SCOPES.ALL_BA], }), ) @@ -99,8 +114,11 @@ export class BillingAccountsController { @ApiQuery({ name: "sortOrder", required: false, enum: ["asc", "desc"] }) @ApiQuery({ name: "page", required: false, type: Number }) @ApiQuery({ name: "perPage", required: false, type: Number }) - async list(@Query() q: QueryBillingAccountsDto) { - return this.service.list(q); + async list( + @Query() q: QueryBillingAccountsDto, + @Req() req: BillingAccountsRequest, + ) { + return this.service.list(q, req.authUser); } @Post() @@ -147,14 +165,14 @@ export class BillingAccountsController { @Get(":billingAccountId") @UseGuards(RolesGuard, ScopesGuard) - @Roles(...BILLING_ACCOUNT_READ_ROLES) + @Roles(...BILLING_ACCOUNT_PROJECT_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 and client data.", - jwtRoles: BILLING_ACCOUNT_READ_ROLES, + "Fetch a billing account by its identifier, including budget and client data. Project Managers can read only billing accounts granted to them.", + jwtRoles: BILLING_ACCOUNT_PROJECT_READ_ROLES, m2mScopes: [SCOPES.READ_BA, SCOPES.ALL_BA], }), ) @@ -164,8 +182,11 @@ export class BillingAccountsController { description: "Billing Account ID", type: Number, }) - async get(@Param("billingAccountId", ParseIntPipe) id: number) { - return this.service.get(id); + async get( + @Param("billingAccountId", ParseIntPipe) id: number, + @Req() req: BillingAccountsRequest, + ) { + return this.service.get(id, req.authUser); } @Patch(":billingAccountId") diff --git a/src/billing-accounts/billing-accounts.service.ts b/src/billing-accounts/billing-accounts.service.ts index 629e98a..4145be6 100644 --- a/src/billing-accounts/billing-accounts.service.ts +++ b/src/billing-accounts/billing-accounts.service.ts @@ -8,6 +8,114 @@ import { LockAmountDto } from "./dto/lock-amount.dto"; import { ConsumeAmountDto } from "./dto/consume-amount.dto"; import { MembersLookupService } from "../common/members-lookup.service"; import SalesforceService from "../common/salesforce.service"; +import { + ADMIN_ROLE, + COPILOT_ROLE, + PROJECT_MANAGER_ROLE, + TALENT_MANAGER_ROLE, + TOPCODER_PROJECT_MANAGER_ROLE, + TOPCODER_TALENT_MANAGER_ROLE, +} from "../auth/constants"; + +export interface BillingAccountsAuthUser { + role?: string; + roles?: string[] | string; + userId?: number | string; +} + +const UNRESTRICTED_BILLING_ACCOUNT_READ_ROLES = [ + ADMIN_ROLE, + COPILOT_ROLE, + TALENT_MANAGER_ROLE, + TOPCODER_TALENT_MANAGER_ROLE, +]; + +const RESTRICTED_PROJECT_MANAGER_READ_ROLES = [ + PROJECT_MANAGER_ROLE, + TOPCODER_PROJECT_MANAGER_ROLE, +]; + +/** + * Normalizes authenticated caller roles for case-insensitive comparisons. + * + * Accepts either array-backed `roles` or the legacy comma-delimited `role` + * payload shapes emitted by different token issuers. + * + * @param authUser Authenticated caller context from `req.authUser`. + * @returns Lower-cased role names. + */ +function getNormalizedAuthUserRoles( + authUser?: BillingAccountsAuthUser, +): string[] { + const roles = Array.isArray(authUser?.roles) + ? authUser.roles + : String(authUser?.roles || authUser?.role || "") + .split(",") + .map((role) => role.trim()) + .filter(Boolean); + + return roles.map((role) => role.toLowerCase()); +} + +/** + * Resolves the caller user id as a trimmed string when present. + * + * @param authUser Authenticated caller context from `req.authUser`. + * @returns Normalized user id or `undefined` when missing. + */ +function getNormalizedAuthUserId( + authUser?: BillingAccountsAuthUser, +): string | undefined { + if ( + typeof authUser?.userId === "number" && + Number.isFinite(authUser.userId) + ) { + return String(authUser.userId); + } + + if (typeof authUser?.userId !== "string") { + return undefined; + } + + const normalizedUserId = authUser.userId.trim(); + + return normalizedUserId || undefined; +} + +/** + * Returns the enforced access-grant user id for restricted Project Manager + * billing-account reads. + * + * `undefined` means the caller keeps unrestricted read behavior. `null` + * indicates a restricted Project Manager 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( + authUser?: BillingAccountsAuthUser, +): string | null | undefined { + const normalizedRoles = getNormalizedAuthUserRoles(authUser); + const hasRestrictedProjectManagerRole = + RESTRICTED_PROJECT_MANAGER_READ_ROLES.some((role) => + normalizedRoles.includes(role.toLowerCase()), + ); + + if (!hasRestrictedProjectManagerRole) { + return undefined; + } + + const hasUnrestrictedReadRole = UNRESTRICTED_BILLING_ACCOUNT_READ_ROLES.some( + (role) => normalizedRoles.includes(role.toLowerCase()), + ); + + if (hasUnrestrictedReadRole) { + return undefined; + } + + return getNormalizedAuthUserId(authUser) ?? null; +} @Injectable() export class BillingAccountsService { @@ -38,7 +146,17 @@ export class BillingAccountsService { return res; } - async list(q: QueryBillingAccountsDto) { + /** + * Lists billing accounts with optional filtering, sorting, and pagination. + * + * Project Manager 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. + * @param authUser Authenticated caller context from `req.authUser`. + * @returns Paginated billing-account result set. + */ + async list(q: QueryBillingAccountsDto, authUser?: BillingAccountsAuthUser) { const { clientId, userId, @@ -53,13 +171,27 @@ export class BillingAccountsService { sortBy, sortOrder = "asc", } = q; + const restrictedProjectManagerUserId = + resolveRestrictedProjectManagerUserId(authUser); + + if (restrictedProjectManagerUserId === null) { + return { + page, + perPage, + total: 0, + totalPages: 0, + data: [], + }; + } const where: Prisma.BillingAccountWhereInput = { ...(clientId ? { clientId } : {}), ...(status ? { status } : {}), }; - if (userId) { + if (restrictedProjectManagerUserId) { + where.accessGrants = { some: { userId: restrictedProjectManagerUserId } }; + } else if (userId) { where.accessGrants = { some: { userId } }; } @@ -175,15 +307,46 @@ export class BillingAccountsService { }); } - async get(billingAccountId: number) { - const ba = await this.prisma.billingAccount.findUnique({ - where: { id: billingAccountId }, - include: { - client: true, - lockedAmounts: true, - consumedAmounts: true, - }, - }); + /** + * Fetches a single billing account and its budget aggregates. + * + * Project Manager callers can read only billing accounts granted to their own + * `userId`. Missing access is surfaced as not found to avoid leaking account + * existence. + * + * @param billingAccountId Billing-account identifier. + * @param authUser Authenticated caller context from `req.authUser`. + * @returns Billing-account details with locked, consumed, and remaining + * budget totals. + * @throws NotFoundException When the billing account does not exist or the + * caller does not have access to it. + */ + async get(billingAccountId: number, authUser?: BillingAccountsAuthUser) { + const restrictedProjectManagerUserId = + resolveRestrictedProjectManagerUserId(authUser); + const include = { + client: true, + lockedAmounts: true, + consumedAmounts: true, + }; + const ba = + restrictedProjectManagerUserId === undefined + ? await this.prisma.billingAccount.findUnique({ + where: { id: billingAccountId }, + include, + }) + : restrictedProjectManagerUserId === null + ? null + : await this.prisma.billingAccount.findFirst({ + where: { + id: billingAccountId, + accessGrants: { + some: { userId: restrictedProjectManagerUserId }, + }, + }, + include, + }); + if (!ba) throw new NotFoundException( `Billing account with ID ${billingAccountId} not found`,