From 7ad1c9a5abb2cac560188b24925268907349112b Mon Sep 17 00:00:00 2001 From: Pierre Troger Date: Wed, 1 May 2024 19:10:33 -0400 Subject: [PATCH 1/3] schemas for serialized requests and usage in engine --- .../rest/controller/evaluation.controller.ts | 11 +++- .../dto/serialized-evaluation-response.dto.ts | 4 ++ .../core/type/open-policy-agent.type.ts | 4 +- .../core/util/evaluation.util.ts | 3 +- .../src/lib/type/action.type.ts | 12 ++++ .../src/lib/type/domain.type.ts | 59 ++++++++++++++----- 6 files changed, 72 insertions(+), 21 deletions(-) create mode 100644 apps/policy-engine/src/engine/http/rest/dto/serialized-evaluation-response.dto.ts diff --git a/apps/policy-engine/src/engine/http/rest/controller/evaluation.controller.ts b/apps/policy-engine/src/engine/http/rest/controller/evaluation.controller.ts index f4d54fed0..e6f456025 100644 --- a/apps/policy-engine/src/engine/http/rest/controller/evaluation.controller.ts +++ b/apps/policy-engine/src/engine/http/rest/controller/evaluation.controller.ts @@ -1,8 +1,10 @@ +import { SerializedEvaluationResponse } from '@narval/policy-engine-shared' import { Body, Controller, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common' import { ClientId } from '../../../../shared/decorator/client-id.decorator' import { ClientSecretGuard } from '../../../../shared/guard/client-secret.guard' import { EvaluationService } from '../../../core/service/evaluation.service' import { EvaluationRequestDto } from '../dto/evaluation-request.dto' +import { SerializedEvaluationResponseDto } from '../dto/serialized-evaluation-response.dto' @Controller('/evaluations') @UseGuards(ClientSecretGuard) @@ -11,7 +13,12 @@ export class EvaluationController { @Post() @HttpCode(HttpStatus.OK) - async evaluate(@ClientId() clientId: string, @Body() body: EvaluationRequestDto) { - return this.evaluationService.evaluate(clientId, body) + async evaluate( + @ClientId() clientId: string, + @Body() body: EvaluationRequestDto + ): Promise { + return this.evaluationService.evaluate(clientId, body).then((response) => { + return SerializedEvaluationResponse.parse(response) + }) } } diff --git a/apps/policy-engine/src/engine/http/rest/dto/serialized-evaluation-response.dto.ts b/apps/policy-engine/src/engine/http/rest/dto/serialized-evaluation-response.dto.ts new file mode 100644 index 000000000..69dfb9dbb --- /dev/null +++ b/apps/policy-engine/src/engine/http/rest/dto/serialized-evaluation-response.dto.ts @@ -0,0 +1,4 @@ +import { SerializedEvaluationResponse } from '@narval/policy-engine-shared' +import { createZodDto } from 'nestjs-zod' + +export class SerializedEvaluationResponseDto extends createZodDto(SerializedEvaluationResponse) {} diff --git a/apps/policy-engine/src/open-policy-agent/core/type/open-policy-agent.type.ts b/apps/policy-engine/src/open-policy-agent/core/type/open-policy-agent.type.ts index ae724ae4c..bafa4c54b 100644 --- a/apps/policy-engine/src/open-policy-agent/core/type/open-policy-agent.type.ts +++ b/apps/policy-engine/src/open-policy-agent/core/type/open-policy-agent.type.ts @@ -5,7 +5,7 @@ import { Address, CredentialEntity, Feed, - TransactionRequest, + SerializedTransactionRequest, UserRole } from '@narval/policy-engine-shared' import { Intent } from '@narval/transaction-request-intent' @@ -20,7 +20,7 @@ export type OpenPolicyAgentInstance = PromiseType> export type Input = { action: Action intent?: Intent - transactionRequest?: TransactionRequest + transactionRequest?: SerializedTransactionRequest principal: CredentialEntity resource?: { uid: string } approvals?: CredentialEntity[] diff --git a/apps/policy-engine/src/open-policy-agent/core/util/evaluation.util.ts b/apps/policy-engine/src/open-policy-agent/core/util/evaluation.util.ts index 5426e167f..f3bc63779 100644 --- a/apps/policy-engine/src/open-policy-agent/core/util/evaluation.util.ts +++ b/apps/policy-engine/src/open-policy-agent/core/util/evaluation.util.ts @@ -5,6 +5,7 @@ import { EvaluationRequest, Feed, Request, + SerializedTransactionRequest, SignMessageAction, SignRawAction, SignTransactionAction, @@ -37,7 +38,7 @@ const toSignTransaction: Mapping = (request, principal, a principal, approvals, intent: result.intent, - transactionRequest: request.transactionRequest, + transactionRequest: SerializedTransactionRequest.parse(request.transactionRequest), resource: { uid: request.resourceId }, feeds } diff --git a/packages/policy-engine-shared/src/lib/type/action.type.ts b/packages/policy-engine-shared/src/lib/type/action.type.ts index 40d2b969a..d1c2e267e 100644 --- a/packages/policy-engine-shared/src/lib/type/action.type.ts +++ b/packages/policy-engine-shared/src/lib/type/action.type.ts @@ -42,6 +42,13 @@ export const TransactionRequest = z.object({ }) export type TransactionRequest = z.infer +export const SerializedTransactionRequest = TransactionRequest.extend({ + gas: z.coerce.string().optional(), + maxFeePerGas: z.coerce.string().optional(), + maxPriorityFeePerGas: z.coerce.string().optional() +}) +export type SerializedTransactionRequest = z.infer + export const Eip712Domain = z.object({ name: z.string().optional(), version: z.string().optional(), @@ -76,6 +83,11 @@ export const SignTransactionAction = BaseAction.merge( ) export type SignTransactionAction = z.infer +export const SerializedTransactionAction = SignTransactionAction.extend({ + transactionRequest: SerializedTransactionRequest +}) +export type SerializedTransactionAction = z.infer + // Matching viem's SignableMessage options // See https://viem.sh/docs/actions/wallet/signMessage#message export const SignableMessage = z.union([ diff --git a/packages/policy-engine-shared/src/lib/type/domain.type.ts b/packages/policy-engine-shared/src/lib/type/domain.type.ts index 435f1a4a7..fb0746a51 100644 --- a/packages/policy-engine-shared/src/lib/type/domain.type.ts +++ b/packages/policy-engine-shared/src/lib/type/domain.type.ts @@ -1,6 +1,12 @@ import { ZodTypeAny, z } from 'zod' import { AccountId } from '../util/caip.util' -import { SignMessageAction, SignRawAction, SignTransactionAction, SignTypedDataAction } from './action.type' +import { + SerializedTransactionAction, + SignMessageAction, + SignRawAction, + SignTransactionAction, + SignTypedDataAction +} from './action.type' export enum Decision { PERMIT = 'Permit', @@ -92,6 +98,14 @@ export const Request = z.discriminatedUnion('action', [ ]) export type Request = z.infer +export const SerializedRequest = z.discriminatedUnion('action', [ + SerializedTransactionAction, + SignMessageAction, + SignTypedDataAction, + SignRawAction +]) +export type SerializedRequest = z.infer + export const Feed = (dataSchema: Data) => z.object({ source: z.string(), @@ -137,6 +151,11 @@ export const EvaluationRequest = z .describe('The action being authorized') export type EvaluationRequest = z.infer +export const SerializedEvaluationRequest = EvaluationRequest.extend({ + request: SerializedRequest +}) +export type SerializedEvaluationRequest = z.infer + export const ApprovalRequirement = z.object({ approvalCount: z.number().min(0), approvalEntityType: z.nativeEnum(EntityType).describe('The number of requried approvals'), @@ -145,22 +164,30 @@ export const ApprovalRequirement = z.object({ }) export type ApprovalRequirement = z.infer -export type AccessToken = { - value: string // JWT - // could include a key-proof -} +export const AccessToken = z.object({ + value: JwtString +}) +export type AccessToken = z.infer + +export const EvaluationResponse = z.object({ + decision: z.nativeEnum(Decision), + request: Request.optional(), + approvals: z + .object({ + required: z.array(ApprovalRequirement).optional(), + missing: z.array(ApprovalRequirement).optional(), + satisfied: z.array(ApprovalRequirement).optional() + }) + .optional(), + accessToken: AccessToken.optional(), + transactionRequestIntent: z.unknown().optional() +}) +export type EvaluationResponse = z.infer -export type EvaluationResponse = { - decision: Decision - request?: Request - approvals?: { - required: ApprovalRequirement[] - missing: ApprovalRequirement[] - satisfied: ApprovalRequirement[] - } - accessToken?: AccessToken - transactionRequestIntent?: unknown -} +export const SerializedEvaluationResponse = EvaluationResponse.extend({ + request: SerializedRequest.optional() +}) +export type SerializedEvaluationResponse = z.infer export type Hex = `0x${string}` From 638c59189df849a0283c65a32d30729311f8ffe4 Mon Sep 17 00:00:00 2001 From: Pierre Troger Date: Thu, 2 May 2024 14:01:12 -0400 Subject: [PATCH 2/3] Zod interceptor to return correct DTO from controller --- .../policy-engine/src/engine/engine.module.ts | 8 +++++-- .../rest/controller/evaluation.controller.ts | 24 ++++++++++++------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/apps/policy-engine/src/engine/engine.module.ts b/apps/policy-engine/src/engine/engine.module.ts index b03bbc65e..fbed68d45 100644 --- a/apps/policy-engine/src/engine/engine.module.ts +++ b/apps/policy-engine/src/engine/engine.module.ts @@ -2,8 +2,8 @@ import { ConfigModule, ConfigService } from '@narval/config-module' import { EncryptionModule } from '@narval/encryption-module' import { HttpModule } from '@nestjs/axios' import { Module, ValidationPipe } from '@nestjs/common' -import { APP_PIPE } from '@nestjs/core' -import { ZodValidationPipe } from 'nestjs-zod' +import { APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core' +import { ZodSerializerInterceptor, ZodValidationPipe } from 'nestjs-zod' import { load } from '../policy-engine.config' import { EncryptionModuleOptionFactory } from '../shared/factory/encryption-module-option.factory' import { AdminApiKeyGuard } from '../shared/guard/admin-api-key.guard' @@ -64,6 +64,10 @@ import { HttpDataStoreRepository } from './persistence/repository/http-data-stor { provide: APP_PIPE, useClass: ZodValidationPipe + }, + { + provide: APP_INTERCEPTOR, + useClass: ZodSerializerInterceptor } ], exports: [EngineService, ProvisionService, BootstrapService] diff --git a/apps/policy-engine/src/engine/http/rest/controller/evaluation.controller.ts b/apps/policy-engine/src/engine/http/rest/controller/evaluation.controller.ts index e6f456025..2faeafb43 100644 --- a/apps/policy-engine/src/engine/http/rest/controller/evaluation.controller.ts +++ b/apps/policy-engine/src/engine/http/rest/controller/evaluation.controller.ts @@ -1,5 +1,7 @@ -import { SerializedEvaluationResponse } from '@narval/policy-engine-shared' import { Body, Controller, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common' +import { ApiHeader, ApiOperation, ApiResponse } from '@nestjs/swagger' +import { REQUEST_HEADER_CLIENT_ID } from 'apps/policy-engine/src/policy-engine.constant' +import { ZodSerializerDto } from 'nestjs-zod' import { ClientId } from '../../../../shared/decorator/client-id.decorator' import { ClientSecretGuard } from '../../../../shared/guard/client-secret.guard' import { EvaluationService } from '../../../core/service/evaluation.service' @@ -13,12 +15,18 @@ export class EvaluationController { @Post() @HttpCode(HttpStatus.OK) - async evaluate( - @ClientId() clientId: string, - @Body() body: EvaluationRequestDto - ): Promise { - return this.evaluationService.evaluate(clientId, body).then((response) => { - return SerializedEvaluationResponse.parse(response) - }) + @ApiOperation({ + summary: 'Evaluates a request into a decision.' + }) + @ApiHeader({ + name: REQUEST_HEADER_CLIENT_ID + }) + @ApiResponse({ + status: HttpStatus.OK, + type: SerializedEvaluationResponseDto + }) + @ZodSerializerDto(SerializedEvaluationResponseDto) + async evaluate(@ClientId() clientId: string, @Body() body: EvaluationRequestDto) { + return this.evaluationService.evaluate(clientId, body) } } From 093666e22708e4e6e5e555a381a419e6dab50583 Mon Sep 17 00:00:00 2001 From: Pierre Troger Date: Thu, 2 May 2024 14:29:41 -0400 Subject: [PATCH 3/3] added e2e test for serialization --- .../engine/__test__/e2e/evaluation.spec.ts | 31 ++++++++++++++++++- .../rest/controller/evaluation.controller.ts | 2 +- .../src/shared/testing/evaluation.testing.ts | 28 +++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/apps/policy-engine/src/engine/__test__/e2e/evaluation.spec.ts b/apps/policy-engine/src/engine/__test__/e2e/evaluation.spec.ts index eb5bb65aa..8e7a48cb4 100644 --- a/apps/policy-engine/src/engine/__test__/e2e/evaluation.spec.ts +++ b/apps/policy-engine/src/engine/__test__/e2e/evaluation.spec.ts @@ -1,6 +1,15 @@ import { ConfigModule, ConfigService } from '@narval/config-module' import { EncryptionModuleOptionProvider } from '@narval/encryption-module' -import { Action, Criterion, Decision, EvaluationRequest, FIXTURE, Then } from '@narval/policy-engine-shared' +import { + Action, + Criterion, + Decision, + EvaluationRequest, + EvaluationResponse, + FIXTURE, + SerializedEvaluationRequest, + Then +} from '@narval/policy-engine-shared' import { Alg, PrivateKey, privateKeyToJwk, secp256k1PrivateKeyToJwk } from '@narval/signature' import { HttpStatus, INestApplication } from '@nestjs/common' import { Test, TestingModule } from '@nestjs/testing' @@ -20,6 +29,7 @@ import { generateSignMessageRequest, generateSignRawRequest, generateSignTransactionRequest, + generateSignTransactionRequestWithGas, generateSignTypedDataRequest } from '../../../shared/testing/evaluation.testing' import { Client } from '../../../shared/type/domain.type' @@ -134,6 +144,25 @@ describe('Evaluation', () => { expect(status).toEqual(HttpStatus.UNAUTHORIZED) }) + it('serializes and parses request at the edge', async () => { + const payload = await generateSignTransactionRequestWithGas() + + const serializedPayload = SerializedEvaluationRequest.parse(payload) + const { body } = await request(app.getHttpServer()) + .post('/evaluations') + .set(REQUEST_HEADER_CLIENT_ID, client.clientId) + .set(REQUEST_HEADER_CLIENT_SECRET, client.clientSecret) + .send(serializedPayload) + + expect(body).toMatchObject({ + decision: Decision.FORBID, + request: serializedPayload.request + }) + + const parsedRequest = EvaluationResponse.parse(body) + expect(parsedRequest.request).toEqual(payload.request) + }) + const useCases = [ { action: Action.SIGN_TRANSACTION, diff --git a/apps/policy-engine/src/engine/http/rest/controller/evaluation.controller.ts b/apps/policy-engine/src/engine/http/rest/controller/evaluation.controller.ts index 2faeafb43..3685d380f 100644 --- a/apps/policy-engine/src/engine/http/rest/controller/evaluation.controller.ts +++ b/apps/policy-engine/src/engine/http/rest/controller/evaluation.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common' import { ApiHeader, ApiOperation, ApiResponse } from '@nestjs/swagger' -import { REQUEST_HEADER_CLIENT_ID } from 'apps/policy-engine/src/policy-engine.constant' import { ZodSerializerDto } from 'nestjs-zod' +import { REQUEST_HEADER_CLIENT_ID } from '../../../../policy-engine.constant' import { ClientId } from '../../../../shared/decorator/client-id.decorator' import { ClientSecretGuard } from '../../../../shared/guard/client-secret.guard' import { EvaluationService } from '../../../core/service/evaluation.service' diff --git a/apps/policy-engine/src/shared/testing/evaluation.testing.ts b/apps/policy-engine/src/shared/testing/evaluation.testing.ts index c2a3ee98a..718964d3d 100644 --- a/apps/policy-engine/src/shared/testing/evaluation.testing.ts +++ b/apps/policy-engine/src/shared/testing/evaluation.testing.ts @@ -20,6 +20,34 @@ const sign = async (request: Request) => { return { aliceSignature, bobSignature, carolSignature } } +export const generateSignTransactionRequestWithGas = async (): Promise => { + const txRequest: TransactionRequest = { + from: FIXTURE.WALLET.Engineering.address, + to: FIXTURE.WALLET.Treasury.address, + chainId: 137, + value: toHex(ONE_ETH), + data: '0x00000000', + gas: BigInt(22000), + nonce: 192, + type: '2' + } + + const request: Request = { + action: Action.SIGN_TRANSACTION, + nonce: uuid(), + transactionRequest: txRequest, + resourceId: FIXTURE.WALLET.Engineering.id + } + + const { aliceSignature, bobSignature, carolSignature } = await sign(request) + + return { + authentication: aliceSignature, + request, + approvals: [bobSignature, carolSignature] + } +} + export const generateSignTransactionRequest = async (): Promise => { const txRequest: TransactionRequest = { from: FIXTURE.WALLET.Engineering.address,