diff --git a/src/utils/middlewares/__tests__/azure_api_auth.test.ts b/src/utils/middlewares/__tests__/azure_api_auth.test.ts index 7cea0816..5f44ff60 100644 --- a/src/utils/middlewares/__tests__/azure_api_auth.test.ts +++ b/src/utils/middlewares/__tests__/azure_api_auth.test.ts @@ -4,8 +4,14 @@ /* eslint-disable sonarjs/no-identical-functions */ import { isLeft, isRight } from "fp-ts/lib/Either"; +import { failure } from "fp-ts/lib/Validation"; +import * as t from "io-ts"; -import { AzureApiAuthMiddleware, UserGroup } from "../azure_api_auth"; +import { + AzureAllowBodyPayloadMiddleware, + AzureApiAuthMiddleware, + UserGroup +} from "../azure_api_auth"; const anAllowedGroupSet = new Set([UserGroup.ApiMessageWrite]); @@ -256,3 +262,132 @@ describe("AzureApiAuthMiddleware", () => { } }); }); + +describe("AzureAllowBodyPayloadMiddleware", () => { + it("should success if pattern is not matched", async () => { + const headers = { + ...someHeaders + }; + const aPayload = { foo: { bar: "baz" } }; + const mockRequest = { + header: jest.fn(lookup(headers)), + body: aPayload + }; + const aNonMatchingCodec = t.interface({ anyField: t.number }); + const anyGroupSet = new Set([UserGroup.ApiMessageWrite]); + + const middleware = AzureAllowBodyPayloadMiddleware( + aNonMatchingCodec, + anyGroupSet + ); + + const result = await middleware(mockRequest as any); + + expect(isRight(result)).toBe(true); + expect(aNonMatchingCodec.decode(aPayload).isLeft()).toBe(true); // test is wrong if it fails + }); + + it("should success if pattern is matched and user do belongs to correct user group", async () => { + const headers = { + ...someHeaders + }; + const aPayload = { foo: { bar: "baz" } }; + const mockRequest = { + header: jest.fn(lookup(headers)), + body: aPayload + }; + const aMatchingCodec = t.interface({ + foo: t.interface({ bar: t.string }) + }); + const allowedGroupSet = new Set([UserGroup.ApiMessageWrite]); + + const middleware = AzureAllowBodyPayloadMiddleware( + aMatchingCodec, + allowedGroupSet + ); + + const result = await middleware(mockRequest as any); + + expect(isRight(result)).toBe(true); + expect(aMatchingCodec.decode(aPayload).isRight()).toBe(true); // test is wrong if it fails + }); + + it("should success if pattern is not matched - test nr. 2", async () => { + const headers = { + ...someHeaders + }; + const aPayload = { foo: { bar: "baz" } }; + const mockRequest = { + header: jest.fn(lookup(headers)), + body: aPayload + }; + const aNonMatchingCodec = t.interface({ anyField: t.string }); + const anyGroupSet = new Set([UserGroup.ApiDebugRead]); // any value + + const middleware = AzureAllowBodyPayloadMiddleware( + aNonMatchingCodec, + anyGroupSet + ); + + const result = await middleware(mockRequest as any); + + expect(isRight(result)).toBe(true); + expect(aNonMatchingCodec.decode(aPayload).isLeft()).toBe(true); // test is wrong if it fails + }); + + it("should fail if pattern is matched and current user does not belongs to user group", async () => { + const headers = { + ...someHeaders + }; + const aPayload = { foo: { bar: "baz" } }; + const mockRequest = { + header: jest.fn(lookup(headers)), + body: aPayload + }; + const aMatchingCodec = t.interface({ + foo: t.interface({ bar: t.string }) + }); + const anotherAllowedGroupSet = new Set([UserGroup.ApiDebugRead]); + + const middleware = AzureAllowBodyPayloadMiddleware( + aMatchingCodec, + anotherAllowedGroupSet + ); + + const result = await middleware(mockRequest as any); + + expect(isLeft(result)).toBe(true); + result.fold( + _ => expect(_.kind).toBe("IResponseErrorForbiddenNotAuthorized"), + _ => fail("Expecting left") + ); + }); + + it("should fail if pattern is matched and current user has no groups", async () => { + const headers = { + ...someHeaders, + "x-user-groups": "" + }; + const aPayload = { foo: { bar: "baz" } }; + const mockRequest = { + header: jest.fn(lookup(headers)), + body: aPayload + }; + const aMatchingCodec = t.interface({ + foo: t.interface({ bar: t.string }) + }); + + const middleware = AzureAllowBodyPayloadMiddleware( + aMatchingCodec, + anAllowedGroupSet + ); + + const result = await middleware(mockRequest as any); + + expect(isLeft(result)).toBe(true); + result.fold( + _ => expect(_.kind).toBe("IResponseErrorForbiddenNoAuthorizationGroups"), + _ => fail("Expecting left") + ); + }); +}); diff --git a/src/utils/middlewares/azure_api_auth.ts b/src/utils/middlewares/azure_api_auth.ts index 0f3440d9..559a1ae1 100644 --- a/src/utils/middlewares/azure_api_auth.ts +++ b/src/utils/middlewares/azure_api_auth.ts @@ -1,4 +1,5 @@ -import { Either, isLeft, left, right } from "fp-ts/lib/Either"; +import * as t from "io-ts"; +import { Either, isLeft, left, right, either } from "fp-ts/lib/Either"; import { fromNullable, isSome, Option, Some } from "fp-ts/lib/Option"; import { @@ -221,3 +222,56 @@ export const AzureApiAuthMiddleware = ( ) ); }); + +type AzureAllowBodyPayloadMiddlewareErrorResponses = + | IResponseErrorForbiddenNoAuthorizationGroups + | IResponseErrorForbiddenNotAuthorized; + +/** + * A middleware that allow a specific payload to be provided only by a specific set of user groups + * + * The middleware expects the following headers: + * + * x-user-groups: A comma separated list of names of Azure API Groups + * + * On success, the middleware just lets the request to continue, + * on failure it triggers a ResponseErrorForbidden. + * + * @param pattern a codec that matches the payload pattern to restrict access to + * @param allowedGroups a set of user groups to allow provided payload + * + */ +export const AzureAllowBodyPayloadMiddleware = ( + pattern: t.Type, + allowedGroups: ReadonlySet +): IRequestMiddleware< + | "IResponseErrorForbiddenNotAuthorized" + | "IResponseErrorForbiddenNoAuthorizationGroups", + void +> => async ( + request +): Promise> => + either + .of(request.body) + .chain(payload => + pattern.decode(payload).fold( + // if pattern does not match payload, just skip the middleware + _ => right(void 0), + _ => + NonEmptyString.decode(request.header("x-user-groups")) + // user groups groups must be valued + .mapLeft( + __ => ResponseErrorForbiddenNoAuthorizationGroups + ) + .map(getGroupsFromHeader) + // check if current user belongs to at least one of the allowed groups + .map(userGroups => + Array.from(allowedGroups).some(e => userGroups.has(e)) + ) + .chain(isInGroup => + isInGroup + ? right(void 0) + : left(ResponseErrorForbiddenNotAuthorized) + ) + ) + );