express-api/src/services/keycloak/keycloakService.ts

Summary

Maintainability
A
0 mins
Test Coverage
import { IKeycloakErrorResponse } from '@/services/keycloak/IKeycloakErrorResponse';
import { IKeycloakRole, IKeycloakRolesResponse } from '@/services/keycloak/IKeycloakRole';
import { IKeycloakUser, IKeycloakUsersResponse } from '@/services/keycloak/IKeycloakUser';
import {
  keycloakRoleSchema,
  keycloakUserRolesSchema,
  keycloakUserSchema,
} from '@/services/keycloak/keycloakSchemas';
import logger from '@/utilities/winstonLogger';

import {
  getRoles,
  getRole,
  updateRole,
  createRole,
  getIDIRUsers,
  getBothBCeIDUser,
  getUserRoles,
  assignUserRoles,
  unassignUserRole,
  IDIRUserQuery,
} from '@bcgov/citz-imb-kc-css-api';
import rolesServices from '@/services/roles/rolesServices';
import { randomUUID } from 'crypto';
import { AppDataSource } from '@/appDataSource';
import { DeepPartial, In, Not } from 'typeorm';
import userServices from '@/services/users/usersServices';
import { Role } from '@/typeorm/Entities/Role';
import { ErrorWithCode } from '@/utilities/customErrors/ErrorWithCode';
import { User } from '@/typeorm/Entities/User';

/**
 * Synchronizes Keycloak roles with internal roles.
 * Retrieves Keycloak roles, adds new roles to the internal system, updates existing roles, and deletes roles not present in Keycloak.
 * @returns Returns the synchronized roles.
 */
const syncKeycloakRoles = async () => {
  const systemUser = await userServices.getUsers({ username: 'system' });
  if (systemUser?.length !== 1) {
    throw new ErrorWithCode('System user was missing.', 500);
  }
  const systemId = systemUser[0].Id;
  const roles = await KeycloakService.getKeycloakRoles();
  for (const role of roles) {
    const internalRole = await rolesServices.getRoles({ name: role.name });
    if (internalRole.length == 0) {
      const newRole: Role = {
        Id: randomUUID(),
        Name: role.name,
        IsDisabled: false,
        SortOrder: 0,
        Description: undefined,
        CreatedById: systemId,
        CreatedBy: undefined,
        CreatedOn: undefined,
        UpdatedById: undefined,
        UpdatedBy: undefined,
        UpdatedOn: undefined,
        Users: [],
      };
      await rolesServices.addRole(newRole);
    } else {
      const overwriteRole: DeepPartial<Role> = {
        Id: internalRole[0].Id,
        Name: role.name,
        IsDisabled: false,
        SortOrder: 0,
        Description: undefined,
        CreatedById: undefined,
        CreatedOn: undefined,
        UpdatedById: systemId,
        UpdatedOn: new Date(),
      };
      await rolesServices.updateRole(overwriteRole);
    }
  }
  //This deletion section is somewhat clunky. Could consider delete cascade on the schema to avoid some of this.
  const internalRolesForDeletion = await AppDataSource.getRepository(Role).findBy({
    Name: Not(In(roles.map((a) => a.name))),
  });

  if (internalRolesForDeletion.length) {
    const roleIdsForDeletion = internalRolesForDeletion.map((role) => role.Id);
    await AppDataSource.getRepository(User)
      .createQueryBuilder()
      .update(User)
      .set({ RoleId: null })
      .where('RoleId IN (:...ids)', { ids: roleIdsForDeletion })
      .execute();
    await AppDataSource.getRepository(Role).delete({
      Id: In(roleIdsForDeletion),
    });
  }
  return roles;
};

/**
 * @description Fetch a list of groups from Keycloak and their associated role within PIMS
 * @returns {IKeycloakRoles[]}  A list of roles from Keycloak.
 */
const getKeycloakRoles = async () => {
  try {
    // Get roles available in Keycloak
    const keycloakRoles: IKeycloakRolesResponse = await getRoles();
    // Return the list of roles
    return keycloakRoles.data;
  } catch (e) {
    throw new ErrorWithCode(
      `Failed to update user's Keycloak roles. ${(e as IKeycloakErrorResponse).message}`,
      500,
    );
  }
};

/**
 * @description Get information on a single Keycloak role from the role name.
 * @param   {string}   roleName String name of role in Keycloak
 * @returns {IKeycloakRole}  A single role object.
 * @throws If the role does not exist in Keycloak.
 */
const getKeycloakRole = async (roleName: string) => {
  // Get single role
  const response: IKeycloakRole | IKeycloakErrorResponse = await getRole(roleName);
  // Did the role exist? If not, it will be of type IKeycloakErrorResponse.
  if (!keycloakRoleSchema.safeParse(response).success) {
    const message = `keycloakService.getKeycloakRole: ${
      (response as IKeycloakErrorResponse).message
    }`;
    logger.warn(message);
    throw new Error(message);
  }
  // Return role info
  return response;
};

/**
 * @description Update a role that exists in Keycloak. Create it if it does not exist.
 * @param   {string}   roleName String name of role in Keycloak
 * @param   {string}   newRoleName The name to change the role name to.
 * @returns {IKeycloakRole} The updated role information. Existing role info if cannot be updated.
 * @throws  {Error} If the newRoleName already exists.
 */
