From 4cefd431ba5de7128bebc4861239f1dd3aea7e2c Mon Sep 17 00:00:00 2001 From: Matt Schoch Date: Mon, 5 Feb 2024 15:55:38 -0500 Subject: [PATCH 1/5] POST /address-book --- apps/authz/src/app/core/admin.service.ts | 9 ++++- .../http/rest/controller/admin.controller.ts | 13 +++++++ .../http/rest/dto/address-book-account-dto.ts | 35 +++++++++++++++++++ .../dto/create-address-book-request.dto.ts | 28 +++++++++++++++ .../dto/create-address-book-response.dto.ts | 15 ++++++++ .../repository/admin.repository.ts | 25 ++++++++++++- .../module/persistence/schema/schema.prisma | 4 +-- apps/authz/src/shared/types/entities.types.ts | 6 ++-- .../authz-shared/src/lib/type/action.type.ts | 23 ++++++++++++ 9 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 apps/authz/src/app/http/rest/dto/address-book-account-dto.ts create mode 100644 apps/authz/src/app/http/rest/dto/create-address-book-request.dto.ts create mode 100644 apps/authz/src/app/http/rest/dto/create-address-book-response.dto.ts diff --git a/apps/authz/src/app/core/admin.service.ts b/apps/authz/src/app/core/admin.service.ts index 125c9b607..298397074 100644 --- a/apps/authz/src/app/core/admin.service.ts +++ b/apps/authz/src/app/core/admin.service.ts @@ -1,10 +1,11 @@ import { AdminRepository } from '@app/authz/app/persistence/repository/admin.repository' -import { Organization, User, Wallet } from '@app/authz/shared/types/entities.types' +import { AddressBookAccount, Organization, User, Wallet } from '@app/authz/shared/types/entities.types' import { AssignUserGroupRequest, AssignUserWalletRequest, AssignWalletGroupRequest, AuthCredential, + CreateAddressBookAccountRequest, CreateCredentialRequest, CreateOrganizationRequest, CreateUserRequest, @@ -84,4 +85,10 @@ export class AdminService { return payload.request.data } + + async createAddressBookAccount(payload: CreateAddressBookAccountRequest): Promise { + await this.adminRepository.createAddressBookAccount(payload.request.account) + + return payload.request.account + } } 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 85afb22aa..2533fc53d 100644 --- a/apps/authz/src/app/http/rest/controller/admin.controller.ts +++ b/apps/authz/src/app/http/rest/controller/admin.controller.ts @@ -5,6 +5,8 @@ import { AssignUserWalletRequestDto } from '@app/authz/app/http/rest/dto/assign- import { AssignUserWalletResponseDto } from '@app/authz/app/http/rest/dto/assign-user-wallet-response.dto' import { AssignWalletGroupRequestDto } from '@app/authz/app/http/rest/dto/assign-wallet-group-request.dto' import { AssignWalletGroupResponseDto } from '@app/authz/app/http/rest/dto/assign-wallet-group-response.dto' +import { CreateAddressBookAccountRequestDto } from '@app/authz/app/http/rest/dto/create-address-book-request.dto' +import { CreateAddressBookAccountResponseDto } from '@app/authz/app/http/rest/dto/create-address-book-response.dto' import { CreateCredentialRequestDto } from '@app/authz/app/http/rest/dto/create-credential-request.dto' import { CreateCredentialResponseDto } from '@app/authz/app/http/rest/dto/create-credential-response.dto' import { CreateOrganizationRequestDto } from '@app/authz/app/http/rest/dto/create-organization-request.dto' @@ -19,6 +21,7 @@ import { AssignUserGroupRequest, AssignUserWalletRequest, AssignWalletGroupRequest, + CreateAddressBookAccountRequest, CreateCredentialRequest, CreateOrganizationRequest, CreateUserRequest, @@ -112,4 +115,14 @@ export class AdminController { const response = new AssignUserWalletResponseDto(userWallet) return response } + + @Post('/address-book') + async createAddressBookEntry(@Body() body: CreateAddressBookAccountRequestDto) { + const payload: CreateAddressBookAccountRequest = body + + const addressBookAccount = await this.adminService.createAddressBookAccount(payload) + + const response = new CreateAddressBookAccountResponseDto(addressBookAccount) + return response + } } diff --git a/apps/authz/src/app/http/rest/dto/address-book-account-dto.ts b/apps/authz/src/app/http/rest/dto/address-book-account-dto.ts new file mode 100644 index 000000000..7b7f79e17 --- /dev/null +++ b/apps/authz/src/app/http/rest/dto/address-book-account-dto.ts @@ -0,0 +1,35 @@ +import { AddressBookAccount } from '@app/authz/shared/types/entities.types' +import { AccountClassification, Address } from '@narval/authz-shared' +import { ApiProperty } from '@nestjs/swagger' +import { IsDefined, IsIn, IsNumber, IsString } from 'class-validator' + +export class AddressBookAccountDataDto { + constructor(addressBookAccount: AddressBookAccount) { + this.uid = addressBookAccount.uid + this.classification = addressBookAccount.classification + this.address = addressBookAccount.address + this.chainId = addressBookAccount.chainId + } + + @IsString() + @IsDefined() + @ApiProperty() + uid: string + + @IsIn(Object.values(AccountClassification)) + @IsDefined() + @ApiProperty({ + enum: Object.values(AccountClassification) + }) + classification: AccountClassification + + @IsString() + @IsDefined() + @ApiProperty() + address: Address + + @IsNumber() + @IsDefined() + @ApiProperty() + chainId: number +} diff --git a/apps/authz/src/app/http/rest/dto/create-address-book-request.dto.ts b/apps/authz/src/app/http/rest/dto/create-address-book-request.dto.ts new file mode 100644 index 000000000..29965bc95 --- /dev/null +++ b/apps/authz/src/app/http/rest/dto/create-address-book-request.dto.ts @@ -0,0 +1,28 @@ +import { AddressBookAccountDataDto } from '@app/authz/app/http/rest/dto/address-book-account-dto' +import { BaseActionDto } from '@app/authz/app/http/rest/dto/base-action.dto' +import { BaseAdminRequestPayloadDto } from '@app/authz/app/http/rest/dto/base-admin-request-payload.dto' +import { Action } from '@narval/authz-shared' +import { ApiProperty } from '@nestjs/swagger' +import { IsDefined, IsIn, ValidateNested } from 'class-validator' + +class CreateAddressBookAccountActionDto extends BaseActionDto { + @IsIn(Object.values(Action)) + @IsDefined() + @ApiProperty({ + enum: Object.values(Action), + default: Action.CREATE_ADDRESS_BOOK_ACCOUNT + }) + action: typeof Action.CREATE_ADDRESS_BOOK_ACCOUNT + + @IsDefined() + @ValidateNested() + @ApiProperty() + account: AddressBookAccountDataDto +} + +export class CreateAddressBookAccountRequestDto extends BaseAdminRequestPayloadDto { + @IsDefined() + @ValidateNested() + @ApiProperty() + request: CreateAddressBookAccountActionDto +} diff --git a/apps/authz/src/app/http/rest/dto/create-address-book-response.dto.ts b/apps/authz/src/app/http/rest/dto/create-address-book-response.dto.ts new file mode 100644 index 000000000..b50fed536 --- /dev/null +++ b/apps/authz/src/app/http/rest/dto/create-address-book-response.dto.ts @@ -0,0 +1,15 @@ +import { AddressBookAccountDataDto } from '@app/authz/app/http/rest/dto/address-book-account-dto' +import { AddressBookAccount } from '@app/authz/shared/types/entities.types' +import { ApiProperty } from '@nestjs/swagger' +import { IsDefined, ValidateNested } from 'class-validator' + +export class CreateAddressBookAccountResponseDto { + constructor(account: AddressBookAccount) { + this.account = new AddressBookAccountDataDto(account) + } + + @IsDefined() + @ValidateNested() + @ApiProperty() + account: AddressBookAccountDataDto +} diff --git a/apps/authz/src/app/persistence/repository/admin.repository.ts b/apps/authz/src/app/persistence/repository/admin.repository.ts index 70f06409a..91b6a2345 100644 --- a/apps/authz/src/app/persistence/repository/admin.repository.ts +++ b/apps/authz/src/app/persistence/repository/admin.repository.ts @@ -1,5 +1,5 @@ import { PrismaService } from '@app/authz/shared/module/persistence/service/prisma.service' -import { Organization, RegoData, User, Wallet } from '@app/authz/shared/types/entities.types' +import { AddressBookAccount, Organization, RegoData, User, Wallet } from '@app/authz/shared/types/entities.types' import { AccountType, Address, Alg, AuthCredential, UserRole } from '@narval/authz-shared' import { Injectable, Logger, OnModuleInit } from '@nestjs/common' import { mockEntityData, userAddressStore, userCredentialStore } from './mock_data' @@ -381,5 +381,28 @@ export class AdminRepository implements OnModuleInit { return true } + async createAddressBookAccount(account: AddressBookAccount): Promise { + await this.prismaService.addressBookAccount.create({ + data: { + uid: account.uid, + address: account.address, + classification: account.classification, + chainId: account.chainId + } + }) + + return true + } + + async deleteAddressBookAccount(uid: string): Promise { + await this.prismaService.addressBookAccount.delete({ + where: { + uid + } + }) + + return true + } + async registerRootKey() {} } diff --git a/apps/authz/src/shared/module/persistence/schema/schema.prisma b/apps/authz/src/shared/module/persistence/schema/schema.prisma index 75dd32e9c..7ef4aa818 100644 --- a/apps/authz/src/shared/module/persistence/schema/schema.prisma +++ b/apps/authz/src/shared/module/persistence/schema/schema.prisma @@ -81,7 +81,7 @@ model UserWalletAssignment { model AddressBookAccount { uid String @id address String - chainId String @map("chain_id") + chainId Int @map("chain_id") classification String @@map("address_book_account") @@ -91,7 +91,7 @@ model Token { uid String @id address String symbol String - chainId String @map("chain_id") + chainId Int @map("chain_id") decimals Int @@map("token") diff --git a/apps/authz/src/shared/types/entities.types.ts b/apps/authz/src/shared/types/entities.types.ts index c5b0b909a..4e5cb528d 100644 --- a/apps/authz/src/shared/types/entities.types.ts +++ b/apps/authz/src/shared/types/entities.types.ts @@ -1,4 +1,4 @@ -import { AccountType, Address, UserRole } from '@narval/authz-shared' +import { AccountClassification, AccountType, Address, UserRole } from '@narval/authz-shared' // ENTITIES: user, user group, wallet, wallet group, and address book. export type Organization = { @@ -30,9 +30,9 @@ export type WalletGroup = { export type AddressBookAccount = { uid: string - address: string + address: Address chainId: number - classification: string + classification: AccountClassification } export type Token = { diff --git a/packages/authz-shared/src/lib/type/action.type.ts b/packages/authz-shared/src/lib/type/action.type.ts index d781c1a42..c1bdd6f02 100644 --- a/packages/authz-shared/src/lib/type/action.type.ts +++ b/packages/authz-shared/src/lib/type/action.type.ts @@ -11,6 +11,7 @@ export const Action = { DELETE_USER: 'user:delete', REGISTER_WALLET: 'REGISTER_WALLET', + CREATE_ADDRESS_BOOK_ACCOUNT: 'CREATE_ADDRESS_BOOK_ACCOUNT', EDIT_WALLET: 'wallet:edit', UNASSIGN_WALLET: 'wallet:unassign', @@ -50,6 +51,14 @@ export const AccountType = { } as const export type AccountType = (typeof AccountType)[keyof typeof AccountType] +export const AccountClassification = { + EXTERNAL: 'external', + COUNTERPARTY: 'counterparty', + INTERNAL: 'internal', + WALLET: 'wallet' +} as const +export type AccountClassification = (typeof AccountClassification)[keyof typeof AccountClassification] + export type UserGroupMembership = { userId: string groupId: string @@ -225,3 +234,17 @@ export type AssignUserWalletAction = BaseAction & { export type AssignUserWalletRequest = BaseAdminRequest & { request: AssignUserWalletAction } + +export type CreateAddressBookAccountAction = BaseAction & { + action: typeof Action.CREATE_ADDRESS_BOOK_ACCOUNT + account: { + uid: string + address: Address + chainId: number + classification: AccountClassification + } +} + +export type CreateAddressBookAccountRequest = BaseAdminRequest & { + request: CreateAddressBookAccountAction +} \ No newline at end of file From 5eb995446564ea8cc9692e050c19f5f5877cdebf Mon Sep 17 00:00:00 2001 From: Matt Schoch Date: Mon, 5 Feb 2024 17:27:13 -0500 Subject: [PATCH 2/5] POST /tokens --- apps/authz/src/app/core/admin.service.ts | 9 +++- .../http/rest/controller/admin.controller.ts | 12 ++++++ .../app/http/rest/dto/register-token-dto.ts | 39 +++++++++++++++++ .../rest/dto/register-tokens-request.dto.ts | 29 +++++++++++++ .../rest/dto/register-tokens-response.dto.ts | 16 +++++++ .../repository/admin.repository.ts | 42 ++++++++++++++++++- apps/authz/src/shared/types/entities.types.ts | 2 +- .../authz-shared/src/lib/type/action.type.ts | 16 +++++++ 8 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 apps/authz/src/app/http/rest/dto/register-token-dto.ts create mode 100644 apps/authz/src/app/http/rest/dto/register-tokens-request.dto.ts create mode 100644 apps/authz/src/app/http/rest/dto/register-tokens-response.dto.ts diff --git a/apps/authz/src/app/core/admin.service.ts b/apps/authz/src/app/core/admin.service.ts index 298397074..e26546a27 100644 --- a/apps/authz/src/app/core/admin.service.ts +++ b/apps/authz/src/app/core/admin.service.ts @@ -1,5 +1,5 @@ import { AdminRepository } from '@app/authz/app/persistence/repository/admin.repository' -import { AddressBookAccount, Organization, User, Wallet } from '@app/authz/shared/types/entities.types' +import { AddressBookAccount, Organization, Token, User, Wallet } from '@app/authz/shared/types/entities.types' import { AssignUserGroupRequest, AssignUserWalletRequest, @@ -9,6 +9,7 @@ import { CreateCredentialRequest, CreateOrganizationRequest, CreateUserRequest, + RegisterTokensRequest, RegisterWalletRequest, UpdateUserRequest, UserGroupMembership, @@ -91,4 +92,10 @@ export class AdminService { return payload.request.account } + + async registerTokens(payload: RegisterTokensRequest): Promise { + await this.adminRepository.registerTokens(payload.request.tokens) + + return payload.request.tokens + } } 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 2533fc53d..75d974e23 100644 --- a/apps/authz/src/app/http/rest/controller/admin.controller.ts +++ b/apps/authz/src/app/http/rest/controller/admin.controller.ts @@ -13,6 +13,8 @@ import { CreateOrganizationRequestDto } from '@app/authz/app/http/rest/dto/creat import { CreateOrganizationResponseDto } from '@app/authz/app/http/rest/dto/create-organization-response.dto' import { CreateUserRequestDto } from '@app/authz/app/http/rest/dto/create-user-request.dto' import { CreateUserResponseDto } from '@app/authz/app/http/rest/dto/create-user-response.dto' +import { RegisterTokensRequestDto } from '@app/authz/app/http/rest/dto/register-tokens-request.dto' +import { RegisterTokensResponseDto } from '@app/authz/app/http/rest/dto/register-tokens-response.dto' import { RegisterWalletRequestDto } from '@app/authz/app/http/rest/dto/register-wallet-request.dto' import { RegisterWalletResponseDto } from '@app/authz/app/http/rest/dto/register-wallet-response.dto' import { UpdateUserRequestDto } from '@app/authz/app/http/rest/dto/update-user-request.dto' @@ -25,6 +27,7 @@ import { CreateCredentialRequest, CreateOrganizationRequest, CreateUserRequest, + RegisterTokensRequest, RegisterWalletRequest, UpdateUserRequest } from '@narval/authz-shared' @@ -125,4 +128,13 @@ export class AdminController { const response = new CreateAddressBookAccountResponseDto(addressBookAccount) return response } + + @Post('/tokens') + async registerTokens(@Body() body: RegisterTokensRequestDto) { + const payload: RegisterTokensRequest = body + const tokens = await this.adminService.registerTokens(payload) + + const response = new RegisterTokensResponseDto(tokens) + return response + } } diff --git a/apps/authz/src/app/http/rest/dto/register-token-dto.ts b/apps/authz/src/app/http/rest/dto/register-token-dto.ts new file mode 100644 index 000000000..f814ba431 --- /dev/null +++ b/apps/authz/src/app/http/rest/dto/register-token-dto.ts @@ -0,0 +1,39 @@ +import { Token } from '@app/authz/shared/types/entities.types' +import { Address } from '@narval/authz-shared' +import { ApiProperty } from '@nestjs/swagger' +import { IsDefined, IsNumber, IsString } from 'class-validator' + +export class TokenDataDto { + constructor(token: Token) { + this.uid = token.uid + this.address = token.address + this.chainId = token.chainId + this.symbol = token.symbol + this.decimals = token.decimals + } + + @IsString() + @IsDefined() + @ApiProperty() + uid: string + + @IsString() + @IsDefined() + @ApiProperty() + address: Address + + @IsNumber() + @IsDefined() + @ApiProperty() + chainId: number + + @IsString() + @IsDefined() + @ApiProperty() + symbol: string + + @IsNumber() + @IsDefined() + @ApiProperty() + decimals: number +} diff --git a/apps/authz/src/app/http/rest/dto/register-tokens-request.dto.ts b/apps/authz/src/app/http/rest/dto/register-tokens-request.dto.ts new file mode 100644 index 000000000..7ec91e4ec --- /dev/null +++ b/apps/authz/src/app/http/rest/dto/register-tokens-request.dto.ts @@ -0,0 +1,29 @@ +import { BaseActionDto } from '@app/authz/app/http/rest/dto/base-action.dto' +import { BaseAdminRequestPayloadDto } from '@app/authz/app/http/rest/dto/base-admin-request-payload.dto' +import { TokenDataDto } from '@app/authz/app/http/rest/dto/register-token-dto' +import { Action } from '@narval/authz-shared' +import { ApiProperty } from '@nestjs/swagger' +import { IsArray, IsDefined, IsIn, ValidateNested } from 'class-validator' + +class RegisterTokensActionDto extends BaseActionDto { + @IsIn(Object.values(Action)) + @IsDefined() + @ApiProperty({ + enum: Object.values(Action), + default: Action.REGISTER_TOKENS + }) + action: typeof Action.REGISTER_TOKENS + + @IsDefined() + @ValidateNested() + @IsArray() + @ApiProperty() + tokens: TokenDataDto[] +} + +export class RegisterTokensRequestDto extends BaseAdminRequestPayloadDto { + @IsDefined() + @ValidateNested() + @ApiProperty() + request: RegisterTokensActionDto +} diff --git a/apps/authz/src/app/http/rest/dto/register-tokens-response.dto.ts b/apps/authz/src/app/http/rest/dto/register-tokens-response.dto.ts new file mode 100644 index 000000000..8467cf09b --- /dev/null +++ b/apps/authz/src/app/http/rest/dto/register-tokens-response.dto.ts @@ -0,0 +1,16 @@ +import { TokenDataDto } from '@app/authz/app/http/rest/dto/register-token-dto' +import { Token } from '@app/authz/shared/types/entities.types' +import { ApiProperty } from '@nestjs/swagger' +import { IsArray, IsDefined, ValidateNested } from 'class-validator' + +export class RegisterTokensResponseDto { + constructor(tokens: Token[]) { + this.tokens = tokens.map((token) => new TokenDataDto(token)) + } + + @IsDefined() + @ValidateNested() + @IsArray() + @ApiProperty() + tokens: TokenDataDto[] +} diff --git a/apps/authz/src/app/persistence/repository/admin.repository.ts b/apps/authz/src/app/persistence/repository/admin.repository.ts index 91b6a2345..c57741a05 100644 --- a/apps/authz/src/app/persistence/repository/admin.repository.ts +++ b/apps/authz/src/app/persistence/repository/admin.repository.ts @@ -1,7 +1,8 @@ import { PrismaService } from '@app/authz/shared/module/persistence/service/prisma.service' -import { AddressBookAccount, Organization, RegoData, User, Wallet } from '@app/authz/shared/types/entities.types' +import { AddressBookAccount, Organization, RegoData, Token, User, Wallet } from '@app/authz/shared/types/entities.types' import { AccountType, Address, Alg, AuthCredential, UserRole } from '@narval/authz-shared' import { Injectable, Logger, OnModuleInit } from '@nestjs/common' +import { castArray } from 'lodash/fp' import { mockEntityData, userAddressStore, userCredentialStore } from './mock_data' function convertResponse( @@ -404,5 +405,44 @@ export class AdminRepository implements OnModuleInit { return true } + async registerTokens(tokens: Token | Token[]): Promise { + await this.prismaService.$transaction(async (txn) => { + await Promise.all( + castArray(tokens).map(async (token) => { + await txn.token.upsert({ + create: { + uid: token.uid, + symbol: token.symbol, + address: token.address, + chainId: token.chainId, + decimals: token.decimals + }, + update: { + symbol: token.symbol, + address: token.address, + chainId: token.chainId, + decimals: token.decimals + }, + where: { + uid: token.uid + } + }) + }) + ) + }) + + return true + } + + async unregisterToken(uid: string): Promise { + await this.prismaService.token.delete({ + where: { + uid + } + }) + + return true + } + async registerRootKey() {} } diff --git a/apps/authz/src/shared/types/entities.types.ts b/apps/authz/src/shared/types/entities.types.ts index 4e5cb528d..7b606d879 100644 --- a/apps/authz/src/shared/types/entities.types.ts +++ b/apps/authz/src/shared/types/entities.types.ts @@ -37,7 +37,7 @@ export type AddressBookAccount = { export type Token = { uid: string - address: string + address: Address symbol: string chainId: number decimals: number diff --git a/packages/authz-shared/src/lib/type/action.type.ts b/packages/authz-shared/src/lib/type/action.type.ts index c1bdd6f02..056707d1e 100644 --- a/packages/authz-shared/src/lib/type/action.type.ts +++ b/packages/authz-shared/src/lib/type/action.type.ts @@ -14,6 +14,7 @@ export const Action = { CREATE_ADDRESS_BOOK_ACCOUNT: 'CREATE_ADDRESS_BOOK_ACCOUNT', EDIT_WALLET: 'wallet:edit', UNASSIGN_WALLET: 'wallet:unassign', + REGISTER_TOKENS: 'REGISTER_TOKENS', EDIT_USER_GROUP: 'user-group:edit', DELETE_USER_GROUP: 'user-group:delete', @@ -247,4 +248,19 @@ export type CreateAddressBookAccountAction = BaseAction & { export type CreateAddressBookAccountRequest = BaseAdminRequest & { request: CreateAddressBookAccountAction +} + +export type RegisterTokensAction = BaseAction & { + action: typeof Action.REGISTER_TOKENS + tokens: { + uid: string + address: Address + chainId: number + symbol: string + decimals: number + }[] +} + +export type RegisterTokensRequest = BaseAdminRequest & { + request: RegisterTokensAction } \ No newline at end of file From 4ab71ab2d50603fdb9908ee90db57f86cde06447 Mon Sep 17 00:00:00 2001 From: Matt Schoch Date: Mon, 5 Feb 2024 17:28:06 -0500 Subject: [PATCH 3/5] Adding e2e endpoint tests for happy-path inserts --- apps/authz/Makefile | 3 + apps/authz/src/app/__test__/e2e/admin.spec.ts | 439 ++++++++++++++++++ 2 files changed, 442 insertions(+) create mode 100644 apps/authz/src/app/__test__/e2e/admin.spec.ts diff --git a/apps/authz/Makefile b/apps/authz/Makefile index b60db8a6d..ccf2db1f3 100644 --- a/apps/authz/Makefile +++ b/apps/authz/Makefile @@ -88,6 +88,9 @@ authz/test/integration: authz/test/e2e: npx nx test:e2e ${AUTHZ_PROJECT_NAME} -- ${ARGS} +authz/test/e2e: + npx nx test:e2e ${AUTHZ_PROJECT_NAME} -- ${ARGS} + # === Open Policy Agent & Rego === authz/rego/compile: diff --git a/apps/authz/src/app/__test__/e2e/admin.spec.ts b/apps/authz/src/app/__test__/e2e/admin.spec.ts new file mode 100644 index 000000000..b39b24fab --- /dev/null +++ b/apps/authz/src/app/__test__/e2e/admin.spec.ts @@ -0,0 +1,439 @@ +import { load } from '@app/authz/app/app.config' +import { AppModule } from '@app/authz/app/app.module' +import { AAUser, AAUser_Credential_1 } from '@app/authz/app/persistence/repository/mock_data' +import { PersistenceModule } from '@app/authz/shared/module/persistence/persistence.module' +import { TestPrismaService } from '@app/authz/shared/module/persistence/service/test-prisma.service' +import { Organization } from '@app/authz/shared/types/entities.types' +import { AccountClassification, AccountType, Action, Alg, Signature, UserRole } 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' + +const REQUEST_HEADER_ORG_ID = 'x-org-id' +describe('Admin Endpoints', () => { + let app: INestApplication + let module: TestingModule + let testPrismaService: TestPrismaService + + // TODO: Real sigs; these will NOT match the test data. + const authentication: Signature = { + alg: Alg.ES256K, + pubKey: '0xd75D626a116D4a1959fE3bB938B2e7c116A05890', + sig: '0xe24d097cea880a40f8be2cf42f497b9fbda5f9e4a31b596827e051d78dce75c032fa7e5ee3046f7c6f116e5b98cb8d268fa9b9d222ff44719e2ec2a0d9159d0d1c' + } + + const approvals: Signature[] = [ + { + alg: Alg.ES256K, + pubKey: '0x501D5c2Ce1EF208aadf9131a98BAa593258CfA06', + sig: '0x48510e3b74799b8e8f4e01aba0d196e18f66d86a62ae91abf5b89be9391c15661c7d29ee4654a300ed6db977da512475ed5a39f70f677e23d1b2f53c1554d0dd1b' + }, + { + alg: Alg.ES256K, + pubKey: '0xab88c8785D0C00082dE75D801Fcb1d5066a6311e', + sig: '0xcc645f43d8df80c4deeb2e60a8c0c15d58586d2c29ea7c85208cea81d1c47cbd787b1c8473dde70c3a7d49f573e491223107933257b2b99ecc4806b7cc16848d1c' + } + ] + + const org: Organization = { + uid: 'ac1374c2-fd62-4b6e-bd49-a4afcdcb91cc' + } + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + load: [load], + isGlobal: true + }), + PersistenceModule, + AppModule + ] + }).compile() + + testPrismaService = module.get(TestPrismaService) + app = module.createNestApplication() + + await app.init() + }) + + afterAll(async () => { + await testPrismaService.truncateAll() + await module.close() + await app.close() + }) + + beforeEach(async () => { + await testPrismaService.getClient().organization.create({ data: org }) + }) + + afterEach(async () => { + await testPrismaService.truncateAll() + }) + + describe('POST /admin/organizations', () => { + it('creates a new organization', async () => { + // Clear the db since we create an org in beforeEach + await testPrismaService.truncateAll() + + const payload = { + authentication, + approvals, + request: { + action: Action.CREATE_ORGANIZATION, + nonce: 'random-nonce-111', + organization: { + uid: org.uid, + credential: AAUser_Credential_1 + } + } + } + + const { status, body } = await request(app.getHttpServer()) + .post('/admin/organizations') + .set(REQUEST_HEADER_ORG_ID, org.uid) + .send(payload) + + expect(status).toEqual(HttpStatus.CREATED) + expect(body).toMatchObject({ organization: org }) + }) + }) + + describe('POST /admin/users', () => { + it('creates a new user & credential', async () => { + const payload = { + authentication, + approvals, + request: { + action: Action.CREATE_USER, + nonce: 'random-nonce-111', + user: { + ...AAUser, + credential: AAUser_Credential_1 + } + } + } + + const { status, body } = await request(app.getHttpServer()) + .post('/admin/users') + .set(REQUEST_HEADER_ORG_ID, org.uid) + .send(payload) + + expect(body).toMatchObject({ + user: AAUser + }) + expect(status).toEqual(HttpStatus.CREATED) + }) + + it('creates a new user without credential', async () => { + const payload = { + authentication, + approvals, + request: { + action: Action.CREATE_USER, + nonce: 'random-nonce-111', + user: AAUser + } + } + + const { status, body } = await request(app.getHttpServer()) + .post('/admin/users') + .set(REQUEST_HEADER_ORG_ID, org.uid) + .send(payload) + + expect(body).toMatchObject({ + user: AAUser + }) + expect(status).toEqual(HttpStatus.CREATED) + }) + + it('errors on duplicate', async () => { + expect.assertions(3) + + const payload = { + authentication, + approvals, + request: { + action: Action.CREATE_USER, + nonce: 'random-nonce-111', + user: AAUser + } + } + + const { status, body } = await request(app.getHttpServer()) + .post('/admin/users') + .set(REQUEST_HEADER_ORG_ID, org.uid) + .send(payload) + + // Repeat it + const { status: duplicateStatus } = await request(app.getHttpServer()) + .post('/admin/users') + .set(REQUEST_HEADER_ORG_ID, org.uid) + .send(payload) + + expect(body).toMatchObject({ user: AAUser }) + expect(status).toEqual(HttpStatus.CREATED) + expect(duplicateStatus).toEqual(HttpStatus.INTERNAL_SERVER_ERROR) + }) + }) + + describe('PATCH /admin/users/:uid', () => { + it('updates a user', async () => { + // First, insert the user who is an ADMIN + await testPrismaService.getClient().user.create({ + data: { + ...AAUser, + role: UserRole.ADMIN + } + }) + const payload = { + authentication, + approvals, + request: { + action: Action.UPDATE_USER, + nonce: 'random-nonce-111', + user: { + ...AAUser, + role: UserRole.MEMBER + } + } + } + + const { status, body } = await request(app.getHttpServer()) + .patch(`/admin/users/${AAUser.uid}`) + .set(REQUEST_HEADER_ORG_ID, org.uid) + .send(payload) + + expect(body).toMatchObject({ + user: { + ...AAUser, + role: UserRole.MEMBER + } + }) + expect(status).toEqual(HttpStatus.OK) + }) + }) + + describe('POST /admin/credentials', () => { + it(`creates a new credential`, async () => { + const payload = { + authentication, + approvals, + request: { + nonce: 'random-nonce-111', + action: Action.CREATE_CREDENTIAL, + credential: AAUser_Credential_1 + } + } + + const { status, body } = await request(app.getHttpServer()) + .post('/admin/credentials') + .set(REQUEST_HEADER_ORG_ID, org.uid) + .send(payload) + + expect(body).toMatchObject({ + credential: AAUser_Credential_1 + }) + expect(status).toEqual(HttpStatus.CREATED) + }) + }) + + describe('POST /user-groups', () => { + it('creates a new user group', async () => { + const payload = { + authentication, + approvals, + request: { + nonce: 'random-nonce-111', + action: Action.ASSIGN_USER_GROUP, + data: { + userId: AAUser.uid, + groupId: 'test-user-group-uid' + } + } + } + + const { status, body } = await request(app.getHttpServer()) + .post('/admin/user-groups') + .set(REQUEST_HEADER_ORG_ID, org.uid) + .send(payload) + + expect(body).toMatchObject({ + data: { + userId: AAUser.uid, + groupId: 'test-user-group-uid' + } + }) + expect(status).toEqual(HttpStatus.CREATED) + }) + }) + + describe('POST /wallets', () => { + it('creates a new wallet', async () => { + // TODO: This data _should_ fail a test later once we add validations. + const payload = { + authentication, + approvals, + request: { + nonce: 'random-nonce-111', + action: Action.REGISTER_WALLET, + wallet: { + uid: 'test-wallet-uid', + address: '0x1234', + accountType: AccountType.EOA, + chainId: 1 + } + } + } + + const { status, body } = await request(app.getHttpServer()) + .post('/admin/wallets') + .set(REQUEST_HEADER_ORG_ID, org.uid) + .send(payload) + + expect(body).toMatchObject({ + wallet: { + uid: 'test-wallet-uid', + address: '0x1234', + accountType: AccountType.EOA, + chainId: 1 + } + }) + expect(status).toEqual(HttpStatus.CREATED) + }) + }) + + describe('POST /wallet-groups', () => { + it('creates a new wallet group', async () => { + const payload = { + authentication, + approvals, + request: { + nonce: 'random-nonce-111', + action: Action.ASSIGN_WALLET_GROUP, + data: { + walletId: 'test-wallet-uid', + groupId: 'test-wallet-group-uid' + } + } + } + + const { status, body } = await request(app.getHttpServer()) + .post('/admin/wallet-groups') + .set(REQUEST_HEADER_ORG_ID, org.uid) + .send(payload) + + expect(body).toMatchObject({ + data: { + walletId: 'test-wallet-uid', + groupId: 'test-wallet-group-uid' + } + }) + expect(status).toEqual(HttpStatus.CREATED) + }) + }) + + describe('POST /user-wallets', () => { + it('creates a new user wallet', async () => { + const payload = { + authentication, + approvals, + request: { + nonce: 'random-nonce-111', + action: Action.ASSIGN_USER_WALLET, + data: { + userId: AAUser.uid, + walletId: 'test-wallet-uid' + } + } + } + + const { status, body } = await request(app.getHttpServer()) + .post('/admin/user-wallets') + .set(REQUEST_HEADER_ORG_ID, org.uid) + .send(payload) + + expect(body).toMatchObject({ + data: { + userId: AAUser.uid, + walletId: 'test-wallet-uid' + } + }) + expect(status).toEqual(HttpStatus.CREATED) + }) + }) + + describe('POST /address-book', () => { + it('creates a new address book entry', async () => { + const payload = { + authentication, + approvals, + request: { + nonce: 'random-nonce-111', + action: Action.CREATE_ADDRESS_BOOK_ACCOUNT, + account: { + uid: 'test-address-book-uid', + address: '0x1234', + chainId: 1, + classification: AccountClassification.INTERNAL + } + } + } + + const { status, body } = await request(app.getHttpServer()) + .post('/admin/address-book') + .set(REQUEST_HEADER_ORG_ID, org.uid) + .send(payload) + + expect(body).toMatchObject({ + account: { + uid: 'test-address-book-uid', + address: '0x1234', + chainId: 1, + classification: AccountClassification.INTERNAL + } + }) + expect(status).toEqual(HttpStatus.CREATED) + }) + }) + + describe('POST /tokens', () => { + it('registers new tokens', async () => { + const payload = { + authentication, + approvals, + request: { + nonce: 'random-nonce', + action: Action.REGISTER_TOKENS, + tokens: [ + { + uid: 'test-token-uid', + address: '0x1234', + chainId: 1, + symbol: 'TT', + decimals: 18 + }, + { + uid: 'test-token-uid-2', + address: '0x1234', + chainId: 137, + symbol: 'TT2', + decimals: 6 + } + ] + } + } + + const { status, body } = await request(app.getHttpServer()) + .post('/admin/tokens') + .set(REQUEST_HEADER_ORG_ID, org.uid) + .send(payload) + + expect(body).toMatchObject({ + tokens: payload.request.tokens + }) + expect(status).toEqual(HttpStatus.CREATED) + }) + }) +}) From 5a4c10ec2c9148639d3ec3ffee42a7e355e9f674 Mon Sep 17 00:00:00 2001 From: Matt Schoch Date: Mon, 5 Feb 2024 18:05:40 -0500 Subject: [PATCH 4/5] Adding seed file w/ mock_data & hooking up real db queries instead of importing hardcoded mock_data --- apps/authz/Makefile | 12 ++- apps/authz/src/app/opa/opa.service.ts | 32 +++--- .../repository/admin.repository.ts | 21 +++- .../app/persistence/repository/mock_data.ts | 9 +- .../src/shared/module/persistence/seed.ts | 99 +++++++++++++++++-- 5 files changed, 143 insertions(+), 30 deletions(-) diff --git a/apps/authz/Makefile b/apps/authz/Makefile index ccf2db1f3..d92c897f7 100644 --- a/apps/authz/Makefile +++ b/apps/authz/Makefile @@ -52,6 +52,7 @@ authz/db/setup: prisma migrate reset \ --schema ${AUTHZ_DATABASE_SCHEMA} \ --force + make authz/db/seed @echo "" @echo "${TERM_GREEN}🛠️ Setting up Authz test database${TERM_NO_COLOR}" @@ -64,6 +65,14 @@ authz/db/create-migration: --schema ${AUTHZ_DATABASE_SCHEMA} \ --name ${NAME} +# To maintain seed data within their respective modules and then import them +# into the main seed.ts file for execution, it's necessary to compile the +# project and resolve its path aliases before running the vanilla JavaScript +# seed entry point. +authz/db/seed: + npx dotenv -e ${AUTHZ_PROJECT_DIR}/.env -- \ + ts-node -r tsconfig-paths/register --project ${AUTHZ_PROJECT_DIR}/tsconfig.app.json ${AUTHZ_PROJECT_DIR}/src/shared/module/persistence/seed.ts + # === Testing === authz/test/db/setup: @@ -88,9 +97,6 @@ authz/test/integration: authz/test/e2e: npx nx test:e2e ${AUTHZ_PROJECT_NAME} -- ${ARGS} -authz/test/e2e: - npx nx test:e2e ${AUTHZ_PROJECT_NAME} -- ${ARGS} - # === Open Policy Agent & Rego === authz/rego/compile: diff --git a/apps/authz/src/app/opa/opa.service.ts b/apps/authz/src/app/opa/opa.service.ts index c6cc4234c..de8cae313 100644 --- a/apps/authz/src/app/opa/opa.service.ts +++ b/apps/authz/src/app/opa/opa.service.ts @@ -5,7 +5,7 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common' import { loadPolicy } from '@open-policy-agent/opa-wasm' import { readFileSync } from 'fs' import path from 'path' -import R from 'remeda' +import * as R from 'remeda' type PromiseType> = T extends Promise ? U : never type OpaEngine = PromiseType> @@ -21,7 +21,13 @@ export class OpaService implements OnApplicationBootstrap { async onApplicationBootstrap(): Promise { this.logger.log('OPA Service boot') - this.opaEngine = await this.getOpaEngine() + const policyWasmPath = OPA_WASM_PATH + const policyWasm = readFileSync(policyWasmPath) + const opaEngine = await loadPolicy(policyWasm, undefined, { + 'time.now_ns': () => new Date().getTime() * 1000000 // TODO: @sam this happens on app bootstrap one time; if you need a timestamp per-request then this needs to be passed in w/ Entity data not into the Policy. + }) + this.opaEngine = opaEngine + await this.reloadEntityData() } async evaluate(input: RegoInput): Promise { @@ -36,9 +42,14 @@ export class OpaService implements OnApplicationBootstrap { const walletGroups = await this.adminRepository.getAllWalletGroups() const userWallets = await this.adminRepository.getAllUserWallets() const userGroups = await this.adminRepository.getAllUserGroups() + const addressBook = await this.adminRepository.getAllAddressBook() + const tokens = await this.adminRepository.getAllTokens() const regoUsers: Record = R.indexBy(users, (u) => u.uid) const regoWallets = R.indexBy(wallets, (w) => w.uid) + const regoAddressBook = R.indexBy(addressBook, (a) => a.uid) + const regoTokens = R.indexBy(tokens, (t) => t.uid) + // Add the assignees into the regoWallets userWallets.forEach((uw) => { if (regoWallets[uw.walletId]) { @@ -81,9 +92,8 @@ export class OpaService implements OnApplicationBootstrap { wallets: regoWallets, userGroups: regoUserGroups, walletGroups: regoWalletGroups, - // TODO: remove mocks here - addressBook: mockData.entities.addressBook, - tokens: {} + addressBook: regoAddressBook, + tokens: regoTokens } } this.logger.log('Fetched OPA Engine data', regoData) @@ -99,13 +109,9 @@ export class OpaService implements OnApplicationBootstrap { } private async getOpaEngine(): Promise { - const policyWasmPath = OPA_WASM_PATH - const policyWasm = readFileSync(policyWasmPath) - const opaEngine = await loadPolicy(policyWasm, undefined, { - 'time.now_ns': () => new Date().getTime() * 1000000 - }) - const mockData = await this.adminRepository.getEntityData() - opaEngine.setData(mockData) - return opaEngine + // Attempt to initialize it if it for some reason isn't. + if (!this.opaEngine) await this.onApplicationBootstrap() + if (!this.opaEngine) throw new Error('OPA Engine not initialized') + return this.opaEngine } } diff --git a/apps/authz/src/app/persistence/repository/admin.repository.ts b/apps/authz/src/app/persistence/repository/admin.repository.ts index c57741a05..4a049f62d 100644 --- a/apps/authz/src/app/persistence/repository/admin.repository.ts +++ b/apps/authz/src/app/persistence/repository/admin.repository.ts @@ -1,6 +1,6 @@ import { PrismaService } from '@app/authz/shared/module/persistence/service/prisma.service' import { AddressBookAccount, Organization, RegoData, Token, User, Wallet } from '@app/authz/shared/types/entities.types' -import { AccountType, Address, Alg, AuthCredential, UserRole } from '@narval/authz-shared' +import { AccountClassification, AccountType, Address, Alg, AuthCredential, UserRole } from '@narval/authz-shared' import { Injectable, Logger, OnModuleInit } from '@nestjs/common' import { castArray } from 'lodash/fp' import { mockEntityData, userAddressStore, userCredentialStore } from './mock_data' @@ -82,6 +82,25 @@ export class AdminRepository implements OnModuleInit { return walletGroups } + async getAllAddressBook(): Promise { + const addressBook = await this.prismaService.addressBookAccount.findMany() + return addressBook.map((d) => ({ + ...convertResponse(d, 'classification', Object.values(AccountClassification)), + address: d.address as Address + })) + } + + async getAllTokens(): Promise { + const tokens = await this.prismaService.token.findMany() + return tokens.map((d) => ({ + uid: d.uid, + address: d.address as Address, + symbol: d.symbol, + chainId: d.chainId, + decimals: d.decimals + })) + } + async createOrganization( organizationId: string, rootCredential: AuthCredential diff --git a/apps/authz/src/app/persistence/repository/mock_data.ts b/apps/authz/src/app/persistence/repository/mock_data.ts index a5e518d2c..2ba7f87b3 100644 --- a/apps/authz/src/app/persistence/repository/mock_data.ts +++ b/apps/authz/src/app/persistence/repository/mock_data.ts @@ -8,6 +8,7 @@ import { WalletGroup } from '@app/authz/shared/types/entities.types' import { + AccountClassification, AccountId, AccountType, Action, @@ -163,28 +164,28 @@ export const SHY_ACCOUNT_137: AddressBookAccount = { uid: 'eip155:137:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e', address: '0xddcf208f219a6e6af072f2cfdc615b2c1805f98e', chainId: 137, - classification: 'wallet' + classification: AccountClassification.WALLET } export const SHY_ACCOUNT_1: AddressBookAccount = { uid: 'eip155:1:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e', address: '0xddcf208f219a6e6af072f2cfdc615b2c1805f98e', chainId: 1, - classification: 'wallet' + classification: AccountClassification.WALLET } export const ACCOUNT_Q_137: AddressBookAccount = { uid: 'eip155:137:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4', address: '0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4', chainId: 137, - classification: 'wallet' + classification: AccountClassification.WALLET } export const ACCOUNT_INTERNAL_WXZ_137: AddressBookAccount = { uid: 'eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3', address: '0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3', chainId: 137, - classification: 'internal' + classification: AccountClassification.INTERNAL } export const NATIVE_TRANSFER_INTENT: TransferNative = { diff --git a/apps/authz/src/shared/module/persistence/seed.ts b/apps/authz/src/shared/module/persistence/seed.ts index 6ee285456..1bb961ea1 100644 --- a/apps/authz/src/shared/module/persistence/seed.ts +++ b/apps/authz/src/shared/module/persistence/seed.ts @@ -1,24 +1,105 @@ +import { mockEntityData } from '@app/authz/app/persistence/repository/mock_data' +import { User } from '@app/authz/shared/types/entities.types' import { Logger } from '@nestjs/common' import { Organization, PrismaClient } from '@prisma/client/authz' const prisma = new PrismaClient() -const orgs: Organization[] = [ - { - uid: '7d704a62-d15e-4382-a826-1eb41563043b' - } -] +const org: Organization = { + uid: '7d704a62-d15e-4382-a826-1eb41563043b' +} async function main() { const logger = new Logger('EngineSeed') logger.log('Seeding Engine database') - - for (const org of orgs) { - await prisma.organization.create({ + await prisma.$transaction(async (txn) => { + await txn.organization.create({ data: org }) - } + + // USERS + for (const user of Object.values(mockEntityData.entities.users) as User[]) { + logger.log(`Creating user ${user.uid}`) + await txn.user.create({ + data: user + }) + } + + // USER GROUPS + for (const userGroup of Object.values(mockEntityData.entities.userGroups)) { + // create the group first + logger.log(`Creating user group ${userGroup.uid}`) + await txn.userGroup.create({ + data: { + uid: userGroup.uid + } + }) + // now assign each user to it + for (const userId of userGroup.users) { + logger.log(`Assigning user ${userId} to group ${userGroup.uid}`) + await txn.userGroupMembership.create({ + data: { + userGroupId: userGroup.uid, + userId + } + }) + } + } + + // WALLETS + for (const wallet of Object.values(mockEntityData.entities.wallets)) { + logger.log(`Creating wallet ${wallet.uid}`) + await txn.wallet.create({ + data: { + uid: wallet.uid, + address: wallet.address, + accountType: wallet.accountType + } + }) + if (wallet.assignees) { + // Assign the wallet to the assignees + for (const assigneeId of wallet.assignees) { + logger.log(`Assigning wallet ${wallet.uid} to user ${assigneeId}`) + await txn.userWalletAssignment.create({ + data: { + walletId: wallet.uid, + userId: assigneeId + } + }) + } + } + } + + // WALLET GROUPS + for (const walletGroup of Object.values(mockEntityData.entities.walletGroups)) { + // create the group first + logger.log(`Creating wallet group ${walletGroup.uid}`) + await txn.walletGroup.create({ + data: { + uid: walletGroup.uid + } + }) + // now assign each wallet to it + for (const walletId of walletGroup.wallets) { + logger.log(`Assigning wallet ${walletId} to group ${walletGroup.uid}`) + await txn.walletGroupMembership.create({ + data: { + walletGroupId: walletGroup.uid, + walletId + } + }) + } + } + + // ADDRESS BOOK + for (const addressBook of Object.values(mockEntityData.entities.addressBook)) { + logger.log(`Creating address book ${addressBook.uid}`) + await txn.addressBookAccount.create({ + data: addressBook + }) + } + }) logger.log('Engine database germinated 🌱') } From e94d238e8c22ceb49797c5b316ffdcdac5374ac7 Mon Sep 17 00:00:00 2001 From: Matt Schoch Date: Mon, 5 Feb 2024 18:19:59 -0500 Subject: [PATCH 5/5] Adding lint-staged to shared library --- packages/authz-shared/.lintstagedrc.js | 6 ++++++ packages/authz-shared/src/lib/type/action.type.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 packages/authz-shared/.lintstagedrc.js diff --git a/packages/authz-shared/.lintstagedrc.js b/packages/authz-shared/.lintstagedrc.js new file mode 100644 index 000000000..3cc2540b7 --- /dev/null +++ b/packages/authz-shared/.lintstagedrc.js @@ -0,0 +1,6 @@ +module.exports = { + '*.{ts,tsx}': (filenames) => [ + `eslint --no-error-on-unmatched-pattern ${filenames.join(' ')}; echo "ESLint completed with exit code $?"`, + `prettier --write ${filenames.join(' ')}` + ] +} diff --git a/packages/authz-shared/src/lib/type/action.type.ts b/packages/authz-shared/src/lib/type/action.type.ts index 056707d1e..64b3542a7 100644 --- a/packages/authz-shared/src/lib/type/action.type.ts +++ b/packages/authz-shared/src/lib/type/action.type.ts @@ -263,4 +263,4 @@ export type RegisterTokensAction = BaseAction & { export type RegisterTokensRequest = BaseAdminRequest & { request: RegisterTokensAction -} \ No newline at end of file +}