oaeproject/Hilary

View on GitHub
packages/oae-principals/lib/libraries/memberships.js

Summary

Maintainability
A
0 mins
Test Coverage
B
84%
/*!
 * Copyright 2015 Apereo Foundation (AF) Licensed under the
 * Educational Community License, Version 2.0 (the "License"); you may
 * not use this file except in compliance with the License. You may
 * obtain a copy of the License at
 *
 *     http://opensource.org/licenses/ECL-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an "AS IS"
 * BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
 * or implied. See the License for the specific language governing
 * permissions and limitations under the License.
 */

/* eslint-disable unicorn/no-array-callback-reference */
import PrincipalsEmitter from 'oae-principals/lib/internal/emitter.js';

import _ from 'underscore';

import * as AuthzAPI from 'oae-authz';
import * as AuthzDelete from 'oae-authz/lib/delete.js';
import * as AuthzUtil from 'oae-authz/lib/util.js';
import * as LibraryAPI from 'oae-library';
import * as SearchUtil from 'oae-search/lib/util.js';
import * as PrincipalsDAO from 'oae-principals/lib/internal/dao.js';
import * as PrincipalsDelete from 'oae-principals/lib/delete.js';
import * as PrincipalsUtil from 'oae-principals/lib/util.js';

import { AuthzConstants } from 'oae-authz/lib/constants.js';
import { SearchConstants } from 'oae-search/lib/constants.js';
import { PrincipalsConstants } from 'oae-principals/lib/constants.js';

import { logger } from 'oae-logger';

const log = logger('principals-memberships');

/// /////////////////////////////////////////
// LIBRARY INDEX AND SEARCH REGISTRATIONS //
/// /////////////////////////////////////////

/*!
 * Register a library indexer that can provide resources to reindex the memberships library
 */
LibraryAPI.Index.registerLibraryIndex(PrincipalsConstants.library.MEMBERSHIPS_INDEX_NAME, {
  pageResources(libraryId, start, limit, callback) {
    // For memberships, we always just get all of them because we need a full graph. So ignore
    // the suggested `limit` and just return all memberships when asked to page. The `null`
    // `nextToken` we give back will tell the pager to stop looking for more
    _getAllGroupMembershipsFromAuthz(libraryId, (error, groupIdRoles) => {
      if (error) {
        return callback(error);
      }

      // Get the properties of the groups in the library that are relevant to building the library
      PrincipalsDAO.getPrincipals(
        _.keys(groupIdRoles),
        ['principalId', 'tenantAlias', 'visibility', 'lastModified'],
        (error, groups) => {
          if (error) {
            return callback(error);
          }

          // Map the groups to library entry items with just the properties needed to populate
          // the library index
          const resources = _.map(groups, (group) => ({
            rank: group.lastModified,
            resource: group,
            value: groupIdRoles[group.id]
          }));

          return callback(null, resources);
        }
      );
    });
  }
});

/*!
 * Register a library search that will search a principal's group memberships
 */
LibraryAPI.Search.registerLibrarySearch('memberships-library', ['group'], {
  searches: {
    private(ctx, libraryOwner, options, callback) {
      // The memberships library search is in essence a graph index, which our search platform
      // does not support. In its place, we will get all our memberships from the memberships
      // library and simply throw them at search to join onto :(
      _getAllGroupMembershipsFromLibrary(libraryOwner.id, (error, groupIds) => {
        if (error) {
          return callback(error);
        }

        // Target the full set of groups that are in this user's memberships to search through
        return callback(
          null,
          SearchUtil.filterAnd(
            SearchUtil.filterTerm('type', SearchConstants.search.MAPPING_RESOURCE),
            SearchUtil.filterTerms('resourceType', ['group']),
            SearchUtil.filterIds(groupIds)
          )
        );
      });
    }
  }
});

/**
 * Get all the group memberships for a principal from the memberships library. This index is built
 * VIA `_getAllGroupMembershipsFromAuthz` but can be thought of as an indirect memberships cache
 * that takes into consideration deleted groups.
 *
 * @param  {String}     principalId             The id of the principal whose memberships to get
 * @param  {Function}   callback                Standard callback function
 * @param  {Object}     callback.err            An error that occurred, if any
 * @param  {String[]}   callback.memberships    The ids of the groups of which the principal is directly or indirectly a member
 * @api private
 */
