From 86b1b2ae2fc569a2bae6e6686c56330c10a438d2 Mon Sep 17 00:00:00 2001 From: Samuel Date: Fri, 16 Aug 2024 17:33:20 +0200 Subject: [PATCH] Add approval requirements to evaluations results (#502) * Add prisma schema * Add migration file * wip * Fix * fix test * fix tests * fixes after CR * standardizing authRequest/evalLog/approvalReq decoding & enforce always fully populated object --------- Co-authored-by: Matt Schoch --- .../service/authorization-request.service.ts | 2 + .../decode/authorization-request.decode.ts | 71 ++++++-- .../authorization-request.repository.spec.ts | 94 ++++++++-- .../authorization-request.repository.ts | 164 ++++++++++++++---- .../persistence/type/model.type.ts | 4 +- .../migration.sql | 19 ++ .../module/persistence/schema/schema.prisma | 31 +++- .../src/lib/type/authorization-server.type.ts | 4 +- .../src/lib/type/domain.type.ts | 22 +-- 9 files changed, 326 insertions(+), 85 deletions(-) create mode 100644 apps/armory/src/shared/module/persistence/schema/migrations/20240816023527_add_approval_requirement_table/migration.sql diff --git a/apps/armory/src/orchestration/core/service/authorization-request.service.ts b/apps/armory/src/orchestration/core/service/authorization-request.service.ts index 9fe20b9e2..7e00bfa50 100644 --- a/apps/armory/src/orchestration/core/service/authorization-request.service.ts +++ b/apps/armory/src/orchestration/core/service/authorization-request.service.ts @@ -147,6 +147,8 @@ export class AuthorizationRequestService { id: uuid(), decision: evaluation.decision, signature: evaluation.accessToken?.value || null, + approvalRequirements: evaluation.approvals, + transactionRequestIntent: evaluation.transactionRequestIntent, createdAt: new Date() } ] diff --git a/apps/armory/src/orchestration/persistence/decode/authorization-request.decode.ts b/apps/armory/src/orchestration/persistence/decode/authorization-request.decode.ts index ab4e649fd..607173a35 100644 --- a/apps/armory/src/orchestration/persistence/decode/authorization-request.decode.ts +++ b/apps/armory/src/orchestration/persistence/decode/authorization-request.decode.ts @@ -1,33 +1,30 @@ -import { Action, AuthorizationRequest, AuthorizationRequestError, Evaluation } from '@narval/policy-engine-shared' import { + Action, + ApprovalRequirement, + Approvals, + AuthorizationRequest, + AuthorizationRequestError, + Evaluation +} from '@narval/policy-engine-shared' +import { + ApprovalRequirement as ApprovalRequirementModel, AuthorizationRequestError as AuthorizationRequestErrorModel, - EvaluationLog as EvaluationLogModel, Prisma } from '@prisma/client/armory' -import { SetOptional } from 'type-fest' import { ZodIssueCode, ZodSchema, z } from 'zod' import { ACTION_REQUEST } from '../../orchestration.constant' import { DecodeAuthorizationRequestException } from '../../persistence/exception/decode-authorization-request.exception' -import { AuthorizationRequestModel } from '../../persistence/type/model.type' - -type Model = SetOptional +import { AuthorizationRequestModel, EvaluationLogWithApprovalsModel } from '../../persistence/type/model.type' const actionSchema = z.nativeEnum(Action) -const buildEvaluation = ({ id, decision, signature, createdAt }: EvaluationLogModel): Evaluation => ({ - id, - decision, - signature, - createdAt -}) - const buildError = ({ id, message, name }: AuthorizationRequestErrorModel): AuthorizationRequestError => ({ id, message, name }) -const buildSharedAttributes = (model: Model): Omit => { +const buildSharedAttributes = (model: AuthorizationRequestModel): Omit => { return { id: model.id, clientId: model.clientId, @@ -35,7 +32,7 @@ const buildSharedAttributes = (model: Model): Omit !error).map((approval) => approval.sig)), - evaluations: (model.evaluationLog || []).map(buildEvaluation), + evaluations: (model.evaluationLog || []).map(decodeEvaluationLog), metadata: model.metadata as Prisma.InputJsonObject, errors: (model.errors || []).map(buildError), createdAt: model.createdAt, @@ -49,7 +46,7 @@ const buildSharedAttributes = (model: Model): Omit { +const decode = ({ model, schema }: { model: AuthorizationRequestModel; schema: ZodSchema }): AuthorizationRequest => { try { const decode = schema.safeParse(model.request) @@ -80,7 +77,7 @@ const decode = ({ model, schema }: { model: Model; schema: ZodSchema }): Authori * @throws {DecodeAuthorizationRequestException} * @returns {AuthorizationRequest} */ -export const decodeAuthorizationRequest = (model: Model): AuthorizationRequest => { +export const decodeAuthorizationRequest = (model: AuthorizationRequestModel): AuthorizationRequest => { const action = actionSchema.safeParse(model.action) if (action.success) { @@ -104,3 +101,43 @@ export const decodeAuthorizationRequest = (model: Model): AuthorizationRequest = } ]) } + +/** + * Decodes the list of approval requirements into the Approval object type, throws on errors. + * + * @throws {DecodeAuthorizationRequestException} + * @returns {Approvals} + */ +export const decodeApprovalRequirement = (requirements: ApprovalRequirementModel[]): Approvals => { + const result: Required = requirements.reduce( + (acc, r) => { + const parsed = ApprovalRequirement.safeParse(r) + if (!parsed.success) { + throw new DecodeAuthorizationRequestException(parsed.error.issues) + } + const requirement = parsed.data + + if (r.isSatisfied) { + acc.satisfied.push(requirement) + } else { + acc.missing.push(requirement) + } + + acc.required.push(requirement) + + return acc + }, + { required: [], satisfied: [], missing: [] } as Required + ) + + return result +} + +export const decodeEvaluationLog = (evaluation: EvaluationLogWithApprovalsModel): Evaluation => ({ + id: evaluation.id, + decision: evaluation.decision, + signature: evaluation.signature, + transactionRequestIntent: evaluation.transactionRequestIntent, + createdAt: evaluation.createdAt, + approvalRequirements: decodeApprovalRequirement(evaluation.approvals) +}) diff --git a/apps/armory/src/orchestration/persistence/repository/__test__/integration/authorization-request.repository.spec.ts b/apps/armory/src/orchestration/persistence/repository/__test__/integration/authorization-request.repository.spec.ts index 578eb6103..53d2c8220 100644 --- a/apps/armory/src/orchestration/persistence/repository/__test__/integration/authorization-request.repository.spec.ts +++ b/apps/armory/src/orchestration/persistence/repository/__test__/integration/authorization-request.repository.spec.ts @@ -4,6 +4,7 @@ import { Action, AuthorizationRequest, AuthorizationRequestError, + EntityType, Evaluation, FIXTURE, SignTransaction @@ -116,27 +117,40 @@ describe(AuthorizationRequestRepository.name, () => { id: '404853b2-1338-47f5-be17-a1aa78da8010', decision: 'Permit', signature: 'test-signature', - createdAt: new Date() + createdAt: new Date(), + transactionRequestIntent: { + action: Action.SIGN_MESSAGE, + nonce: '99', + resourceId: '239bb48b-f708-47ba-97fa-ef336be4dffe', + message: 'Test request' + }, + approvalRequirements: { + required: [ + { + approvalCount: 1, + approvalEntityType: EntityType.User, + entityIds: [client.id], + countPrincipal: true + } + ], + missing: [ + { + approvalCount: 1, + approvalEntityType: EntityType.User, + entityIds: [client.id], + countPrincipal: true + } + ], + satisfied: [] + } } - await repository.create({ + const { evaluations } = await repository.create({ ...signMessageRequest, evaluations: [permit] }) - const evaluations = await testPrismaService.getClient().evaluationLog.findMany({ - where: { - requestId: signMessageRequest.id - } - }) - - expect(evaluations).toEqual([ - { - ...permit, - requestId: signMessageRequest.id, - clientId: signMessageRequest.clientId - } - ]) + expect(evaluations).toEqual([permit]) }) it('creates approvals', async () => { @@ -237,8 +251,28 @@ describe(AuthorizationRequestRepository.name, () => { evaluations: [ { id: '404853b2-1338-47f5-be17-a1aa78da8010', - decision: 'Permit', + decision: 'Confirm', signature: 'test-signature', + transactionRequestIntent: null, + approvalRequirements: { + required: [ + { + approvalCount: 1, + approvalEntityType: EntityType.User, + entityIds: [client.id], + countPrincipal: true + } + ], + missing: [ + { + approvalCount: 1, + approvalEntityType: EntityType.User, + entityIds: [client.id], + countPrincipal: true + } + ], + satisfied: [] + }, createdAt: new Date() } ] @@ -251,6 +285,26 @@ describe(AuthorizationRequestRepository.name, () => { id: 'cc329386-a2dd-4024-86fd-323a630ed703', decision: 'Permit', signature: 'test-signature', + approvalRequirements: { + required: [ + { + approvalCount: 1, + approvalEntityType: EntityType.User, + entityIds: [client.id], + countPrincipal: true + } + ], + missing: [], + satisfied: [ + { + approvalCount: 1, + approvalEntityType: EntityType.User, + entityIds: [client.id], + countPrincipal: true + } + ] + }, + transactionRequestIntent: null, createdAt: new Date() } ] @@ -261,6 +315,14 @@ describe(AuthorizationRequestRepository.name, () => { expect(authzRequestOne.evaluations.length).toEqual(1) expect(authzRequestTwo.evaluations.length).toEqual(2) expect(actual?.evaluations.length).toEqual(2) + + expect(actual?.evaluations[0].approvalRequirements?.required?.length).toEqual(1) + expect(actual?.evaluations[0].approvalRequirements?.missing?.length).toEqual(1) + expect(actual?.evaluations[0].approvalRequirements?.satisfied?.length).toEqual(0) + + expect(actual?.evaluations[1].approvalRequirements?.required?.length).toEqual(1) + expect(actual?.evaluations[1].approvalRequirements?.missing?.length).toEqual(0) + expect(actual?.evaluations[1].approvalRequirements?.satisfied?.length).toEqual(1) }) it('appends approvals', async () => { diff --git a/apps/armory/src/orchestration/persistence/repository/authorization-request.repository.ts b/apps/armory/src/orchestration/persistence/repository/authorization-request.repository.ts index 9727f065a..71b8d8abc 100644 --- a/apps/armory/src/orchestration/persistence/repository/authorization-request.repository.ts +++ b/apps/armory/src/orchestration/persistence/repository/authorization-request.repository.ts @@ -5,10 +5,13 @@ import { CreateAuthorizationRequest, Evaluation } from '@narval/policy-engine-shared' -import { Injectable } from '@nestjs/common' +import { hash } from '@narval/signature' +import { HttpStatus, Injectable } from '@nestjs/common' +import { Prisma } from '@prisma/client/armory' import { v4 as uuid } from 'uuid' +import { ApplicationException } from '../../../shared/exception/application.exception' import { PrismaService } from '../../../shared/module/persistence/service/prisma.service' -import { decodeAuthorizationRequest } from '../decode/authorization-request.decode' +import { decodeAuthorizationRequest, decodeEvaluationLog } from '../decode/authorization-request.decode' import { createRequestSchema } from '../schema/request.schema' @Injectable() @@ -21,9 +24,8 @@ export class AuthorizationRequestRepository { const request = createRequestSchema.parse(input.request) const approvals = (input.approvals || []).map((sig) => ({ sig })) const errors = this.toErrors(clientId, input.errors) - const evaluationLogs = this.toEvaluationLogs(clientId, input.evaluations) - const model = await this.prismaService.authorizationRequest.create({ + await this.prismaService.authorizationRequest.create({ data: { id, status, @@ -44,35 +46,115 @@ export class AuthorizationRequestRepository { createMany: { data: errors } - }, - evaluationLog: { - createMany: { - data: evaluationLogs - } } }, include: { approvals: true, - errors: true, - evaluationLog: true + errors: true } }) - return decodeAuthorizationRequest(model) + await this.createEvaluationLogs({ + requestId: id, + clientId, + evaluations: input.evaluations + }) + + const authRequest = await this.findById(id) + if (!authRequest) { + throw new ApplicationException({ + message: 'Authorization request not found', + suggestedHttpStatusCode: HttpStatus.BAD_REQUEST + }) + } + return authRequest } - private toEvaluationLogs(clientId?: string, evaluations?: Evaluation[]) { + private async createEvaluationLogs({ + requestId, + clientId, + evaluations + }: { + requestId: string + clientId?: string + evaluations?: Evaluation[] + }) { + const evaluationLogs = this.toEvaluationLogs({ requestId, clientId, evaluations }) + const approvalRequirements = this.toApprovalRequirements(evaluations) + + await this.prismaService.evaluationLog.createMany({ + data: evaluationLogs + }) + + await this.prismaService.approvalRequirement.createMany({ + data: approvalRequirements + }) + + return this.getEvaluationLogs(requestId) + } + + private async getEvaluationLogs(requestId: string) { + const evaluationLogs = await this.prismaService.evaluationLog.findMany({ + where: { requestId }, + include: { + approvals: true + } + }) + + return evaluationLogs.map(decodeEvaluationLog) + } + + private toEvaluationLogs({ + requestId, + clientId, + evaluations + }: { + requestId: string + clientId?: string + evaluations?: Evaluation[] + }): Prisma.EvaluationLogCreateManyInput[] { if (clientId && evaluations?.length) { - return evaluations.map((evaluation) => ({ - ...evaluation, - clientId - })) + return evaluations.map((e) => { + const { transactionRequestIntent, approvalRequirements, ...evaluation } = e + + return { + ...evaluation, + requestId, + clientId, + transactionRequestIntent: transactionRequestIntent as Prisma.InputJsonObject | undefined + } + }) } return [] } - private toErrors(clientId?: string, errors?: AuthorizationRequestError[]) { + private toApprovalRequirements(evaluations?: Evaluation[]): Prisma.ApprovalRequirementCreateManyInput[] { + return ( + evaluations?.flatMap((e) => { + const { id: evaluationId, approvalRequirements } = e + + const satisfied = + approvalRequirements?.satisfied?.map((approvalRequirement) => ({ + ...approvalRequirement, + id: hash(approvalRequirement) + })) || [] + + return ( + approvalRequirements?.required?.map((requirement) => ({ + isSatisfied: satisfied.find((s) => s.id === hash(requirement)) ? true : false, + evaluationId, + ...requirement + })) || [] + ) + }) || [] + ) + } + + private toErrors( + clientId?: string, + errors?: AuthorizationRequestError[] + ): Prisma.AuthorizationRequestErrorCreateManyRequestInput[] { if (clientId && errors?.length) { return errors.map((error) => ({ id: error.id, @@ -101,10 +183,9 @@ export class AuthorizationRequestRepository { const { id, clientId, status } = input const approvals = (input.approvals || []).map((sig) => ({ sig })) const errors = this.toErrors(clientId, input.errors) - const evaluationLogs = this.toEvaluationLogs(clientId, input.evaluations) // TODO (@wcalderipe, 19/01/24): Cover the skipDuplicate with tests. - const model = await this.prismaService.authorizationRequest.update({ + await this.prismaService.authorizationRequest.update({ where: { id }, data: { status, @@ -114,12 +195,6 @@ export class AuthorizationRequestRepository { skipDuplicates: true } }, - evaluationLog: { - createMany: { - data: evaluationLogs, - skipDuplicates: true - } - }, errors: { createMany: { data: errors, @@ -129,26 +204,43 @@ export class AuthorizationRequestRepository { }, include: { approvals: true, - evaluationLog: true, errors: true } }) - return decodeAuthorizationRequest(model) + await this.createEvaluationLogs({ + requestId: id, + clientId, + evaluations: input.evaluations + }) + + const authRequest = await this.findById(id) + if (!authRequest) { + throw new ApplicationException({ + message: 'Authorization request not found', + suggestedHttpStatusCode: HttpStatus.BAD_REQUEST + }) + } + return authRequest } async findById(id: string): Promise { - const model = await this.prismaService.authorizationRequest.findUnique({ + const authorizationRequestModel = await this.prismaService.authorizationRequest.findUnique({ where: { id }, include: { approvals: true, - evaluationLog: true, - errors: true + errors: true, + evaluationLog: { + include: { + approvals: true + } + } } }) - if (model) { - return decodeAuthorizationRequest(model) + if (authorizationRequestModel) { + const authRequest = decodeAuthorizationRequest(authorizationRequestModel) + return authRequest } return null @@ -163,7 +255,11 @@ export class AuthorizationRequestRepository { }, include: { approvals: true, - evaluationLog: true, + evaluationLog: { + include: { + approvals: true + } + }, errors: true } }) diff --git a/apps/armory/src/orchestration/persistence/type/model.type.ts b/apps/armory/src/orchestration/persistence/type/model.type.ts index 8b7e61229..c3ac6c40b 100644 --- a/apps/armory/src/orchestration/persistence/type/model.type.ts +++ b/apps/armory/src/orchestration/persistence/type/model.type.ts @@ -1,12 +1,14 @@ import { + ApprovalRequirement as ApprovalRequirementModel, AuthorizationRequest, AuthorizationRequestApproval, AuthorizationRequestError, EvaluationLog } from '@prisma/client/armory' +export type EvaluationLogWithApprovalsModel = EvaluationLog & { approvals: ApprovalRequirementModel[] } export type AuthorizationRequestModel = AuthorizationRequest & { - evaluationLog: EvaluationLog[] + evaluationLog: EvaluationLogWithApprovalsModel[] approvals: AuthorizationRequestApproval[] errors?: AuthorizationRequestError[] } diff --git a/apps/armory/src/shared/module/persistence/schema/migrations/20240816023527_add_approval_requirement_table/migration.sql b/apps/armory/src/shared/module/persistence/schema/migrations/20240816023527_add_approval_requirement_table/migration.sql new file mode 100644 index 000000000..bfaab9841 --- /dev/null +++ b/apps/armory/src/shared/module/persistence/schema/migrations/20240816023527_add_approval_requirement_table/migration.sql @@ -0,0 +1,19 @@ +-- AlterTable +ALTER TABLE "evaluation_log" ADD COLUMN "transaction_request_intent" JSONB; + +-- CreateTable +CREATE TABLE "approval_requirement" ( + "id" VARCHAR(255) NOT NULL, + "evaluation_id" TEXT NOT NULL, + "approval_count" INTEGER NOT NULL, + "approval_entity_type" TEXT NOT NULL, + "entity_ids" TEXT[], + "count_principal" BOOLEAN NOT NULL, + "is_satisfied" BOOLEAN NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "approval_requirement_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "approval_requirement" ADD CONSTRAINT "approval_requirement_evaluation_id_fkey" FOREIGN KEY ("evaluation_id") REFERENCES "evaluation_log"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/armory/src/shared/module/persistence/schema/schema.prisma b/apps/armory/src/shared/module/persistence/schema/schema.prisma index 1fa1a1032..166519b9e 100644 --- a/apps/armory/src/shared/module/persistence/schema/schema.prisma +++ b/apps/armory/src/shared/module/persistence/schema/schema.prisma @@ -106,18 +106,37 @@ model AuthorizationRequestError { } model EvaluationLog { - id String @id @default(uuid()) @db.VarChar(255) - clientId String @map("client_id") - requestId String @map("request_id") - decision String - signature String? - createdAt DateTime @default(now()) @map("created_at") + id String @id @default(uuid()) @db.VarChar(255) + clientId String @map("client_id") + requestId String @map("request_id") + transactionRequestIntent Json? @map("transaction_request_intent") + decision String + signature String? + createdAt DateTime @default(now()) @map("created_at") + + approvals ApprovalRequirement[] request AuthorizationRequest @relation(fields: [requestId], references: [id]) @@map("evaluation_log") } +model ApprovalRequirement { + id String @id @default(uuid()) @db.VarChar(255) + evaluationId String @map("evaluation_id") + + approvalCount Int @map("approval_count") + approvalEntityType String @map("approval_entity_type") + entityIds String[] @map("entity_ids") + countPrincipal Boolean @map("count_principal") + isSatisfied Boolean @map("is_satisfied") + createdAt DateTime @default(now()) @map("created_at") + + evaluationLog EvaluationLog @relation(fields: [evaluationId], references: [id]) + + @@map("approval_requirement") +} + // // Transfer Tracking Module // diff --git a/packages/policy-engine-shared/src/lib/type/authorization-server.type.ts b/packages/policy-engine-shared/src/lib/type/authorization-server.type.ts index d19361ef6..40f629d9a 100644 --- a/packages/policy-engine-shared/src/lib/type/authorization-server.type.ts +++ b/packages/policy-engine-shared/src/lib/type/authorization-server.type.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { Action, SerializedTransactionRequest, TransactionRequest } from './action.type' -import { JwtString, Request, SerializedRequest } from './domain.type' +import { Approvals, JwtString, Request, SerializedRequest } from './domain.type' export const AuthorizationRequestStatus = { CREATED: 'CREATED', @@ -17,6 +17,8 @@ export const Evaluation = z.object({ id: z.string(), decision: z.string(), signature: z.string().nullable(), + transactionRequestIntent: z.unknown().optional(), + approvalRequirements: Approvals.optional(), createdAt: z.coerce.date() }) export type Evaluation = z.infer 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 8ba9a927c..510658deb 100644 --- a/packages/policy-engine-shared/src/lib/type/domain.type.ts +++ b/packages/policy-engine-shared/src/lib/type/domain.type.ts @@ -175,6 +175,11 @@ export const SerializedEvaluationRequest = EvaluationRequest.extend({ }) export type SerializedEvaluationRequest = z.infer +export const AccessToken = z.object({ + value: JwtString +}) +export type AccessToken = z.infer + export const ApprovalRequirement = z.object({ approvalCount: z.number().min(0), approvalEntityType: z.nativeEnum(EntityType).describe('The number of requried approvals'), @@ -183,21 +188,18 @@ export const ApprovalRequirement = z.object({ }) export type ApprovalRequirement = z.infer -export const AccessToken = z.object({ - value: JwtString +export const Approvals = z.object({ + required: z.array(ApprovalRequirement).optional(), + missing: z.array(ApprovalRequirement).optional(), + satisfied: z.array(ApprovalRequirement).optional() }) -export type AccessToken = z.infer + +export type Approvals = 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(), + approvals: Approvals.optional(), principal: credentialEntitySchema.optional().describe('The credential identified as the principal in the request'), accessToken: AccessToken.optional(), transactionRequestIntent: z.unknown().optional(),