From f0124d29a9e673d102bfbc5f2d08d6ca12461292 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Fri, 12 Nov 2021 11:44:24 +0000 Subject: [PATCH] authz: use zod to validate responses in PermissionClient Signed-off-by: Mike Lewis --- .../src/PermissionClient.test.ts | 18 +++++++- .../permission-common/src/PermissionClient.ts | 42 +++++++++++++++---- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/packages/permission-common/src/PermissionClient.test.ts b/packages/permission-common/src/PermissionClient.test.ts index 91ad16d24e..a461078e1a 100644 --- a/packages/permission-common/src/PermissionClient.test.ts +++ b/packages/permission-common/src/PermissionClient.test.ts @@ -115,7 +115,7 @@ describe('PermissionClient', () => { ).rejects.toThrowError(/request failed with 401/i); }); - it('should reject invalid responses', async () => { + it('should reject responses with missing ids', async () => { mockAuthorizeHandler.mockImplementationOnce( (_req, res, { json }: RestContext) => { return res(json([{ id: 'wrong-id', result: AuthorizeResult.ALLOW }])); @@ -125,5 +125,21 @@ describe('PermissionClient', () => { client.authorize([mockAuthorizeRequest], { token }), ).rejects.toThrowError(/Unexpected authorization response/i); }); + + it('should reject invalid responses', async () => { + mockAuthorizeHandler.mockImplementationOnce( + (req, res, { json }: RestContext) => { + const responses = req.body.map((a: Identified) => ({ + id: a.id, + outcome: AuthorizeResult.ALLOW, + })); + + return res(json(responses)); + }, + ); + await expect( + client.authorize([mockAuthorizeRequest], { token }), + ).rejects.toThrowError(/invalid input/i); + }); }); }); diff --git a/packages/permission-common/src/PermissionClient.ts b/packages/permission-common/src/PermissionClient.ts index 52fb0478ae..b8b2a03c44 100644 --- a/packages/permission-common/src/PermissionClient.ts +++ b/packages/permission-common/src/PermissionClient.ts @@ -17,14 +17,47 @@ import { ResponseError } from '@backstage/errors'; import fetch from 'cross-fetch'; import * as uuid from 'uuid'; +import { z } from 'zod'; import { AuthorizeResult, AuthorizeRequest, AuthorizeResponse, Identified, + PermissionCriteria, + PermissionCondition, } from './types/api'; import { DiscoveryApi } from './types/discovery'; +const permissionCriteriaSchema: z.ZodSchema< + PermissionCriteria +> = z.lazy(() => + z + .object({ + rule: z.string(), + params: z.array(z.unknown()), + }) + .or(z.object({ anyOf: z.array(permissionCriteriaSchema) })) + .or(z.object({ allOf: z.array(permissionCriteriaSchema) })) + .or(z.object({ not: permissionCriteriaSchema })), +); + +const responseSchema = z.array( + z + .object({ + id: z.string(), + result: z + .literal(AuthorizeResult.ALLOW) + .or(z.literal(AuthorizeResult.DENY)), + }) + .or( + z.object({ + id: z.string(), + result: z.literal(AuthorizeResult.CONDITIONAL), + conditions: permissionCriteriaSchema, + }), + ), +); + /** * Options for authorization requests; currently only an optional auth token. * @public @@ -103,14 +136,7 @@ export class PermissionClient { requests: Identified[], json: any, ): asserts json is Identified[] { - const responses = Array.isArray(json) ? json : []; - const authorizedResponses: Identified[] = - responses.filter( - (r: any): r is Identified => - typeof r === 'object' && - typeof r.id === 'string' && - r.result in AuthorizeResult, - ); + const authorizedResponses = responseSchema.parse(json); const responseIds = authorizedResponses.map(r => r.id); const hasAllRequestIds = requests.every(r => responseIds.includes(r.id)); if (!hasAllRequestIds) {