diff --git a/apps/authz/src/app/__test__/unit/app.service.spec.ts b/apps/authz/src/app/__test__/unit/app.service.spec.ts new file mode 100644 index 000000000..13a5d3f3d --- /dev/null +++ b/apps/authz/src/app/__test__/unit/app.service.spec.ts @@ -0,0 +1,149 @@ +import { finalizeDecision } from '@app/authz/app/app.service' +import { NarvalDecision, NarvalEntities } from '@app/authz/shared/types/domain.type' +import { OpaResult } from '@app/authz/shared/types/rego' + +describe('finalizeDecision', () => { + it('should return Forbid if any of the reasons is Forbid', () => { + const response: OpaResult[] = [ + { + decision: 'forbid', + reasons: [ + { + policyId: 'forbid-rule-id', + type: 'forbid', + decision: 'forbid', + approvalsMissing: [], + approvalsSatisfied: [] + }, + { + policyId: 'permit-rule-id', + type: 'permit', + decision: 'permit', + approvalsMissing: [], + approvalsSatisfied: [] + } + ] + } + ] + const result = finalizeDecision(response) + expect(result.decision).toEqual(NarvalDecision.Forbid) + }) + + it('should return Permit if all of the reasons are Permit', () => { + const response: OpaResult[] = [ + { + decision: 'permit', + reasons: [ + { + policyId: 'permit-rule-id', + type: 'permit', + decision: 'permit', + approvalsMissing: [], + approvalsSatisfied: [] + }, + { + policyId: 'permit-rule-id', + type: 'permit', + decision: 'permit', + approvalsMissing: [], + approvalsSatisfied: [] + } + ] + } + ] + const result = finalizeDecision(response) + expect(result.decision).toEqual(NarvalDecision.Permit) + }) + + it('should return Confirm if any of the reasons are Forbid for a Permit type rule where approvals are missing', () => { + const response: OpaResult[] = [ + { + decision: 'forbid', + reasons: [ + { + policyId: 'permit-rule-id', + type: 'permit', + decision: 'forbid', + approvalsMissing: [ + { + policyId: 'permit-rule-id', + approvalCount: 1, + approvalEntityType: NarvalEntities.User, + entityIds: ['user-id'], + countPrincipal: true + } + ], + approvalsSatisfied: [] + } + ] + } + ] + const result = finalizeDecision(response) + expect(result.decision).toEqual(NarvalDecision.Confirm) + }) + + it('should return all missing, satisfied, and total approvals', () => { + const missingApproval = { + policyId: 'permit-rule-id', + approvalCount: 1, + approvalEntityType: NarvalEntities.User, + entityIds: ['user-id'], + countPrincipal: true + } + const missingApproval2 = { + policyId: 'permit-rule-id-4', + approvalCount: 1, + approvalEntityType: NarvalEntities.User, + entityIds: ['user-id'], + countPrincipal: true + } + const satisfiedApproval = { + policyId: 'permit-rule-id-2', + approvalCount: 1, + approvalEntityType: NarvalEntities.User, + entityIds: ['user-id'], + countPrincipal: true + } + const satisfiedApproval2 = { + policyId: 'permit-rule-id-3', + approvalCount: 1, + approvalEntityType: NarvalEntities.User, + entityIds: ['user-id'], + countPrincipal: true + } + const response: OpaResult[] = [ + { + decision: 'forbid', + reasons: [ + { + policyId: 'permit-rule-id', + type: 'permit', + decision: 'forbid', + approvalsMissing: [missingApproval], + approvalsSatisfied: [satisfiedApproval] + } + ] + }, + { + decision: 'forbid', + reasons: [ + { + policyId: 'permit-rule-id', + type: 'permit', + decision: 'forbid', + approvalsMissing: [missingApproval2], + approvalsSatisfied: [satisfiedApproval2] + } + ] + } + ] + const result = finalizeDecision(response) + expect(result).toEqual({ + originalResponse: response, + decision: NarvalDecision.Confirm, + totalApprovalsRequired: [missingApproval, missingApproval2, satisfiedApproval, satisfiedApproval2], + approvalsMissing: [missingApproval, missingApproval2], + approvalsSatisfied: [satisfiedApproval, satisfiedApproval2] + }) + }) +}) diff --git a/apps/authz/src/app/__test__/unit/example.spec.ts b/apps/authz/src/app/__test__/unit/example.spec.ts deleted file mode 100644 index b9383243d..000000000 --- a/apps/authz/src/app/__test__/unit/example.spec.ts +++ /dev/null @@ -1,5 +0,0 @@ -describe('Example unit test', () => { - it('foo', () => { - expect(1).toEqual(1) - }) -}) diff --git a/apps/authz/src/app/app.service.ts b/apps/authz/src/app/app.service.ts index d4b121b2b..c73ecb9e0 100644 --- a/apps/authz/src/app/app.service.ts +++ b/apps/authz/src/app/app.service.ts @@ -20,6 +20,45 @@ import { OpaService } from './opa/opa.service' const ENGINE_PRIVATE_KEY = '0x7cfef3303797cbc7515d9ce22ffe849c701b0f2812f999b0847229c47951fca5' +export const finalizeDecision = (response: OpaResult[]) => { + // Explicit Forbid - a Forbid rule type that matches & decides Forbid + const anyExplicitForbid = response.some( + (r) => r.decision === 'forbid' && r.reasons?.some((rr) => rr.decision === 'forbid' && rr.type === 'forbid') + ) + + const allPermit = response.every( + (r) => r.decision === 'permit' && r.reasons?.every((rr) => rr.decision === 'permit' && rr.type === 'permit') + ) + + const anyPermitWithMissingApprovals = response.some((r) => + r.reasons?.some((rr) => rr.decision === 'forbid' && rr.type === 'permit' && rr.approvalsMissing.length > 0) + ) + + if (anyExplicitForbid) { + return { + originalResponse: response, + decision: NarvalDecision.Forbid, + approvalsMissing: [], + approvalsSatisfied: [] + } + } + // Collect all the approvalsMissing & approvalsSatisfied using functional map/flat operators + const approvalsSatisfied = response + .flatMap((r) => r.reasons?.flatMap((rr) => rr.approvalsSatisfied)) + .filter((v) => !!v) + const approvalsMissing = response.flatMap((r) => r.reasons?.flatMap((rr) => rr.approvalsMissing)).filter((v) => !!v) + const totalApprovalsRequired = approvalsMissing.concat(approvalsSatisfied) + + const decision = allPermit && !anyPermitWithMissingApprovals ? NarvalDecision.Permit : NarvalDecision.Confirm + return { + originalResponse: response, + decision, + totalApprovalsRequired, + approvalsMissing, + approvalsSatisfied + } +} + @Injectable() export class AppService { constructor(private persistenceRepository: PersistenceRepository, private opaService: OpaService) {} @@ -83,33 +122,6 @@ export class AppService { } } - #finalizeDecision(response: OpaResult[]) { - const firstResponse = response[0] - if (firstResponse.decision === 'forbid' && firstResponse.reasons?.every((r) => r.decision === 'forbid')) { - return { - originalResponse: firstResponse, - decision: NarvalDecision.Forbid - } - } - // TODO: also verify errors - - if (firstResponse.reasons.some((r) => r.approvalsMissing.length > 0)) { - // TODO: find the approvalsSatisfied and approvalsMissing data & format/return here - return { - originalResponse: firstResponse, - decision: NarvalDecision.Confirm - } - } - - return { - originalResponse: firstResponse, - decision: NarvalDecision.Permit, - totalApprovalsRequired: [], - approvalsSatisfied: [], - approvalsMissing: [] - } - } - /** * Actual Eval Flow */ @@ -144,14 +156,14 @@ export class AppService { console.log('OPA Result Set', JSON.stringify(resultSet, null, 2)) // Post-processing to evaluate multisigs - const finalDecision = this.#finalizeDecision(resultSet) + const finalDecision = finalizeDecision(resultSet) const authzResponse: AuthZResponse = { decision: finalDecision.decision, request, totalApprovalsRequired: finalDecision.totalApprovalsRequired, - approvalsSatisfied: finalDecision.approvalsSatisfied, - approvalsMissing: finalDecision.approvalsMissing + approvalsMissing: finalDecision.approvalsMissing, + approvalsSatisfied: finalDecision.approvalsSatisfied } // If we are allowing, then the ENGINE signs the verification too diff --git a/apps/authz/src/shared/types/domain.type.ts b/apps/authz/src/shared/types/domain.type.ts index d67b162f2..f5be437de 100644 --- a/apps/authz/src/shared/types/domain.type.ts +++ b/apps/authz/src/shared/types/domain.type.ts @@ -111,8 +111,8 @@ export type AuthZResponse = { permitSignature?: RequestSignature // The ENGINE's approval signature request?: AuthZRequest // The actual authorized request totalApprovalsRequired?: ApprovalRequirement[] - approvalsSatisfied?: ApprovalRequirement[] approvalsMissing?: ApprovalRequirement[] + approvalsSatisfied?: ApprovalRequirement[] } export type VerifiedApproval = {