Skip to content

Commit

Permalink
Ensure type-safety of the OPA results
Browse files Browse the repository at this point in the history
  • Loading branch information
wcalderipe committed Mar 18, 2024
1 parent b59e631 commit 5ffdd67
Show file tree
Hide file tree
Showing 6 changed files with 109 additions and 68 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import { HttpStatus } from '@nestjs/common'
import { loadPolicy } from '@open-policy-agent/opa-wasm'
import { resolve } from 'path'
import { v4 } from 'uuid'
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 { OpenPolicyAgentInstance, Result } from './type/open-policy-agent.type'
import { toData, toInput } from './util/evaluation.util'
import { build } from './util/wasm-build.util'
Expand Down Expand Up @@ -92,13 +94,6 @@ export class OpenPolicyAgentEngine implements Engine<OpenPolicyAgentEngine> {
}

async evaluate(request: EvaluationRequest): Promise<EvaluationResponse> {
if (!this.opa) {
throw new OpenPolicyAgentException({
message: 'Open Policy Agent engine not loaded',
suggestedHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY
})
}

const { action } = request.request

if (action !== Action.SIGN_TRANSACTION) {
Expand All @@ -109,39 +104,58 @@ export class OpenPolicyAgentEngine implements Engine<OpenPolicyAgentEngine> {
})
}

const results = (await this.opa.evaluate(toInput(request), POLICY_ENTRYPOINT)) as Result[]

// [ ] Finalize the decision
// [ ] Build the EvaluationResponse
// [ ] Maybe sign it? Must input the data somehow

const results = await this.opaEvaluate(request)
const decision = this.decide(results)

return {
decision: decision.decision,
request: request.request,
approvals: decision.totalApprovalsRequired?.length
? {
required: decision.totalApprovalsRequired,
satisfied: decision.approvalsSatisfied,
missing: decision.approvalsMissing
}
: undefined
approvals: undefined
//approvals: decision.totalApprovalsRequired?.length
// ? {
// required: decision.totalApprovalsRequired,
// satisfied: decision.approvalsSatisfied,
// missing: decision.approvalsMissing
// }
// : undefined
}
}