const _getAllGroupMembershipsFromLibrary = function (principalId, callback, _groupIds, _nextToken) {
  _groupIds = _groupIds || [];
  if (_nextToken === null) {
    return callback(null, _groupIds);
  }

  // Get the next batch of memberships from the library
  const options = { start: _nextToken, limit: 100 };
  LibraryAPI.Index.list(
    PrincipalsConstants.library.MEMBERSHIPS_INDEX_NAME,
    principalId,
    AuthzConstants.visibility.PRIVATE,
    options,
    (error, entries, nextToken) => {
      if (error) {
        return callback(error);
      }

      _groupIds = _.union(_groupIds, _.pluck(entries, 'resourceId'));
      return _getAllGroupMembershipsFromLibrary(principalId, callback, _groupIds, nextToken);
    }
  );
};

/**
 * Get all the group memberships for a principal from the AuthzAPI. This will take into
 * consideration group deletes that have happened in the system. Not only must deleted groups not
 * show in the library, indirect group membership that has been broken by a deleted group must not
 * show, which is the rationale of using the memberships graph
 *
 * @param  {String}     principalId             The id of the principal whose memberships to get
 * @param  {Function}   callback                Standard callback function
 * @param  {Object}     callback.err            An error that occurred, if any
 * @param  {Object}     callback.memberships    An object whose keys are the ids of the groups in the memberships graph, and the values are the applicable role principal has on the group
 * @api private
 */
const _getAllGroupMembershipsFromAuthz = function (principalId, callback) {
  // Get the full memberships graph from the AuthzAPI. Note that this includes all groups
  // including those that have since been marked as deleted
  AuthzAPI.getPrincipalMembershipsGraph(principalId, (error, graph) => {
    if (error) {
      return callback(error);
    }

    // Extract all group ids from the graph, excluding the principal id we actually searched for
    const allGroupIds = _.chain(graph.getNodes()).pluck('id').without(principalId).value();

    // Determine which of the groups in the memberships graph have been deleted
    AuthzDelete.isDeleted(allGroupIds, (error, deleted) => {
      if (error) {
        return callback(error);
      }

      // Delete all group nodes from the graph that have been deleted
      _.chain(deleted)
        .keys()
        .each((deletedGroupId) => {
          graph.removeNode(deletedGroupId);
        });

      // The resulting membership will be all group nodes that are reachable VIA a full
      // outbound edge traversal ("member of") starting from the `principalId`
      const membershipIds = _.chain(graph.traverseOut(principalId)).pluck('id').without(principalId).value();

      // Delete all groups from the graph that did not have a path to the principal
      _.chain(graph.getNodes())
        .pluck('id')
        .filter((nodeId) => !_.contains(membershipIds, nodeId))
        .each((nodeId) => {
          graph.removeNode(nodeId);
        });

      // For the remaining nodes, get the maximum role available in their inbound edges, this
      // will tell us the applicable role the user has on the group
      const memberRoles = {};
      _.each(membershipIds, (membershipId) => {
        const hasManager = _.chain(graph.getInEdgesOf(membershipId))
          .pluck('role')
          .contains(AuthzConstants.role.MANAGER)
          .value();
        if (hasManager) {
          memberRoles[membershipId] = AuthzConstants.role.MANAGER;
        } else {
          memberRoles[membershipId] = AuthzConstants.role.MEMBER;
        }
      });

      return callback(null, memberRoles);
    });
  });
};

/// ////////////////////////////////////
// LIBRARY MEMBERSHIPS INDEX UPDATES //
/// ////////////////////////////////////

/*!
 * When a group is created, we need to insert the group into the memberships libraries of all users
 * who are now a member of this group
 */
PrincipalsEmitter.when(PrincipalsConstants.events.CREATED_GROUP, (ctx, group, memberChangeInfo, callback) => {
  _touchMembershipLibraries(group, null, memberChangeInfo, (error) => {
    if (error) {
      log().warn(
        {
          err: error,
          groupId: group.id,
          memberIds: _.pluck(memberChangeInfo.members.added, 'id')
        },
        'An error occurred while updating membership libraries after creating a group'
      );
    }

    return callback();
  });
});

/*!
 * When a group is updated, we need to promote its rank in all memberships libraries to which it
 * belongs
 */
PrincipalsEmitter.on(PrincipalsConstants.events.UPDATED_GROUP, (ctx, updatedGroup, oldGroup) => {
  _touchMembershipLibraries(updatedGroup, oldGroup.lastModified, null, (error) => {
    if (error) {
      log().warn(
        {
          err: error,
          groupId: updatedGroup.id
        },
        'An error occurred while updating membership libraries after updating a group'
      );
    }
  });
});

