text: // Copyright IBM Corp. 2014,2019. All Rights Reserved.
  // Node module: loopback
  // This file is licensed under the MIT License.
  // License text available at https://opensource.org/licenses/MIT
  
  'use strict';
  const loopback = require('../../lib/loopback');
  const debug = require('debug')('loopback:security:role');
  const assert = require('assert');
  const async = require('async');
  const utils = require('../../lib/utils');
  const ctx = require('../../lib/access-context');
  const AccessContext = ctx.AccessContext;
  const Principal = ctx.Principal;
  const RoleMapping = loopback.RoleMapping;
  
  assert(RoleMapping, 'RoleMapping model must be defined before Role model');
  
  /**
   * The Role model
   * @class Role
   * @header Role object
   */
  module.exports = function(Role) {
    Role.resolveRelatedModels = function() {
      if (!this.userModel) {
        const reg = this.registry;
        this.roleMappingModel = reg.getModelByType('RoleMapping');
        this.userModel = reg.getModelByType('User');
        this.applicationModel = reg.getModelByType('Application');
      }
    };
  
    // Set up the connection to users/applications/roles once the model
    Role.once('dataSourceAttached', function(roleModel) {
      ['users', 'applications', 'roles'].forEach(function(rel) {
        /**
         * Fetch all users assigned to this role
         * @function Role.prototype#users
         * @param {object} [query] query object passed to model find call
         * @callback {Function} [callback] The callback function
         * @param {String|Error} err The error string or object
         * @param {Array} list The list of users.
         * @promise
         */
        /**
         * Fetch all applications assigned to this role
         * @function Role.prototype#applications
         * @param {object} [query] query object passed to model find call
         * @callback {Function} [callback] The callback function
         * @param {String|Error} err The error string or object
         * @param {Array} list The list of applications.
         * @promise
         */
        /**
         * Fetch all roles assigned to this role
         * @function Role.prototype#roles
         * @param {object} [query] query object passed to model find call
         * @callback {Function} [callback] The callback function
         * @param {String|Error} err The error string or object
         * @param {Array} list The list of roles.
         * @promise
         */
        Role.prototype[rel] = function(query, callback) {
          if (!callback) {
            if (typeof query === 'function') {
              callback = query;
              query = {};
            } else {
              callback = utils.createPromiseCallback();
            }
          }
          query = query || {};
          query.where = query.where || {};
  
          roleModel.resolveRelatedModels();
          const relsToModels = {
            users: roleModel.userModel,
            applications: roleModel.applicationModel,
            roles: roleModel,
          };
  
          const ACL = loopback.ACL;
          const relsToTypes = {
            users: ACL.USER,
            applications: ACL.APP,
            roles: ACL.ROLE,
          };
  
          let principalModel = relsToModels[rel];
          let principalType = relsToTypes[rel];
  
          // redefine user model and user type if user principalType is custom (available and not "USER")
          const isCustomUserPrincipalType = rel === 'users' &&
            query.where.principalType &&
            query.where.principalType !== RoleMapping.USER;
  
          if (isCustomUserPrincipalType) {
            const registry = this.constructor.registry;
            principalModel = registry.findModel(query.where.principalType);
            principalType = query.where.principalType;
          }
          // make sure we don't keep principalType in userModel query
          delete query.where.principalType;
  
          if (principalModel) {
            listByPrincipalType(this, principalModel, principalType, query, callback);
          } else {
            process.nextTick(function() {
              callback(null, []);
            });
          }
          return callback.promise;
        };
      });
  
      /**
       * Fetch all models assigned to this role
       * @private
       * @param {object} Context role context
       * @param {*} model model type to fetch
       * @param {String} [principalType] principalType used in the rolemapping for model
       * @param {object} [query] query object passed to model find call
       * @param  {Function} [callback] callback function called with `(err, models)` arguments.
       */
      function listByPrincipalType(context, model, principalType, query, callback) {
        if (callback === undefined && typeof query === 'function') {
          callback = query;
          query = {};
        }
        query = query || {};
  
        roleModel.roleMappingModel.find({
          where: {roleId: context.id, principalType: principalType},
        }, function(err, mappings) {
          if (err) {
            return callback(err);
          }
          const ids = mappings.map(function(m) {
            return m.principalId;
          });
          query.where = query.where || {};
          query.where.id = {inq: ids};
          model.find(query, function(err, models) {
            callback(err, models);
          });
        });
      }
    });
  
    // Special roles
    Role.OWNER = '$owner'; // owner of the object
    Role.RELATED = '$related'; // any User with a relationship to the object
    Role.AUTHENTICATED = '$authenticated'; // authenticated user
    Role.UNAUTHENTICATED = '$unauthenticated'; // authenticated user
    Role.EVERYONE = '$everyone'; // everyone
  
    /**
     * Add custom handler for roles.
     * @param {String} role Name of role.
     * @param {Function} resolver Function that determines
     * if a principal is in the specified role.
     * Should provide a callback or return a promise.
     */
    Role.registerResolver = function(role, resolver) {
      if (!Role.resolvers) {
        Role.resolvers = {};
      }
      Role.resolvers[role] = resolver;
    };
  
    Role.registerResolver(Role.OWNER, function(role, context, callback) {
      if (!context || !context.model || !context.modelId) {
        process.nextTick(function() {
          if (callback) callback(null, false);
        });
        return;
      }
      const modelClass = context.model;
      const modelId = context.modelId;
      const user = context.getUser();
      const userId = user && user.id;
      const principalType = user && user.principalType;
      const opts = {accessToken: context.accessToken};
      Role.isOwner(modelClass, modelId, userId, principalType, opts, callback);
    });
  
    function isUserClass(modelClass) {
      if (!modelClass) return false;
      const User = modelClass.modelBuilder.models.User;
      if (!User) return false;
      return modelClass == User || modelClass.prototype instanceof User;
    }
  
    /*!
     * Check if two user IDs matches
     * @param {*} id1
     * @param {*} id2
     * @returns {boolean}
     */
    function matches(id1, id2) {
      if (id1 === undefined || id1 === null || id1 === '' ||
        id2 === undefined || id2 === null || id2 === '') {
        return false;
      }
      // The id can be a MongoDB ObjectID
      return id1 === id2 || id1.toString() === id2.toString();
    }
  
    /**
     * Check if a given user ID is the owner the model instance.
     * @param {Function} modelClass The model class
     * @param {*} modelId The model ID
     * @param {*} userId The user ID
     * @param {String} principalType The user principalType (optional)
     * @options {Object} options
     * @property {accessToken} The access token used to authorize the current user.
     * @callback {Function} [callback] The callback function
     * @param {String|Error} err The error string or object
     * @param {Boolean} isOwner True if the user is an owner.
     * @promise
     */
    Role.isOwner = function isOwner(modelClass, modelId, userId, principalType, options, callback) {
      const _this = this;
  
      if (!callback && typeof options === 'function') {
        callback = options;
        options = {};
      } else if (!callback && typeof principalType === 'function') {
        callback = principalType;
        principalType = undefined;
        options = {};
      }
      principalType = principalType || Principal.USER;
  
      assert(modelClass, 'Model class is required');
      if (!callback) callback = utils.createPromiseCallback();
  
      debug('isOwner(): %s %s userId: %s principalType: %s',
        modelClass && modelClass.modelName, modelId, userId, principalType);
  
      // Resolve isOwner false if userId is missing
      if (!userId) {
        debug('isOwner(): no user id was set, returning false');
        process.nextTick(function() {
          callback(null, false);
        });
        return callback.promise;
      }
  
      // At this stage, principalType is valid in one of 2 following condition:
      // 1. the app has a single user model and principalType is 'USER'
      // 2. the app has multiple user models and principalType is not 'USER'
      // multiple user models
      const isMultipleUsers = _isMultipleUsers();
      const isPrincipalTypeValid =
        (!isMultipleUsers && principalType === Principal.USER) ||
        (isMultipleUsers && principalType !== Principal.USER);
  
      debug('isOwner(): isMultipleUsers?', isMultipleUsers,
        'isPrincipalTypeValid?', isPrincipalTypeValid);
  
      // Resolve isOwner false if principalType is invalid
      if (!isPrincipalTypeValid) {
        process.nextTick(function() {
          callback(null, false);
        });
        return callback.promise;
      }
  
      // Is the modelClass User or a subclass of User?
      if (isUserClass(modelClass)) {
        const userModelName = modelClass.modelName;
        // matching ids is enough if principalType is USER or matches given user model name
        if (principalType === Principal.USER || principalType === userModelName) {
          process.nextTick(function() {
            callback(null, matches(modelId, userId));
          });
          return callback.promise;
        }
        // otherwise continue with the regular owner resolution
      }
  
      modelClass.findById(modelId, options, function(err, inst) {
        if (err || !inst) {
          debug('Model not found for id %j', modelId);
          return callback(err, false);
        }
        debug('Model found: %j', inst);
  
        const ownerRelations = modelClass.settings.ownerRelations;
        if (!ownerRelations) {
          return legacyOwnershipCheck(inst);
        } else {
          return checkOwnership(inst);
        }
      });
      return callback.promise;
  
      // NOTE Historically, for principalType USER, we were resolving isOwner()
      // as true if the model has "userId" or "owner" property matching
      // id of the current user (principalId), even though there was no
      // belongsTo relation set up.
      // Additionaly, the original implementation did not support the
      // possibility for a model to have multiple related users: when
      // testing belongsTo relations, the first related user failing the
      // ownership check induced the whole isOwner() to resolve as false.
      // This behaviour will be pruned at next LoopBack major release.
      function legacyOwnershipCheck(inst) {
        const ownerId = inst.userId || inst.owner;
        if (principalType === Principal.USER && ownerId && 'function' !== typeof ownerId) {
          return callback(null, matches(ownerId, userId));
        }
  
        // Try to follow belongsTo
        for (const r in modelClass.relations) {
          const rel = modelClass.relations[r];
          // relation should be belongsTo and target a User based class
          const belongsToUser = rel.type === 'belongsTo' && isUserClass(rel.modelTo);
          if (!belongsToUser) {
            continue;
          }
  
          // checking related user
          const relatedUser = rel.modelTo;
          const userModelName = relatedUser.modelName;
          const isMultipleUsers = _isMultipleUsers(relatedUser);
          // a relation can be considered for isOwner resolution if:
          // 1. the app has a single user model and principalType is 'USER'
          // 2. the app has multiple user models and principalType is the related user model name
          if ((!isMultipleUsers && principalType === Principal.USER) ||
              (isMultipleUsers && principalType === userModelName)) {
            debug('Checking relation %s to %s: %j', r, userModelName, rel);
            inst[r](processRelatedUser);
            return;
          }
        }
        debug('No matching belongsTo relation found for model %j - user %j principalType %j',
          modelId, userId, principalType);
        callback(null, false);
  
        function processRelatedUser(err, user) {
          if (err || !user) return callback(err, false);
  
          debug('User found: %j', user.id);
          callback(null, matches(user.id, userId));
        }
      }
  
      function checkOwnership(inst) {
        const ownerRelations = inst.constructor.settings.ownerRelations;
        // collecting related users
        const relWithUsers = [];
        for (const r in modelClass.relations) {
          const rel = modelClass.relations[r];
          // relation should be belongsTo and target a User based class
          if (rel.type !== 'belongsTo' || !isUserClass(rel.modelTo)) {
            continue;
          }
  
          // checking related user
          const relatedUser = rel.modelTo;
          const userModelName = relatedUser.modelName;
          const isMultipleUsers = _isMultipleUsers(relatedUser);
          // a relation can be considered for isOwner resolution if:
          // 1. the app has a single user model and principalType is 'USER'
          // 2. the app has multiple user models and principalType is the related user model name
          // In addition, if an array of relations if provided with the ownerRelations option,
          // then the given relation name is further checked against this array
          if ((!isMultipleUsers && principalType === Principal.USER) ||
              (isMultipleUsers && principalType === userModelName)) {
            debug('Checking relation %s to %s: %j', r, userModelName, rel);
            if (ownerRelations === true) {
              relWithUsers.push(r);
            } else if (Array.isArray(ownerRelations) && ownerRelations.indexOf(r) !== -1) {
              relWithUsers.push(r);
            }
          }
        }
        if (relWithUsers.length === 0) {
          debug('No matching belongsTo relation found for model %j and user: %j principalType: %j',
            modelId, userId, principalType);
          return callback(null, false);
        }
  
        // check related users: someSeries is used to avoid spamming the db
        async.someSeries(relWithUsers, processRelation, callback);
  
        function processRelation(r, cb) {
          inst[r](function processRelatedUser(err, user) {
            if (err || !user) return cb(err, false);
  
            debug('User found: %j (through %j)', user.id, r);
            cb(null, matches(user.id, userId));
          });
        }
      }
  
      // A helper function to check if the app user config is multiple users or
      // single user. It can be used with or without a reference user model.
      // In case no user model is provided, we use the registry to get any of the
      // user model by type. The relation with AccessToken is used to check
      // if polymorphism is used, and thus if multiple users.
      function _isMultipleUsers(userModel) {
        const oneOfUserModels = userModel || _this.registry.getModelByType('User');
        const accessTokensRel = oneOfUserModels.relations.accessTokens;
        return !!(accessTokensRel && accessTokensRel.polymorphic);
      }
    };
  
    Role.registerResolver(Role.AUTHENTICATED, function(role, context, callback) {
      if (!context) {
        process.nextTick(function() {
          if (callback) callback(null, false);
        });
        return;
      }
      Role.isAuthenticated(context, callback);
    });
  
    /**
     * Check if the user ID is authenticated
     * @param {Object} context The security context.
     *
     * @callback {Function} callback Callback function.
     * @param {Error} err Error object.
     * @param {Boolean} isAuthenticated True if the user is authenticated.
     * @promise
     */
    Role.isAuthenticated = function isAuthenticated(context, callback) {
      if (!callback) callback = utils.createPromiseCallback();
      process.nextTick(function() {
        if (callback) callback(null, context.isAuthenticated());
      });
      return callback.promise;
    };
  
    Role.registerResolver(Role.UNAUTHENTICATED, function(role, context, callback) {
      process.nextTick(function() {
        if (callback) callback(null, !context || !context.isAuthenticated());
      });
    });
  
    Role.registerResolver(Role.EVERYONE, function(role, context, callback) {
      process.nextTick(function() {
        if (callback) callback(null, true); // Always true
      });
    });
  
    /**
     * Check if a given principal is in the specified role.
     *
     * @param {String} role The role name.
     * @param {Object} context The context object.
     *
     * @callback {Function} callback Callback function.
     * @param {Error} err Error object.
     * @param {Boolean} isInRole True if the principal is in the specified role.
     * @promise
     */
    Role.isInRole = function(role, context, callback) {
      context.registry = this.registry;
      if (!(context instanceof AccessContext)) {
        context = new AccessContext(context);
      }
  
      if (!callback) {
        callback = utils.createPromiseCallback();
        // historically, isInRole is returning the Role instance instead of true
        // we are preserving that behaviour for callback-based invocation,
        // but fixing it when invoked in Promise mode
        callback.promise = callback.promise.then(function(isInRole) {
          return !!isInRole;
        });
      }
  
      this.resolveRelatedModels();
  
      debug('isInRole(): %s', role);
      context.debug();
  
      const resolver = Role.resolvers[role];
      if (resolver) {
        debug('Custom resolver found for role %s', role);
  
        const promise = resolver(role, context, callback);
        if (promise && typeof promise.then === 'function') {
          promise.then(
            function(result) { callback(null, result); },
            callback,
          );
        }
        return callback.promise;
      }
  
      if (context.principals.length === 0) {
        debug('isInRole() returns: false');
        process.nextTick(function() {
          if (callback) callback(null, false);
        });
        return callback.promise;
      }
  
      const inRole = context.principals.some(function(p) {
        const principalType = p.type || undefined;
        const principalId = p.id || undefined;
  
        // Check if it's the same role
        return principalType === RoleMapping.ROLE && principalId === role;
      });
  
      if (inRole) {
        debug('isInRole() returns: %j', inRole);
        process.nextTick(function() {
          if (callback) callback(null, true);
        });
        return callback.promise;
      }
  
      const roleMappingModel = this.roleMappingModel;
      this.findOne({where: {name: role}}, function(err, result) {
        if (err) {
          if (callback) callback(err);
          return;
        }
        if (!result) {
          if (callback) callback(null, false);
          return;
        }
        debug('Role found: %j', result);
  
        // Iterate through the list of principals
        async.some(context.principals, function(p, done) {
          const principalType = p.type || undefined;
          let principalId = p.id || undefined;
          const roleId = result.id.toString();
          const principalIdIsString = typeof principalId === 'string';
  
          if (principalId !== null && principalId !== undefined && !principalIdIsString) {
            principalId = principalId.toString();
          }
  
          if (principalType && principalId) {
            roleMappingModel.findOne({where: {roleId: roleId,
              principalType: principalType, principalId: principalId}},
            function(err, result) {
              debug('Role mapping found: %j', result);
              done(!err && result); // The only arg is the result
            });
          } else {
            process.nextTick(function() {
              done(false);
            });
          }
        }, function(inRole) {
          debug('isInRole() returns: %j', inRole);
          if (callback) callback(null, inRole);
        });
      });
      return callback.promise;
    };
  
    /**
     * List roles for a given principal.
     * @param {Object} context The security context.
     *
     * @callback {Function} callback Callback function.
     * @param {Error} err Error object.
     * @param {String[]} roles An array of role IDs
     * @promise
     */
    Role.getRoles = function(context, options, callback) {
      if (!callback) {
        if (typeof options === 'function') {
          callback = options;
          options = {};
        } else {
          callback = utils.createPromiseCallback();
        }
      }
      if (!options) options = {};
  
      context.registry = this.registry;
      if (!(context instanceof AccessContext)) {
        context = new AccessContext(context);
      }
      const roles = [];
      this.resolveRelatedModels();
  
      const addRole = function(role) {
        if (role && roles.indexOf(role) === -1) {
          roles.push(role);
        }
      };
  
      const self = this;
      // Check against the smart roles
      const inRoleTasks = [];
      Object.keys(Role.resolvers).forEach(function(role) {
        inRoleTasks.push(function(done) {
          self.isInRole(role, context, function(err, inRole) {
            if (debug.enabled) {
              debug('In role %j: %j', role, inRole);
            }
            if (!err && inRole) {
              addRole(role);
              done();
            } else {
              done(err, null);
            }
          });
        });
      });
  
      const roleMappingModel = this.roleMappingModel;
      context.principals.forEach(function(p) {
        // Check against the role mappings
        const principalType = p.type || undefined;
        let principalId = p.id == null ? undefined : p.id;
  
        if (typeof principalId !== 'string' && principalId != null) {
          principalId = principalId.toString();
        }
  
        // Add the role itself
        if (principalType === RoleMapping.ROLE && principalId) {
          addRole(principalId);
        }
  
        if (principalType && principalId) {
          // Please find() treat undefined matches all values
          inRoleTasks.push(function(done) {
            const filter = {where: {principalType: principalType, principalId: principalId}};
            if (options.returnOnlyRoleNames === true) {
              filter.include = ['role'];
            }
            roleMappingModel.find(filter, function(err, mappings) {
              debug('Role mappings found: %s %j', err, mappings);
              if (err) {
                if (done) done(err);
                return;
              }
              mappings.forEach(function(m) {
                let role;
                if (options.returnOnlyRoleNames === true) {
                  role = m.toJSON().role.name;
                } else {
                  role = m.roleId;
                }
                addRole(role);
              });
              if (done) done();
            });
          });
        }
      });
  
      async.parallel(inRoleTasks, function(err, results) {
        debug('getRoles() returns: %j %j', err, roles);
        if (callback) callback(err, roles);
      });
      return callback.promise;
    };
  
    Role.validatesUniquenessOf('name', {message: 'already exists'});
  };
  