packages/oae-principals/lib/libraries/memberships.js
/*!
* 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);