API Docs for: v4.0.0
Show:

File: roles/roles_client.js

/* global Roles */
import { Meteor } from 'meteor/meteor'

/**
 * Provides functions related to user authorization. Compatible with built-in Meteor accounts packages.
 *
 * Roles are accessible through `Meteor.roles` collection and documents consist of:
 *  - `_id`: role name
 *  - `children`: list of subdocuments:
 *    - `_id`
 *
 * Children list elements are subdocuments so that they can be easier extended in the future or by plugins.
 *
 * Roles can have multiple parents and can be children (subroles) of multiple roles.
 *
 * Example: `{_id: 'admin', children: [{_id: 'editor'}]}`
 *
 * The assignment of a role to a user is stored in a collection, accessible through `RoleAssignmentCollection`.
 * It's documents consist of
 *  - `_id`: Internal MongoDB id
 *  - `role`: A role object which got assigned. Usually only contains the `_id` property
 *  - `user`: A user object, usually only contains the `_id` property
 *  - `scope`: scope name
 *  - `inheritedRoles`: A list of all the roles objects inherited by the assigned role.
 *
 * @module Roles
 */

/**
 * @class Roles
 */
if (typeof Roles === 'undefined') {
  Roles = {} // eslint-disable-line no-global-assign
}

let getGroupsForUserDeprecationWarning = false

Object.assign(Roles, {

  /**
   * Used as a global group (now scope) name. Not used anymore.
   *
   * @property GLOBAL_GROUP
   * @static
   * @deprecated
   */
  GLOBAL_GROUP: null,

  /**
   * Create a new role.
   *
   * @method createRole
   * @param {String} roleName Name of role.
   * @param {Object} [options] Options:
   *   - `unlessExists`: if `true`, exception will not be thrown in the role already exists
   * @return {String} ID of the new role or null.
   * @static
   */
  createRole: function (roleName, options) {
    Roles._checkRoleName(roleName)

    options = Object.assign({
      unlessExists: false
    }, options)

    const result = Meteor.roles.upsert({ _id: roleName }, { $setOnInsert: { children: [] } })

    if (!result.insertedId) {
      if (options.unlessExists) return null
      throw new Error('Role \'' + roleName + '\' already exists.')
    }

    return result.insertedId
  },

  /**
   * Delete an existing role.
   *
   * If the role is set for any user, it is automatically unset.
   *
   * @method deleteRole
   * @param {String} roleName Name of role.
   * @static
   */
  deleteRole: function (roleName) {
    let roles
    let inheritedRoles

    Roles._checkRoleName(roleName)

    // Remove all assignments
    Meteor.roleAssignment.remove({
      'role._id': roleName
    })

    do {
      // For all roles who have it as a dependency ...
      roles = Roles._getParentRoleNames(Meteor.roles.findOne({ _id: roleName }))

      for (const r of Meteor.roles.find({ _id: { $in: roles } }).fetch()) {
        Meteor.roles.update({
          _id: r._id
        }, {
          $pull: {
            children: {
              _id: roleName
            }
          }
        })

        inheritedRoles = Roles._getInheritedRoleNames(Meteor.roles.findOne({ _id: r._id }))
        Meteor.roleAssignment.update({
          'role._id': r._id
        }, {
          $set: {
            inheritedRoles: [r._id, ...inheritedRoles].map(r2 => ({ _id: r2 }))
          }
        }, { multi: true })
      }
    } while (roles.length > 0)

    // And finally remove the role itself
    Meteor.roles.remove({ _id: roleName })
  },

  /**
   * Rename an existing role.
   *
   * @method renameRole
   * @param {String} oldName Old name of a role.
   * @param {String} newName New name of a role.
   * @static
   */
  renameRole: function (oldName, newName) {
    let count

    Roles._checkRoleName(oldName)
    Roles._checkRoleName(newName)

    if (oldName === newName) return

    const role = Meteor.roles.findOne({ _id: oldName })

    if (!role) {
      throw new Error('Role \'' + oldName + '\' does not exist.')
    }

    role._id = newName

    Meteor.roles.insert(role)

    do {
      count = Meteor.roleAssignment.update({
        'role._id': oldName
      }, {
        $set: {
          'role._id': newName
        }
      }, { multi: true })
    } while (count > 0)

    do {
      count = Meteor.roleAssignment.update({
        'inheritedRoles._id': oldName
      }, {
        $set: {
          'inheritedRoles.$._id': newName
        }
      }, { multi: true })
    } while (count > 0)

    do {
      count = Meteor.roles.update({
        'children._id': oldName
      }, {
        $set: {
          'children.$._id': newName
        }
      }, { multi: true })
    } while (count > 0)

    Meteor.roles.remove({ _id: oldName })
  },

  /**
   * Add role parent to roles.
   *
   * Previous parents are kept (role can have multiple parents). For users which have the
   * parent role set, new subroles are added automatically.
   *
   * @method addRolesToParent
   * @param {Array|String} rolesNames Name(s) of role(s).
   * @param {String} parentName Name of parent role.
   * @static
   */
  addRolesToParent: function (rolesNames, parentName) {
    // ensure arrays
    if (!Array.isArray(rolesNames)) rolesNames = [rolesNames]

    for (const roleName of rolesNames) {
      Roles._addRoleToParent(roleName, parentName)
    }
  },

  /**
   * @method _addRoleToParent
   * @param {String} roleName Name of role.
   * @param {String} parentName Name of parent role.
   * @private
   * @static
   */
  _addRoleToParent: function (roleName, parentName) {
    Roles._checkRoleName(roleName)
    Roles._checkRoleName(parentName)

    // query to get role's children
    const role = Meteor.roles.findOne({ _id: roleName })

    if (!role) {
      throw new Error('Role \'' + roleName + '\' does not exist.')
    }

    // detect cycles
    if (Roles._getInheritedRoleNames(role).includes(parentName)) {
      throw new Error('Roles \'' + roleName + '\' and \'' + parentName + '\' would form a cycle.')
    }

    const count = Meteor.roles.update({
      _id: parentName,
      'children._id': {
        $ne: role._id
      }
    }, {
      $push: {
        children: {
          _id: role._id
        }
      }
    })

    // if there was no change, parent role might not exist, or role is
    // already a subrole; in any case we do not have anything more to do
    if (!count) return

    Meteor.roleAssignment.update({
      'inheritedRoles._id': parentName
    }, {
      $push: {
        inheritedRoles: { $each: [role._id, ...Roles._getInheritedRoleNames(role)].map(r => ({ _id: r })) }
      }
    }, { multi: true })
  },

  /**
   * Remove role parent from roles.
   *
   * Other parents are kept (role can have multiple parents). For users which have the
   * parent role set, removed subrole is removed automatically.
   *
   * @method removeRolesFromParent
   * @param {Array|String} rolesNames Name(s) of role(s).
   * @param {String} parentName Name of parent role.
   * @static
   */
  removeRolesFromParent: function (rolesNames, parentName) {
    // ensure arrays
    if (!Array.isArray(rolesNames)) rolesNames = [rolesNames]

    for (const roleName of rolesNames) {
      Roles._removeRoleFromParent(roleName, parentName)
    }
  },

  /**
   * @method _removeRoleFromParent
   * @param {String} roleName Name of role.
   * @param {String} parentName Name of parent role.
   * @private
   * @static
   */
  _removeRoleFromParent: function (roleName, parentName) {
    Roles._checkRoleName(roleName)
    Roles._checkRoleName(parentName)

    // check for role existence
    // this would not really be needed, but we are trying to match addRolesToParent
    const role = Meteor.roles.findOne({ _id: roleName }, { fields: { _id: 1 } })

    if (!role) {
      throw new Error('Role \'' + roleName + '\' does not exist.')
    }

    const count = Meteor.roles.update({
      _id: parentName
    }, {
      $pull: {
        children: {
          _id: role._id
        }
      }
    })

    // if there was no change, parent role might not exist, or role was
    // already not a subrole; in any case we do not have anything more to do
    if (!count) return

    // For all roles who have had it as a dependency ...
    const roles = [...Roles._getParentRoleNames(Meteor.roles.findOne({ _id: parentName })), parentName]

    for (const r of Meteor.roles.find({ _id: { $in: roles } }).fetch()) {
      const inheritedRoles = Roles._getInheritedRoleNames(Meteor.roles.findOne({ _id: r._id }))
      Meteor.roleAssignment.update({
        'role._id': r._id,
        'inheritedRoles._id': role._id
      }, {
        $set: {
          inheritedRoles: [r._id, ...inheritedRoles].map(r2 => ({ _id: r2 }))
        }
      }, { multi: true })
    }
  },

  /**
   * Add users to roles.
   *
   * Adds roles to existing roles for each user.
   *
   * @example
   *     Roles.addUsersToRoles(userId, 'admin')
   *     Roles.addUsersToRoles(userId, ['view-secrets'], 'example.com')
   *     Roles.addUsersToRoles([user1, user2], ['user','editor'])
   *     Roles.addUsersToRoles([user1, user2], ['glorious-admin', 'perform-action'], 'example.org')
   *
   * @method addUsersToRoles
   * @param {Array|String} users User ID(s) or object(s) with an `_id` field.
   * @param {Array|String} roles Name(s) of roles to add users to. Roles have to exist.
   * @param {Object|String} [options] Options:
   *   - `scope`: name of the scope, or `null` for the global role
   *   - `ifExists`: if `true`, do not throw an exception if the role does not exist
   *
   * Alternatively, it can be a scope name string.
   * @static
   */
  addUsersToRoles: function (users, roles, options) {
    let id

    if (!users) throw new Error('Missing \'users\' param.')
    if (!roles) throw new Error('Missing \'roles\' param.')

    options = Roles._normalizeOptions(options)

    // ensure arrays
    if (!Array.isArray(users)) users = [users]
    if (!Array.isArray(roles)) roles = [roles]

    Roles._checkScopeName(options.scope)

    options = Object.assign({
      ifExists: false
    }, options)

    for (const user of users) {
      if (typeof user === 'object') {
        id = user._id
      } else {
        id = user
      }

      for (const role of roles) {
        Roles._addUserToRole(id, role, options)
      }
    }
  },

  /**
   * Set users' roles.
   *
   * Replaces all existing roles with a new set of roles.
   *
   * @example
   *     Roles.setUserRoles(userId, 'admin')
   *     Roles.setUserRoles(userId, ['view-secrets'], 'example.com')
   *     Roles.setUserRoles([user1, user2], ['user','editor'])
   *     Roles.setUserRoles([user1, user2], ['glorious-admin', 'perform-action'], 'example.org')
   *
   * @method setUserRoles
   * @param {Array|String} users User ID(s) or object(s) with an `_id` field.
   * @param {Array|String} roles Name(s) of roles to add users to. Roles have to exist.
   * @param {Object|String} [options] Options:
   *   - `scope`: name of the scope, or `null` for the global role
   *   - `anyScope`: if `true`, remove all roles the user has, of any scope, if `false`, only the one in the same scope
   *   - `ifExists`: if `true`, do not throw an exception if the role does not exist
   *
   * Alternatively, it can be a scope name string.
   * @static
   */
  setUserRoles: function (users, roles, options) {
    let id

    if (!users) throw new Error('Missing \'users\' param.')
    if (!roles) throw new Error('Missing \'roles\' param.')

    options = Roles._normalizeOptions(options)

    // ensure arrays
    if (!Array.isArray(users)) users = [users]
    if (!Array.isArray(roles)) roles = [roles]

    Roles._checkScopeName(options.scope)

    options = Object.assign({
      ifExists: false,
      anyScope: false
    }, options)

    for (const user of users) {
      if (typeof user === 'object') {
        id = user._id
      } else {
        id = user
      }
      // we first clear all roles for the user
      const selector = { 'user._id': id }
      if (!options.anyScope) {
        selector.scope = options.scope
      }

      Meteor.roleAssignment.remove(selector)

      // and then add all
      for (const role of roles) {
        Roles._addUserToRole(id, role, options)
      }
    }
  },

  /**
   * Add one user to one role.
   *
   * @method _addUserToRole
   * @param {String} userId The user ID.
   * @param {String} roleName Name of the role to add the user to. The role have to exist.
   * @param {Object} options Options:
   *   - `scope`: name of the scope, or `null` for the global role
   *   - `ifExists`: if `true`, do not throw an exception if the role does not exist
   * @private
   * @static
   */
  _addUserToRole: function (userId, roleName, options) {
    Roles._checkRoleName(roleName)
    Roles._checkScopeName(options.scope)

    if (!userId) {
      return
    }

    const role = Meteor.roles.findOne({ _id: roleName }, { fields: { children: 1 } })

    if (!role) {
      if (options.ifExists) {
        return []
      } else {
        throw new Error('Role \'' + roleName + '\' does not exist.')
      }
    }

    // This might create duplicates, because we don't have a unique index, but that's all right. In case there are two, withdrawing the role will effectively kill them both.
    const res = Meteor.roleAssignment.upsert({
      'user._id': userId,
      'role._id': roleName,
      scope: options.scope
    }, {
      $setOnInsert: {
        user: { _id: userId },
        role: { _id: roleName },
        scope: options.scope
      }
    })

    if (res.insertedId) {
      Meteor.roleAssignment.update({ _id: res.insertedId }, {
        $set: {
          inheritedRoles: [roleName, ...Roles._getInheritedRoleNames(role)].map(r => ({ _id: r }))
        }
      })
    }

    return res
  },

  /**
   * Returns an array of role names the given role name is a child of.
   *
   * @example
   *     Roles._getParentRoleNames({ _id: 'admin', children; [] })
   *
   * @method _getParentRoleNames
   * @param {object} role The role object
   * @private
   * @static
   */
  _getParentRoleNames: function (role) {
    if (!role) {
      return []
    }

    const parentRoles = new Set([role._id])

    for (const roleName of parentRoles) {
      Meteor.roles.find({ 'children._id': roleName }).fetch().forEach(parentRole => {
        parentRoles.add(parentRole._id)
      })
    }

    parentRoles.delete(role._id)

    return [...parentRoles]
  },

  /**
   * Returns an array of role names the given role name is a parent of.
   *
   * @example
   *     Roles._getInheritedRoleNames({ _id: 'admin', children; [] })
   *
   * @method _getInheritedRoleNames
   * @param {object} role The role object
   * @private
   * @static
   */
  _getInheritedRoleNames: function (role) {
    const inheritedRoles = new Set()
    const nestedRoles = new Set([role])

    for (const r of nestedRoles) {
      const roles = Meteor.roles.find({ _id: { $in: r.children.map(r => r._id) } }, { fields: { children: 1 } }).fetch()

      for (const r2 of roles) {
        inheritedRoles.add(r2._id)
        nestedRoles.add(r2)
      }
    }

    return [...inheritedRoles]
  },

  /**
   * Remove users from assigned roles.
   *
   * @example
   *     Roles.removeUsersFromRoles(userId, 'admin')
   *     Roles.removeUsersFromRoles([userId, user2], ['editor'])
   *     Roles.removeUsersFromRoles(userId, ['user'], 'group1')
   *
   * @method removeUsersFromRoles
   * @param {Array|String} users User ID(s) or object(s) with an `_id` field.
   * @param {Array|String} roles Name(s) of roles to remove users from. Roles have to exist.
   * @param {Object|String} [options] Options:
   *   - `scope`: name of the scope, or `null` for the global role
   *   - `anyScope`: if set, role can be in any scope (`scope` option is ignored)
   *
   * Alternatively, it can be a scope name string.
   * @static
   */
  removeUsersFromRoles: function (users, roles, options) {
    if (!users) throw new Error('Missing \'users\' param.')
    if (!roles) throw new Error('Missing \'roles\' param.')

    options = Roles._normalizeOptions(options)

    // ensure arrays
    if (!Array.isArray(users)) users = [users]
    if (!Array.isArray(roles)) roles = [roles]

    Roles._checkScopeName(options.scope)

    for (const user of users) {
      if (!user) continue

      for (const role of roles) {
        let id
        if (typeof user === 'object') {
          id = user._id
        } else {
          id = user
        }

        Roles._removeUserFromRole(id, role, options)
      }
    }
  },

  /**
   * Remove one user from one role.
   *
   * @method _removeUserFromRole
   * @param {String} userId The user ID.
   * @param {String} roleName Name of the role to add the user to. The role have to exist.
   * @param {Object} options Options:
   *   - `scope`: name of the scope, or `null` for the global role
   *   - `anyScope`: if set, role can be in any scope (`scope` option is ignored)
   * @private
   * @static
   */
  _removeUserFromRole: function (userId, roleName, options) {
    Roles._checkRoleName(roleName)
    Roles._checkScopeName(options.scope)

    if (!userId) return

    const selector = {
      'user._id': userId,
      'role._id': roleName
    }

    if (!options.anyScope) {
      selector.scope = options.scope
    }

    Meteor.roleAssignment.remove(selector)
  },

  /**
   * Check if user has specified roles.
   *
   * @example
   *     // global roles
   *     Roles.userIsInRole(user, 'admin')
   *     Roles.userIsInRole(user, ['admin','editor'])
   *     Roles.userIsInRole(userId, 'admin')
   *     Roles.userIsInRole(userId, ['admin','editor'])
   *
   *     // scope roles (global roles are still checked)
   *     Roles.userIsInRole(user, 'admin', 'group1')
   *     Roles.userIsInRole(userId, ['admin','editor'], 'group1')
   *     Roles.userIsInRole(userId, ['admin','editor'], {scope: 'group1'})
   *
   * @method userIsInRole
   * @param {String|Object} user User ID or an actual user object.
   * @param {Array|String} roles Name of role or an array of roles to check against. If array,
   *                             will return `true` if user is in _any_ role.
   *                             Roles do not have to exist.
   * @param {Object|String} [options] Options:
   *   - `scope`: name of the scope; if supplied, limits check to just that scope
   *     the user's global roles will always be checked whether scope is specified or not
   *   - `anyScope`: if set, role can be in any scope (`scope` option is ignored)
   *
   * Alternatively, it can be a scope name string.
   * @return {Boolean} `true` if user is in _any_ of the target roles
   * @static
   */
  userIsInRole: function (user, roles, options) {
    let id
    options = Roles._normalizeOptions(options)

    // ensure array to simplify code
    if (!Array.isArray(roles)) roles = [roles]

    roles = roles.filter(r => r != null)

    if (!roles.length) return false

    Roles._checkScopeName(options.scope)

    options = Object.assign({
      anyScope: false
    }, options)

    if (user && typeof user === 'object') {
      id = user._id
    } else {
      id = user
    }

    if (!id) return false
    if (typeof id !== 'string') return false

    const selector = { 'user._id': id }

    if (!options.anyScope) {
      selector.scope = { $in: [options.scope, null] }
    }

    return roles.some((roleName) => {
      selector['inheritedRoles._id'] = roleName

      return Meteor.roleAssignment.find(selector, { limit: 1 }).count() > 0
    })
  },

  /**
   * Retrieve user's roles.
   *
   * @method getRolesForUser
   * @param {String|Object} user User ID or an actual user object.
   * @param {Object|String} [options] Options:
   *   - `scope`: name of scope to provide roles for; if not specified, global roles are returned
   *   - `anyScope`: if set, role can be in any scope (`scope` and `onlyAssigned` options are ignored)
   *   - `onlyScoped`: if set, only roles in the specified scope are returned
   *   - `onlyAssigned`: return only assigned roles and not automatically inferred (like subroles)
   *   - `fullObjects`: return full roles objects (`true`) or just names (`false`) (`onlyAssigned` option is ignored) (default `false`)
   *     If you have a use-case for this option, please file a feature-request. You shouldn't need to use it as it's
   *     result strongly dependent on the internal data structure of this plugin.
   *
   * Alternatively, it can be a scope name string.
   * @return {Array} Array of user's roles, unsorted.
   * @static
   */
  getRolesForUser: function (user, options) {
    let id

    options = Roles._normalizeOptions(options)

    Roles._checkScopeName(options.scope)

    options = Object.assign({
      fullObjects: false,
      onlyAssigned: false,
      anyScope: false,
      onlyScoped: false
    }, options)

    if (user && typeof user === 'object') {
      id = user._id
    } else {
      id = user
    }

    if (!id) return []

    const selector = { 'user._id': id }
    const filter = { fields: { 'inheritedRoles._id': 1 } }

    if (!options.anyScope) {
      selector.scope = { $in: [options.scope] }

      if (!options.onlyScoped) {
        selector.scope.$in.push(null)
      }
    }

    if (options.onlyAssigned) {
      delete filter.fields['inheritedRoles._id']
      filter.fields['role._id'] = 1
    }

    if (options.fullObjects) {
      delete filter.fields
    }

    const roles = Meteor.roleAssignment.find(selector, filter).fetch()

    if (options.fullObjects) {
      return roles
    }

    return [...new Set(roles.reduce((rev, current) => {
      if (current.inheritedRoles) {
        return rev.concat(current.inheritedRoles.map(r => r._id))
      } else if (current.role) {
        rev.push(current.role._id)
      }
      return rev
    }, []))]
  },

  /**
   * Retrieve cursor of all existing roles.
   *
   * @method getAllRoles
   * @param {Object} queryOptions Options which are passed directly
   *                                through to `RolesCollection.find(query, options)`.
   * @return {Cursor} Cursor of existing roles.
   * @static
   */
  getAllRoles: function (queryOptions) {
    queryOptions = queryOptions || { sort: { _id: 1 } }

    return Meteor.roles.find({}, queryOptions)
  },

  /**
   * Retrieve all users who are in target role.
   *
   * Options:
   *
   * @method getUsersInRole
   * @param {Array|String} roles Name of role or an array of roles. If array, users
   *                             returned will have at least one of the roles
   *                             specified but need not have _all_ roles.
   *                             Roles do not have to exist.
   * @param {Object|String} [options] Options:
   *   - `scope`: name of the scope to restrict roles to; user's global
   *     roles will also be checked
   *   - `anyScope`: if set, role can be in any scope (`scope` option is ignored)
   *   - `onlyScoped`: if set, only roles in the specified scope are returned
   *   - `queryOptions`: options which are passed directly
   *     through to `Meteor.users.find(query, options)`
   *
   * Alternatively, it can be a scope name string.
   * @param {Object} [queryOptions] Options which are passed directly
   *                                through to `Meteor.users.find(query, options)`
   * @return {Cursor} Cursor of users in roles.
   * @static
   */
  getUsersInRole: function (roles, options, queryOptions) {
    const ids = Roles.getUserAssignmentsForRole(roles, options).fetch().map(a => a.user._id)

    return Meteor.users.find({ _id: { $in: ids } }, ((options && options.queryOptions) || queryOptions) || {})
  },

  /**
   * Retrieve all assignments of a user which are for the target role.
   *
   * Options:
   *
   * @method getUserAssignmentsForRole
   * @param {Array|String} roles Name of role or an array of roles. If array, users
   *                             returned will have at least one of the roles
   *                             specified but need not have _all_ roles.
   *                             Roles do not have to exist.
   * @param {Object|String} [options] Options:
   *   - `scope`: name of the scope to restrict roles to; user's global
   *     roles will also be checked
   *   - `anyScope`: if set, role can be in any scope (`scope` option is ignored)
   *   - `queryOptions`: options which are passed directly
   *     through to `RoleAssignmentCollection.find(query, options)`
   *
   * Alternatively, it can be a scope name string.
   * @return {Cursor} Cursor of user assignments for roles.
   * @static
   */
  getUserAssignmentsForRole: function (roles, options) {
    options = Roles._normalizeOptions(options)

    options = Object.assign({
      anyScope: false,
      queryOptions: {}
    }, options)

    return Roles._getUsersInRoleCursor(roles, options, options.queryOptions)
  },

  /**
   * @method _getUsersInRoleCursor
   * @param {Array|String} roles Name of role or an array of roles. If array, ids of users are
   *                             returned which have at least one of the roles
   *                             assigned but need not have _all_ roles.
   *                             Roles do not have to exist.
   * @param {Object|String} [options] Options:
   *   - `scope`: name of the scope to restrict roles to; user's global
   *     roles will also be checked
   *   - `anyScope`: if set, role can be in any scope (`scope` option is ignored)
   *
   * Alternatively, it can be a scope name string.
   * @param {Object} [filter] Options which are passed directly
   *                                through to `RoleAssignmentCollection.find(query, options)`
   * @return {Object} Cursor to the assignment documents
   * @private
   * @static
   */
  _getUsersInRoleCursor: function (roles, options, filter) {
    options = Roles._normalizeOptions(options)

    options = Object.assign({
      anyScope: false,
      onlyScoped: false
    }, options)

    // ensure array to simplify code
    if (!Array.isArray(roles)) roles = [roles]

    Roles._checkScopeName(options.scope)

    filter = Object.assign({
      fields: { 'user._id': 1 }
    }, filter)

    const selector = { 'inheritedRoles._id': { $in: roles } }

    if (!options.anyScope) {
      selector.scope = { $in: [options.scope] }

      if (!options.onlyScoped) {
        selector.scope.$in.push(null)
      }
    }

    return Meteor.roleAssignment.find(selector, filter)
  },

  /**
   * Deprecated. Use `getScopesForUser` instead.
   *
   * @method getGroupsForUser
   * @static
   * @deprecated
   */
  getGroupsForUser: function (...args) {
    if (!getGroupsForUserDeprecationWarning) {
      getGroupsForUserDeprecationWarning = true
      console && console.warn('getGroupsForUser has been deprecated. Use getScopesForUser instead.')
    }

    return Roles.getScopesForUser(...args)
  },

  /**
   * Retrieve users scopes, if any.
   *
   * @method getScopesForUser
   * @param {String|Object} user User ID or an actual user object.
   * @param {Array|String} [roles] Name of roles to restrict scopes to.
   *
   * @return {Array} Array of user's scopes, unsorted.
   * @static
   */
  getScopesForUser: function (user, roles) {
    let id

    if (roles && !Array.isArray(roles)) roles = [roles]

    if (user && typeof user === 'object') {
      id = user._id
    } else {
      id = user
    }

    if (!id) return []

    const selector = {
      'user._id': id,
      scope: { $ne: null }
    }

    if (roles) {
      selector['inheritedRoles._id'] = { $in: roles }
    }

    const scopes = Meteor.roleAssignment.find(selector, { fields: { scope: 1 } }).fetch().map(obi => obi.scope)

    return [...new Set(scopes)]
  },

  /**
   * Rename a scope.
   *
   * Roles assigned with a given scope are changed to be under the new scope.
   *
   * @method renameScope
   * @param {String} oldName Old name of a scope.
   * @param {String} newName New name of a scope.
   * @static
   */
  renameScope: function (oldName, newName) {
    let count

    Roles._checkScopeName(oldName)
    Roles._checkScopeName(newName)

    if (oldName === newName) return

    do {
      count = Meteor.roleAssignment.update({
        scope: oldName
      }, {
        $set: {
          scope: newName
        }
      }, { multi: true })
    } while (count > 0)
  },

  /**
   * Remove a scope.
   *
   * Roles assigned with a given scope are removed.
   *
   * @method removeScope
   * @param {String} name The name of a scope.
   * @static
   */
  removeScope: function (name) {
    Roles._checkScopeName(name)

    Meteor.roleAssignment.remove({ scope: name })
  },

  /**
   * Throw an exception if `roleName` is an invalid role name.
   *
   * @method _checkRoleName
   * @param {String} roleName A role name to match against.
   * @private
   * @static
   */
  _checkRoleName: function (roleName) {
    if (!roleName || typeof roleName !== 'string' || roleName.trim() !== roleName) {
      throw new Error('Invalid role name \'' + roleName + '\'.')
    }
  },

  /**
   * Find out if a role is an ancestor of another role.
   *
   * WARNING: If you check this on the client, please make sure all roles are published.
   *
   * @method isParentOf
   * @param {String} parentRoleName The role you want to research.
   * @param {String} childRoleName The role you expect to be among the children of parentRoleName.
   * @static
   */
  isParentOf: function (parentRoleName, childRoleName) {
    if (parentRoleName === childRoleName) {
      return true
    }

    if (parentRoleName == null || childRoleName == null) {
      return false
    }

    Roles._checkRoleName(parentRoleName)
    Roles._checkRoleName(childRoleName)

    let rolesToCheck = [parentRoleName]
    while (rolesToCheck.length !== 0) {
      const roleName = rolesToCheck.pop()

      if (roleName === childRoleName) {
        return true
      }

      const role = Meteor.roles.findOne({ _id: roleName })

      // This should not happen, but this is a problem to address at some other time.
      if (!role) continue

      rolesToCheck = rolesToCheck.concat(role.children.map(r => r._id))
    }

    return false
  },

  /**
   * Normalize options.
   *
   * @method _normalizeOptions
   * @param {Object} options Options to normalize.
   * @return {Object} Normalized options.
   * @private
   * @static
   */
  _normalizeOptions: function (options) {
    options = options === undefined ? {} : options

    if (options === null || typeof options === 'string') {
      options = { scope: options }
    }

    options.scope = Roles._normalizeScopeName(options.scope)

    return options
  },

  /**
   * Normalize scope name.
   *
   * @method _normalizeScopeName
   * @param {String} scopeName A scope name to normalize.
   * @return {String} Normalized scope name.
   * @private
   * @static
   */
  _normalizeScopeName: function (scopeName) {
    // map undefined and null to null
    if (scopeName == null) {
      return null
    } else {
      return scopeName
    }
  },

  /**
   * Throw an exception if `scopeName` is an invalid scope name.
   *
   * @method _checkRoleName
   * @param {String} scopeName A scope name to match against.
   * @private
   * @static
   */
  _checkScopeName: function (scopeName) {
    if (scopeName === null) return

    if (
      !scopeName ||
      typeof scopeName !== 'string' ||
      scopeName.trim() !== scopeName
    ) {
      throw new Error(`Invalid scope name '${scopeName}'.`)
    }
  }
})