diff --git a/apps/orchestration/src/main.ts b/apps/orchestration/src/main.ts index b3f61c7c3..39c0bef56 100644 --- a/apps/orchestration/src/main.ts +++ b/apps/orchestration/src/main.ts @@ -1,5 +1,6 @@ import { OrchestrationModule } from '@app/orchestration/orchestration.module' import { ApplicationExceptionFilter } from '@app/orchestration/shared/filter/application-exception.filter' +import { ZodExceptionFilter } from '@app/orchestration/shared/filter/zod-exception.filter' import { ClassSerializerInterceptor, INestApplication, Logger, ValidationPipe } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { NestFactory, Reflector } from '@nestjs/core' @@ -64,10 +65,10 @@ const withGlobalInterceptors = (app: INestApplication): INestApplication => { * @param configService - The configuration service instance. * @returns The modified Nest application instance. */ -const withGlobalExceptionFilter = +const withGlobalFilters = (configService: ConfigService) => (app: INestApplication): INestApplication => { - app.useGlobalFilters(new ApplicationExceptionFilter(configService)) + app.useGlobalFilters(new ApplicationExceptionFilter(configService), new ZodExceptionFilter(configService)) return app } @@ -89,7 +90,7 @@ async function bootstrap(): Promise { map(withSwagger), map(withGlobalPipes), map(withGlobalInterceptors), - map(withGlobalExceptionFilter(configService)), + map(withGlobalFilters(configService)), switchMap((app) => app.listen(port)) ) ) diff --git a/apps/orchestration/src/orchestration.module.ts b/apps/orchestration/src/orchestration.module.ts index a3a30b074..db2f80fdb 100644 --- a/apps/orchestration/src/orchestration.module.ts +++ b/apps/orchestration/src/orchestration.module.ts @@ -1,6 +1,7 @@ import { PolicyEngineModule } from '@app/orchestration/policy-engine/policy-engine.module' -import { Module } from '@nestjs/common' +import { ClassSerializerInterceptor, Module } from '@nestjs/common' import { ConfigModule } from '@nestjs/config' +import { APP_INTERCEPTOR } from '@nestjs/core' import { load } from './orchestration.config' import { QueueModule } from './shared/module/queue/queue.module' @@ -12,6 +13,12 @@ import { QueueModule } from './shared/module/queue/queue.module' }), QueueModule.forRoot(), PolicyEngineModule + ], + providers: [ + { + provide: APP_INTERCEPTOR, + useClass: ClassSerializerInterceptor + } ] }) export class OrchestrationModule {} diff --git a/apps/orchestration/src/policy-engine/__test__/e2e/facade.spec.ts b/apps/orchestration/src/policy-engine/__test__/e2e/facade.spec.ts index 907c7e975..8ed5f7631 100644 --- a/apps/orchestration/src/policy-engine/__test__/e2e/facade.spec.ts +++ b/apps/orchestration/src/policy-engine/__test__/e2e/facade.spec.ts @@ -118,7 +118,8 @@ describe('Policy Engine Cluster Facade', () => { const signTransactionRequest = { from: '0xaaa8ee1cbaa1856f4550c6fc24abb16c5c9b2a43', to: '0xbbb7be636c3ad8cf9d08ba8bdba4abd2ef29bd23', - data: '0x' + data: '0x', + gas: '5000' } const payload = { action: Action.SIGN_TRANSACTION, @@ -143,7 +144,6 @@ describe('Policy Engine Cluster Facade', () => { .set(REQUEST_HEADER_ORG_ID, org.id) .send(payload) - expect(status).toEqual(HttpStatus.OK) expect(body).toMatchObject({ id: expect.any(String), status: AuthorizationRequestStatus.CREATED, @@ -154,6 +154,7 @@ describe('Policy Engine Cluster Facade', () => { createdAt: expect.any(String), updatedAt: expect.any(String) }) + expect(status).toEqual(HttpStatus.OK) }) }) diff --git a/apps/orchestration/src/policy-engine/core/service/authorization-request.service.ts b/apps/orchestration/src/policy-engine/core/service/authorization-request.service.ts index 5aad554cb..658396e75 100644 --- a/apps/orchestration/src/policy-engine/core/service/authorization-request.service.ts +++ b/apps/orchestration/src/policy-engine/core/service/authorization-request.service.ts @@ -38,7 +38,14 @@ export class AuthorizationRequestService { ) {} async create(input: CreateAuthorizationRequest): Promise { - const authzRequest = await this.authzRequestRepository.create(input) + const now = new Date() + + const authzRequest = await this.authzRequestRepository.create({ + id: input.id || uuid(), + createdAt: input.createdAt || now, + updatedAt: input.updatedAt || now, + ...input + }) await this.authzRequestProcessingProducer.add(authzRequest) diff --git a/apps/orchestration/src/policy-engine/core/type/domain.type.ts b/apps/orchestration/src/policy-engine/core/type/domain.type.ts index dd3cdb33f..b5c810e33 100644 --- a/apps/orchestration/src/policy-engine/core/type/domain.type.ts +++ b/apps/orchestration/src/policy-engine/core/type/domain.type.ts @@ -63,6 +63,7 @@ export type TransactionRequest = { data?: Hex from: Address to?: Address | null + gas: bigint } export type SignTransactionAuthorizationRequest = SharedAuthorizationRequest & { diff --git a/apps/orchestration/src/policy-engine/http/rest/controller/facade.controller.ts b/apps/orchestration/src/policy-engine/http/rest/controller/facade.controller.ts index 8fd7c9ece..89487f985 100644 --- a/apps/orchestration/src/policy-engine/http/rest/controller/facade.controller.ts +++ b/apps/orchestration/src/policy-engine/http/rest/controller/facade.controller.ts @@ -1,44 +1,10 @@ import { AuthorizationRequestService } from '@app/orchestration/policy-engine/core/service/authorization-request.service' -import { Action, CreateAuthorizationRequest } from '@app/orchestration/policy-engine/core/type/domain.type' import { AuthorizationRequestDto } from '@app/orchestration/policy-engine/http/rest/dto/authorization-request.dto' import { AuthorizationResponseDto } from '@app/orchestration/policy-engine/http/rest/dto/authorization-response.dto' +import { toCreateAuthorizationRequest } from '@app/orchestration/policy-engine/http/rest/util' import { OrgId } from '@app/orchestration/shared/decorator/org-id.decorator' import { Body, Controller, Get, HttpCode, HttpStatus, NotFoundException, Param, Post } from '@nestjs/common' import { ApiResponse, ApiTags } from '@nestjs/swagger' -import { plainToInstance } from 'class-transformer' - -// Not in love with the gymnastics required to bend a DTO to a domain object. -// Most of the complexity came from the discriminated union type. -// It's fine for now to keep it ugly here but I'll look at the problem later -const toDomainType = (orgId: string, body: AuthorizationRequestDto): CreateAuthorizationRequest => { - const dto = plainToInstance(AuthorizationRequestDto, body) - const shared = { - orgId, - initiatorId: '97389cac-20f0-4d02-a3a9-b27c564ffd18', - hash: dto.hash, - evaluations: [] - } - - if (dto.isSignMessage(dto.request)) { - return { - ...shared, - action: Action.SIGN_MESSAGE, - request: { - message: dto.request.message - } - } - } - - return { - ...shared, - action: Action.SIGN_TRANSACTION, - request: { - from: dto.request.from, - to: dto.request.to, - data: dto.request.data - } - } -} @Controller('/policy-engine') @ApiTags('Policy Engine') @@ -53,7 +19,7 @@ export class FacadeController { type: AuthorizationResponseDto }) async evaluation(@OrgId() orgId: string, @Body() body: AuthorizationRequestDto): Promise { - const authzRequest = await this.authorizationRequestService.create(toDomainType(orgId, body)) + const authzRequest = await this.authorizationRequestService.create(toCreateAuthorizationRequest(orgId, body)) return new AuthorizationResponseDto(authzRequest) } diff --git a/apps/orchestration/src/policy-engine/http/rest/dto/authorization-request.dto.ts b/apps/orchestration/src/policy-engine/http/rest/dto/authorization-request.dto.ts index edd8d57cf..f2e18abe2 100644 --- a/apps/orchestration/src/policy-engine/http/rest/dto/authorization-request.dto.ts +++ b/apps/orchestration/src/policy-engine/http/rest/dto/authorization-request.dto.ts @@ -1,27 +1,16 @@ import { Action, Address, Hex } from '@app/orchestration/policy-engine/core/type/domain.type' +import { SignatureDto } from '@app/orchestration/policy-engine/http/rest/dto/validator/signature.dto' import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger' -import { Type } from 'class-transformer' +import { Transform, Type } from 'class-transformer' import { IsDefined, IsEnum, IsEthereumAddress, IsString, Validate, ValidateNested } from 'class-validator' import { RequestHash } from './validator/request-hash.validator' -export class SignatureDto { - @IsDefined() - @IsString() - @ApiProperty() - hash: string - - @ApiProperty({ - enum: ['ECDSA'] - }) - type?: string = 'ECDSA' -} - -export class AuthenticationDto { +class AuthenticationDto { @ApiProperty() signature: SignatureDto } -export class ApprovalDto { +class ApprovalDto { @ApiProperty({ type: () => SignatureDto, isArray: true @@ -30,26 +19,36 @@ export class ApprovalDto { } export class SignTransactionRequestDto { + @IsString() @IsDefined() @IsEthereumAddress() + @Transform(({ value }) => value.toLowerCase()) @ApiProperty({ required: true, format: 'EthereumAddress' }) from: Address + @IsString() @IsEthereumAddress() + @Transform(({ value }) => value.toLowerCase()) @ApiProperty({ format: 'EthereumAddress' }) - to: Address + to?: Address | null @IsString() @ApiProperty({ type: 'string', format: 'Hexadecimal' }) - data: Hex + data?: Hex + + @Transform(({ value }) => BigInt(value)) + @ApiProperty({ + type: 'string' + }) + gas: bigint } export class SignMessageRequestDto { diff --git a/apps/orchestration/src/policy-engine/http/rest/dto/authorization-response.dto.ts b/apps/orchestration/src/policy-engine/http/rest/dto/authorization-response.dto.ts index 791f028f2..ed7e41b98 100644 --- a/apps/orchestration/src/policy-engine/http/rest/dto/authorization-response.dto.ts +++ b/apps/orchestration/src/policy-engine/http/rest/dto/authorization-response.dto.ts @@ -1,24 +1,34 @@ import { Action, AuthorizationRequestStatus } from '@app/orchestration/policy-engine/core/type/domain.type' -import { ApiProperty } from '@nestjs/swagger' -import { Type } from 'class-transformer' +import { + SignMessageRequestDto, + SignTransactionRequestDto +} from '@app/orchestration/policy-engine/http/rest/dto/authorization-request.dto' +import { EvaluationDto } from '@app/orchestration/policy-engine/http/rest/dto/validator/evaluation.dto' +import { ApiProperty, getSchemaPath } from '@nestjs/swagger' +import { Transform, Type } from 'class-transformer' import { IsString } from 'class-validator' -export class EvaluationDto { - @ApiProperty() - id: string - - @ApiProperty() - decision: string - +/** + * The transformer function in the "@Transformer" decorator for bigint + * properties differs between the request and response. This variation is due to + * the limitations of JS' built-in functions, such as JSON, when handling + * bigints. + * + * - Request: The transformer converts from a string to bigint. + * - Response: The transformer converts from bigint to a string. + */ +class SignTransactionResponseDto extends SignTransactionRequestDto { + @IsString() + @Transform(({ value }) => value.toString()) @ApiProperty({ - type: String + type: 'string' }) - signature?: string | null - - @ApiProperty() - createdAt: Date + gas: bigint } +// Just for keeping consistency on the naming. +class SignMessageResponseDto extends SignMessageRequestDto {} + export class AuthorizationResponseDto { @ApiProperty() id: string @@ -53,11 +63,13 @@ export class AuthorizationResponseDto { @Type(() => EvaluationDto) evaluations: EvaluationDto[] - // TODO: Figure out the request discrimination. It's been too painful. - // @ApiProperty({ - // oneOf: [{ $ref: getSchemaPath(SignTransactionRequestDto) }, { $ref: getSchemaPath(SignMessageRequestDto) }] - // }) - // request: SignTransactionRequestDto | SignMessageRequestDto + @Type((opts) => { + return opts?.object.action === Action.SIGN_TRANSACTION ? SignTransactionResponseDto : SignMessageResponseDto + }) + @ApiProperty({ + oneOf: [{ $ref: getSchemaPath(SignMessageResponseDto) }, { $ref: getSchemaPath(SignTransactionResponseDto) }] + }) + request: SignTransactionResponseDto | SignMessageResponseDto constructor(partial: Partial) { Object.assign(this, partial) diff --git a/apps/orchestration/src/policy-engine/http/rest/dto/validator/evaluation.dto.ts b/apps/orchestration/src/policy-engine/http/rest/dto/validator/evaluation.dto.ts new file mode 100644 index 000000000..a21754830 --- /dev/null +++ b/apps/orchestration/src/policy-engine/http/rest/dto/validator/evaluation.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger' + +export class EvaluationDto { + @ApiProperty() + id: string + + @ApiProperty() + decision: string + + @ApiProperty({ + type: String + }) + signature?: string | null + + @ApiProperty() + createdAt: Date +} diff --git a/apps/orchestration/src/policy-engine/http/rest/dto/validator/request-hash.validator.ts b/apps/orchestration/src/policy-engine/http/rest/dto/validator/request-hash.validator.ts index bb7f51273..d7d381619 100644 --- a/apps/orchestration/src/policy-engine/http/rest/dto/validator/request-hash.validator.ts +++ b/apps/orchestration/src/policy-engine/http/rest/dto/validator/request-hash.validator.ts @@ -1,3 +1,4 @@ +import { stringify } from '@app/orchestration/shared/lib/json' import { ValidationArguments, ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator' import { hashMessage } from 'viem' @@ -18,6 +19,6 @@ export class RequestHash implements ValidatorConstraintInterface { } static hash(request: unknown): string { - return hashMessage(JSON.stringify(request)) + return hashMessage(stringify(request)) } } diff --git a/apps/orchestration/src/policy-engine/http/rest/dto/validator/signature.dto.ts b/apps/orchestration/src/policy-engine/http/rest/dto/validator/signature.dto.ts new file mode 100644 index 000000000..3e299bef2 --- /dev/null +++ b/apps/orchestration/src/policy-engine/http/rest/dto/validator/signature.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger' +import { IsDefined, IsString } from 'class-validator' + +export class SignatureDto { + @IsDefined() + @IsString() + @ApiProperty() + hash: string + + @ApiProperty({ + enum: ['ECDSA'] + }) + type?: string = 'ECDSA' +} diff --git a/apps/orchestration/src/policy-engine/http/rest/util.ts b/apps/orchestration/src/policy-engine/http/rest/util.ts new file mode 100644 index 000000000..b15c69fa0 --- /dev/null +++ b/apps/orchestration/src/policy-engine/http/rest/util.ts @@ -0,0 +1,40 @@ +import { Action, CreateAuthorizationRequest } from '@app/orchestration/policy-engine/core/type/domain.type' +import { AuthorizationRequestDto } from '@app/orchestration/policy-engine/http/rest/dto/authorization-request.dto' +import { plainToInstance } from 'class-transformer' + +// Not in love with the gymnastics required to bend a DTO to a domain object. +// Most of the complexity came from the discriminated union type. +// It's fine for now to keep it ugly here but I'll look at the problem later +export const toCreateAuthorizationRequest = ( + orgId: string, + body: AuthorizationRequestDto +): CreateAuthorizationRequest => { + const dto = plainToInstance(AuthorizationRequestDto, body) + const shared = { + orgId, + initiatorId: '97389cac-20f0-4d02-a3a9-b27c564ffd18', + hash: dto.hash, + evaluations: [] + } + + if (dto.isSignMessage(dto.request)) { + return { + ...shared, + action: Action.SIGN_MESSAGE, + request: { + message: dto.request.message + } + } + } + + return { + ...shared, + action: Action.SIGN_TRANSACTION, + request: { + from: dto.request.from, + to: dto.request.to, + data: dto.request.data, + gas: dto.request.gas + } + } +} diff --git a/apps/orchestration/src/policy-engine/persistence/decode/__test__/unit/authorization-request.decode.spec.ts b/apps/orchestration/src/policy-engine/persistence/decode/__test__/unit/authorization-request.decode.spec.ts index 3c46709f9..70d32cecc 100644 --- a/apps/orchestration/src/policy-engine/persistence/decode/__test__/unit/authorization-request.decode.spec.ts +++ b/apps/orchestration/src/policy-engine/persistence/decode/__test__/unit/authorization-request.decode.spec.ts @@ -24,7 +24,8 @@ describe('decodeAuthorizationRequest', () => { request: { from: '0xaaa8ee1cbaa1856f4550c6fc24abb16c5c9b2a43', to: '0xbbb7be636c3ad8cf9d08ba8bdba4abd2ef29bd23', - data: '0x' + data: '0x', + gas: '5000' } } @@ -37,13 +38,37 @@ describe('decodeAuthorizationRequest', () => { const invalidModel = { ...sharedModel, action: Action.SIGN_TRANSACTION, - request: {} + request: { + from: 'not-an-ethereum-address', + gas: '5000' + } } expect(() => { decodeAuthorizationRequest(invalidModel) }).toThrow(DecodeAuthorizationRequestException) }) + + it('throws DecodeAuthorizationRequestException when null/undefined coerces to bigint error', () => { + const requestWithGasNull = { + from: '0xaaa8ee1cbaa1856f4550c6fc24abb16c5c9b2a43', + to: '0xbbb7be636c3ad8cf9d08ba8bdba4abd2ef29bd23', + data: '0x', + gas: null + } + const model = { + ...sharedModel, + action: Action.SIGN_TRANSACTION + } + + expect(() => { + decodeAuthorizationRequest({ ...model, request: requestWithGasNull }) + }).toThrow(DecodeAuthorizationRequestException) + + expect(() => { + decodeAuthorizationRequest({ ...model, request: { ...requestWithGasNull, gas: undefined } }) + }).toThrow(DecodeAuthorizationRequestException) + }) }) describe('sign message', () => { diff --git a/apps/orchestration/src/policy-engine/persistence/decode/authorization-request.decode.ts b/apps/orchestration/src/policy-engine/persistence/decode/authorization-request.decode.ts index 7564707fd..dd713db00 100644 --- a/apps/orchestration/src/policy-engine/persistence/decode/authorization-request.decode.ts +++ b/apps/orchestration/src/policy-engine/persistence/decode/authorization-request.decode.ts @@ -1,14 +1,11 @@ -import { Action, AuthorizationRequest, Evaluation } from '@app/orchestration/policy-engine/core/type/domain.type' +import { AuthorizationRequest, Evaluation } from '@app/orchestration/policy-engine/core/type/domain.type' import { DecodeAuthorizationRequestException } from '@app/orchestration/policy-engine/persistence/exception/decode-authorization-request.exception' -import { signMessageRequestSchema } from '@app/orchestration/policy-engine/persistence/schema/sign-message-request.schema' -import { signTransactionRequestSchema } from '@app/orchestration/policy-engine/persistence/schema/sign-transaction-request.schema' +import { ACTION_REQUEST } from '@app/orchestration/policy-engine/policy-engine.constant' import { AuthorizationRequest as AuthorizationRequestModel, EvaluationLog } from '@prisma/client/orchestration' import { ZodIssueCode, ZodSchema } from 'zod' type Model = AuthorizationRequestModel & { evaluationLog?: EvaluationLog[] } -type Decode = (model: Model) => AuthorizationRequest - const buildEvaluation = ({ id, decision, signature, createdAt }: EvaluationLog): Evaluation => ({ id, decision, @@ -34,16 +31,8 @@ const buildSharedAttributes = (model: Model) => ({ * @throws {DecodeAuthorizationRequestException} * @returns {AuthorizationRequest} */ -const withErrorHandling = ({ - model, - action, - schema -}: { - model: Model - action: Action - schema: ZodSchema -}): AuthorizationRequest => { - if (model.action === action) { +const decode = ({ model, schema }: { model: Model; schema: ZodSchema }): AuthorizationRequest => { + try { const decode = schema.safeParse(model.request) if (decode.success) { @@ -55,31 +44,20 @@ const withErrorHandling = ({ } throw new DecodeAuthorizationRequestException(decode.error.issues) + } catch (error) { + // The try/catch statement is implemented here specifically to prevent the + // irony of "safeParse" throwing a TypeError due to bigint coercion on + // null and undefined values. + throw new DecodeAuthorizationRequestException([ + { + code: ZodIssueCode.custom, + message: `Unknown decode exception ${error.message}`, + path: ['request'] + } + ]) } - - throw new DecodeAuthorizationRequestException([ - { - code: ZodIssueCode.custom, - message: 'Invalid decode strategy action', - path: ['action'] - } - ]) } -const decodeSignMessage: Decode = (model: Model) => - withErrorHandling({ - model, - action: Action.SIGN_MESSAGE, - schema: signMessageRequestSchema - }) - -const decodeSignTransaction: Decode = (model: Model) => - withErrorHandling({ - model, - action: Action.SIGN_TRANSACTION, - schema: signTransactionRequestSchema - }) - /** * Decodes an authorization request based on its action, throws on errors. * @@ -87,15 +65,13 @@ const decodeSignTransaction: Decode = (model: Model) => * @returns {AuthorizationRequest} */ export const decodeAuthorizationRequest = (model: Model): AuthorizationRequest => { - const decoders = new Map<`${Action}`, Decode>([ - [Action.SIGN_MESSAGE, decodeSignMessage], - [Action.SIGN_TRANSACTION, decodeSignTransaction] - ]) - - const decode = decoders.get(model.action) + const config = ACTION_REQUEST[model.action] - if (decode) { - return decode(model) + if (config) { + return decode({ + model, + schema: config.schema.read + }) } throw new DecodeAuthorizationRequestException([ @@ -103,7 +79,7 @@ export const decodeAuthorizationRequest = (model: Model): AuthorizationRequest = code: ZodIssueCode.invalid_literal, message: 'Authorization request decoder not found for action type', path: ['action'], - expected: Array.from(decoders.keys()), + expected: Object.keys(ACTION_REQUEST), received: model.action } ]) diff --git a/apps/orchestration/src/policy-engine/persistence/repository/__test__/integration/authorization-request.repository.spec.ts b/apps/orchestration/src/policy-engine/persistence/repository/__test__/integration/authorization-request.repository.spec.ts index d49b73485..89f48b307 100644 --- a/apps/orchestration/src/policy-engine/persistence/repository/__test__/integration/authorization-request.repository.spec.ts +++ b/apps/orchestration/src/policy-engine/persistence/repository/__test__/integration/authorization-request.repository.spec.ts @@ -1,5 +1,11 @@ import { load } from '@app/orchestration/orchestration.config' -import { Action, AuthorizationRequest, Evaluation } from '@app/orchestration/policy-engine/core/type/domain.type' +import { + Action, + Evaluation, + SignMessageAuthorizationRequest, + SignTransactionAuthorizationRequest, + isSignTransaction +} from '@app/orchestration/policy-engine/core/type/domain.type' import { AuthorizationRequestRepository } from '@app/orchestration/policy-engine/persistence/repository/authorization-request.repository' import { PersistenceModule } from '@app/orchestration/shared/module/persistence/persistence.module' import { TestPrismaService } from '@app/orchestration/shared/module/persistence/service/test-prisma.service' @@ -44,10 +50,10 @@ describe(AuthorizationRequestRepository.name, () => { }) describe('create', () => { - const authzRequest: AuthorizationRequest = { + const signMessageRequest: SignMessageAuthorizationRequest = { id: '6c7e92fc-d2b0-4840-8e9b-485393ecdf89', orgId: org.id, - initiatorId: 'bob', + initiatorId: '5c6df361-8ec7-4cfa-bff6-53ffa7c985ff', status: AuthorizationRequestStatus.PROCESSING, action: Action.SIGN_MESSAGE, request: { @@ -61,23 +67,23 @@ describe(AuthorizationRequestRepository.name, () => { } it('creates a new authorization request', async () => { - await repository.create(authzRequest) + await repository.create(signMessageRequest) const request = await testPrismaService.getClient().authorizationRequest.findFirst({ where: { - id: authzRequest.id + id: signMessageRequest.id } }) - expect(request).toMatchObject(omit('evaluations', authzRequest)) + expect(request).toMatchObject(omit('evaluations', signMessageRequest)) }) it('defaults status to CREATED', async () => { - await repository.create(omit('status', authzRequest)) + await repository.create(omit('status', signMessageRequest)) const request = await testPrismaService.getClient().authorizationRequest.findFirst({ where: { - id: authzRequest.id + id: signMessageRequest.id } }) @@ -93,23 +99,48 @@ describe(AuthorizationRequestRepository.name, () => { } await repository.create({ - ...authzRequest, + ...signMessageRequest, evaluations: [permit] }) const evaluations = await testPrismaService.getClient().evaluationLog.findMany({ where: { - requestId: authzRequest.id + requestId: signMessageRequest.id } }) expect(evaluations).toEqual([ { ...permit, - requestId: authzRequest.id, - orgId: authzRequest.orgId + requestId: signMessageRequest.id, + orgId: signMessageRequest.orgId } ]) }) + + describe(`when action is ${Action.SIGN_TRANSACTION}`, () => { + const signTransactionRequest: SignTransactionAuthorizationRequest = { + ...signMessageRequest, + action: Action.SIGN_TRANSACTION, + request: { + from: '0xaaa8ee1cbaa1856f4550c6fc24abb16c5c9b2a43', + to: '0xbbb7be636c3ad8cf9d08ba8bdba4abd2ef29bd23', + data: '0x', + gas: BigInt(5_000) + } + } + + it('encodes bigints as strings', async () => { + await repository.create(signTransactionRequest) + + const authzRequest = await repository.findById(signTransactionRequest.id) + + expect(authzRequest).not.toEqual(null) + + if (authzRequest && isSignTransaction(authzRequest)) { + expect(authzRequest?.request.gas).toEqual(signTransactionRequest.request.gas) + } + }) + }) }) }) diff --git a/apps/orchestration/src/policy-engine/persistence/repository/authorization-request.repository.ts b/apps/orchestration/src/policy-engine/persistence/repository/authorization-request.repository.ts index 85b205915..5f88a3500 100644 --- a/apps/orchestration/src/policy-engine/persistence/repository/authorization-request.repository.ts +++ b/apps/orchestration/src/policy-engine/persistence/repository/authorization-request.repository.ts @@ -4,6 +4,10 @@ import { CreateAuthorizationRequest } from '@app/orchestration/policy-engine/core/type/domain.type' import { decodeAuthorizationRequest } from '@app/orchestration/policy-engine/persistence/decode/authorization-request.decode' +import { + createAuthorizationRequestSchema, + updateAuthorizationRequestSchema +} from '@app/orchestration/policy-engine/persistence/schema/authorization-request.schema' import { PrismaService } from '@app/orchestration/shared/module/persistence/service/prisma.service' import { Injectable } from '@nestjs/common' import { EvaluationLog } from '@prisma/client/orchestration' @@ -13,19 +17,10 @@ import { omit } from 'lodash/fp' export class AuthorizationRequestRepository { constructor(private prismaService: PrismaService) {} - async create({ - id, - action, - request, - orgId, - initiatorId, - hash, - status, - idempotencyKey, - createdAt, - updatedAt, - evaluations - }: CreateAuthorizationRequest): Promise { + async create(input: CreateAuthorizationRequest): Promise { + const { id, action, request, orgId, initiatorId, hash, status, idempotencyKey, createdAt, updatedAt, evaluations } = + createAuthorizationRequestSchema.parse(input) + const model = await this.prismaService.authorizationRequest.create({ data: { status: status || AuthorizationRequestStatus.CREATED, @@ -39,10 +34,12 @@ export class AuthorizationRequestRepository { createdAt, updatedAt, evaluationLog: { - create: evaluations.map((evaluation) => ({ - ...evaluation, - orgId - })) + createMany: { + data: evaluations.map((evaluation) => ({ + ...evaluation, + orgId + })) + } } }, include: { @@ -53,32 +50,45 @@ export class AuthorizationRequestRepository { return decodeAuthorizationRequest(model) } - async update(input: Partial & Pick): Promise { - const evaluations: EvaluationLog[] = - input.orgId && input.evaluations?.length - ? input.evaluations?.map((evaluation) => ({ + /** + * Updates only allowed attributes of an authorization request. + * + * This restriction is in place because altering the data of an authorization + * request would mean tampering with the user's original request. + * + * @param input Partial version of {AuthorizationRequest} including the {id}. + * @returns {AuthorizationRequest} + */ + async update( + input: Partial> & Pick + ): Promise { + const { id } = input + const { orgId, status, evaluations } = updateAuthorizationRequestSchema.parse(omit('evaluations', input)) + + const evaluationLogs: EvaluationLog[] = + orgId && evaluations?.length + ? evaluations.map((evaluation) => ({ ...evaluation, - signature: evaluation.signature, - requestId: input.id, - orgId: input.orgId as string + requestId: id, + orgId })) : [] - const model = await this.prismaService.authorizationRequest.update({ - where: { - id: input.id - }, + await this.prismaService.authorizationRequest.update({ + where: { id }, data: { - ...omit('evaluations', input), - evaluationLog: { - createMany: { - data: evaluations + status, + ...(evaluationLogs.length && { + evaluationLog: { + createMany: { + data: evaluationLogs + } } - } + }) } }) - return decodeAuthorizationRequest(model) + return Promise.resolve({} as AuthorizationRequest) } async findById(id: string): Promise { diff --git a/apps/orchestration/src/policy-engine/persistence/schema/__test__/unit/sign-transaction-request.schema.spec.ts b/apps/orchestration/src/policy-engine/persistence/schema/__test__/unit/sign-transaction-request.schema.spec.ts index 5dc02f221..b8b4ec050 100644 --- a/apps/orchestration/src/policy-engine/persistence/schema/__test__/unit/sign-transaction-request.schema.spec.ts +++ b/apps/orchestration/src/policy-engine/persistence/schema/__test__/unit/sign-transaction-request.schema.spec.ts @@ -1,18 +1,21 @@ -import { signTransactionRequestSchema } from '@app/orchestration/policy-engine/persistence/schema/sign-transaction-request.schema' +import { readSignTransactionRequestSchema } from '@app/orchestration/policy-engine/persistence/schema/sign-transaction-request.schema' import { z } from 'zod' -type SignTransactionRequest = z.infer +type ReadSignTransactionRequest = z.infer describe('signTransactionRequestSchema', () => { - it('parses a sign transaction request', () => { - const signTransactionRequest: SignTransactionRequest = { - from: '0xaaa8ee1cbaa1856f4550c6fc24abb16c5c9b2a43', - to: '0xbbb7be636c3ad8cf9d08ba8bdba4abd2ef29bd23', - data: '0x' - } + describe('read', () => { + it('parses a sign transaction request', () => { + const signTransactionRequest: ReadSignTransactionRequest = { + from: '0xaaa8ee1cbaa1856f4550c6fc24abb16c5c9b2a43', + to: '0xbbb7be636c3ad8cf9d08ba8bdba4abd2ef29bd23', + data: '0x', + gas: BigInt(5_000) + } - const parse = signTransactionRequestSchema.safeParse(signTransactionRequest) + const parse = readSignTransactionRequestSchema.safeParse(signTransactionRequest) - expect(parse.success).toEqual(true) + expect(parse.success).toEqual(true) + }) }) }) diff --git a/apps/orchestration/src/policy-engine/persistence/schema/authorization-request.schema.ts b/apps/orchestration/src/policy-engine/persistence/schema/authorization-request.schema.ts new file mode 100644 index 000000000..22ef12af7 --- /dev/null +++ b/apps/orchestration/src/policy-engine/persistence/schema/authorization-request.schema.ts @@ -0,0 +1,71 @@ +import { Action } from '@app/orchestration/policy-engine/core/type/domain.type' +import { + createSignMessageRequestSchema, + readSignMessageRequestSchema +} from '@app/orchestration/policy-engine/persistence/schema/sign-message-request.schema' +import { + createSignTransactionRequestSchema, + readSignTransactionRequestSchema +} from '@app/orchestration/policy-engine/persistence/schema/sign-transaction-request.schema' +import { AuthorizationRequestStatus } from '@prisma/client/orchestration' +import { z } from 'zod' + +const evaluationSchema = z.object({ + id: z.string().uuid(), + decision: z.string(), + signature: z.string().nullable(), + createdAt: z.date() +}) + +const sharedAuthorizationRequestSchema = z.object({ + id: z.string().uuid(), + orgId: z.string().uuid(), + initiatorId: z.string().uuid(), + status: z.nativeEnum(AuthorizationRequestStatus), + hash: z.string(), + idempotencyKey: z.string().nullish(), + createdAt: z.date(), + updatedAt: z.date(), + evaluations: z.array(evaluationSchema) +}) + +export const readAuthorizationRequestSchema = z.discriminatedUnion('action', [ + sharedAuthorizationRequestSchema.extend({ + action: z.literal(Action.SIGN_MESSAGE), + request: readSignMessageRequestSchema + }), + sharedAuthorizationRequestSchema.extend({ + action: z.literal(Action.SIGN_TRANSACTION), + request: readSignTransactionRequestSchema + }) +]) + +const createSharedAuthorizationRequestSchema = sharedAuthorizationRequestSchema.partial({ + status: true +}) + +export const createAuthorizationRequestSchema = z.discriminatedUnion('action', [ + createSharedAuthorizationRequestSchema.extend({ + action: z.literal(Action.SIGN_MESSAGE), + request: createSignMessageRequestSchema + }), + createSharedAuthorizationRequestSchema.extend({ + action: z.literal(Action.SIGN_TRANSACTION), + request: createSignTransactionRequestSchema + }) +]) + +/** + * Only allow updating a few attributes of the authorization request. + * + * This restriction is in place because altering the data of an authorization + * request would mean tampering with the user's original request. + */ +export const updateAuthorizationRequestSchema = sharedAuthorizationRequestSchema + .pick({ + id: true, + orgId: true, + status: true, + evaluations: true + }) + .partial() diff --git a/apps/orchestration/src/policy-engine/persistence/schema/sign-message-request.schema.ts b/apps/orchestration/src/policy-engine/persistence/schema/sign-message-request.schema.ts index 44ec4f53f..329e44ce9 100644 --- a/apps/orchestration/src/policy-engine/persistence/schema/sign-message-request.schema.ts +++ b/apps/orchestration/src/policy-engine/persistence/schema/sign-message-request.schema.ts @@ -1,5 +1,7 @@ import { z } from 'zod' -export const signMessageRequestSchema = z.object({ +export const readSignMessageRequestSchema = z.object({ message: z.string() }) + +export const createSignMessageRequestSchema = readSignMessageRequestSchema diff --git a/apps/orchestration/src/policy-engine/persistence/schema/sign-transaction-request.schema.ts b/apps/orchestration/src/policy-engine/persistence/schema/sign-transaction-request.schema.ts index 66318da5f..9e10503ee 100644 --- a/apps/orchestration/src/policy-engine/persistence/schema/sign-transaction-request.schema.ts +++ b/apps/orchestration/src/policy-engine/persistence/schema/sign-transaction-request.schema.ts @@ -41,8 +41,13 @@ const hexSchema = z.custom<`0x${string}`>( } ) -export const signTransactionRequestSchema = z.object({ +export const readSignTransactionRequestSchema = z.object({ from: addressSchema, to: addressSchema.optional(), - data: hexSchema.optional() + data: hexSchema.optional(), + gas: z.coerce.bigint() +}) + +export const createSignTransactionRequestSchema = readSignTransactionRequestSchema.extend({ + gas: z.coerce.string() }) diff --git a/apps/orchestration/src/policy-engine/policy-engine.constant.ts b/apps/orchestration/src/policy-engine/policy-engine.constant.ts new file mode 100644 index 000000000..a496d91a5 --- /dev/null +++ b/apps/orchestration/src/policy-engine/policy-engine.constant.ts @@ -0,0 +1,35 @@ +import { Action } from '@app/orchestration/policy-engine/core/type/domain.type' +import { + createSignMessageRequestSchema, + readSignMessageRequestSchema +} from '@app/orchestration/policy-engine/persistence/schema/sign-message-request.schema' +import { + createSignTransactionRequestSchema, + readSignTransactionRequestSchema +} from '@app/orchestration/policy-engine/persistence/schema/sign-transaction-request.schema' +import { ZodType } from 'zod' + +type ActionRequestConfig = { + action: Action + schema: { + read: ZodType + create: ZodType + } +} + +export const ACTION_REQUEST: Record = { + [Action.SIGN_MESSAGE]: { + action: Action.SIGN_MESSAGE, + schema: { + read: readSignMessageRequestSchema, + create: createSignMessageRequestSchema + } + }, + [Action.SIGN_TRANSACTION]: { + action: Action.SIGN_TRANSACTION, + schema: { + read: readSignTransactionRequestSchema, + create: createSignTransactionRequestSchema + } + } +} diff --git a/apps/orchestration/src/policy-engine/policy-engine.module.ts b/apps/orchestration/src/policy-engine/policy-engine.module.ts index 981918edd..e5c440fb5 100644 --- a/apps/orchestration/src/policy-engine/policy-engine.module.ts +++ b/apps/orchestration/src/policy-engine/policy-engine.module.ts @@ -1,13 +1,14 @@ import { AUTHORIZATION_REQUEST_PROCESSING_QUEUE } from '@app/orchestration/orchestration.constant' import { ApplicationExceptionFilter } from '@app/orchestration/shared/filter/application-exception.filter' +import { ZodExceptionFilter } from '@app/orchestration/shared/filter/zod-exception.filter' import { PersistenceModule } from '@app/orchestration/shared/module/persistence/persistence.module' import { BullAdapter } from '@bull-board/api/bullAdapter' import { BullBoardModule } from '@bull-board/nestjs' import { HttpModule } from '@nestjs/axios' import { BullModule } from '@nestjs/bull' -import { Logger, Module, OnApplicationBootstrap } from '@nestjs/common' +import { ClassSerializerInterceptor, Logger, Module, OnApplicationBootstrap, ValidationPipe } from '@nestjs/common' import { ConfigModule } from '@nestjs/config' -import { APP_FILTER } from '@nestjs/core' +import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core' import { AuthorizationRequestService } from './core/service/authorization-request.service' import { FacadeController } from './http/rest/controller/facade.controller' import { AuthorizationRequestRepository } from './persistence/repository/authorization-request.repository' @@ -40,6 +41,18 @@ import { AuthorizationRequestProcessingProducer } from './queue/producer/authori { provide: APP_FILTER, useClass: ApplicationExceptionFilter + }, + { + provide: APP_FILTER, + useClass: ZodExceptionFilter + }, + { + provide: APP_INTERCEPTOR, + useClass: ClassSerializerInterceptor + }, + { + provide: APP_PIPE, + useClass: ValidationPipe } ] }) diff --git a/apps/orchestration/src/policy-engine/queue/consumer/__test__/integration/authorization-request-processing.consumer.spec.ts b/apps/orchestration/src/policy-engine/queue/consumer/__test__/integration/authorization-request-processing.consumer.spec.ts index fa1a5ef68..5423051f8 100644 --- a/apps/orchestration/src/policy-engine/queue/consumer/__test__/integration/authorization-request-processing.consumer.spec.ts +++ b/apps/orchestration/src/policy-engine/queue/consumer/__test__/integration/authorization-request-processing.consumer.spec.ts @@ -42,7 +42,7 @@ describe(AuthorizationRequestProcessingConsumer.name, () => { const authzRequest: AuthorizationRequest = { id: '6c7e92fc-d2b0-4840-8e9b-485393ecdf89', orgId: org.id, - initiatorId: 'bob', + initiatorId: 'a1d3d09d-1a0d-4c42-b580-f54c636a5155', status: AuthorizationRequestStatus.PROCESSING, action: Action.SIGN_MESSAGE, request: { diff --git a/apps/orchestration/src/shared/filter/zod-exception.filter.ts b/apps/orchestration/src/shared/filter/zod-exception.filter.ts new file mode 100644 index 000000000..8d84a5fd6 --- /dev/null +++ b/apps/orchestration/src/shared/filter/zod-exception.filter.ts @@ -0,0 +1,39 @@ +import { Config, Env } from '@app/orchestration/orchestration.config' +import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { Response } from 'express' +import { ZodError } from 'zod' + +@Catch(ZodError) +export class ZodExceptionFilter implements ExceptionFilter { + private logger = new Logger(ZodExceptionFilter.name) + + constructor(private configService: ConfigService) {} + + catch(exception: ZodError, host: ArgumentsHost) { + const ctx = host.switchToHttp() + const response = ctx.getResponse() + const status = HttpStatus.UNPROCESSABLE_ENTITY + const isProduction = this.configService.get('env') === Env.PRODUCTION + + // Log as error level because Zod issues should be handled by the caller. + this.logger.error('Uncaught ZodError', { + exception + }) + + response.status(status).json( + isProduction + ? { + statusCode: status, + message: 'Internal validation error', + context: exception.errors + } + : { + statusCode: status, + message: 'Internal validation error', + context: exception.errors, + stacktrace: exception.stack + } + ) + } +} diff --git a/apps/orchestration/src/shared/lib/__test__/unit/json.spec.ts b/apps/orchestration/src/shared/lib/__test__/unit/json.spec.ts new file mode 100644 index 000000000..493034457 --- /dev/null +++ b/apps/orchestration/src/shared/lib/__test__/unit/json.spec.ts @@ -0,0 +1,30 @@ +import { stringify } from '@app/orchestration/shared/lib/json' + +describe('json', () => { + describe('stringify', () => { + it('stringifies base primitives exactly like JSON.stringify', () => { + const primitives = { + string: 'example', + number: 123, + boolean: true, + null: null, + undefined: undefined, + symbol: Symbol('example'), + object: { key: 'value' }, + array: [1, 2, 3], + nan: NaN, + infinity: Infinity + } + + expect(stringify(primitives)).toEqual(JSON.stringify(primitives)) + }) + + it('stringifies bigint', () => { + const bigint = { + bigint: BigInt(5_000) + } + + expect(stringify(bigint)).toEqual('{"bigint":"5000"}') + }) + }) +}) diff --git a/apps/orchestration/src/shared/lib/json.ts b/apps/orchestration/src/shared/lib/json.ts new file mode 100644 index 000000000..1709ecb78 --- /dev/null +++ b/apps/orchestration/src/shared/lib/json.ts @@ -0,0 +1,58 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +type Transformation = (key: string, value: any) => any + +type ExtendType = { + replacer: Transformation +} + +const EXTENDED_TYPES: Record = { + bigint: { + replacer: (key: string, value: any) => { + // IMPORTANT: The bigint replacer [1] has a significant limitation that + // hinders its use with a reviver [2]. + // + // In theory, JSON parse and stringify operations should allow for + // round-trip conversion. However, once a bigint is converted to a string, + // it becomes challenging to discern whether the string value was + // originally a number or a bigint. + // + // To address this, one proposed solution is to either prefix the + // stringified bigint with a unique marker, like + // bigint:, or transform the bigint into an object + // with a special key indicating its type, such as { __type: 'bigint', + // value: }. + // + // Personally, I'm not in favor of these methods as transforming the key + // into an object requires the entire system to understand how to process + // it, or it forces consumers to be aware of the format for sending + // correct data. + // + // Given these considerations, I would advise against using the reviver in + // this scenario and only apply the replacer during stringify operations, + // with the understanding that it won't be possible to revert the string + // back to its original bigint form. + // + // Reference + // [1] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#replacer + // [2] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse#reviver + if (typeof value === 'bigint') { + return value.toString() + } else { + return value + } + } + } +} + +export const stringify = (value: any) => { + return JSON.stringify(value, (key: string, value: any) => { + const type = typeof value + + if (EXTENDED_TYPES[type]) { + return EXTENDED_TYPES[type].replacer(key, value) + } else { + return value + } + }) +}