diff --git a/src/api/admin/admin.controller.ts b/src/api/admin/admin.controller.ts index 9a5c28d..ad34d55 100644 --- a/src/api/admin/admin.controller.ts +++ b/src/api/admin/admin.controller.ts @@ -24,8 +24,6 @@ import { TopcoderMembersService } from 'src/shared/topcoder/members.service'; import { Role } from 'src/core/auth/auth.constants'; import { Roles, User } from 'src/core/auth/decorators'; -import { UserInfo } from 'src/dto/user.type'; - import { AdminService } from './admin.service'; import { ResponseDto, ResponseStatusType } from 'src/dto/api-response.dto'; import { WinningAuditDto, AuditPayoutDto } from './dto/audit.dto'; @@ -33,6 +31,7 @@ import { WinningAuditDto, AuditPayoutDto } from './dto/audit.dto'; import { WinningRequestDto, SearchWinningResult } from 'src/dto/winning.dto'; import { WinningsRepository } from '../repository/winnings.repo'; import { WinningUpdateRequestDto } from './dto/winnings.dto'; +import { AccessControlService } from 'src/shared/access-control'; @ApiTags('AdminWinnings') @Controller('/admin') @@ -42,20 +41,14 @@ export class AdminController { private readonly adminService: AdminService, private readonly winningsRepo: WinningsRepository, private readonly tcMembersService: TopcoderMembersService, + private readonly accessControlService: AccessControlService, ) {} - private isBaAdmin(user?: { roles?: string[] }) { - return (user?.roles || []).some( - (r) => - r && - r.trim().toLowerCase() === Role.PaymentBaAdmin.trim().toLowerCase(), - ); - } - @Post('/winnings/search') @Roles( Role.PaymentAdmin, Role.PaymentBaAdmin, + Role.EngagementPaymentApprover, Role.PaymentEditor, Role.PaymentViewer, ) @@ -77,13 +70,14 @@ export class AdminController { @Body() body: WinningRequestDto, @User() user: any, ): Promise> { - const result = await this.winningsRepo.searchWinnings( - await this.adminService.applyBaAdminUserFilters( + const filters = + await this.accessControlService.applyFilters( user.id, - this.isBaAdmin(user), + user.roles, body, - ), - ); + ); + + const result = await this.winningsRepo.searchWinnings(filters); if (result.error) { result.status = ResponseStatusType.ERROR; @@ -98,6 +92,7 @@ export class AdminController { @Roles( Role.PaymentAdmin, Role.PaymentBaAdmin, + Role.EngagementPaymentApprover, Role.PaymentEditor, Role.PaymentViewer, ) @@ -118,16 +113,16 @@ export class AdminController { @Header('Content-Type', 'text/csv') @Header('Content-Disposition', 'attachment; filename="winnings.csv"') async exportWinnings(@Body() body: WinningRequestDto, @User() user: any) { - const result = await this.winningsRepo.searchWinnings( - await this.adminService.applyBaAdminUserFilters( + const filters = + await this.accessControlService.applyFilters( user.id, - this.isBaAdmin(user), + user.roles, { ...body, limit: 999, }, - ), - ); + ); + const result = await this.winningsRepo.searchWinnings(filters); const handles = await this.tcMembersService.getHandlesByUserIds( result.data.winnings.map((d) => d.winnerId), @@ -181,7 +176,12 @@ export class AdminController { } @Patch('/winnings') - @Roles(Role.PaymentAdmin, Role.PaymentBaAdmin, Role.PaymentEditor) + @Roles( + Role.PaymentAdmin, + Role.PaymentBaAdmin, + Role.EngagementPaymentApprover, + Role.PaymentEditor, + ) @ApiOperation({ summary: 'Update winnings with given parameter', description: @@ -194,7 +194,7 @@ export class AdminController { }) async updateWinning( @Body() body: WinningUpdateRequestDto, - @User() user: UserInfo, + @User() user: any, ): Promise> { if ( !body.paymentAmount && @@ -210,7 +210,7 @@ export class AdminController { const result = await this.adminService.updateWinnings( body, user.id, - this.isBaAdmin(user), + user.roles, ); result.status = ResponseStatusType.SUCCESS; @@ -225,6 +225,7 @@ export class AdminController { @Roles( Role.PaymentAdmin, Role.PaymentBaAdmin, + Role.EngagementPaymentApprover, Role.PaymentEditor, Role.PaymentViewer, ) @@ -246,9 +247,11 @@ export class AdminController { @Param('winningID') winningId: string, @User() user: any, ): Promise> { - if (this.isBaAdmin(user)) { - await this.adminService.verifyBaAdminAccessToWinning(winningId, user.id); - } + await this.adminService.verifyUserAccessToWinning( + winningId, + user.id, + user.roles, + ); const result = await this.adminService.getWinningAudit(winningId); @@ -264,6 +267,7 @@ export class AdminController { @Roles( Role.PaymentAdmin, Role.PaymentBaAdmin, + Role.EngagementPaymentApprover, Role.PaymentEditor, Role.PaymentViewer, ) @@ -286,9 +290,11 @@ export class AdminController { @Param('winningID') winningId: string, @User() user: any, ): Promise> { - if (this.isBaAdmin(user)) { - await this.adminService.verifyBaAdminAccessToWinning(winningId, user.id); - } + await this.adminService.verifyUserAccessToWinning( + winningId, + user.id, + user.roles, + ); const result = await this.adminService.getWinningAuditPayout(winningId); diff --git a/src/api/admin/admin.module.ts b/src/api/admin/admin.module.ts index 2439f5c..6a9be9f 100644 --- a/src/api/admin/admin.module.ts +++ b/src/api/admin/admin.module.ts @@ -4,9 +4,10 @@ import { AdminService } from './admin.service'; import { WinningsRepository } from '../repository/winnings.repo'; import { TopcoderModule } from 'src/shared/topcoder/topcoder.module'; import { PaymentsModule } from 'src/shared/payments'; +import { AccessControlModule } from 'src/shared/access-control'; @Module({ - imports: [TopcoderModule, PaymentsModule], + imports: [TopcoderModule, PaymentsModule, AccessControlModule], controllers: [AdminController], providers: [AdminService, WinningsRepository], }) diff --git a/src/api/admin/admin.service.ts b/src/api/admin/admin.service.ts index 8326481..4c5de83 100644 --- a/src/api/admin/admin.service.ts +++ b/src/api/admin/admin.service.ts @@ -3,18 +3,19 @@ import { HttpStatus, NotFoundException, BadRequestException, + UnauthorizedException, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from 'src/shared/global/prisma.service'; import { PaymentsService } from 'src/shared/payments'; +import { AccessControlService } from 'src/shared/access-control/access-control.service'; import { ResponseDto } from 'src/dto/api-response.dto'; import { PaymentStatus } from 'src/dto/payment.dto'; import { WinningAuditDto, AuditPayoutDto } from './dto/audit.dto'; import { WinningUpdateRequestDto } from './dto/winnings.dto'; import { Logger } from 'src/shared/global'; -import { WinningRequestDto } from 'src/dto/winning.dto'; import { BillingAccountsService } from 'src/shared/topcoder/billing-accounts.service'; /** @@ -32,21 +33,19 @@ export class AdminService { private readonly prisma: PrismaService, private readonly paymentsService: PaymentsService, private readonly baService: BillingAccountsService, + private readonly accessControlService: AccessControlService, ) {} - async applyBaAdminUserFilters( + async verifyUserAccessToWinning( + winningsId: string, userId: string, - isBaAdmin?: boolean, - filters: WinningRequestDto = {}, - ) { - if (!isBaAdmin) { - return filters; + roles: string[] = [], + ): Promise { + try { + await this.accessControlService.verifyAccess(winningsId, userId, roles); + } catch (err) { + throw new UnauthorizedException(err?.message ?? 'access denied'); } - - return { - ...filters, - billingAccounts: await this.baService.getBillingAccountsForUser(userId), - }; } private getWinningById(winningId: string) { @@ -121,7 +120,7 @@ export class AdminService { async updateWinnings( body: WinningUpdateRequestDto, userId: string, - isBaAdmin?: boolean, + roles: string[] = [], ): Promise> { const result = new ResponseDto(); @@ -132,9 +131,7 @@ export class AdminService { ); this.logger.log(`updateWinnings payload: ${JSON.stringify(body)}`); - if (isBaAdmin) { - await this.verifyBaAdminAccessToWinning(body.winningsId, userId); - } + await this.verifyUserAccessToWinning(body.winningsId, userId, roles); try { const payments = await this.getPaymentsByWinningsId( diff --git a/src/core/auth/auth.constants.ts b/src/core/auth/auth.constants.ts index 6a5ec7c..7363eaf 100644 --- a/src/core/auth/auth.constants.ts +++ b/src/core/auth/auth.constants.ts @@ -2,6 +2,7 @@ export enum Role { Administrator = 'Administrator', PaymentAdmin = 'Payment Admin', PaymentBaAdmin = 'Payment BA Admin', + EngagementPaymentApprover = 'Engagement Payment Approver', PaymentEditor = 'Payment Editor', PaymentViewer = 'Payment Viewer', TaskManager = 'Task Manager', diff --git a/src/shared/access-control/access-control.module.ts b/src/shared/access-control/access-control.module.ts new file mode 100644 index 0000000..3014a2b --- /dev/null +++ b/src/shared/access-control/access-control.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; +import { AccessControlService } from 'src/shared/access-control/access-control.service'; +import { PaymentBaProvider } from 'src/shared/access-control/payment-ba.provider'; +import { EngagementPaymentApproverProvider } from 'src/shared/access-control/engagement-pa.provider'; +import { Injectable } from '@nestjs/common'; +import { TopcoderModule } from '../topcoder/topcoder.module'; + +@Injectable() +class AccessControlRegistrar { + constructor( + accessControlService: AccessControlService, + paymentBaProvider: PaymentBaProvider, + engagementPaymentApproverProvider: EngagementPaymentApproverProvider, + ) { + accessControlService.register(paymentBaProvider); + accessControlService.register(engagementPaymentApproverProvider); + } +} + +@Module({ + imports: [TopcoderModule], + controllers: [], + providers: [ + AccessControlService, + PaymentBaProvider, + EngagementPaymentApproverProvider, + AccessControlRegistrar, + ], + exports: [AccessControlService], +}) +export class AccessControlModule {} diff --git a/src/shared/access-control/access-control.service.ts b/src/shared/access-control/access-control.service.ts new file mode 100644 index 0000000..6e2fb41 --- /dev/null +++ b/src/shared/access-control/access-control.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { RoleAccessProvider } from './role-access.interface'; + +@Injectable() +export class AccessControlService { + private providers = new Map>(); + + register(provider: RoleAccessProvider) { + this.providers.set(provider.roleName.trim().toLowerCase(), provider); + } + + async applyFilters( + userId: string, + roles: string[] = [], + req: any, + ): Promise { + let out = { ...req }; + for (const r of roles || []) { + const p = this.providers.get(r?.trim().toLowerCase()); + if (p?.applyFilter) { + out = await p.applyFilter(userId, out); + } + } + return out as T; + } + + async verifyAccess(resourceId: string, userId: string, roles: string[] = []) { + for (const r of roles || []) { + const p = this.providers.get(r?.trim().toLowerCase()); + if (p?.verifyAccessToResource) { + await p.verifyAccessToResource(resourceId, userId); + } + } + } +} diff --git a/src/shared/access-control/engagement-pa.provider.ts b/src/shared/access-control/engagement-pa.provider.ts new file mode 100644 index 0000000..99fb01a --- /dev/null +++ b/src/shared/access-control/engagement-pa.provider.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { RoleAccessProvider } from './role-access.interface'; +import { PrismaService } from 'src/shared/global/prisma.service'; +import { Role } from 'src/core/auth/auth.constants'; +import { winnings_category } from '@prisma/client'; + +@Injectable() +export class EngagementPaymentApproverProvider implements RoleAccessProvider { + roleName = Role.EngagementPaymentApprover; + + constructor(private readonly prisma: PrismaService) {} + + // disable rule: prefer this format instead of returning resolved promise (required by interface) + // eslint-disable-next-line @typescript-eslint/require-await + async applyFilter(userId: string, req: any): Promise { + return { ...req, category: winnings_category.ENGAGEMENT_PAYMENT } as T; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async verifyAccessToResource(winningsId: string | string[], _userId: string) { + const winningsIds = ([] as string[]).concat(winningsId); + + const winnings = await this.prisma.winnings.findMany({ + where: { winning_id: { in: winningsIds } }, + select: { category: true }, + }); + + const unauthorized = winnings.filter( + (w) => w.category !== winnings_category.ENGAGEMENT_PAYMENT, + ); + if (unauthorized.length > 0) { + throw new Error( + `${Role.EngagementPaymentApprover} user is trying to access winning with category='${unauthorized.map(w => w.category).join(', ')}'`, + ); + } + } +} diff --git a/src/shared/access-control/index.ts b/src/shared/access-control/index.ts new file mode 100644 index 0000000..8dad96e --- /dev/null +++ b/src/shared/access-control/index.ts @@ -0,0 +1,4 @@ +export * from './access-control.module'; +export * from './access-control.service'; +export * from './payment-ba.provider'; +export * from './role-access.interface'; diff --git a/src/shared/access-control/payment-ba.provider.ts b/src/shared/access-control/payment-ba.provider.ts new file mode 100644 index 0000000..38a8a15 --- /dev/null +++ b/src/shared/access-control/payment-ba.provider.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { RoleAccessProvider } from './role-access.interface'; +import { BillingAccountsService } from 'src/shared/topcoder/billing-accounts.service'; +import { PrismaService } from 'src/shared/global/prisma.service'; +import { Role } from 'src/core/auth/auth.constants'; + +@Injectable() +export class PaymentBaProvider implements RoleAccessProvider { + roleName = Role.PaymentBaAdmin; + + constructor( + private readonly baService: BillingAccountsService, + private readonly prisma: PrismaService, + ) {} + + async applyFilter(userId: string, req: any): Promise { + const bas = await this.baService.getBillingAccountsForUser(userId); + return { ...req, billingAccounts: bas } as T; + } + + async verifyAccessToResource(winningsId: string, userId: string) { + const payments = await this.prisma.payment.findMany({ + where: { winnings_id: { equals: winningsId } }, + select: { billing_account: true }, + }); + + if (!payments || payments.length === 0) { + return; + } + + const allowedBAs = await this.baService.getBillingAccountsForUser(userId); + const paymentBAs = payments + .map((p) => p.billing_account) + .filter((b) => b !== null && b !== undefined); + + const unauthorized = paymentBAs.some((ba) => !allowedBAs.includes(`${ba}`)); + if (unauthorized) { + throw new Error( + 'BA admin user does not have access to the billing account for this winnings', + ); + } + } +} diff --git a/src/shared/access-control/role-access.interface.ts b/src/shared/access-control/role-access.interface.ts new file mode 100644 index 0000000..97c8e78 --- /dev/null +++ b/src/shared/access-control/role-access.interface.ts @@ -0,0 +1,5 @@ +export interface RoleAccessProvider { + roleName: string; + applyFilter?(userId: string, req: Req): Promise; + verifyAccessToResource?(resourceId: string, userId: string): Promise; +}