/*!
 * When a user leaves a group, we need to remove it as well as any indirect ancestors from the
 * memberhips library of the user that left
 */
PrincipalsEmitter.when(PrincipalsConstants.events.LEFT_GROUP, (ctx, group, memberChangeInfo, callback) => {
  _touchMembershipLibraries(group, null, memberChangeInfo, (error) => {
    if (error) {
      log().warn(
        {
          err: error,
          groupId: group.id,
          userIds: _.keys(memberChangeInfo.changes)
        },
        'An error occurred while updating membership libraries after a user left a group'
      );
    }

    return callback();
  });
});

/*!
 * When a user joins a group, we need to add the group as well as its ancestors into the memberships
 * library of the user that joined
 */
PrincipalsEmitter.when(PrincipalsConstants.events.JOINED_GROUP, (ctx, group, oldGroup, memberChangeInfo, callback) => {
  // Add the group into the memberships library of the user that joined, as well as update
  // the group rank in all memberships libraries it already belongs to
  _touchMembershipLibraries(group, oldGroup.lastModified, memberChangeInfo, (error) => {
    if (error) {
      log().warn(
        {
          err: error,
          group,
          userIds: _.keys(memberChangeInfo.changes)
        },
        'An error occurred while updating the membership libraries after a group join'
      );
    }

    return callback();
  });
});

/*!
 * When the members of a group are updated, we need to insert/remove the group from the memberships
 * libraries of all children/grand-children. We also need to promote the rank of the group in all
 * memberships libraries it still belongs to, as its lastModified time-stamp gets updated
 */
PrincipalsEmitter.when(
  PrincipalsConstants.events.UPDATED_GROUP_MEMBERS,
  (ctx, group, oldGroup, memberChangeInfo, options, callback) => {
    // Remove the group from the membership library of the user that just left the group
    _touchMembershipLibraries(group, oldGroup.lastModified, memberChangeInfo, (error) => {
      if (error) {
        log().warn(
          {
            err: error,
            group,
            userIds: _.keys(memberChangeInfo.changes)
          },
          'An error occurred while updating the membership libraries after managing group access'
        );
      }

      return callback();
    });
  }
);

/**
 * Given an authz change on a group, update all the membership libraries that are involved
 *
 * @param  {Group}          group                   The group that was changed
 * @param  {Number}         [oldLastModified]       The timestamp when the group was previously changed. If this is left `null`, the group will not be moved to the top of the group membership libraries for the members of the group
 * @param  {Object}         memberChangeInfo        The member changes to use to touch the memberships libraries
 * @param  {Function}       callback                Standard callback function
 * @param  {Object}         callback.err            An error object, if any
 * @api private
 */
const _touchMembershipLibraries = function (group, oldLastModified, memberChangeInfo, callback) {
  const addedMemberIds = memberChangeInfo ? _.pluck(memberChangeInfo.members.added, 'id') : [];
  const removedMemberIds = memberChangeInfo ? _.pluck(memberChangeInfo.members.removed, 'id') : [];

  // Get the ancestors of this group. Since a user's membership library contains all indirect
  // group memberships, we need to insert/update/remove all indirect group ancestors
  _getGroupAncestorsIncludingDeleted(group, (error, ancestorGroups) => {
    if (error) {
      return callback(error);
    }

    // Create a set of groups that holds the group we changed and all its parents
    const changedGroup = _.extend({}, group, { oldLastModified });
    const groups = [...ancestorGroups, changedGroup];

    // Insert the group (and its ancestors) into the membership libraries of the new members
    _insertMembershipsLibraries(groups, addedMemberIds, (error, explodedInsertedPrincipals) => {
      if (error) {
        return callback(error);
      }

      // Remove the group (and its ancestors) from the membership libraries of the removed
      // principals
      _removeMembershipsLibraries(removedMemberIds, groups, (error_) => {
        if (error_) {
          return callback(error_);
        }

        // Update the membership libraries of all the other members of the changed group to
        // ensure it shows at the top of their membership library
        if (oldLastModified) {
          return _updateMembershipsLibraries(changedGroup, explodedInsertedPrincipals, callback);
        }

        return callback();
      });
    });
  });
};

/**
 * Insert the given `groups` into the memberships libraries of `addedMemberIds` AND
 * all the children of `addedMemberIds`.
 *
 * @param  {Group}          groups                  The groups to insert into the membership libraries
 * @param  {String[]}       addedMemberIds          The ids of the members that were added to the group
 * @param  {Function}       callback                Standard callback function
 * @param  {Object}         callback.err            An error that occurred, if any
 * @param  {String[]}       callback.principals     The ids of the principals for which to update the membership libraries
 * @api private
 */
const _insertMembershipsLibraries = function (groups, addedMemberIds, callback) {
  if (_.isEmpty(addedMemberIds)) {
    return callback(null, []);
  }

  // Get all the children of the members we've added so we can insert the group
  // and its ancestors into their membership libraries
  _getAllGroupChildren(addedMemberIds, [], (error, allChildren) => {
    if (error) {
      return callback(error);
    }

    // The principals for which the groups will be inserted in their libraries
    const principalIds = [...allChildren, ...addedMemberIds];

    const entries = _.chain(groups)
      .map((group) =>
        _.map(principalIds, (principalId) => ({
          id: principalId,
          rank: group.lastModified,
          resource: group
        }))
      )
      .flatten()
      .value();

    LibraryAPI.Index.insert(PrincipalsConstants.library.MEMBERSHIPS_INDEX_NAME, entries, (error_) => {
      if (error_) {
        return callback(error_);
      }

      return callback(null, allChildren);
    });
  });
};

/**
 * Update the group entries in the memberships libraries of the given member ids
 *
 * @param  {Group}          group                   The group to update in the libraries
 * @param  {String[]}       excludePrincipalIds     The principal ids for which the membership libraries should not be updated
 * @param  {Function}       callback                Standard callback function
 * @api private
 */
const _updateMembershipsLibraries = function (group, excludePrincipalIds, callback) {
  // Get the exploded members list of the group we've updated excluding any
  // principals we've dealth with earlier
  _getAllGroupChildren([group.id], excludePrincipalIds, (error, allChildren) => {
    if (error) {
      return callback(error);
    }

    // The principals for which the groups will be updated in their libraries
    const principalIdsToUpdate = [...allChildren, group.id];
    const entries = _.map(principalIdsToUpdate, (principalId) => ({
      id: principalId,
      oldRank: group.oldLastModified,
      newRank: group.lastModified,
      resource: group
    }));

    // Update all the groups in the libraries of the updated members (and their children)
    LibraryAPI.Index.update(PrincipalsConstants.library.MEMBERSHIPS_INDEX_NAME, entries, (error_) => {
      if (error_) {
        log().error(
          {
            err: error_,
            group
          },
          "Unable to update a group in a principal's membership library"
        );
      }

      return callback();
    });
  });
};

/**
 * Remove the group entries from the memberships libraries of the given member ids
 *
 * @param  {String[]}   removedMemberIds    The ids of the members for which the groups need to be removed from their membership libraries
 * @param  {Group[]}    groups              The groups that should be removed from the `removedMemberIds` their membership libraries
 * @param  {Function}   callback            Standard callback function
 * @param  {Object}     callback.err        An error that occurred, if any
 * @api private
 */
const _removeMembershipsLibraries = function (removedMemberIds, groups, callback) {
  if (_.isEmpty(removedMemberIds)) {
    return callback();
  }

  _getAllGroupChildren(removedMemberIds, [], (error, allChildren) => {
    if (error) {
      return callback(error);
    }

    // The principals for which to remove the groups from their libraries
    const principalIds = [...allChildren, ...removedMemberIds];

    // Gather all index removal entries to persist
    const entries = _.chain(groups)
      .map((group) =>
        _.map(principalIds, (principalId) => ({
          id: principalId,
          rank: group.oldLastModified || group.lastModified,
          resource: group
        }))
      )
      .flatten()
      .value();

    // Remove the groups from the libraries of the removed members (and their children)
    LibraryAPI.Index.remove(PrincipalsConstants.library.MEMBERSHIPS_INDEX_NAME, entries, (error_) => {
      if (error_) {
        log().error(
          {
            err: error_,
            memberIds: removedMemberIds,
            groupIds: _.pluck(groups, 'id')
          },
          "Unable to remove groups from a principal's membership library"
        );
      }

      return callback();
    });
  });
};

/**
 * Get a group's ancestor groups, including those that are deleted
 *
 * @param  {Group}          group               The group to retrieve the ancestors for
 * @param  {Function}       callback            Standard callback function
 * @param  {Object}         callback.err        An error object, if any
 * @param  {Group[]}        callback.groups     The ancestor groups
 * @api private
 */