decide(results: Result[]) {
// Implicit Forbid - not root user and no rules matching
const implicitForbid = results.some((r) => r?.default === true && r.permit === false && r.reasons?.length === 0)
private async opaEvaluate(evaluation: EvaluationRequest): Promise<Result[]> {
if (!this.opa) {
throw new OpenPolicyAgentException({
message: 'Open Policy Agent engine not loaded',
suggestedHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY
})
}

// Explicit Forbid - a Forbid rule type that matches & decides Forbid
const anyExplicitForbid = results.some((r) => r.permit === false && r.reasons?.some((rr) => rr.type === 'forbid'))
// 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
// satisfy TypeScript compiler. Later, we parse the schema a few lines
// below to ensure type-safety for data coming from external sources.
const results = (await this.opa.evaluate(toInput(evaluation), POLICY_ENTRYPOINT)) as { result: unknown }[]

const allPermit = results.every((r) => r.permit === true && r.reasons?.every((rr) => rr.type === 'permit'))
const parse = z.array(resultSchema).safeParse(results.map(({ result }) => result))

const anyPermitWithMissingApprovals = results.some((r) =>
r.reasons?.some((rr) => rr.type === 'permit' && rr.approvalsMissing.length > 0)
)
if (parse.success) {
return parse.data
}

throw new OpenPolicyAgentException({
message: 'Invalid Open Policy Agent result schema',
suggestedHttpStatusCode: HttpStatus.INTERNAL_SERVER_ERROR,
context: {
results,
error: parse.error.errors
}
})
}

decide(results: Result[]) {
const implicitForbid = results.some(this.isImplictForbid)
const anyExplicitForbid = results.some(this.isExplictForbid)
const allPermit = results.every(this.isPermit)
const anyPermitWithMissingApprovals = results.some(this.isPermitMissingApproval)

if (implicitForbid || anyExplicitForbid) {
return {
Expand All @@ -155,9 +169,11 @@ export class OpenPolicyAgentEngine implements Engine<OpenPolicyAgentEngine> {
// Collect all the approvalsMissing & approvalsSatisfied using functional
// map/flat operators
const approvalsSatisfied = results
.flatMap((r) => r.reasons?.flatMap((rr) => rr.approvalsSatisfied))
.flatMap((result) => result.reasons?.flatMap((reason) => reason.approvalsSatisfied))
.filter((v) => !!v)
const approvalsMissing = results
.flatMap((result) => result.reasons?.flatMap((reason) => reason.approvalsMissing))
.filter((v) => !!v)
const approvalsMissing = results.flatMap((r) => r.reasons?.flatMap((rr) => rr.approvalsMissing)).filter((v) => !!v)
const totalApprovalsRequired = approvalsMissing.concat(approvalsSatisfied)

const decision = allPermit && !anyPermitWithMissingApprovals ? Decision.PERMIT : Decision.CONFIRM
Expand All @@ -170,4 +186,20 @@ export class OpenPolicyAgentEngine implements Engine<OpenPolicyAgentEngine> {
approvalsSatisfied
}
}

private isImplictForbid(result: Result): boolean {
return result.default === true && result.permit === false && result.reasons?.length === 0
}

private isExplictForbid(result: Result): boolean {
return Boolean(result.permit === false && result.reasons?.some((reason) => reason.type === 'forbid'))
}

private isPermit(result: Result): boolean {
return Boolean(result.permit === true && result.reasons?.every((reason) => reason.type === 'permit'))
}

private isPermitMissingApproval(result: Result): boolean {
return Boolean(result.reasons?.some((reason) => reason.type === 'permit' && reason.approvalsMissing.length > 0))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { approvalRequirementSchema } from '@narval/policy-engine-shared'
import { z } from 'zod'

export const resultSchema = z.object({
default: z.boolean().optional(),
permit: z.boolean(),
reasons: z
.array(
z.object({
policyName: z.string(),
policyId: z.string(),
type: z.enum(['permit', 'forbid']),
approvalsSatisfied: z.array(approvalRequirementSchema),
approvalsMissing: z.array(approvalRequirementSchema)
})
)
.optional()
})
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import {
AccountType,
Action,
Address,
ApprovalRequirement,
CredentialEntity,
HistoricalTransfer,
TransactionRequest,
UserRole
} from '@narval/policy-engine-shared'
import { Intent } from '@narval/transaction-request-intent'
import { loadPolicy } from '@open-policy-agent/opa-wasm'
import { z } from 'zod'
import { resultSchema } from '../schema/open-policy-agent.schema'

type PromiseType<T extends Promise<unknown>> = T extends Promise<infer U> ? U : never

Expand Down Expand Up @@ -78,18 +79,4 @@ export type Data = {
}
}

type MatchedRule = {
policyName: string
policyId: string
// TODO: Check with @samteb why we're not using Decision constant. Can we use
// it?
type: 'permit' | 'forbid'
approvalsSatisfied: ApprovalRequirement[]
approvalsMissing: ApprovalRequirement[]
}

export type Result = {
default?: boolean
permit: boolean
reasons: MatchedRule[]
}
export type Result = z.infer<typeof resultSchema>
13 changes: 7 additions & 6 deletions packages/policy-engine-shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ export * from './lib/decorators/is-not-empty-array-string.decorator'

export * from './lib/dto'

export * from './lib/schema/address.schema'
export * from './lib/schema/data-store.schema'
export * from './lib/schema/domain.schema'
export * from './lib/schema/entity.schema'
export * from './lib/schema/hex.schema'
export * from './lib/schema/policy.schema'

export * from './lib/type/action.type'
export * from './lib/type/data-store.type'
export * from './lib/type/domain.type'
Expand All @@ -22,10 +29,4 @@ export * from './lib/util/evm.util'
export * from './lib/util/json.util'
export * from './lib/util/typeguards'

export * from './lib/schema/address.schema'
export * from './lib/schema/data-store.schema'
export * from './lib/schema/entity.schema'
export * from './lib/schema/hex.schema'
export * from './lib/schema/policy.schema'

export * as FIXTURE from './lib/dev.fixture'
15 changes: 15 additions & 0 deletions packages/policy-engine-shared/src/lib/schema/domain.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { z } from 'zod'
import { EntityType } from '../type/domain.type'

export const approvalRequirementSchema = z.object({
approvalCount: z.number().min(0),
/**
* The number of requried approvals
*/
approvalEntityType: z.nativeEnum(EntityType),
/**
* List of entities IDs that must satisfy the requirements.
*/
entityIds: z.array(z.string()),
countPrincipal: z.boolean()
})
18 changes: 3 additions & 15 deletions packages/policy-engine-shared/src/lib/type/domain.type.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { z } from 'zod'
import { approvalRequirementSchema } from '../schema/domain.schema'
import { AssetId } from '../util/caip.util'
import { CreateOrganizationAction, SignMessageAction, SignTransactionAction, SignTypedDataAction } from './action.type'

Expand Down Expand Up @@ -138,21 +140,7 @@ export type EvaluationRequest = {
feeds?: Feed<unknown>[]
}

export type ApprovalRequirement = {
/**
* The number of requried approvals
*/
approvalCount: number // Number approvals required
/**
* The entity type required to approve.
*/
approvalEntityType: EntityType
/**
* List of entities IDs that must satisfy the requirements.
*/
entityIds: string[]
countPrincipal: boolean
}
export type ApprovalRequirement = z.infer<typeof approvalRequirementSchema>

export type AccessToken = {
value: string // JWT
Expand Down

0 comments on commit 5ffdd67

Please sign in to comment.