diff --git a/CreateUser/__tests__/handler.ts b/CreateUser/__tests__/handler.ts index 5488770f..0c592f3f 100644 --- a/CreateUser/__tests__/handler.ts +++ b/CreateUser/__tests__/handler.ts @@ -5,6 +5,7 @@ import { GraphRbacManagementClient } from "@azure/graph"; import * as msRestNodeAuth from "@azure/ms-rest-nodeauth"; import { left } from "fp-ts/lib/Either"; import { fromEither, fromLeft } from "fp-ts/lib/TaskEither"; +import { NonEmptyString } from "italia-ts-commons/lib/strings"; import { User } from "../../generated/definitions/User"; import { UserPayload } from "../../generated/definitions/UserPayload"; import { UserStateEnum } from "../../generated/definitions/UserState"; @@ -64,7 +65,9 @@ mockApiManagementClient.mockImplementation(() => ({ } })); +const fakeAdb2cExtensionAppClientId = "extension-client-id" as NonEmptyString; const mockedContext = { log: { error: mockLog } }; + describe("CreateUser", () => { it("should return an internal error response if the ADB2C client can not be got", async () => { mockLoginWithServicePrincipalSecret.mockImplementationOnce(() => @@ -74,7 +77,8 @@ describe("CreateUser", () => { const createUserHandler = CreateUserHandler( fakeServicePrincipalCredentials, fakeServicePrincipalCredentials, - fakeApimConfig + fakeApimConfig, + fakeAdb2cExtensionAppClientId ); const response = await createUserHandler( @@ -94,7 +98,8 @@ describe("CreateUser", () => { const createUserHandler = CreateUserHandler( fakeServicePrincipalCredentials, fakeServicePrincipalCredentials, - fakeApimConfig + fakeApimConfig, + fakeAdb2cExtensionAppClientId ); const response = await createUserHandler( @@ -119,7 +124,8 @@ describe("CreateUser", () => { const createUserHandler = CreateUserHandler( fakeServicePrincipalCredentials, fakeServicePrincipalCredentials, - fakeApimConfig + fakeApimConfig, + fakeAdb2cExtensionAppClientId ); const response = await createUserHandler( @@ -142,7 +148,8 @@ describe("CreateUser", () => { const createUserHandler = CreateUserHandler( fakeServicePrincipalCredentials, fakeServicePrincipalCredentials, - fakeApimConfig + fakeApimConfig, + fakeAdb2cExtensionAppClientId ); const response = await createUserHandler( @@ -187,7 +194,8 @@ describe("CreateUser", () => { const createUserHandler = CreateUserHandler( fakeServicePrincipalCredentials, fakeServicePrincipalCredentials, - fakeApimConfig + fakeApimConfig, + fakeAdb2cExtensionAppClientId ); const response = await createUserHandler( diff --git a/CreateUser/handler.ts b/CreateUser/handler.ts index 380536ba..ffc75453 100644 --- a/CreateUser/handler.ts +++ b/CreateUser/handler.ts @@ -16,6 +16,8 @@ import { IResponseSuccessJson, ResponseSuccessJson } from "italia-ts-commons/lib/responses"; +import { NonEmptyString } from "italia-ts-commons/lib/strings"; +import { withoutUndefinedValues } from "italia-ts-commons/lib/types"; import * as randomString from "randomstring"; import { ulid } from "ulid"; import { UserCreated } from "../generated/definitions/UserCreated"; @@ -38,7 +40,8 @@ type ICreateUserHandler = ( export function CreateUserHandler( adb2cCredentials: IServicePrincipalCreds, apimCredentials: IServicePrincipalCreds, - azureApimConfig: IAzureApimConfig + azureApimConfig: IAzureApimConfig, + adb2cTokenAttributeName: NonEmptyString ): ICreateUserHandler { return async (context, _, userPayload) => { const internalErrorHandler = (errorMessage: string, error: Error) => @@ -55,26 +58,29 @@ export function CreateUserHandler( .chain(graphRbacManagementClient => tryCatch( () => - graphRbacManagementClient.users.create({ - accountEnabled: true, - creationType: "LocalAccount", - displayName: `${userPayload.first_name} ${userPayload.last_name}`, - givenName: userPayload.first_name, - mailNickname: userPayload.email.split("@")[0], - passwordProfile: { - forceChangePasswordNextLogin: true, - password: randomString.generate({ length: 24 }) - }, - signInNames: [ - { - type: "emailAddress", - value: userPayload.email - } - ], - surname: userPayload.last_name, - userPrincipalName: `${ulid()}@${adb2cCredentials.tenantId}`, - userType: "Member" - }), + graphRbacManagementClient.users.create( + withoutUndefinedValues({ + accountEnabled: true, + creationType: "LocalAccount", + displayName: `${userPayload.first_name} ${userPayload.last_name}`, + givenName: userPayload.first_name, + mailNickname: userPayload.email.split("@")[0], + passwordProfile: { + forceChangePasswordNextLogin: true, + password: randomString.generate({ length: 24 }) + }, + signInNames: [ + { + type: "emailAddress", + value: userPayload.email + } + ], + surname: userPayload.last_name, + userPrincipalName: `${ulid()}@${adb2cCredentials.tenantId}`, + userType: "Member", + [adb2cTokenAttributeName]: userPayload.token_name + }) + ), toError ).mapLeft(error => internalErrorHandler("Could not create the user on the ADB2C", error) @@ -136,12 +142,14 @@ export function CreateUserHandler( export function CreateUser( adb2cCreds: IServicePrincipalCreds, servicePrincipalCreds: IServicePrincipalCreds, - azureApimConfig: IAzureApimConfig + azureApimConfig: IAzureApimConfig, + adb2cTokenAttributeName: NonEmptyString ): express.RequestHandler { const handler = CreateUserHandler( adb2cCreds, servicePrincipalCreds, - azureApimConfig + azureApimConfig, + adb2cTokenAttributeName ); const middlewaresWrap = withRequestMiddlewares( diff --git a/CreateUser/index.ts b/CreateUser/index.ts index 71450ddc..3b32a042 100644 --- a/CreateUser/index.ts +++ b/CreateUser/index.ts @@ -32,6 +32,10 @@ const azureApimConfig = { subscriptionId: config.AZURE_SUBSCRIPTION_ID }; +const adb2cTokenAttributeName = getRequiredStringEnv( + "ADB2C_TOKEN_ATTRIBUTE_NAME" +); + // tslint:disable-next-line: no-let let logger: Context["log"] | undefined; const contextTransport = new AzureContextTransport(() => logger, { @@ -46,7 +50,12 @@ secureExpressApp(app); // Add express route app.post( "/adm/users", - CreateUser(adb2cCreds, servicePrincipalCreds, azureApimConfig) + CreateUser( + adb2cCreds, + servicePrincipalCreds, + azureApimConfig, + adb2cTokenAttributeName + ) ); const azureFunctionHandler = createAzureFunctionHandler(app); diff --git a/UpdateUser/__tests__/handler.ts b/UpdateUser/__tests__/handler.ts new file mode 100644 index 00000000..6a2ca09a --- /dev/null +++ b/UpdateUser/__tests__/handler.ts @@ -0,0 +1,157 @@ +// tslint:disable:no-any + +import { GraphRbacManagementClient } from "@azure/graph"; +import * as msRestNodeAuth from "@azure/ms-rest-nodeauth"; +import { NonEmptyString } from "italia-ts-commons/lib/strings"; +import { EmailAddress } from "../../generated/definitions/EmailAddress"; +import { UserCreated } from "../../generated/definitions/UserCreated"; +import { UserStateEnum } from "../../generated/definitions/UserState"; +import { UserUpdatePayload } from "../../generated/definitions/UserUpdatePayload"; +import { IServicePrincipalCreds } from "../../utils/apim"; +import { UpdateUserHandler } from "../handler"; + +const aTokenName = "ATokenName" as NonEmptyString; +const fakeServicePrincipalCredentials: IServicePrincipalCreds = { + clientId: "client-id", + secret: "secret", + tenantId: "tenant-id" +}; + +const aUserEmail = "user@example.com" as EmailAddress; +const fakeRequestPayload = { + first_name: "first-name", + last_name: "family-name", + token_name: aTokenName +} as UserUpdatePayload; + +const fakeObjectId = "ADB2C-user"; + +const mockLoginWithServicePrincipalSecret = jest.spyOn( + msRestNodeAuth, + "loginWithServicePrincipalSecret" +); + +const updateErrorDetail = + "Internal server error: Could not update the user on the ADB2C"; +jest.mock("@azure/graph"); +jest.mock("@azure/arm-apimanagement"); +const mockGraphRbacManagementClient = GraphRbacManagementClient as jest.Mock; +const mockLog = jest.fn(); +const mockGetToken = jest.fn(); + +mockLoginWithServicePrincipalSecret.mockImplementation(() => { + return Promise.resolve({ getToken: mockGetToken }); +}); +mockGetToken.mockImplementation(() => { + return Promise.resolve(undefined); +}); +const mockUsersUpdate = jest.fn(); + +mockGraphRbacManagementClient.mockImplementation(() => ({ + users: { + list: jest.fn(() => + Promise.resolve([ + { + email: "user@example.com" + } + ]) + ), + update: mockUsersUpdate + } +})); + +const fakeAdb2cExtensionAppClientId = "extension-client-id" as NonEmptyString; + +const mockedContext = { log: { error: mockLog } }; + +describe("UpdateUser", () => { + it("should return an internal error response if the ADB2C client can not be got", async () => { + mockLoginWithServicePrincipalSecret.mockImplementationOnce(() => + Promise.reject("Error from ApiManagementClient constructor") + ); + + const updateUserHandler = UpdateUserHandler( + fakeServicePrincipalCredentials, + fakeAdb2cExtensionAppClientId + ); + + const response = await updateUserHandler( + mockedContext as any, + undefined as any, + undefined as any, + undefined as any + ); + + expect(response.kind).toEqual("IResponseErrorInternal"); + }); + + it("should return an internal error response if the ADB2C client can not update the user", async () => { + mockUsersUpdate.mockImplementationOnce(() => + Promise.reject("Users update error") + ); + + const updateUserHandler = UpdateUserHandler( + fakeServicePrincipalCredentials, + fakeAdb2cExtensionAppClientId + ); + + const response = await updateUserHandler( + mockedContext as any, + undefined as any, + aUserEmail, + fakeRequestPayload + ); + expect(mockUsersUpdate).toBeCalledTimes(1); + expect(response).toEqual({ + apply: expect.any(Function), + detail: updateErrorDetail, + kind: "IResponseErrorInternal" + }); + }); + + it("should return the user updated", async () => { + const fakeApimUser = { + email: aUserEmail, + firstName: fakeRequestPayload.first_name, + id: "user-id", + identities: [ + { + id: fakeObjectId, + provider: "AadB2C" + } + ], + lastName: fakeRequestPayload.last_name, + name: fakeObjectId, + registrationDate: new Date(), + state: UserStateEnum.active, + type: "Microsoft.ApiManagement/service/users" + }; + const expectedUpdatedUser: UserCreated = { + email: fakeApimUser.email, + first_name: fakeApimUser.firstName, + id: fakeApimUser.name, + last_name: fakeApimUser.lastName, + token_name: aTokenName + }; + mockUsersUpdate.mockImplementationOnce(() => + Promise.resolve({ objectId: fakeObjectId }) + ); + + const updateUserHandler = UpdateUserHandler( + fakeServicePrincipalCredentials, + fakeAdb2cExtensionAppClientId + ); + + const response = await updateUserHandler( + mockedContext as any, + undefined as any, + aUserEmail, + fakeRequestPayload + ); + expect(response).toEqual({ + apply: expect.any(Function), + kind: "IResponseSuccessJson", + value: expectedUpdatedUser + }); + }); +}); diff --git a/UpdateUser/function.json b/UpdateUser/function.json new file mode 100644 index 00000000..682caae6 --- /dev/null +++ b/UpdateUser/function.json @@ -0,0 +1,20 @@ +{ + "bindings": [ + { + "authLevel": "function", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "route": "adm/users/{email}", + "methods": [ + "put" + ] + }, + { + "type": "http", + "direction": "out", + "name": "res" + } + ], + "scriptFile": "../dist/UpdateUser/index.js" +} diff --git a/UpdateUser/handler.ts b/UpdateUser/handler.ts new file mode 100644 index 00000000..0140785e --- /dev/null +++ b/UpdateUser/handler.ts @@ -0,0 +1,147 @@ +import { Context } from "@azure/functions"; +import { GraphRbacManagementClient } from "@azure/graph"; +import { User } from "@azure/graph/esm/models"; +import * as express from "express"; +import { toError } from "fp-ts/lib/Either"; +import { identity } from "fp-ts/lib/function"; +import { fromEither, TaskEither, tryCatch } from "fp-ts/lib/TaskEither"; +import { + AzureApiAuthMiddleware, + IAzureApiAuthorization, + UserGroup +} from "io-functions-commons/dist/src/utils/middlewares/azure_api_auth"; +import { ContextMiddleware } from "io-functions-commons/dist/src/utils/middlewares/context_middleware"; +import { RequiredBodyPayloadMiddleware } from "io-functions-commons/dist/src/utils/middlewares/required_body_payload"; +import { RequiredParamMiddleware } from "io-functions-commons/dist/src/utils/middlewares/required_param"; +import { withRequestMiddlewares } from "io-functions-commons/dist/src/utils/request_middleware"; +import { wrapRequestHandler } from "italia-ts-commons/lib/request_middleware"; +import { + IResponseErrorInternal, + IResponseSuccessJson, + ResponseSuccessJson +} from "italia-ts-commons/lib/responses"; +import { NonEmptyString } from "italia-ts-commons/lib/strings"; +import { withoutUndefinedValues } from "italia-ts-commons/lib/types"; +import { EmailAddress } from "../generated/definitions/EmailAddress"; +import { UserUpdated } from "../generated/definitions/UserUpdated"; +import { UserUpdatePayload } from "../generated/definitions/UserUpdatePayload"; +import { + getGraphRbacManagementClient, + IServicePrincipalCreds +} from "../utils/apim"; +import { genericInternalErrorHandler } from "../utils/errorHandler"; + +type IUpdateUserHandler = ( + context: Context, + auth: IAzureApiAuthorization, + email: EmailAddress, + userPayload: UserUpdatePayload +) => Promise | IResponseErrorInternal>; + +const getUserFromList = (client: GraphRbacManagementClient, email: string) => + tryCatch( + () => + client.users.list({ + filter: `signInNames/any(x:x/value eq '${email}')` + }), + toError + ).map(userList => userList[0]); + +const updateUser = ( + client: GraphRbacManagementClient, + email: EmailAddress, + user: User, + adb2cTokenAttributeName: string, + userPayload: UserUpdatePayload +): TaskEither => + tryCatch( + () => + client.users.update( + user.userPrincipalName, + withoutUndefinedValues({ + displayName: + userPayload.first_name && userPayload.last_name + ? `${userPayload.first_name} ${userPayload.last_name}` + : undefined, + givenName: userPayload.first_name, + surname: userPayload.last_name, + [adb2cTokenAttributeName]: userPayload.token_name + }) + ), + toError + ).chain(updateUserResponse => + fromEither( + UserUpdated.decode({ + email, + first_name: userPayload.first_name, + id: updateUserResponse.objectId, + last_name: userPayload.last_name, + token_name: userPayload.token_name + }).mapLeft(toError) + ) + ); + +export function UpdateUserHandler( + adb2cCredentials: IServicePrincipalCreds, + adb2cTokenAttributeName: NonEmptyString +): IUpdateUserHandler { + return async (context, _, email, userPayload) => { + const internalErrorHandler = (errorMessage: string, error: Error) => + genericInternalErrorHandler( + context, + "UpdateUser | " + errorMessage, + error, + errorMessage + ); + return getGraphRbacManagementClient(adb2cCredentials) + .mapLeft(error => + internalErrorHandler("Could not get the ADB2C client", error) + ) + .chain(graphRbacManagementClient => + getUserFromList(graphRbacManagementClient, email) + .chain(user => + updateUser( + graphRbacManagementClient, + email, + user, + adb2cTokenAttributeName, + userPayload + ) + ) + .mapLeft(error => + internalErrorHandler( + "Could not update the user on the ADB2C", + error + ) + ) + ) + .fold | IResponseErrorInternal>( + identity, + updatedUser => ResponseSuccessJson(updatedUser) + ) + .run(); + }; +} + +/** + * Wraps an UpdateUser handler inside an Express request handler. + */ +export function UpdateUser( + adb2cCreds: IServicePrincipalCreds, + adb2cTokenAttributeName: NonEmptyString +): express.RequestHandler { + const handler = UpdateUserHandler(adb2cCreds, adb2cTokenAttributeName); + + const middlewaresWrap = withRequestMiddlewares( + // Extract Azure Functions bindings + ContextMiddleware(), + // Allow only users in the ApiUserAdmin group + AzureApiAuthMiddleware(new Set([UserGroup.ApiUserAdmin])), + // Extract the email value from the request + RequiredParamMiddleware("email", EmailAddress), + // Extract the body payload from the request + RequiredBodyPayloadMiddleware(UserUpdatePayload) + ); + + return wrapRequestHandler(middlewaresWrap(handler)); +} diff --git a/UpdateUser/index.ts b/UpdateUser/index.ts new file mode 100644 index 00000000..6e5642c7 --- /dev/null +++ b/UpdateUser/index.ts @@ -0,0 +1,48 @@ +import { Context } from "@azure/functions"; + +import * as express from "express"; +import * as winston from "winston"; + +import { getRequiredStringEnv } from "io-functions-commons/dist/src/utils/env"; +import { secureExpressApp } from "io-functions-commons/dist/src/utils/express"; +import { AzureContextTransport } from "io-functions-commons/dist/src/utils/logging"; +import { setAppContext } from "io-functions-commons/dist/src/utils/middlewares/context_middleware"; + +import createAzureFunctionHandler from "io-functions-express/dist/src/createAzureFunctionsHandler"; + +import { UpdateUser } from "./handler"; + +const adb2cCreds = { + clientId: getRequiredStringEnv("ADB2C_CLIENT_ID"), + secret: getRequiredStringEnv("ADB2C_CLIENT_KEY"), + tenantId: getRequiredStringEnv("ADB2C_TENANT_ID") +}; + +const adb2cTokenAttributeName = getRequiredStringEnv( + "ADB2C_TOKEN_ATTRIBUTE_NAME" +); + +// tslint:disable-next-line: no-let +let logger: Context["log"] | undefined; +const contextTransport = new AzureContextTransport(() => logger, { + level: "debug" +}); +winston.add(contextTransport); + +// Setup Express +const app = express(); +secureExpressApp(app); + +// Add express route +app.put("/adm/users/:email", UpdateUser(adb2cCreds, adb2cTokenAttributeName)); + +const azureFunctionHandler = createAzureFunctionHandler(app); + +// Binds the express app to an Azure Function handler +function httpStart(context: Context): void { + logger = context.log; + setAppContext(app, context); + azureFunctionHandler(context); +} + +export default httpStart; diff --git a/openapi/index.yaml b/openapi/index.yaml index 1f76f0d9..5ee5aa74 100644 --- a/openapi/index.yaml +++ b/openapi/index.yaml @@ -336,6 +336,33 @@ paths: description: User not found "500": description: Internal server error + put: + summary: Update user + description: Update an existing ADB2C User. + operationId: updateUser + parameters: + - name: email + in: path + type: string + format: email + required: true + description: The email of the User + - name: body + in: body + required: true + schema: + $ref: "#/definitions/UserUpdatePayload" + responses: + "200": + description: The updated User + schema: + $ref: "#/definitions/UserUpdated" + "400": + description: Bad request + "403": + description: Forbidden + "500": + description: Internal server error /users/{email}/groups: put: summary: Update user groups @@ -483,10 +510,25 @@ definitions: last_name: type: string minLength: 1 + token_name: + type: string + minLength: 1 required: - email - first_name - last_name + UserUpdatePayload: + type: object + properties: + first_name: + type: string + minLength: 1 + last_name: + type: string + minLength: 1 + token_name: + type: string + minLength: 1 UserCreated: allOf: - $ref: "#/definitions/UserPayload" @@ -496,6 +538,18 @@ definitions: type: string required: - id + UserUpdated: + allOf: + - $ref: "#/definitions/UserUpdatePayload" + - type: object + properties: + email: + $ref: "#/definitions/EmailAddress" + id: + type: string + required: + - id + - email GroupCollection: type: object properties: