oaeproject/Hilary

View on GitHub
packages/oae-principals/lib/search.js

Summary

Maintainability
C
1 day
Test Coverage
A
96%
/*!
 * Copyright 2014 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 camelcase */
import _ from 'underscore';
import {
  pluck,
  filter,
  without,
  partition,
  compose,
  prop,
  has,
  not,
  objOf,
  identity,
  pipe,
  pick,
  map,
  assoc,
  head,
  defaultTo,
  mapObjIndexed,
  mergeDeepLeft,
  mergeLeft
} from 'ramda';
import { logger } from 'oae-logger';

import * as AuthzSearch from 'oae-authz/lib/search.js';
import * as AuthzUtil from 'oae-authz/lib/util.js';
import * as ContentUtil from 'oae-content/lib/internal/util.js';
import * as OaeUtil from 'oae-util/lib/util.js';
import * as SearchAPI from 'oae-search';
import * as TenantsAPI from 'oae-tenants';

import { emitter } from 'oae-principals';
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 { PrincipalsConstants } from 'oae-principals/lib/constants.js';
import { User } from 'oae-principals/lib/model.js';

const { getTenant } = TenantsAPI;
const getTenantAlias = prop('tenantAlias');
const log = logger('principals-search');
const { getResourceFromId } = AuthzUtil;
const getResourceId = prop('resourceId');

/**
 * Indexing tasks
 */

/*!
 * When a user is created, we must index the user resource document
 */
emitter.on(PrincipalsConstants.events.CREATED_USER, (ctx, user) => {
  SearchAPI.postIndexTask('user', [{ id: user.id }], {
    resource: true
  });
});

/*!
 * When a user is updated, we must reindex the user resource document
 */
emitter.on(PrincipalsConstants.events.UPDATED_USER, (ctx, user) => {
  SearchAPI.postIndexTask('user', [{ id: user.id }], {
    resource: true
  });
});

/*!
 * When a user is deleted, we must reindex the user resource document
 */
emitter.on(PrincipalsConstants.events.DELETED_USER, (ctx, user) => {
  SearchAPI.postIndexTask('user', [{ id: user.id }], {
    resource: true
  });
});

/*!
 * When a user is restored, we must reindex the user resource document
 */
emitter.on(PrincipalsConstants.events.RESTORED_USER, (ctx, user) => {
  SearchAPI.postIndexTask('user', [{ id: user.id }], {
    resource: true
  });
});

/*!
 * When a user's email is verified, we must reindex the user resource document
 */
emitter.on(PrincipalsConstants.events.VERIFIED_EMAIL, (ctx, user) => {
  SearchAPI.postIndexTask('user', [{ id: user.id }], {
    resource: true
  });
});

/*!
 * When a group is created, we must index the group resource document and its members child document
 */
emitter.on(PrincipalsConstants.events.CREATED_GROUP, (ctx, group, memberChangeInfo) => {
  SearchAPI.postIndexTask('group', [{ id: group.id }], {
    resource: true,
    children: {
      resource_members: true
    }
  });

  // Fire additional tasks to update the memberships of the members
  AuthzSearch.fireMembershipUpdateTasks(_.keys(memberChangeInfo.changes));
});

/*!
 * When a group is updated, we must reindex the user resource document
 */
emitter.on(PrincipalsConstants.events.UPDATED_GROUP, (ctx, group) => {
  SearchAPI.postIndexTask('group', [{ id: group.id }], {
    resource: true
  });
});

/*!
 * When group members have been updated, we must both the group's members child document and all the
 * principals' child memberships documents
 */
emitter.on(
  PrincipalsConstants.events.UPDATED_GROUP_MEMBERS,
  // eslint-disable-next-line no-unused-vars
  (ctx, group, oldGroup, memberChangeInfo, options) => {
    _handleUpdateGroupMembers(ctx, group, _.keys(memberChangeInfo.changes));
  }
);

/*!
 * When someone joins a group, we must both the group's members child document and the user's child
 * memberships documents
 */
emitter.on(PrincipalsConstants.events.JOINED_GROUP, (ctx, group, oldGroup, memberChangeInfo) => {
  _handleUpdateGroupMembers(ctx, group, _.keys(memberChangeInfo.changes));
});

/*!
 * When someone leaves a group, we must both the group's members child document and the user's child
 * memberships documents
 */
emitter.on(PrincipalsConstants.events.LEFT_GROUP, (ctx, group, memberChangeInfo) => {
  _handleUpdateGroupMembers(ctx, group, _.keys(memberChangeInfo.changes));
});

/**
 * Submits the indexing operation required when a group's members have changed.
 *
 * @param  {Context}    ctx             Current execution context
 * @param  {Group}      group           The group object whose membership changed
 * @param  {String[]}   principalIds    The ids of all the members whose status in the group changed
 * @api private
 */
const _handleUpdateGroupMembers = function (ctx, group, principalIds) {
  SearchAPI.postIndexTask('group', [{ id: group.id }], {
    children: {
      resource_members: true
    }
  });

  // Fire additional tasks to update the memberships of the members
  AuthzSearch.fireMembershipUpdateTasks(principalIds);
};

/**
 * Document producers
 */

/**
 * Produces search documents for 'user' resources.
 *
 * @see SearchAPI#registerSearchDocumentProducer
 * @api private
 */
const _produceUserSearchDocuments = function (resources, callback, _documents, _errs) {
  _documents = _documents || [];
  if (_.isEmpty(resources)) {
    return callback(_errs, _documents);
  }

  const resource = resources.pop();

  // No need to retrieve the user object if it was provided
  if (resource.user) {
    _documents.push(_produceUserSearchDocument(resource.user));
    return _produceUserSearchDocuments(resources, callback, _documents, _errs);
  }

  // We'll need to retrieve the user if the full object wasn't provided
  PrincipalsDAO.getPrincipal(resource.id, (error, user) => {
    if (error) {
      _errs = _.union(_errs, [error]);
      return _produceUserSearchDocuments(resources, callback, _documents, _errs);
    }

    _documents.push(_produceUserSearchDocument(user));
    return _produceUserSearchDocuments(resources, callback, _documents, _errs);
  });
};

/**
 * Given a user, create a search document based on its information.
 *
 * @param  {User}  user    The user document
 * @return {Object}        The search document that represents the user
 * @api private
 */
const _produceUserSearchDocument = function (user) {
  const searchDoc = {
    resourceType: user.resourceType,
    id: user.id,
    tenantAlias: user.tenant.alias,
    email: user.email,
    deleted: user.deleted,
    displayName: user.displayName,
    visibility: user.visibility,
    q_high: user.displayName,
    sort: user.displayName,
    lastModified: user.lastModified,
    _extra: {
      publicAlias: user.publicAlias,
      userExtra: user.extra
    }
  };

  if (user.picture.mediumUri) {
    searchDoc.thumbnailUrl = user.picture.mediumUri;
  }

  return searchDoc;
};

/**
 * Produces search documents for 'group' resources.
 *
 * @see SearchAPI#registerSearchDocumentProducer
 * @api private
 */
const _produceGroupSearchDocuments = function (resources, callback, _documents, _errs) {
  _documents = _documents || [];
  if (_.isEmpty(resources)) return callback(_errs, _documents);

  const resource = resources.pop();
  if (resource.group) {
    _documents.push(_produceGroupSearchDocument(resource.group));
    return _produceGroupSearchDocuments(resources, callback, _documents, _errs);
  }

  PrincipalsDAO.getPrincipal(resource.id, (error, group) => {
    if (error) {
      _errs = _.union(_errs, [error]);
      return _produceGroupSearchDocuments(resources, callback, _documents, _errs);
    }

    _documents.push(_produceGroupSearchDocument(group));
    return _produceGroupSearchDocuments(resources, callback, _documents, _errs);
  });
};

/**
 * Given a group, create a search document based on its information.
 *
 * @param  {Group}  group  The group document
 * @return {Object}        The search document that represents the group
 * @api private
 */
const _produceGroupSearchDocument = function (group) {
  // Full text searching is done on the name, alias and description. Though, the displayName is scored higher through `q_high`.
  const fullText = _.compact([group.displayName, group.alias, group.description]).join(' ');

  const searchDoc = {
    resourceType: group.resourceType,
    id: group.id,
    tenantAlias: group.tenant.alias,
    deleted: group.deleted,
    displayName: group.displayName,
    visibility: group.visibility,
    joinable: group.joinable,
    q_high: group.displayName,
    q_low: fullText,
    sort: group.displayName,
    dateCreated: group.created,
    lastModified: group.lastModified,
    createdBy: group.createdBy,
    _extra: {
      alias: group.alias
    }
  };

  if (group.picture.mediumUri) {
    searchDoc.thumbnailUrl = group.picture.mediumUri;
  }

  return searchDoc;
};

// Bind the document producers
SearchAPI.registerSearchDocumentProducer('user', _produceUserSearchDocuments);
SearchAPI.registerSearchDocumentProducer('group', _produceGroupSearchDocuments);

/**
 * Document transformers
 */

/**
 * A function that returns a function that either associates an `thumbnailUrl` field
 * to the user object or alternatively just returns the identity function
 *
 * @function _assignThumbnailIfNeeded
 * @param  {Object} ctx  The http context requesting
 * @param  {Object} user the user object
 */
const _assignThumbnailIfNeeded = (ctx, user) => {
  if (user.picture.mediumUri) {
    return assoc('thumbnailUrl', ContentUtil.getSignedDownloadUrl(ctx, user.picture.mediumUri));
  }

  return identity;
};

/**
 * A function that returns a function that either associates an `extra` field
 * to the user object or alternatively just returns the identity function
 *
 * @function _assignExtraIfNeeded
 * @param {Object} user The user object
 */
const _assignExtraIfNeeded = (user) => {
  if (user.extra) {
    return assoc('extra', user.extra);
  }

  return identity;
};

/**
 * Given an array of user search documents, transform them into search documents
 * suitable to be displayed to the user in context.
 *
 * @param  {Context}   ctx             Current execution context
 * @param  {Object}    docs            A hash, keyed by the document id, while the value is the document to transform
 * @param  {Function}  callback        Standard callback function
 * @param  {Object}    callback.err    An error that occurred, if any
 * @param  {Object}    callback.docs   The transformed docs, in the same form as the `docs` parameter.
 * @api private
 */
const _transformUserDocuments = function (ctx, docs, callback) {
  const transformedDocs = mapObjIndexed((doc, docId) => {
    const scalarExtraField = head(doc.fields._extra);
    const extra = defaultTo({}, scalarExtraField);
    const scalarFields = map(head, doc.fields);
    const { thumbnailUrl, email, displayName, tenantAlias, visibility } = scalarFields;

    const user = assoc(
      'extra',
      extra.userExtra,
      new User(tenantAlias, docId, displayName, email, {
        visibility,
        publicAlias: extra.publicAlias,
        mediumPictureUri: thumbnailUrl
      })
    );

    /**
     * First we need to convert the data in this document back into the source user object
     * so that we may use PrincipalsUtil.hideUserData to hide its information.
     * We will then after convert the user *back* to a search document once the user information
     * has been scrubbed
     */
    // Hide information that is sensitive to the current session
    PrincipalsUtil.hideUserData(ctx, user);

    /**
     * Convert the user object back to a search document using the producer.
     * We use this simply to re-use the logic of turning a user object into a search document
     */
    const tenantAndProfileInfo = {
      profilePath: user.profilePath,
      tenant: user.tenant
    };

    const result = pipe(
      _produceUserSearchDocument,
      mergeDeepLeft(tenantAndProfileInfo),
      // The UI search model expects the 'extra' parameter if it was not scrubbed
      _assignExtraIfNeeded(user),
      // If the mediumPictureUri wasn't scrubbed from the user object that means the current user can see it
      _assignThumbnailIfNeeded(ctx, user)
    )(user);

    /**
     * We need to delete these fields which are added by the producer but aren't
     * supposed to be included in the UI
     */
    delete result.q_high;
    delete result.sort;

    return result;
  }, docs);

  return callback(null, transformedDocs);
};

// Bind the transformer to the search API
SearchAPI.registerSearchDocumentTransformer('user', _transformUserDocuments);

/**
 * A function that either returns a function that conditionally assigns the `thumbnailUrl`
 * to an object or alternatively just returns the identity function
 *
 * @function _signThumbnail
 * @param  {Object} ctx          The http context of the request
 * @param  {String} thumbnailUrl The thumbnailUrl to assign conditionally
 * @param  {Object} result       Search result object
 */
const _signThumbnailIfNeeded = (ctx, thumbnailUrl, result) => {
  if (has('thumbnailUrl', result)) {
    return assoc('thumbnailUrl', ContentUtil.getSignedDownloadUrl(ctx, thumbnailUrl));
  }

  return identity;
};

/**
 * A function that either returns a function that conditionally assigns the `profilePath`
 * to an object or alternatively just returns the identity function
 * @function _assignProfilePathIfNeeded
 * @param  {Object} tenantAlias The tenant alias the profile belongs to
 * @param  {String} resourceId  The resourceId representing the group
 * @param  {Object} result      Search result object
 */
const _assignProfilePathIfNeeded = (tenantAlias, resourceId, result) => {
  if (not(result.deleted)) {
    return assoc('profilePath', `/group/${tenantAlias}/${resourceId}`);
  }

  return identity;
};

/**
 * Given an array of group search documents, transform them into search documents suitable to be displayed to the user in context.
 *
 * @param  {Context}   ctx             Current execution context
 * @param  {Object}    docs            A hash, keyed by the document id, while the value is the document to transform
 * @param  {Function}  callback        Standard callback function
 * @param  {Object}    callback.err    An error that occurred, if any
 * @param  {Object}    callback.docs   The transformed docs, in the same form as the `docs` parameter.
 * @api private
 */
const _transformGroupDocuments = function (ctx, docs, callback) {
  const transformedDocs = mapObjIndexed((doc, docId) => {
    const scalarExtraField = head(doc.fields._extra);
    const extraFields = defaultTo({}, scalarExtraField);
    const alias = pick(['alias'], extraFields);
    const scalarFields = map(head, doc.fields);
    const tenantAlias = getTenantAlias(scalarFields);
    const tenant = getTenant(tenantAlias).compact();
    const resourceId = compose(getResourceId, getResourceFromId)(docId);

    return pipe(
      mergeLeft({ id: docId }),
      mergeLeft(scalarFields),
      // Sign the thumbnail URL so it may be downloaded by the client
      _signThumbnailIfNeeded(ctx, scalarFields.thumbnailUrl, scalarFields),
      // Add the profile path, only if the group is not deleted
      _assignProfilePathIfNeeded(tenantAlias, resourceId, scalarFields),
      mergeDeepLeft({ alias }),
      mergeDeepLeft({ tenant })
    )(extraFields);
  }, docs);

  return callback(null, transformedDocs);
};

// Bind the transformer to the search API
SearchAPI.registerSearchDocumentTransformer('group', _transformGroupDocuments);

/**
 * Reindex all handlers
 */

/*!
 * Binds a reindexAll handler that reindexes all rows from the Principals CF (users and groups)
 */
