diff --git a/apps/authz/src/app/http/rest/controller/admin.controller.ts b/apps/authz/src/app/http/rest/controller/admin.controller.ts index f87dea229..fcf674884 100644 --- a/apps/authz/src/app/http/rest/controller/admin.controller.ts +++ b/apps/authz/src/app/http/rest/controller/admin.controller.ts @@ -103,6 +103,7 @@ export class AdminController { return response } + // DONE @Post('/wallets') async registerWallet(@Body() body: RegisterWalletRequestDto) { const payload: RegisterWalletRequest = body @@ -113,6 +114,7 @@ export class AdminController { return response } + // DONE @Post('/wallet-groups') async assignWalletGroup(@Body() body: AssignWalletGroupRequestDto) { const payload: AssignWalletGroupRequest = body diff --git a/apps/orchestration/src/main.ts b/apps/orchestration/src/main.ts index c9fdacc53..82e979e16 100644 --- a/apps/orchestration/src/main.ts +++ b/apps/orchestration/src/main.ts @@ -23,7 +23,9 @@ const withSwagger = (app: INestApplication): INestApplication => { .setVersion('1.0') .build() ) - SwaggerModule.setup('docs', app, document) + SwaggerModule.setup('docs', app, document, { + customSiteTitle: 'Orchestration API' + }) return app } diff --git a/apps/orchestration/src/store/entity/__test__/e2e/wallet-group.spec.ts b/apps/orchestration/src/store/entity/__test__/e2e/wallet-group.spec.ts new file mode 100644 index 000000000..d941b71de --- /dev/null +++ b/apps/orchestration/src/store/entity/__test__/e2e/wallet-group.spec.ts @@ -0,0 +1,110 @@ +import { Action, OrganizationEntity, Signature } from '@narval/authz-shared' +import { HttpStatus, INestApplication } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { Test, TestingModule } from '@nestjs/testing' +import request from 'supertest' +import { generateSignature } from '../../../../__test__/fixture/authorization-request.fixture' +import { load } from '../../../../orchestration.config' +import { REQUEST_HEADER_ORG_ID } from '../../../../orchestration.constant' +import { PolicyEngineModule } from '../../../../policy-engine/policy-engine.module' +import { PersistenceModule } from '../../../../shared/module/persistence/persistence.module' +import { TestPrismaService } from '../../../../shared/module/persistence/service/test-prisma.service' +import { QueueModule } from '../../../../shared/module/queue/queue.module' +import { EntityStoreModule } from '../../entity-store.module' +import { OrganizationRepository } from '../../persistence/repository/organization.repository' +import { WalletGroupRepository } from '../../persistence/repository/wallet-group.repository' + +const API_RESOURCE_USER_ENTITY = '/store/wallet-groups' + +describe('Wallet Group Store', () => { + let app: INestApplication + let module: TestingModule + let testPrismaService: TestPrismaService + let orgRepository: OrganizationRepository + let walletGroupRepository: WalletGroupRepository + + const organization: OrganizationEntity = { + uid: 'ac1374c2-fd62-4b6e-bd49-a4afcdcb91cc' + } + + const authentication: Signature = generateSignature() + + const approvals: Signature[] = [generateSignature(), generateSignature()] + + const nonce = 'b6d826b4-72cb-4c14-a6ca-235a2d8e9060' + + const walletId = 'c7eac7d1-7572-4756-b52e-0caebe208364' + + const groupId = '2a1509ad-ea87-422e-bebd-974547cd4fee' + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + load: [load], + isGlobal: true + }), + PersistenceModule, + QueueModule.forRoot(), + PolicyEngineModule, + EntityStoreModule + ] + }).compile() + + testPrismaService = module.get(TestPrismaService) + orgRepository = module.get(OrganizationRepository) + walletGroupRepository = module.get(WalletGroupRepository) + + app = module.createNestApplication() + + await app.init() + }) + + afterAll(async () => { + await testPrismaService.truncateAll() + await module.close() + await app.close() + }) + + beforeEach(async () => { + await orgRepository.create(organization.uid) + }) + + afterEach(async () => { + await testPrismaService.truncateAll() + }) + + describe(`POST ${API_RESOURCE_USER_ENTITY}`, () => { + it('assigns a wallet to a group', async () => { + const payload = { + authentication, + approvals, + request: { + action: Action.ASSIGN_WALLET_GROUP, + nonce, + data: { walletId, groupId } + } + } + + const { status, body } = await request(app.getHttpServer()) + .post(API_RESOURCE_USER_ENTITY) + .set(REQUEST_HEADER_ORG_ID, organization.uid) + .send(payload) + + const actualGroup = await walletGroupRepository.findById(groupId) + + expect(body).toEqual({ + data: { + walletId, + groupId + } + }) + expect(status).toEqual(HttpStatus.CREATED) + + expect(actualGroup).toEqual({ + uid: groupId, + wallets: [walletId] + }) + }) + }) +}) diff --git a/apps/orchestration/src/store/entity/core/service/wallet.service.ts b/apps/orchestration/src/store/entity/core/service/wallet.service.ts index bcf813851..6e727aa36 100644 --- a/apps/orchestration/src/store/entity/core/service/wallet.service.ts +++ b/apps/orchestration/src/store/entity/core/service/wallet.service.ts @@ -1,12 +1,22 @@ -import { WalletEntity } from '@narval/authz-shared' +import { WalletEntity, WalletGroupMembership } from '@narval/authz-shared' import { Injectable } from '@nestjs/common' +import { WalletGroupRepository } from '../../persistence/repository/wallet-group.repository' import { WalletRepository } from '../../persistence/repository/wallet.repository' @Injectable() export class WalletService { - constructor(private walletRepository: WalletRepository) {} + constructor(private walletRepository: WalletRepository, private walletGroupRepository: WalletGroupRepository) {} async create(orgId: string, wallet: WalletEntity): Promise { return this.walletRepository.create(orgId, wallet) } + + async assignGroup(orgId: string, walletId: string, groupId: string): Promise { + await this.walletGroupRepository.maybeCreate(orgId, { + uid: groupId, + wallets: [walletId] + }) + + return { groupId, walletId } + } } diff --git a/apps/orchestration/src/store/entity/entity-store.module.ts b/apps/orchestration/src/store/entity/entity-store.module.ts index c4896c173..5876ca1c9 100644 --- a/apps/orchestration/src/store/entity/entity-store.module.ts +++ b/apps/orchestration/src/store/entity/entity-store.module.ts @@ -11,16 +11,18 @@ import { WalletService } from './core/service/wallet.service' import { OrganizationController } from './http/rest/controller/organization.controller' import { UserGroupController } from './http/rest/controller/user-group.controller' import { UserController } from './http/rest/controller/user.controller' +import { WalletGroupController } from './http/rest/controller/wallet-group.controller' import { WalletController } from './http/rest/controller/wallet.controller' import { AuthCredentialRepository } from './persistence/repository/auth-credential.repository' import { OrganizationRepository } from './persistence/repository/organization.repository' import { UserGroupRepository } from './persistence/repository/user-group.repository' import { UserRepository } from './persistence/repository/user.repository' +import { WalletGroupRepository } from './persistence/repository/wallet-group.repository' import { WalletRepository } from './persistence/repository/wallet.repository' @Module({ imports: [ConfigModule.forRoot({ load: [load] }), PersistenceModule, PolicyEngineModule], - controllers: [OrganizationController, UserController, UserGroupController, WalletController], + controllers: [OrganizationController, UserController, UserGroupController, WalletController, WalletGroupController], providers: [ OrganizationService, OrganizationRepository, @@ -31,6 +33,7 @@ import { WalletRepository } from './persistence/repository/wallet.repository' AuthCredentialRepository, WalletService, WalletRepository, + WalletGroupRepository, { provide: APP_INTERCEPTOR, useClass: ClassSerializerInterceptor diff --git a/apps/orchestration/src/store/entity/http/rest/controller/user-group.controller.ts b/apps/orchestration/src/store/entity/http/rest/controller/user-group.controller.ts index c72d68b40..16b73fcf8 100644 --- a/apps/orchestration/src/store/entity/http/rest/controller/user-group.controller.ts +++ b/apps/orchestration/src/store/entity/http/rest/controller/user-group.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, HttpStatus, Post } from '@nestjs/common' -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger' +import { ApiHeader, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger' +import { REQUEST_HEADER_ORG_ID } from '../../../../../orchestration.constant' import { OrgId } from '../../../../../shared/decorator/org-id.decorator' import { UserGroupService } from '../../../core/service/user-group.service' import { AssignUserGroupRequestDto } from '../dto/assign-user-group-request.dto' @@ -14,6 +15,9 @@ export class UserGroupController { @ApiOperation({ summary: "Assigns a user to a group. If the group doesn't exist, creates it first." }) + @ApiHeader({ + name: REQUEST_HEADER_ORG_ID + }) @ApiResponse({ status: HttpStatus.CREATED, type: AssignUserGroupResponseDto diff --git a/apps/orchestration/src/store/entity/http/rest/controller/user.controller.ts b/apps/orchestration/src/store/entity/http/rest/controller/user.controller.ts index 58399b274..bd2133f92 100644 --- a/apps/orchestration/src/store/entity/http/rest/controller/user.controller.ts +++ b/apps/orchestration/src/store/entity/http/rest/controller/user.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, HttpStatus, Patch, Post } from '@nestjs/common' -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger' +import { ApiHeader, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger' +import { REQUEST_HEADER_ORG_ID } from '../../../../../orchestration.constant' import { OrgId } from '../../../../../shared/decorator/org-id.decorator' import { UserService } from '../../../core/service/user.service' import { CreateUserRequestDto } from '../dto/create-user-request.dto' @@ -14,7 +15,10 @@ export class UserController { @Post() @ApiOperation({ - summary: 'Creates a new user entity' + summary: 'Creates a new user entity.' + }) + @ApiHeader({ + name: REQUEST_HEADER_ORG_ID }) @ApiResponse({ status: HttpStatus.CREATED, @@ -32,7 +36,10 @@ export class UserController { @Patch('/:uid') @ApiOperation({ - summary: 'Updates an existing user' + summary: 'Updates an existing user.' + }) + @ApiHeader({ + name: REQUEST_HEADER_ORG_ID }) @ApiResponse({ status: HttpStatus.OK, diff --git a/apps/orchestration/src/store/entity/http/rest/controller/wallet-group.controller.ts b/apps/orchestration/src/store/entity/http/rest/controller/wallet-group.controller.ts new file mode 100644 index 000000000..870f91dd5 --- /dev/null +++ b/apps/orchestration/src/store/entity/http/rest/controller/wallet-group.controller.ts @@ -0,0 +1,37 @@ +import { Body, Controller, HttpStatus, Post } from '@nestjs/common' +import { ApiHeader, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger' +import { REQUEST_HEADER_ORG_ID } from '../../../../../orchestration.constant' +import { OrgId } from '../../../../../shared/decorator/org-id.decorator' +import { WalletService } from '../../../core/service/wallet.service' +import { AssignWalletGroupRequestDto } from '../dto/assign-wallet-group-request.dto' +import { AssignWalletGroupResponseDto } from '../dto/assign-wallet-group-response.dto' + +@Controller('/store/wallet-groups') +@ApiTags('Entity Store') +export class WalletGroupController { + constructor(private walletService: WalletService) {} + + @Post() + @ApiOperation({ + summary: "Assigns a wallet to a group. If the group doesn't exist, creates it first." + }) + @ApiHeader({ + name: REQUEST_HEADER_ORG_ID + }) + @ApiResponse({ + status: HttpStatus.CREATED, + type: AssignWalletGroupResponseDto + }) + async assign( + @OrgId() orgId: string, + @Body() body: AssignWalletGroupRequestDto + ): Promise { + const membership = await this.walletService.assignGroup( + orgId, + body.request.data.walletId, + body.request.data.groupId + ) + + return new AssignWalletGroupResponseDto({ data: membership }) + } +} diff --git a/apps/orchestration/src/store/entity/http/rest/controller/wallet.controller.ts b/apps/orchestration/src/store/entity/http/rest/controller/wallet.controller.ts index 927df6664..9c0b6be63 100644 --- a/apps/orchestration/src/store/entity/http/rest/controller/wallet.controller.ts +++ b/apps/orchestration/src/store/entity/http/rest/controller/wallet.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, HttpStatus, Post } from '@nestjs/common' -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger' +import { ApiHeader, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger' +import { REQUEST_HEADER_ORG_ID } from '../../../../../orchestration.constant' import { OrgId } from '../../../../../shared/decorator/org-id.decorator' import { WalletService } from '../../../core/service/wallet.service' import { RegisterWalletRequestDto } from '../dto/register-wallet-request.dto' @@ -12,7 +13,10 @@ export class WalletController { @Post() @ApiOperation({ - summary: 'Registers wallet as an entity' + summary: 'Registers wallet as an entity.' + }) + @ApiHeader({ + name: REQUEST_HEADER_ORG_ID }) @ApiResponse({ status: HttpStatus.CREATED, diff --git a/apps/orchestration/src/store/entity/http/rest/dto/assign-wallet-group-request.dto.ts b/apps/orchestration/src/store/entity/http/rest/dto/assign-wallet-group-request.dto.ts new file mode 100644 index 000000000..de9e65514 --- /dev/null +++ b/apps/orchestration/src/store/entity/http/rest/dto/assign-wallet-group-request.dto.ts @@ -0,0 +1,34 @@ +import { Action, BaseAdminRequestPayloadDto } from '@narval/authz-shared' +import { ApiProperty } from '@nestjs/swagger' +import { Type } from 'class-transformer' +import { IsDefined, IsEnum, ValidateNested } from 'class-validator' +import { BaseActionDto } from './base-action.dto' +import { WalletGroupMembershipDto } from './wallet-group-membership.dto' + +class AssignWalletGroupActionDto extends BaseActionDto { + @IsEnum(Action) + @IsDefined() + @ApiProperty({ + enum: Object.values(Action), + default: Action.ASSIGN_WALLET_GROUP + }) + action: typeof Action.ASSIGN_WALLET_GROUP + + @IsDefined() + @Type(() => WalletGroupMembershipDto) + @ValidateNested() + @ApiProperty({ + type: WalletGroupMembershipDto + }) + data: WalletGroupMembershipDto +} + +export class AssignWalletGroupRequestDto extends BaseAdminRequestPayloadDto { + @IsDefined() + @Type(() => AssignWalletGroupActionDto) + @ValidateNested() + @ApiProperty({ + type: AssignWalletGroupActionDto + }) + request: AssignWalletGroupActionDto +} diff --git a/apps/orchestration/src/store/entity/http/rest/dto/assign-wallet-group-response.dto.ts b/apps/orchestration/src/store/entity/http/rest/dto/assign-wallet-group-response.dto.ts new file mode 100644 index 000000000..b2e5988e7 --- /dev/null +++ b/apps/orchestration/src/store/entity/http/rest/dto/assign-wallet-group-response.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger' +import { Type } from 'class-transformer' +import { IsDefined, ValidateNested } from 'class-validator' +import { WalletGroupMembershipDto } from './wallet-group-membership.dto' + +export class AssignWalletGroupResponseDto { + @IsDefined() + @Type(() => WalletGroupMembershipDto) + @ValidateNested() + @ApiProperty({ + type: WalletGroupMembershipDto + }) + data: WalletGroupMembershipDto + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} diff --git a/apps/orchestration/src/store/entity/http/rest/dto/wallet-dto.ts b/apps/orchestration/src/store/entity/http/rest/dto/wallet-dto.ts index c79e562fd..2d3e48a4b 100644 --- a/apps/orchestration/src/store/entity/http/rest/dto/wallet-dto.ts +++ b/apps/orchestration/src/store/entity/http/rest/dto/wallet-dto.ts @@ -17,6 +17,7 @@ export class WalletDto { @IsEthereumAddress() @IsNotEmpty() @ApiProperty({ + type: String, format: 'address' }) address: Address diff --git a/apps/orchestration/src/store/entity/http/rest/dto/wallet-group-membership.dto.ts b/apps/orchestration/src/store/entity/http/rest/dto/wallet-group-membership.dto.ts new file mode 100644 index 000000000..a3cbdf285 --- /dev/null +++ b/apps/orchestration/src/store/entity/http/rest/dto/wallet-group-membership.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger' +import { IsNotEmpty, IsString } from 'class-validator' + +export class WalletGroupMembershipDto { + @IsString() + @IsNotEmpty() + @ApiProperty() + walletId: string + + @IsString() + @IsNotEmpty() + @ApiProperty() + groupId: string + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} diff --git a/apps/orchestration/src/store/entity/persistence/repository/wallet-group.repository.ts b/apps/orchestration/src/store/entity/persistence/repository/wallet-group.repository.ts new file mode 100644 index 000000000..365445e32 --- /dev/null +++ b/apps/orchestration/src/store/entity/persistence/repository/wallet-group.repository.ts @@ -0,0 +1,76 @@ +import { WalletGroupEntity } from '@narval/authz-shared' +import { Injectable } from '@nestjs/common' +import { PrismaService } from '../../../../shared/module/persistence/service/prisma.service' + +@Injectable() +export class WalletGroupRepository { + constructor(private prismaService: PrismaService) {} + + async maybeCreate(orgId: string, walletGroup: WalletGroupEntity): Promise { + await this.prismaService.$transaction(async (tx) => { + const group = await tx.walletGroupEntity.findUnique({ + where: { uid: walletGroup.uid } + }) + + if (!group) { + await tx.walletGroupEntity.create({ + data: { + orgId, + uid: walletGroup.uid + } + }) + } + + if (walletGroup.wallets.length) { + await this.enroll(walletGroup.uid, walletGroup.wallets) + } + }) + + return walletGroup + } + + async update(walletGroup: WalletGroupEntity): Promise { + if (walletGroup.wallets.length) { + await this.enroll(walletGroup.uid, walletGroup.wallets) + } + + return walletGroup + } + + private async enroll(groupId: string, walletIds: string[]): Promise { + try { + const memberships = walletIds.map((walletId) => ({ + wallet: walletId, + group: groupId + })) + + await this.prismaService.walletGroupMembership.createMany({ + data: memberships, + skipDuplicates: true + }) + + return true + } catch (error) { + return false + } + } + + async findById(uid: string): Promise { + const group = await this.prismaService.walletGroupEntity.findUnique({ + where: { uid } + }) + + if (group) { + const wallets = await this.prismaService.walletGroupMembership.findMany({ + where: { group: uid } + }) + + return { + uid: group.uid, + wallets: wallets.map(({ wallet }) => wallet) + } + } + + return null + } +}