Skip to content

Commit 409b628

Browse files
authored
Merge pull request #142 from topcoder-platform/PM-3697_handle-engagement-payment-approver-role
PM-3697 handle engagement payment approver role
2 parents a4617c8 + da56556 commit 409b628

File tree

10 files changed

+206
-46
lines changed

10 files changed

+206
-46
lines changed

src/api/admin/admin.controller.ts

Lines changed: 35 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,14 @@ import { TopcoderMembersService } from 'src/shared/topcoder/members.service';
2424
import { Role } from 'src/core/auth/auth.constants';
2525
import { Roles, User } from 'src/core/auth/decorators';
2626

27-
import { UserInfo } from 'src/dto/user.type';
28-
2927
import { AdminService } from './admin.service';
3028
import { ResponseDto, ResponseStatusType } from 'src/dto/api-response.dto';
3129
import { WinningAuditDto, AuditPayoutDto } from './dto/audit.dto';
3230

3331
import { WinningRequestDto, SearchWinningResult } from 'src/dto/winning.dto';
3432
import { WinningsRepository } from '../repository/winnings.repo';
3533
import { WinningUpdateRequestDto } from './dto/winnings.dto';
34+
import { AccessControlService } from 'src/shared/access-control';
3635

3736
@ApiTags('AdminWinnings')
3837
@Controller('/admin')
@@ -42,20 +41,14 @@ export class AdminController {
4241
private readonly adminService: AdminService,
4342
private readonly winningsRepo: WinningsRepository,
4443
private readonly tcMembersService: TopcoderMembersService,
44+
private readonly accessControlService: AccessControlService,
4545
) {}
4646