const _getGroupAncestorsIncludingDeleted = function (group, callback) {
  // Get all the ancestors of the group
  AuthzAPI.getPrincipalMembershipsGraph(group.id, (error, graph) => {
    if (error) {
      return callback(error);
    }

    // Extract the ids of all groups in the memberships list from the graph
    const membershipIds = _.chain(graph.getNodes()).pluck('id').without(group.id).value();

    // Get a light-weight group representation for each ancestor
    PrincipalsDAO.getPrincipals(
      membershipIds,
      ['principalId', 'tenantAlias', 'lastModified', 'visibility'],
      (error, parentGroupsByGroupId) => {
        if (error) {
          return callback(error);
        }

        return callback(null, _.values(parentGroupsByGroupId));
      }
    );
  });
};

/**
 * Get all the children for a set of principals
 *
 * @param  {String[]}   principalIds        The ids of the principals to retrieve all children for
 * @param  {Function}   callback            Standard callback function
 * @param  {Object}     callback.err        An error object, if any
 * @param  {String[]}   callback.children   The ids of the principals that are a direct or indirect member of any of the given principal ids. The passed in principal ids will be included in this result set
 * @api private
 */
const _getAllGroupChildren = function (principalIds, excludePrincipals, callback, _groupsToExplode, _allChildren) {
  _allChildren = _allChildren || [];
  _groupsToExplode = _groupsToExplode || _.filter(principalIds, AuthzUtil.isGroupId);

  // If there are no groups left to explode, we can remove the group from all the affected
  // member libraries
  if (_.isEmpty(_groupsToExplode)) {
    return callback(null, _allChildren);
  }

  // Get the next group to explode
  const groupId = _groupsToExplode.shift();

  // Get all of the members of the group, so they can be invalidated
  AuthzAPI.getAuthzMembers(groupId, null, 10_000, (error, members) => {
    if (error) {
      return callback(error);
    }

    _.each(members, (member) => {
      // Groups need to be further exploded. In order to do this, we need to check whether or not the list
      // of groups that have already been invalidated and the list of groups that are queued up to be invalidated
      // don't contain this group, otherwise we'll invalidate the group twice.
      if (
        AuthzUtil.isGroupId(member.id) &&
        !_.contains(_allChildren, member.id) &&
        !_.contains(_groupsToExplode, member.id) &&
        !_.contains(excludePrincipals, member.id)
      ) {
        _groupsToExplode.push(member.id);
      }

      // The members can be invalidated
      if (!_.contains(_allChildren, member.id) && !_.contains(excludePrincipals, member.id)) {
        _allChildren.push(member.id);
      }
    });

    _getAllGroupChildren(principalIds, excludePrincipals, callback, _groupsToExplode, _allChildren);
  });
};

/// //////////////////
// DELETE HANDLERS //
/// //////////////////

/**
 * Handler to invalidate the memberships libraries of all user ids in the group's memberships graph
 *
 * @param  {Group}          group               The group that needs to be invalidated
 * @param  {AuthzGraph}     membershipsGraph    The graph of group memberships of the group
 * @param  {AuthzGraph}     membersGraph        The graph of group members of the group
 * @param  {Function}       callback            Standard callback function
 * @param  {Object[]}       callback.errs       All errs that occurred while trying to invalidate the group memberships library
 * @api private
 */
const _handleInvalidateMembershipsLibraries = function (
  group,
  membershipsGraph,
  membersGraph,
  callback,
  _errs,
  _userIds
) {
  _userIds = _userIds || _.chain(membersGraph.getNodes()).pluck('id').filter(PrincipalsUtil.isUser).value();
  if (_.isEmpty(_userIds)) {
    return callback(_errs, _userIds);
  }

  // Purge the memberships library for the next user
  const userId = _userIds.shift();
  LibraryAPI.Index.purge(PrincipalsConstants.library.MEMBERSHIPS_INDEX_NAME, userId, (error) => {
    if (error) {
      _errs = _errs || [];
      _errs.push(error);
    }

    return _handleInvalidateMembershipsLibraries(group, membershipsGraph, membersGraph, callback, _errs, _userIds);
  });
};

/*!
 * Register group delete and restore handlers that invalidate memberships libraries so they can
 * be reconstructed with or without the group
 */
PrincipalsDelete.registerGroupDeleteHandler('memberships-library', _handleInvalidateMembershipsLibraries);
PrincipalsDelete.registerGroupRestoreHandler('memberships-library', _handleInvalidateMembershipsLibraries);