diff --git a/.changeset/puny-olives-tie.md b/.changeset/puny-olives-tie.md new file mode 100644 index 0000000..c407bbd --- /dev/null +++ b/.changeset/puny-olives-tie.md @@ -0,0 +1,5 @@ +--- +"@spur.us/monocle-backend": minor +--- + +Add `evaluateAssessment` function diff --git a/.changeset/quiet-words-enter.md b/.changeset/quiet-words-enter.md new file mode 100644 index 0000000..bf712d9 --- /dev/null +++ b/.changeset/quiet-words-enter.md @@ -0,0 +1,5 @@ +--- +"@spur.us/types": minor +--- + +Add `MonoclePolicyDecision` type diff --git a/packages/monocle-backend/src/__tests__/monocle-client.test.ts b/packages/monocle-backend/src/__tests__/monocle-client.test.ts index cbddebc..61fe840 100644 --- a/packages/monocle-backend/src/__tests__/monocle-client.test.ts +++ b/packages/monocle-backend/src/__tests__/monocle-client.test.ts @@ -148,4 +148,90 @@ describe('MonocleClient', () => { }); }); }); + + describe('evaluateAssessment', () => { + const mockPolicyDecision = { + allowed: true, + reason: 'All checks passed', + }; + + it('should successfully evaluate assessment', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockPolicyDecision), + } as Response); + + const result = await client.evaluateAssessment(mockEncryptedAssessment); + + expect(global.fetch).toHaveBeenCalledWith( + `https://decrypt.${mockBaseDomain}/api/v1/policy`, + { + method: 'POST', + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'User-Agent': '@spur.us/monocle-backend@0.0.0-test', + TOKEN: mockSecretKey, + }, + body: JSON.stringify({ + assessment: mockEncryptedAssessment, + }), + } + ); + expect(result).toEqual(mockPolicyDecision); + }); + + it('should pass options to the API when provided', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockPolicyDecision), + } as Response); + + const options = { ip: '10.0.0.1', cpd: 'custom-policy' }; + const result = await client.evaluateAssessment( + mockEncryptedAssessment, + options + ); + + expect(global.fetch).toHaveBeenCalledWith( + `https://decrypt.${mockBaseDomain}/api/v1/policy`, + { + method: 'POST', + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'User-Agent': '@spur.us/monocle-backend@0.0.0-test', + TOKEN: mockSecretKey, + }, + body: JSON.stringify({ + assessment: mockEncryptedAssessment, + ip: '10.0.0.1', + cpd: 'custom-policy', + }), + } + ); + expect(result).toEqual(mockPolicyDecision); + }); + + it('should throw error when API request fails with non-200 status', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + } as Response); + + const promise = client.evaluateAssessment(mockEncryptedAssessment); + await expect(promise).rejects.toThrow(MonocleAPIError); + await expect(promise).rejects.toThrow( + '[@spur.us/monocle-backend] API request failed with status 500: Internal Server Error' + ); + }); + + it('should throw error when fetch fails', async () => { + vi.mocked(global.fetch).mockRejectedValueOnce(new Error('Network error')); + + const promise = client.evaluateAssessment(mockEncryptedAssessment); + await expect(promise).rejects.toThrow( + '[@spur.us/monocle-backend] Failed to communicate with Monocle API' + ); + }); + }); }); diff --git a/packages/monocle-backend/src/index.ts b/packages/monocle-backend/src/index.ts index 210d8ae..6106564 100644 --- a/packages/monocle-backend/src/index.ts +++ b/packages/monocle-backend/src/index.ts @@ -2,4 +2,4 @@ export { createMonocleClient } from './createMonocleClient.js'; export { MonocleAPIError, MonocleDecryptionError } from './errors.js'; export type { MonocleClient } from './monocleClient.js'; -export type { MonocleAssessment } from '@spur.us/types'; +export type { MonocleAssessment, MonoclePolicyDecision } from '@spur.us/types'; diff --git a/packages/monocle-backend/src/monocleClient.ts b/packages/monocle-backend/src/monocleClient.ts index 25b0405..5c4cdb6 100644 --- a/packages/monocle-backend/src/monocleClient.ts +++ b/packages/monocle-backend/src/monocleClient.ts @@ -1,7 +1,7 @@ -import { MonocleAssessment } from '@spur.us/types'; +import { MonocleAssessment, MonoclePolicyDecision } from '@spur.us/types'; import * as jose from 'jose'; import { BASE_DOMAIN, USER_AGENT } from './constants.js'; -import { MonocleOptions } from './types.js'; +import { EvaluateAssessmentOptions, MonocleOptions } from './types.js'; import { MonocleAPIError, MonocleDecryptionError, @@ -57,6 +57,46 @@ export class MonocleClient { return this.decryptViaApi(encryptedAssessment); } + /** + * Evaluates an assessment against a Monocle Policy. + * @param encryptedAssessment - The encrypted assessment to verify + * @returns A promise that resolves to the MonoclePolicyDecision + * @throws Error if the API request fails + */ + async evaluateAssessment( + encryptedAssessment: string, + options: EvaluateAssessmentOptions = {} + ): Promise { + try { + const response = await fetch(`${this.decryptApiUrl}/api/v1/policy`, { + method: 'POST', + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'User-Agent': USER_AGENT, + TOKEN: this.secretKey, + }, + body: JSON.stringify({ + assessment: encryptedAssessment, + ...options, + }), + }); + + if (!response.ok) { + throw new MonocleAPIError(response.status, response.statusText); + } + + const decision: MonoclePolicyDecision = await response.json(); + return decision; + } catch (error) { + if (error instanceof MonocleAPIError) { + throw error; + } + return throwError(errorCodes.API_REQUEST_FAILED, { + message: `Failed to communicate with Monocle API`, + }); + } + } + /** * Decrypts an assessment using the Monocle API * @param encryptedAssessment - The encrypted assessment string to decrypt diff --git a/packages/monocle-backend/src/types.ts b/packages/monocle-backend/src/types.ts index f6ba8e1..bf8f694 100644 --- a/packages/monocle-backend/src/types.ts +++ b/packages/monocle-backend/src/types.ts @@ -7,3 +7,13 @@ export interface MonocleOptions { /** Optional base domain for the Monocle API. Defaults to `mcl.spur.us` if not provided */ baseDomain?: string; } + +/** + * Options for evaluating an assessment + */ +export interface EvaluateAssessmentOptions { + /** IP address of the client making the request */ + ip?: string; + /** Client private data to be used for evaluation */ + cpd?: string; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 82840df..f15fc2f 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -31,6 +31,23 @@ export interface MonocleAssessment { service: string; } +/** + * A Monocle Policy decision made based on Monocle Assessment data. + */ +export interface MonoclePolicyDecision { + /** + * Indicates whether the connection is allowed based on the policy evaluation. + * `true` means the assessment passed all policy checks and is permitted. + * `false` means the assessment has been rejected. + */ + allowed: boolean; + /** + * A descriptive message explaining the rationale behind the policy decision. + * Provides context about why an assessment was allowed or rejected. + */ + reason: string; +} + /** * Configuration options for the MonocleLoader. This wrapper crafts the request * to the Monocle backend to load the Monocle core library.