From 071f140752cdd682926d87bc789a98eb789875a2 Mon Sep 17 00:00:00 2001 From: William Calderipe Date: Thu, 4 Apr 2024 15:59:33 +0200 Subject: [PATCH] Support sign message in the Policy Engine (#219) * Support sign message * Fix engine unit tests --- .../engine/__test__/e2e/evaluation.spec.ts | 164 ++++++++++++------ .../src/engine/app.controller.ts | 6 +- .../unit/open-policy-agent.engine.spec.ts | 27 +-- .../core/open-policy-agent.engine.ts | 16 +- .../__test__/unit/evaluation.util.spec.ts | 98 +++++++++-- .../core/util/evaluation.util.ts | 22 ++- .../src/shared/testing/evaluation.testing.ts | 38 +++- 7 files changed, 274 insertions(+), 97 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 dc6e70755..d4877e50d 100644 --- a/apps/policy-engine/src/engine/__test__/e2e/evaluation.spec.ts +++ b/apps/policy-engine/src/engine/__test__/e2e/evaluation.spec.ts @@ -16,7 +16,7 @@ import { InMemoryKeyValueRepository } from '../../../shared/module/key-value/per import { TestPrismaService } from '../../../shared/module/persistence/service/test-prisma.service' import { getEntityStore, getPolicyStore } from '../../../shared/testing/data-store.testing' import { getTestRawAesKeyring } from '../../../shared/testing/encryption.testing' -import { generateInboundEvaluationRequest } from '../../../shared/testing/evaluation.testing' +import { generateSignMessageRequest, generateSignTransactionRequest } from '../../../shared/testing/evaluation.testing' import { Client } from '../../../shared/type/domain.type' import { ClientService } from '../../core/service/client.service' import { EngineSignerConfigService } from '../../core/service/engine-signer-config.service' @@ -114,68 +114,132 @@ describe('Evaluation', () => { }) describe('POST /evaluations', () => { - it('evaluates a forbid', async () => { - const payload = await generateInboundEvaluationRequest() + describe('when sign transaction', () => { + it('evaluates a forbid', async () => { + const payload = await generateSignTransactionRequest() - const { status, body } = await request(app.getHttpServer()) - .post('/evaluations') - .set(REQUEST_HEADER_CLIENT_ID, client.clientId) - .set(REQUEST_HEADER_CLIENT_SECRET, client.clientSecret) - .send(payload) + const { status, body } = await request(app.getHttpServer()) + .post('/evaluations') + .set(REQUEST_HEADER_CLIENT_ID, client.clientId) + .set(REQUEST_HEADER_CLIENT_SECRET, client.clientSecret) + .send(payload) - expect(body).toEqual({ - decision: Decision.FORBID, - request: payload.request + expect(body).toEqual({ + decision: Decision.FORBID, + request: payload.request + }) + expect(status).toEqual(HttpStatus.OK) + }) + + it('evaluates a permit', async () => { + await clientService.savePolicyStore( + client.clientId, + await getPolicyStore( + [ + { + id: 'test-permit-policy', + then: Then.PERMIT, + description: 'test permit policy', + when: [ + { + criterion: Criterion.CHECK_ACTION, + args: [Action.SIGN_TRANSACTION] + } + ] + } + ], + privateKey + ) + ) + + const payload = await generateSignTransactionRequest() + + const { status, body } = await request(app.getHttpServer()) + .post('/evaluations') + .set(REQUEST_HEADER_CLIENT_ID, client.clientId) + .set(REQUEST_HEADER_CLIENT_SECRET, client.clientSecret) + .send(payload) + + expect(body).toMatchObject({ + decision: Decision.PERMIT, + request: payload.request, + accessToken: { + value: expect.any(String) + }, + approvals: { + missing: [], + required: [], + satisfied: [] + } + }) + expect(status).toEqual(HttpStatus.OK) }) - expect(status).toEqual(HttpStatus.OK) }) - it('evaluates a permit', async () => { - await clientService.savePolicyStore( - client.clientId, - await getPolicyStore( - [ - { - id: 'test-permit-policy', - then: Then.PERMIT, - description: 'test permit policy', - when: [ - { - criterion: Criterion.CHECK_ACTION, - args: [Action.SIGN_TRANSACTION] - } - ] - } - ], - privateKey + describe('when sign message ', () => { + it('evaluates a forbid', async () => { + const payload = await generateSignMessageRequest() + + const { status, body } = await request(app.getHttpServer()) + .post('/evaluations') + .set(REQUEST_HEADER_CLIENT_ID, client.clientId) + .set(REQUEST_HEADER_CLIENT_SECRET, client.clientSecret) + .send(payload) + + expect(body).toEqual({ + decision: Decision.FORBID, + request: payload.request + }) + expect(status).toEqual(HttpStatus.OK) + }) + + it('evaluates a permit', async () => { + await clientService.savePolicyStore( + client.clientId, + await getPolicyStore( + [ + { + id: 'test-permit-policy', + then: Then.PERMIT, + description: 'test permit policy', + when: [ + { + criterion: Criterion.CHECK_ACTION, + args: [Action.SIGN_MESSAGE] + } + ] + } + ], + privateKey + ) ) - ) - const payload = await generateInboundEvaluationRequest() + const payload = await generateSignMessageRequest() - const { status, body } = await request(app.getHttpServer()) - .post('/evaluations') - .set(REQUEST_HEADER_CLIENT_ID, client.clientId) - .set(REQUEST_HEADER_CLIENT_SECRET, client.clientSecret) - .send(payload) + const { status, body } = await request(app.getHttpServer()) + .post('/evaluations') + .set(REQUEST_HEADER_CLIENT_ID, client.clientId) + .set(REQUEST_HEADER_CLIENT_SECRET, client.clientSecret) + .send(payload) - expect(body).toMatchObject({ - decision: Decision.PERMIT, - request: payload.request, - accessToken: { - value: expect.any(String) - }, - approvals: { - missing: [], - required: [], - satisfied: [] - } + expect(body).toMatchObject({ + decision: Decision.PERMIT, + request: payload.request, + accessToken: { + value: expect.any(String) + }, + approvals: { + missing: [], + required: [], + satisfied: [] + } + }) + expect(status).toEqual(HttpStatus.OK) }) - expect(status).toEqual(HttpStatus.OK) }) it('responds with forbid when client secret is missing', async () => { - const payload = await generateInboundEvaluationRequest() + const payload = await generateSignTransactionRequest() const { status, body } = await request(app.getHttpServer()) .post('/evaluations') diff --git a/apps/policy-engine/src/engine/app.controller.ts b/apps/policy-engine/src/engine/app.controller.ts index a8e3f4371..80f3f98b8 100644 --- a/apps/policy-engine/src/engine/app.controller.ts +++ b/apps/policy-engine/src/engine/app.controller.ts @@ -1,6 +1,6 @@ import { FIXTURE } from '@narval/policy-engine-shared' import { Controller, Get, Logger, Post } from '@nestjs/common' -import { generateInboundEvaluationRequest } from '../shared/testing/evaluation.testing' +import { generateSignTransactionRequest } from '../shared/testing/evaluation.testing' import { EvaluationService } from './core/service/evaluation.service' @Controller() @@ -24,7 +24,7 @@ export class AppController { @Post('/evaluation-demo') async evaluateDemo() { - const evaluation = await generateInboundEvaluationRequest() + const evaluation = await generateSignTransactionRequest() this.logger.log('Received evaluation', { evaluation }) @@ -43,6 +43,6 @@ export class AppController { @Get('/generate-inbound-request') generateInboundRequest() { - return generateInboundEvaluationRequest() + return generateSignTransactionRequest() } } diff --git a/apps/policy-engine/src/open-policy-agent/core/__test__/unit/open-policy-agent.engine.spec.ts b/apps/policy-engine/src/open-policy-agent/core/__test__/unit/open-policy-agent.engine.spec.ts index 449a2d7a0..8e7d7103b 100644 --- a/apps/policy-engine/src/open-policy-agent/core/__test__/unit/open-policy-agent.engine.spec.ts +++ b/apps/policy-engine/src/open-policy-agent/core/__test__/unit/open-policy-agent.engine.spec.ts @@ -10,6 +10,7 @@ import { JwtString, Policy, Request, + SignMessageAction, Then, toHex } from '@narval/policy-engine-shared' @@ -107,16 +108,22 @@ describe('OpenPolicyAgentEngine', () => { describe('evaluate', () => { it('throws OpenPolicyAgentException when action is unsupported', async () => { - const request: Partial = { - request: { - action: Action.SIGN_MESSAGE, - nonce: 'test-nonce', - resourceId: 'test-resource-id', - message: 'test-message' - } + const request = { + action: 'UNSUPPORTED ACTION', + nonce: 'test-nonce', + resourceId: 'test-resource-id', + message: 'test-message' + } + const evaluation = { + request, + authentication: await getJwt({ + privateKey: FIXTURE.UNSAFE_PRIVATE_KEY.Alice, + sub: FIXTURE.USER.Alice.id, + request: request as SignMessageAction + }) } - await expect(() => engine.evaluate(request as EvaluationRequest)).rejects.toThrow(OpenPolicyAgentException) + await expect(() => engine.evaluate(evaluation as EvaluationRequest)).rejects.toThrow(OpenPolicyAgentException) }) it('evaluates a forbid rule', async () => { @@ -154,12 +161,12 @@ describe('OpenPolicyAgentEngine', () => { } const evaluation: EvaluationRequest = { + request, authentication: await getJwt({ privateKey: FIXTURE.UNSAFE_PRIVATE_KEY.Alice, sub: FIXTURE.USER.Alice.id, request - }), - request + }) } const response = await e.evaluate(evaluation) diff --git a/apps/policy-engine/src/open-policy-agent/core/open-policy-agent.engine.ts b/apps/policy-engine/src/open-policy-agent/core/open-policy-agent.engine.ts index cbe96afef..9cce0baa4 100644 --- a/apps/policy-engine/src/open-policy-agent/core/open-policy-agent.engine.ts +++ b/apps/policy-engine/src/open-policy-agent/core/open-policy-agent.engine.ts @@ -20,11 +20,13 @@ import { z } from 'zod' import { POLICY_ENTRYPOINT } from '../open-policy-agent.constant' import { OpenPolicyAgentException } from './exception/open-policy-agent.exception' import { resultSchema } from './schema/open-policy-agent.schema' -import { Input, OpenPolicyAgentInstance, Result } from './type/open-policy-agent.type' +import { OpenPolicyAgentInstance, Result } from './type/open-policy-agent.type' import { toData, toInput } from './util/evaluation.util' import { getRegoRuleTemplatePath } from './util/rego-transpiler.util' import { build, getRegoCorePath } from './util/wasm-build.util' +const SUPPORTED_ACTIONS: Action[] = [Action.SIGN_MESSAGE, Action.SIGN_TRANSACTION] + export class OpenPolicyAgentEngine implements Engine { private policies: Policy[] @@ -108,7 +110,7 @@ export class OpenPolicyAgentEngine implements Engine { async evaluate(evaluation: EvaluationRequest): Promise { const { action } = evaluation.request - if (action !== Action.SIGN_TRANSACTION) { + if (!SUPPORTED_ACTIONS.includes(action)) { throw new OpenPolicyAgentException({ message: 'Open Policy Agent engine unsupported action', suggestedHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, @@ -191,13 +193,11 @@ export class OpenPolicyAgentEngine implements Engine { }) } - const input: Input = { - ...toInput(evaluation), + const input = toInput({ + evaluation, principal: credentials.principal, - // TODO: Why the EvaluationRequest specifies approvals as optional but - // the OPA input doesn't? - approvals: credentials.approvals || [] - } + approvals: credentials.approvals + }) // NOTE: When we evaluate an input against the Rego policy core, it returns // an array of results with an inner result. We perform a typecast here to diff --git a/apps/policy-engine/src/open-policy-agent/core/util/__test__/unit/evaluation.util.spec.ts b/apps/policy-engine/src/open-policy-agent/core/util/__test__/unit/evaluation.util.spec.ts index bea324d7f..4708af36a 100644 --- a/apps/policy-engine/src/open-policy-agent/core/util/__test__/unit/evaluation.util.spec.ts +++ b/apps/policy-engine/src/open-policy-agent/core/util/__test__/unit/evaluation.util.spec.ts @@ -1,48 +1,81 @@ -import { Action, EvaluationRequest, FIXTURE, SignTransactionAction } from '@narval/policy-engine-shared' +import { + Action, + EvaluationRequest, + FIXTURE, + SignMessageAction, + SignTransactionAction +} from '@narval/policy-engine-shared' import { InputType, decode } from '@narval/transaction-request-intent' -import { generateInboundEvaluationRequest } from '../../../../../shared/testing/evaluation.testing' +import { + generateSignMessageRequest, + generateSignTransactionRequest +} from '../../../../../shared/testing/evaluation.testing' import { OpenPolicyAgentException } from '../../../exception/open-policy-agent.exception' import { toData, toInput } from '../../evaluation.util' describe('toInput', () => { - it('throws OpenPolicyAgentException when action is unsupported', () => { - const evaluation = { + const principal = FIXTURE.CREDENTIAL.Alice + const approvals = [FIXTURE.CREDENTIAL.Alice, FIXTURE.CREDENTIAL.Bob, FIXTURE.CREDENTIAL.Carol] + + it('throws OpenPolicyAgentException when action is unsupported', async () => { + const evaluation = await generateSignTransactionRequest() + const unsupportedEvaluationRequest = { + ...evaluation, request: { + ...evaluation.request, action: 'UNSUPPORTED ACTION' } } - expect(() => toInput(evaluation as EvaluationRequest)).toThrow(OpenPolicyAgentException) + expect(() => + toInput({ evaluation: unsupportedEvaluationRequest as EvaluationRequest, principal, approvals }) + ).toThrow(OpenPolicyAgentException) + expect(() => + toInput({ evaluation: unsupportedEvaluationRequest as EvaluationRequest, principal, approvals }) + ).toThrow('Unsupported evaluation request action') }) describe(`when action is ${Action.SIGN_TRANSACTION}`, () => { let evaluation: EvaluationRequest beforeEach(async () => { - evaluation = await generateInboundEvaluationRequest() + evaluation = await generateSignTransactionRequest() }) - it('maps the request action', () => { - const input = toInput(evaluation) + it('maps action', () => { + const input = toInput({ evaluation, principal, approvals }) expect(input.action).toEqual(evaluation.request.action) }) - it('maps the transaction request', () => { - const input = toInput(evaluation) + it('maps principal', () => { + const input = toInput({ evaluation, principal, approvals }) + + expect(input.principal).toEqual(principal) + }) + + it('maps resource', () => { + const input = toInput({ evaluation, principal, approvals }) const request = evaluation.request as SignTransactionAction - expect(input.transactionRequest).toEqual(request.transactionRequest) + expect(input.resource).toEqual({ uid: request.resourceId }) }) - it('maps the transfers', () => { - const input = toInput(evaluation) + it('maps approvals', () => { + const input = toInput({ evaluation, principal, approvals }) - expect(input.transfers).toEqual(evaluation.transfers) + expect(input.approvals).toEqual(approvals) }) - it('adds the transaction request intent', () => { - const input = toInput(evaluation) + it('maps transaction request', () => { + const input = toInput({ evaluation, principal, approvals }) + const request = evaluation.request as SignTransactionAction + + expect(input.transactionRequest).toEqual(request.transactionRequest) + }) + + it('adds transaction request intent', () => { + const input = toInput({ evaluation, principal, approvals }) const intent = decode({ input: { type: InputType.TRANSACTION_REQUEST, @@ -53,6 +86,39 @@ describe('toInput', () => { expect(input.intent).toEqual(intent) }) }) + + describe(`when action is ${Action.SIGN_MESSAGE}`, () => { + let evaluation: EvaluationRequest + + beforeEach(async () => { + evaluation = await generateSignMessageRequest() + }) + + it('maps action', () => { + const input = toInput({ evaluation, principal, approvals }) + + expect(input.action).toEqual(evaluation.request.action) + }) + + it('maps principal', () => { + const input = toInput({ evaluation, principal, approvals }) + + expect(input.principal).toEqual(principal) + }) + + it('maps resource', () => { + const input = toInput({ evaluation, principal, approvals }) + const request = evaluation.request as SignMessageAction + + expect(input.resource).toEqual({ uid: request.resourceId }) + }) + + it('maps approvals', () => { + const input = toInput({ evaluation, principal, approvals }) + + expect(input.approvals).toEqual(approvals) + }) + }) }) describe('toData', () => { 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 e2f70b05c..f60029367 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 @@ -1,11 +1,16 @@ -import { Action, Entities, EvaluationRequest } from '@narval/policy-engine-shared' +import { Action, CredentialEntity, Entities, EvaluationRequest } from '@narval/policy-engine-shared' import { InputType, safeDecode } from '@narval/transaction-request-intent' import { HttpStatus } from '@nestjs/common' import { indexBy } from 'lodash/fp' import { OpenPolicyAgentException } from '../exception/open-policy-agent.exception' import { Data, Input, UserGroup, WalletGroup } from '../type/open-policy-agent.type' -export const toInput = (evaluation: EvaluationRequest): Omit => { +export const toInput = (params: { + evaluation: EvaluationRequest + principal: CredentialEntity + approvals?: CredentialEntity[] +}): Input => { + const { evaluation, principal, approvals } = params const { action } = evaluation.request if (action === Action.SIGN_TRANSACTION) { @@ -19,9 +24,11 @@ export const toInput = (evaluation: EvaluationRequest): Omit => { +const sign = async (request: Request) => { + const message = hash(request) + const payload: Payload = { + requestHash: message + } + + const aliceSignature = await signJwt(payload, privateKeyToJwk(UNSAFE_PRIVATE_KEY.Alice, Alg.ES256K)) + const bobSignature = await signJwt(payload, privateKeyToJwk(UNSAFE_PRIVATE_KEY.Bob, Alg.ES256K)) + const carolSignature = await signJwt(payload, privateKeyToJwk(UNSAFE_PRIVATE_KEY.Carol, Alg.ES256K)) + + return { aliceSignature, bobSignature, carolSignature } +} + +export const generateSignTransactionRequest = async (): Promise => { const txRequest: TransactionRequest = { from: FIXTURE.WALLET.Engineering.address, to: FIXTURE.WALLET.Treasury.address, @@ -23,14 +37,24 @@ export const generateInboundEvaluationRequest = async (): Promise => { + const request: Request = { + action: Action.SIGN_MESSAGE, + nonce: uuid(), + resourceId: FIXTURE.WALLET.Engineering.id, + message: 'generated sign message request' + } + + const { aliceSignature, bobSignature, carolSignature } = await sign(request) return { authentication: aliceSignature,