const updateKeycloakRole = async (roleName: string, newRoleName: string) => {
  const roleWithNameAlready: IKeycloakRole = await getRole(newRoleName);
  // If it already exists, log the error and return existing role
  if (keycloakRoleSchema.safeParse(roleWithNameAlready).success) {
    const message = `keycloakService.updateKeycloakRole: Role ${newRoleName} already exists`;
    logger.warn(message);
    throw new Error(message);
  }
  const response: IKeycloakRole = await getRole(roleName);
  // Did the role to be changed exist? If not, it will be of type IKeycloakErrorResponse.
  let role: IKeycloakRole;
  if (keycloakRoleSchema.safeParse(response).success) {
    // Already existed. Update the role.
    role = await updateRole(roleName, newRoleName);
  } else {
    // Didn't exist already. Add the role.
    role = await createRole(newRoleName);
  }

  // Return role info
  return role;
};

/**
 * @description Sync the given username string wtih keycloak
 * @param   {string}   username String username to sync
 * @returns A promise that resolves to the user object with associated Agency and Role.
 * @throws  {ErrorWithCode} If the username was not found.
 */
const syncKeycloakUser = async (username: string) => {
  const users = await userServices.getUsers({ username: username });
  if (users?.length !== 1) {
    throw new ErrorWithCode('User was missing during keycloak role sync.', 500);
  }
  const user = users[0];
  const kroles = await KeycloakService.getKeycloakUserRoles(user.Username);

  if (kroles.length > 1) {
    logger.warn(
      `User ${user.Username} was assigned multiple roles in keycloak. This is not fully supported internally. A single role will be assigned arbitrarily.`,
    );
  }

  const krole = kroles?.[0];
  if (!krole) {
    logger.warn(`User ${user.Username} has no roles in keycloak.`);
    await userServices.updateUser({ Id: user.Id, RoleId: null });
    return userServices.getUserById(user.Id);
  }

  const internalRole = await rolesServices.getRoleByName(krole.name);
  await userServices.updateUser({ Id: user.Id, RoleId: internalRole.Id });
  return userServices.getUserById(user.Id);
};

/**
 * @description Retrieves Keycloak users based on the provided filter.
 * @param {IKeycloakUsersFilter} filter The filter to apply when retrieving users.
 * @returns {IKeycloakUser[]} A list of Keycloak users.
 */
const getKeycloakUsers = async (filter: IDIRUserQuery) => {
  // Get all users from Keycloak for IDIR
  // CSS API returns an empty list if no match.
  let users: IKeycloakUser[] = ((await getIDIRUsers(filter)) as IKeycloakUsersResponse).data;
  // Add BCeID if GUID was included.
  if (filter.guid) {
    users = users.concat(((await getBothBCeIDUser(filter.guid)) as IKeycloakUsersResponse).data);
  }
  // Return list of users
  return users;
};

/**
 * @description Retrieves a Keycloak user that matches the provided guid.
 * @param {string} guid The guid of the desired user.
 * @returns {IKeycloakUser} A single Keycloak user.
 * @throws If the user is not found.
 */
const getKeycloakUser = async (guid: string) => {
  // Should be by guid. Only way to guarantee uniqueness.
  const user: IKeycloakUser = (await getKeycloakUsers({ guid: guid }))?.at(0);
  if (keycloakUserSchema.safeParse(user).success) {
    // User found
    return user;
  } else {
    // User not found
    throw new Error(`keycloakService.getKeycloakUser: User ${guid} not found.`);
  }
};

/**
 * @description Retrieves a Keycloak user's roles.
 * @param {string} username The user's username.
 * @returns {IKeycloakRole[]} A list of the user's roles.
 * @throws If the user is not found.
 */
const getKeycloakUserRoles = async (username: string): Promise<IKeycloakRole[]> => {
  const existingRolesResponse: IKeycloakRolesResponse | IKeycloakErrorResponse =
    await getUserRoles(username);

  if (!keycloakUserRolesSchema.safeParse(existingRolesResponse).success) {
    const message = `keycloakService.getKeycloakUserRoles: ${(existingRolesResponse as IKeycloakErrorResponse).message}`;
    logger.warn(message);
    throw new Error(message);
  }
  // Ensure the response always returns an array of roles
  return (existingRolesResponse as IKeycloakRolesResponse).data || [];
};

/**
 * @description Updates a user's roles in Keycloak.
 * @param {string} username The user's username.
 * @param {string[]} roles A list of roles that the user should have.
 * @returns {IKeycloakRole[]} A list of the updated Keycloak roles.
 * @throws If the user does not exist.
 */
const updateKeycloakUserRoles = async (username: string, roles: string[]) => {
  try {
    const existingRolesResponse = await getKeycloakUserRoles(username);

    // User is found in Keycloak.
    const existingRoles: string[] = existingRolesResponse.map((role) => role.name);

    // Find roles that are in Keycloak but are not in new user info.
    const rolesToRemove = existingRoles.filter((existingRole) => !roles.includes(existingRole));
    // Remove old roles
    // No call to remove all as list, so have to loop.
    rolesToRemove.forEach(async (role) => {
      await unassignUserRole(username, role);
    });

    // Find new roles that aren't in Keycloak already.
    const rolesToAdd = roles.filter((newRole) => !existingRoles.includes(newRole));
    // Add new roles
    const updatedRoles: IKeycloakRolesResponse = await assignUserRoles(username, rolesToAdd);

    // Return updated list of roles
    return updatedRoles.data;
  } catch (e: unknown) {
    const message = `keycloakService.updateKeycloakUserRoles: ${
      (e as IKeycloakErrorResponse).message
    }`;
    logger.warn(message);
    throw new ErrorWithCode(
      `Failed to update user ${username}'s Keycloak roles. User's Keycloak account may not be active.`,
      500,
    );
  }
};

const KeycloakService = {
  getKeycloakUserRoles,
  syncKeycloakRoles,
  getKeycloakRole,
  getKeycloakRoles,
  updateKeycloakRole,
  syncKeycloakUser,
  getKeycloakUser,
  getKeycloakUsers,
  updateKeycloakUserRoles,
};

export default KeycloakService;