SearchAPI.registerReindexAllHandler('principal', (callback) => {
  /*!
   * Handles each iteration of the PrincipalsDAO iterate all method, firing tasks for all principals to
   * be reindexed.
   *
   * @see PrincipalsDAO#iterateAll
   */
  const _onEach = function (principalRows, done) {
    // Aggregate group and user reindexing task resources
    const groupResources = [];
    const userResources = [];
    _.each(principalRows, (principal) => {
      const { principalId } = principal;
      if (principalId) {
        if (AuthzUtil.isGroupId(principalId)) {
          groupResources.push({ id: principalId });
        } else if (AuthzUtil.isUserId(principalId)) {
          userResources.push({ id: principalId });
        }
      }
    });

    log().info('Firing re-indexing task for %s users and %s groups', userResources.length, groupResources.length);

    if (!_.isEmpty(userResources)) {
      SearchAPI.postIndexTask('user', userResources, { resource: true, children: true });
    }

    if (!_.isEmpty(groupResources)) {
      SearchAPI.postIndexTask('group', groupResources, { resource: true, children: true });
    }

    return done();
  };

  return PrincipalsDAO.iterateAll(null, 100, _onEach, callback);
});

/**
 * Delete handlers
 */

/**
 * Handler to invoke the search tasks required to invalidate search documents necessary
 *
 * @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 fire the search update tasks, if any
 * @api private
 */
const _handleInvalidateSearch = function (group, membershipsGraph, membersGraph, callback) {
  /**
   * All members (direct and indirect, users and groups) of the group that was deleted need to
   * have their memberships child search documents invalidated
   */
  const groupAndUserIds = pipe(
    pluck('id'),
    filter(AuthzUtil.isPrincipalId),
    without(group.id),
    partition(AuthzUtil.isGroupId)
  )(membersGraph.getNodes());
  const memberGroupIds = groupAndUserIds[0];
  const memberUserIds = groupAndUserIds[1];

  // Create the index task that will tell search to update the deleted group's resource document
  const resourceGroupIndexTask = [{ id: group.id }];

  // Create the index tasks that will tell search which resource's memberships document to update
  const memberGroupIndexTasks = map(objOf('id'), memberGroupIds);

  const memberUserIndexTasks = map(objOf('id'), memberUserIds);

  // The index operation that tells search to update only the resource document of the target
  // resources. This is needed for the group being deleted only
  const resourceIndexOp = { resource: true };

  // The index operation that tells search to only update the memberships child document of the
  // target resources
  const membershipsIndexOp = {
    children: {
      resource_memberships: true
    }
  };

  let allErrs = null;

  // Update the resource document of the group that was deleted so its `deleted` flag may be
  // set/unset for the updated delete date
  SearchAPI.postIndexTask('group', resourceGroupIndexTask, resourceIndexOp, (error) => {
    if (error) {
      allErrs = _.union(allErrs, [error]);
    }

    // If there are group index tasks to invoke, do it
    OaeUtil.invokeIfNecessary(
      !_.isEmpty(memberGroupIndexTasks),
      SearchAPI.postIndexTask,
      'group',
      memberGroupIndexTasks,
      membershipsIndexOp,
      (error) => {
        if (error) {
          allErrs = _.union(allErrs, [error]);
        }

        // If there are user index tasks to invoke, do it
        OaeUtil.invokeIfNecessary(
          !_.isEmpty(memberUserIndexTasks),
          SearchAPI.postIndexTask,
          'user',
          memberUserIndexTasks,
          membershipsIndexOp,
          (error) => {
            if (error) {
              allErrs = _.union(allErrs, [error]);
            }

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

/*!
 * When a group is deleted or restored, invalidate the memberships search documents of all members
 * affected by the change
 */
PrincipalsDelete.registerGroupDeleteHandler('search', _handleInvalidateSearch);
PrincipalsDelete.registerGroupRestoreHandler('search', _handleInvalidateSearch);