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 89779bc1c..655680d58 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 @@ -15,7 +15,6 @@ import { import { SigningAlg, buildSignerEip191, hash, privateKeyToJwk, signJwt } from '@narval/signature' import { ConfigModule, ConfigService, Path, PathValue } from '@nestjs/config' import { Test, TestingModule } from '@nestjs/testing' -import { resolve } from 'path' import { Config, load } from '../../../../policy-engine.config' import { OpenPolicyAgentException } from '../../exception/open-policy-agent.exception' import { OpenPolicyAgentEngine } from '../../open-policy-agent.engine' @@ -23,7 +22,7 @@ import { Result } from '../../type/open-policy-agent.type' const ONE_ETH = toHex(BigInt('1000000000000000000')) -const REGO_CORE_PATH = resolve(__dirname, '../../rego') +const UNSAFE_ENGINE_PRIVATE_KEY = '0x7cfef3303797cbc7515d9ce22ffe849c701b0f2812f999b0847229c47951fca5' const getJwt = (option: { privateKey: Hex; request: Request; sub: string }): Promise => { const jwk = privateKeyToJwk(option.privateKey) @@ -54,12 +53,18 @@ describe('OpenPolicyAgentEngine', () => { let engine: OpenPolicyAgentEngine beforeEach(async () => { - engine = await OpenPolicyAgentEngine.empty(await getConfig('resourcePath')).load() + engine = await OpenPolicyAgentEngine.empty({ + resourcePath: await getConfig('resourcePath'), + privateKey: UNSAFE_ENGINE_PRIVATE_KEY + }).load() }) describe('empty', () => { it('starts with an empty state', async () => { - const e = OpenPolicyAgentEngine.empty(await getConfig('resourcePath')) + const e = OpenPolicyAgentEngine.empty({ + resourcePath: await getConfig('resourcePath'), + privateKey: UNSAFE_ENGINE_PRIVATE_KEY + }) expect(e.getPolicies()).toEqual([]) expect(e.getEntities()).toEqual({ @@ -120,10 +125,6 @@ describe('OpenPolicyAgentEngine', () => { { criterion: Criterion.CHECK_ACTION, args: [Action.SIGN_TRANSACTION] - }, - { - criterion: Criterion.CHECK_PRINCIPAL_ID, - args: [FIXTURE.USER.Alice.id] } ] } @@ -132,6 +133,7 @@ describe('OpenPolicyAgentEngine', () => { const e = await new OpenPolicyAgentEngine({ policies, entities: FIXTURE.ENTITIES, + privateKey: UNSAFE_ENGINE_PRIVATE_KEY, resourcePath: await getConfig('resourcePath') }).load() @@ -163,6 +165,58 @@ describe('OpenPolicyAgentEngine', () => { request: evaluation.request }) }) + + it('adds access token on permit responses', async () => { + const policies: Policy[] = [ + { + then: Then.PERMIT, + name: 'test-policy', + when: [ + { + criterion: Criterion.CHECK_ACTION, + args: [Action.SIGN_TRANSACTION] + } + ] + } + ] + + const e = await new OpenPolicyAgentEngine({ + policies, + entities: FIXTURE.ENTITIES, + privateKey: UNSAFE_ENGINE_PRIVATE_KEY, + resourcePath: await getConfig('resourcePath') + }).load() + + const request = { + action: Action.SIGN_TRANSACTION, + nonce: 'test-nonce', + transactionRequest: { + from: FIXTURE.WALLET.Engineering.address, + to: FIXTURE.WALLET.Testing.address, + value: ONE_ETH, + chainId: 1 + }, + resourceId: FIXTURE.WALLET.Engineering.id + } + + const evaluation: EvaluationRequest = { + authentication: await getJwt({ + privateKey: FIXTURE.UNSAFE_PRIVATE_KEY.Alice, + sub: FIXTURE.USER.Alice.id, + request + }), + request + } + + const response = await e.evaluate(evaluation) + + expect(response.decision).toEqual(Decision.PERMIT) + // TODO: (@wcalderipe, 20/03/24) Today we can't assert the signature + // because the function isn't pure due to the dependency on Date.now + expect(response.accessToken).toMatchObject({ + value: expect.any(String) + }) + }) }) describe('decide', () => { 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 27c4b9e08..8a985b33f 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 @@ -7,10 +7,23 @@ import { Entities, EvaluationRequest, EvaluationResponse, + JsonWebKey, JwtString, Policy } from '@narval/policy-engine-shared' -import { Hex, decode, hash, publicKeyToJwk, verifyJwt } from '@narval/signature' +import { + Hex, + Payload, + SigningAlg, + base64UrlToHex, + buildSignerEip191, + decode, + hash, + privateKeyToJwk, + publicKeyToJwk, + signJwt, + verifyJwt +} from '@narval/signature' import { HttpStatus } from '@nestjs/common' import { loadPolicy } from '@open-policy-agent/opa-wasm' import { compact } from 'lodash/fp' @@ -31,15 +44,22 @@ export class OpenPolicyAgentEngine implements Engine { private resourcePath: string + // TODO: (@wcalderipe, 20/03/24) How we store and recover a signing key will + // change very soon because we need to support MPC signing. + // This code is here just for feature parity with the existing proof of + // concept. + private privateKey: Hex + private opa?: OpenPolicyAgentInstance - constructor(params: { policies: Policy[]; entities: Entities; resourcePath: string }) { + constructor(params: { policies: Policy[]; entities: Entities; resourcePath: string; privateKey: Hex }) { this.entities = params.entities this.policies = params.policies + this.privateKey = params.privateKey this.resourcePath = params.resourcePath } - static empty(resourcePath: string): OpenPolicyAgentEngine { + static empty(params: { resourcePath: string; privateKey: Hex }): OpenPolicyAgentEngine { return new OpenPolicyAgentEngine({ entities: { addressBook: [], @@ -54,7 +74,8 @@ export class OpenPolicyAgentEngine implements Engine { wallets: [] }, policies: [], - resourcePath: resourcePath + resourcePath: params.resourcePath, + privateKey: params.privateKey }) } @@ -120,13 +141,11 @@ export class OpenPolicyAgentEngine implements Engine { }) } - const signedMessage = hash(evaluation.request) - const principalCredential = await this.verifySignature(evaluation.authentication, signedMessage) + const message = hash(evaluation.request) + const principalCredential = await this.verifySignature(evaluation.authentication, message) const approvalsCredential = await Promise.all( - (evaluation.approvals ? evaluation.approvals : []).map((signature) => - this.verifySignature(signature, signedMessage) - ) + (evaluation.approvals ? evaluation.approvals : []).map((signature) => this.verifySignature(signature, message)) ) const results = await this.opaEvaluate(evaluation, { @@ -135,11 +154,25 @@ export class OpenPolicyAgentEngine implements Engine { }) const decision = this.decide(results) - return { + const response: EvaluationResponse = { decision: decision.decision, approvals: decision.approvals, request: evaluation.request } + + if (response.decision === Decision.PERMIT) { + return { + ...response, + accessToken: { + value: await this.sign({ + principalCredential, + message + }) + } + } + } + + return response } private async verifySignature(signature: JwtString, message: string) { @@ -269,4 +302,32 @@ export class OpenPolicyAgentEngine implements Engine { private isPermitMissingApproval(result: Result): boolean { return Boolean(result.reasons?.some((reason) => reason.type === 'permit' && reason.approvalsMissing.length > 0)) } + + private async sign(params: { principalCredential: CredentialEntity; message: string }): Promise { + const engineJwk: JsonWebKey = privateKeyToJwk(this.privateKey) + const principalJwk = publicKeyToJwk(params.principalCredential.pubKey as Hex) + + const payload: Payload = { + requestHash: params.message, + sub: params.principalCredential.userId, + // TODO: iat & exp values must be arguments, cannot generate timestamps + // because of cluster mis-match + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 60 * 10, // 10 minutes + // TODO: allow client-specific; should come from config + iss: 'https://armory.narval.xyz', + // aud: TODO + // jti: TODO + cnf: principalJwk + } + + if (!engineJwk.d) { + throw new OpenPolicyAgentException({ + message: 'Missing signing private key', + suggestedHttpStatusCode: HttpStatus.INTERNAL_SERVER_ERROR + }) + } + + return signJwt(payload, engineJwk, { alg: SigningAlg.EIP191 }, buildSignerEip191(base64UrlToHex(engineJwk.d))) + } }