47-
private isBaAdmin(user?: { roles?: string[] }) {
48-
return (user?.roles || []).some(
49-
(r) =>
50-
r &&
51-
r.trim().toLowerCase() === Role.PaymentBaAdmin.trim().toLowerCase(),
52-
);
53-
}
54-
5547
@Post('/winnings/search')
5648
@Roles(
5749
Role.PaymentAdmin,
5850
Role.PaymentBaAdmin,
51+
Role.EngagementPaymentApprover,
5952
Role.PaymentEditor,
6053
Role.PaymentViewer,
6154
)
@@ -77,13 +70,14 @@ export class AdminController {
7770
@Body() body: WinningRequestDto,
7871
@User() user: any,
7972
): Promise<ResponseDto<SearchWinningResult>> {
80-
const result = await this.winningsRepo.searchWinnings(
81-
await this.adminService.applyBaAdminUserFilters(
73+
const filters =
74+
await this.accessControlService.applyFilters<WinningRequestDto>(
8275
user.id,
83-
this.isBaAdmin(user),
76+
user.roles,
8477
body,
85-
),
86-
);
78+
);
79+
80+
const result = await this.winningsRepo.searchWinnings(filters);
8781

8882
if (result.error) {
8983
result.status = ResponseStatusType.ERROR;
@@ -98,6 +92,7 @@ export class AdminController {
9892
@Roles(
9993
Role.PaymentAdmin,
10094
Role.PaymentBaAdmin,
95+
Role.EngagementPaymentApprover,
10196
Role.PaymentEditor,
10297
Role.PaymentViewer,
10398
)
@@ -118,16 +113,16 @@ export class AdminController {
118113
@Header('Content-Type', 'text/csv')
119114
@Header('Content-Disposition', 'attachment; filename="winnings.csv"')
120115
async exportWinnings(@Body() body: WinningRequestDto, @User() user: any) {
121-
const result = await this.winningsRepo.searchWinnings(
122-
await this.adminService.applyBaAdminUserFilters(
116+
const filters =
117+
await this.accessControlService.applyFilters<WinningRequestDto>(
123118
user.id,
124-
this.isBaAdmin(user),
119+
user.roles,
125120
{
126121
...body,
127122
limit: 999,
128123
},
129-
),
130-
);
124+
);
125+
const result = await this.winningsRepo.searchWinnings(filters);
131126

132127
const handles = await this.tcMembersService.getHandlesByUserIds(
133128
result.data.winnings.map((d) => d.winnerId),
@@ -181,7 +176,12 @@ export class AdminController {
181176
}
182177

183178
@Patch('/winnings')
184-
@Roles(Role.PaymentAdmin, Role.PaymentBaAdmin, Role.PaymentEditor)
179+
@Roles(
180+
Role.PaymentAdmin,
181+
Role.PaymentBaAdmin,
182+
Role.EngagementPaymentApprover,
183+
Role.PaymentEditor,
184+
)
185185
@ApiOperation({
186186
summary: 'Update winnings with given parameter',
187187
description:
@@ -194,7 +194,7 @@ export class AdminController {
194194
})
195195
async updateWinning(
196196
@Body() body: WinningUpdateRequestDto,
197-
@User() user: UserInfo,
197+
@User() user: any,
198198
): Promise<ResponseDto<string>> {
199199
if (
200200
!body.paymentAmount &&
@@ -210,7 +210,7 @@ export class AdminController {
210210
const result = await this.adminService.updateWinnings(
211211
body,
212212
user.id,
213-
this.isBaAdmin(user),
213+
user.roles,
214214
);
215215

216216
result.status = ResponseStatusType.SUCCESS;
@@ -225,6 +225,7 @@ export class AdminController {
225225
@Roles(
226226
Role.PaymentAdmin,
227227
Role.PaymentBaAdmin,
228+
Role.EngagementPaymentApprover,
228229
Role.PaymentEditor,
229230
Role.PaymentViewer,
230231
)
@@ -246,9 +247,11 @@ export class AdminController {
246247
@Param('winningID') winningId: string,
247248
@User() user: any,
248249
): Promise<ResponseDto<WinningAuditDto[]>> {
249-
if (this.isBaAdmin(user)) {
250-
await this.adminService.verifyBaAdminAccessToWinning(winningId, user.id);
251-
}
250+
await this.adminService.verifyUserAccessToWinning(
251+
winningId,
252+
user.id,
253+
user.roles,
254+
);
252255

253256
const result = await this.adminService.getWinningAudit(winningId);
254257

@@ -264,6 +267,7 @@ export class AdminController {
264267
@Roles(
265268
Role.PaymentAdmin,
266269
Role.PaymentBaAdmin,
270+
Role.EngagementPaymentApprover,
267271
Role.PaymentEditor,
268272
Role.PaymentViewer,
269273
)
@@ -286,9 +290,11 @@ export class AdminController {
286290
@Param('winningID') winningId: string,
287291
@User() user: any,
288292
): Promise<ResponseDto<AuditPayoutDto[]>> {
289-
if (this.isBaAdmin(user)) {
290-
await this.adminService.verifyBaAdminAccessToWinning(winningId, user.id);
291-
}
293+
await this.adminService.verifyUserAccessToWinning(
294+
winningId,
295+
user.id,
296+
user.roles,
297+
);
292298

293299
const result = await this.adminService.getWinningAuditPayout(winningId);
294300

src/api/admin/admin.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import { AdminService } from './admin.service';
44
import { WinningsRepository } from '../repository/winnings.repo';
55
import { TopcoderModule } from 'src/shared/topcoder/topcoder.module';
66
import { PaymentsModule } from 'src/shared/payments';
7+
import { AccessControlModule } from 'src/shared/access-control';
78

89
@Module({
9-
imports: [TopcoderModule, PaymentsModule],
10+
imports: [TopcoderModule, PaymentsModule, AccessControlModule],
1011
controllers: [AdminController],
1112
providers: [AdminService, WinningsRepository],
1213
})

src/api/admin/admin.service.ts

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,19 @@ import {
33
HttpStatus,
44
NotFoundException,
55
BadRequestException,
6+
UnauthorizedException,
67
} from '@nestjs/common';
78

89
import { Prisma } from '@prisma/client';
910
import { PrismaService } from 'src/shared/global/prisma.service';
1011
import { PaymentsService } from 'src/shared/payments';
12+
import { AccessControlService } from 'src/shared/access-control/access-control.service';
1113

1214
import { ResponseDto } from 'src/dto/api-response.dto';
1315
import { PaymentStatus } from 'src/dto/payment.dto';
1416
import { WinningAuditDto, AuditPayoutDto } from './dto/audit.dto';
1517
import { WinningUpdateRequestDto } from './dto/winnings.dto';
1618
import { Logger } from 'src/shared/global';
17-
import { WinningRequestDto } from 'src/dto/winning.dto';
1819
import { BillingAccountsService } from 'src/shared/topcoder/billing-accounts.service';
1920

2021
/**
@@ -32,21 +33,19 @@ export class AdminService {
3233
private readonly prisma: PrismaService,
3334
private readonly paymentsService: PaymentsService,
3435
private readonly baService: BillingAccountsService,
36+
private readonly accessControlService: AccessControlService,
3537
) {}
3638

37-
async applyBaAdminUserFilters(
39+
async verifyUserAccessToWinning(
40+
winningsId: string,
3841
userId: string,
39-
isBaAdmin?: boolean,
40-
filters: WinningRequestDto = {},
41-
) {
42-
if (!isBaAdmin) {
43-
return filters;
42+
roles: string[] = [],
43+
): Promise<void> {
44+
try {
45+
await this.accessControlService.verifyAccess(winningsId, userId, roles);
46+
} catch (err) {
47+
throw new UnauthorizedException(err?.message ?? 'access denied');
4448
}
45-
46-
return {
47-
...filters,
48-
billingAccounts: await this.baService.getBillingAccountsForUser(userId),
49-
};
5049
}
5150

5251
private getWinningById(winningId: string) {
@@ -121,7 +120,7 @@ export class AdminService {
121120
async updateWinnings(
122121
body: WinningUpdateRequestDto,
123122
userId: string,
124-
isBaAdmin?: boolean,
123+
roles: string[] = [],
125124
): Promise<ResponseDto<string>> {
126125
const result = new ResponseDto<string>();
127126

@@ -132,9 +131,7 @@ export class AdminService {
132131
);
133132
this.logger.log(`updateWinnings payload: ${JSON.stringify(body)}`);
134133

135-
if (isBaAdmin) {
136-
await this.verifyBaAdminAccessToWinning(body.winningsId, userId);
137-
}
134+
await this.verifyUserAccessToWinning(body.winningsId, userId, roles);
138135

139136
try {
140137
const payments = await this.getPaymentsByWinningsId(

src/core/auth/auth.constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export enum Role {
22
Administrator = 'Administrator',
33
PaymentAdmin = 'Payment Admin',
44
PaymentBaAdmin = 'Payment BA Admin',
5+
EngagementPaymentApprover = 'Engagement Payment Approver',
56
PaymentEditor = 'Payment Editor',
67
PaymentViewer = 'Payment Viewer',
78
TaskManager = 'Task Manager',
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Module } from '@nestjs/common';
2+
import { AccessControlService } from 'src/shared/access-control/access-control.service';
3+
import { PaymentBaProvider } from 'src/shared/access-control/payment-ba.provider';
4+
import { EngagementPaymentApproverProvider } from 'src/shared/access-control/engagement-pa.provider';
5+
import { Injectable } from '@nestjs/common';
6+
import { TopcoderModule } from '../topcoder/topcoder.module';
7+
8+
@Injectable()
9+
class AccessControlRegistrar {
10+
constructor(
11+
accessControlService: AccessControlService,
12+
paymentBaProvider: PaymentBaProvider,
13+
engagementPaymentApproverProvider: EngagementPaymentApproverProvider,
14+
) {
15+
accessControlService.register(paymentBaProvider);
16+
accessControlService.register(engagementPaymentApproverProvider);
17+
}
18+
}
19+
20+
@Module({
21+
imports: [TopcoderModule],
22+
controllers: [],
23+
providers: [
24+
AccessControlService,
25+
PaymentBaProvider,
26+
EngagementPaymentApproverProvider,
27+
AccessControlRegistrar,
28+
],
29+
exports: [AccessControlService],
30+
})
31+
export class AccessControlModule {}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { RoleAccessProvider } from './role-access.interface';
3+
4+
@Injectable()
5+
export class AccessControlService {
6+
private providers = new Map<string, RoleAccessProvider<any>>();
7+
8+
register(provider: RoleAccessProvider) {
9+
this.providers.set(provider.roleName.trim().toLowerCase(), provider);
10+
}
11+
12+
async applyFilters<T>(
13+
userId: string,
14+
roles: string[] = [],
15+
req: any,
16+
): Promise<T> {
17+
let out = { ...req };
18+
for (const r of roles || []) {
19+
const p = this.providers.get(r?.trim().toLowerCase());
20+
if (p?.applyFilter) {
21+
out = await p.applyFilter(userId, out);
22+
}
23+
}
24+
return out as T;
25+
}
26+
27+
async verifyAccess(resourceId: string, userId: string, roles: string[] = []) {
28+
for (const r of roles || []) {
29+
const p = this.providers.get(r?.trim().toLowerCase());
30+
if (p?.verifyAccessToResource) {
31+
await p.verifyAccessToResource(resourceId, userId);
32+
}
33+
}
34+
}
35+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { RoleAccessProvider } from './role-access.interface';
3+
import { PrismaService } from 'src/shared/global/prisma.service';
4+
import { Role } from 'src/core/auth/auth.constants';
5+
import { winnings_category } from '@prisma/client';
6+
7+
@Injectable()
8+
export class EngagementPaymentApproverProvider implements RoleAccessProvider {
9+
roleName = Role.EngagementPaymentApprover;
10+
11+
constructor(private readonly prisma: PrismaService) {}
12+
13+
// disable rule: prefer this format instead of returning resolved promise (required by interface)
14+
// eslint-disable-next-line @typescript-eslint/require-await
15+
async applyFilter<T>(userId: string, req: any): Promise<T> {
16+
return { ...req, category: winnings_category.ENGAGEMENT_PAYMENT } as T;
17+
}
18+
19+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
20+
async verifyAccessToResource(winningsId: string | string[], _userId: string) {
21+
const winningsIds = ([] as string[]).concat(winningsId);
22+
23+
const winnings = await this.prisma.winnings.findMany({
24+
where: { winning_id: { in: winningsIds } },
25+
select: { category: true },
26+
});
27+
28+
const unauthorized = winnings.filter(
29+
(w) => w.category !== winnings_category.ENGAGEMENT_PAYMENT,
30+
);
31+
if (unauthorized.length > 0) {
32+
throw new Error(
33+
`${Role.EngagementPaymentApprover} user is trying to access winning with category='${unauthorized.map(w => w.category).join(', ')}'`,
34+
);
35+
}
36+
}
37+
}

src/shared/access-control/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './access-control.module';
2+
export * from './access-control.service';
3+
export * from './payment-ba.provider';
4+
export * from './role-access.interface';

0 commit comments

Comments
 (0)