@inspire-platform/sails-hook-permissions
Version:
Comprehensive user permissions and entitlements system for sails.js and Waterline. Supports user authentication with passport.js, role-based permissioning, object ownership, and row-level security.
549 lines (483 loc) • 17.8 kB
JavaScript
var _slicedToArray = (function () { function sliceIterator(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i['return']) _i['return'](); } finally { if (_d) throw _e; } } return _arr; } return function (arr, i) { if (Array.isArray(arr)) { return arr; } else if (Symbol.iterator in Object(arr)) { return sliceIterator(arr, i); } else { throw new TypeError('Invalid attempt to destructure non-iterable instance'); } }; })();
var _ = require('lodash');
var helpers = require('../../lib/helpers');
var methodMap = {
POST: 'create',
GET: 'read',
PUT: 'update',
PATCH: 'update',
DELETE: 'delete'
};
var wlFilter = require('waterline-criteria');
module.exports = {
/**
* Given an object, or a list of objects, return true if the list contains
* objects not owned by the specified user.
*/
hasForeignObjects: function hasForeignObjects(objects, user) {
if (!_.isArray(objects)) {
return PermissionService.isForeignObject(user.id)(objects);
}
return _.any(objects, PermissionService.isForeignObject(user.id));
},
/**
* Return whether the specified object is NOT owned by the specified user.
*/
isForeignObject: function isForeignObject(owner) {
return function (object) {
//sails.log.verbose('object', object);
//sails.log.verbose('object.owner: ', object.owner, ', owner:', owner);
return object.owner !== owner;
};
},
/**
* Find objects that some arbitrary action would be performed on, given the
* same request.
*
* @param options.model
* @param options.query
*
* TODO this will be less expensive when waterline supports a caching layer
*/
findTargetObjects: function findTargetObjects(req) {
var action = PermissionService.getMethod(req.method);
// handle add/remove routes that use :parentid and :childid as the primary key fields
var parentId;
var childId;
if (req.params.parentid) {
parentId = req.params.parentid;
req.params.id = req.params.parentid;
delete req.params.parentid;
}
if (req.params.childid) {
childId = req.params.childid;
delete req.params.childid;
}
return new Promise(function (resolve, reject) {
var findAction;
switch (action) {
case 'read':
findAction = require('sails/lib/hooks/blueprints/actions/find');
break;
default:
findAction = require('sails/lib/hooks/blueprints/actions/findOne');
break;
}
findAction(req, {
ok: resolve,
badRequest: reject,
serverError: reject,
// this isn't perfect, since it returns a 500 error instead of a 404 error
// but it is better than crashing the app when a record doesn't exist
notFound: reject
});
}).then(function (result) {
if (parentId !== undefined) {
delete req.params.id;
req.params.parentid = parentId;
}
if (childId !== undefined) {
req.params.childid = childId;
}
return result;
});
},
/**
* Query Permissions that grant privileges to a role/user on an action for a
* model.
*
* @param options.method
* @param options.model
* @param options.user
*/
findModelPermissions: function findModelPermissions(options) {
//console.log('findModelPermissions options', options)
//console.log('findModelPermissions action', action)
var helperOptions = {
model: options.model,
action: PermissionService.getMethod(options.method),
populate: ['criteria', 'objectFilters']
};
return helpers.findUserPermissions(options.user, helperOptions);
},
/**
* Generate final permissions criteria from permissions object.
*
* @param perm
* @returns {Array}
*/
genPermissionCriteria: function genPermissionCriteria(perm) {
var permCriteria = [];
var objFilterCriteria = [];
// have criteria?
if (true === _.has(perm, 'criteria') && true === _.isArray(perm.criteria) && false === _.isEmpty(perm.criteria)) {
// yes, loop all
perm.criteria.forEach(function (criteria) {
// set owner flag?
if (perm.relation === 'owner') {
// yes
criteria.owner = true;
}
// push onto perm criteria
permCriteria.push(criteria);
});
}
// have object filters?
if (true === _.has(perm, 'objectFilters') && true === _.isArray(perm.objectFilters) && false === _.isEmpty(perm.objectFilters)) {
// yes, build where criteria for every id
perm.objectFilters.forEach(function (o) {
objFilterCriteria.push({ where: { id: o.objectId } });
});
}
// have both?
if (permCriteria.length && objFilterCriteria.length) {
// yes, its an and
var finalCriteria = {
where: {
and: []
}
};
// more than one criteria?
if (permCriteria.length > 1) {
// yes, use sub-or
finalCriteria.where.and.push({
or: permCriteria.map(function (o) {
return o.where;
})
});
} else {
// just one object filter
finalCriteria.where.and.push(permCriteria[0].where);
}
// more than one object filter?
if (objFilterCriteria.length > 1) {
// yes, use sub-or
finalCriteria.where.and.push({
or: objFilterCriteria.map(function (o) {
return o.where;
})
});
} else {
// just one object filter
finalCriteria.where.and.push(objFilterCriteria[0].where);
}
// all done
return [finalCriteria];
} else if (permCriteria.length) {
// only return perm criteria
return permCriteria;
} else if (objFilterCriteria.length) {
// only return object filters
return objFilterCriteria;
} else {
// If a permission has no criteria then it passes for all cases
// (like the admin role)
return [{ where: {} }];
}
},
/**
* Given a list of objects, determine if they all satisfy at least one permission's
* where clause/attribute blacklist combination
*
* @param {Array of objects} objects - The result of the query, or if the action is create,
* the body of the object to be created
* @param {Array of Permission objects} permissions - An array of permission objects
* that are relevant to this particular user query
* @param {Object} attributes - The body of the request, in an update or create request.
* The keys of this object are checked against the permissions blacklist
* @returns boolean - True if there is at least one granted permission that allows the requested action,
* otherwise false
*/
hasPassingCriteria: function hasPassingCriteria(objects, permissions, attributes, user) {
// return success if there are no permissions or objects
if (_.isEmpty(permissions) || _.isEmpty(objects)) return true;
if (!_.isArray(objects)) {
objects = [objects];
}
var criteria = permissions.reduce(function (memo, perm) {
if (perm) {
var permCriteria = PermissionService.genPermissionCriteria(perm);
memo = memo.concat(permCriteria);
return memo;
}
}, []);
if (!_.isArray(criteria)) {
criteria = [criteria];
}
if (_.isEmpty(criteria)) {
return true;
}
// every object must have at least one permission that has a passing criteria and a passing attribute check
return objects.every(function (obj) {
return criteria.some(function (criteria) {
var match = wlFilter([obj], {
where: criteria.where
}).results;
var hasUnpermittedAttributes = PermissionService.hasUnpermittedAttributes(attributes, criteria.blacklist);
var hasOwnership = true; // edge case for scenario where a user has some permissions that are owner based and some that are role based
if (criteria.owner) {
hasOwnership = !PermissionService.isForeignObject(user)(obj);
}
return match.length === 1 && !hasUnpermittedAttributes && hasOwnership;
});
});
},
hasUnpermittedAttributes: function hasUnpermittedAttributes(attributes, blacklist) {
if (_.isEmpty(attributes) || _.isEmpty(blacklist)) {
return false;
}
return _.intersection(Object.keys(attributes), blacklist).length ? true : false;
},
/**
* Return true if the specified model supports the ownership policy; false
* otherwise.
*/
hasOwnershipPolicy: function hasOwnershipPolicy(model) {
return true === 'autoCreatedBy' in model && true === model.autoCreatedBy;
},
/**
* Build an error message
*/
getErrorMessage: function getErrorMessage(options) {
var user = options.user.email || options.user.username;
return ['User', user, 'is not permitted to', options.method, options.model.name].join(' ');
},
/**
* Given an action, return the CRUD method it maps to.
*/
getMethod: function getMethod(method) {
return methodMap[method];
},
/**
* create a new role
* @param options
* @param options.name {string} - role name
* @param options.permissions {permission object, or array of permissions objects}
* @param options.permissions.model {string} - the name of the model that the permission is associated with
* @param options.permissions.criteria - optional criteria object
* @param options.permissions.criteria.where - optional waterline query syntax object for specifying permissions
* @param options.permissions.criteria.blacklist {string array} - optional attribute blacklist
* @param options.users {array of user names} - optional array of user ids that have this role
*/
createRole: function createRole(options) {
var ok = Promise.resolve();
var permissions = options.permissions;
if (!_.isArray(permissions)) {
permissions = [permissions];
}
// look up the model id based on the model name for each permission, and change it to an id
ok = ok.then(function () {
return Promise.all(permissions.map(function (permission) {
return Model.findOne({
name: permission.model
}).then(function (model) {
permission.model = model.id;
return permission;
});
}));
});
// look up user ids based on usernames, and replace the names with ids
ok = ok.then(function (permissions) {
if (options.users) {
return User.find({
username: options.users
}).then(function (users) {
options.users = users;
});
}
});
ok = ok.then(function (users) {
return Role.create(options).meta({ fetch: true });
});
return ok;
},
/**
*
* @param options {permission object, or array of permissions objects}
* @param options.role {string} - the role name that the permission is associated with,
* either this or user should be supplied, but not both
* @param options.user {string} - the user than that the permission is associated with,
* either this or role should be supplied, but not both
* @param options.model {string} - the model name that the permission is associated with
* @param options.action {string} - the http action that the permission allows
* @param options.criteria - optional criteria object
* @param options.criteria.where - optional waterline query syntax object for specifying permissions
* @param options.criteria.blacklist {string array} - optional attribute blacklist
*/
grant: function grant(permissions) {
if (!_.isArray(permissions)) {
permissions = [permissions];
}
// look up the models based on name, and replace them with ids
var ok = Promise.all(permissions.map(function (permission) {
var findRole = permission.role ? Role.findOne({
name: permission.role
}) : null;
var findUser = permission.user ? User.findOne({
username: permission.user
}) : null;
return Promise.all([findRole, findUser, Model.findOne({
name: permission.model
})]).then(function (_ref) {
var _ref2 = _slicedToArray(_ref, 3);
var role = _ref2[0];
var user = _ref2[1];
var model = _ref2[2];
permission.model = model.id;
if (role && role.id) {
permission.role = role.id;
} else if (user && user.id) {
permission.user = user.id;
} else {
return Promise.reject(new Error('no role or user specified'));
}
});
}));
ok = ok.then(function () {
return Permission.create(permissions).meta({ fetch: true });
});
return ok;
},
/**
* add one or more users to a particular role
* TODO should this work with multiple roles?
* @param usernames {string or string array} - list of names of users
* @param rolename {string} - the name of the role that the users should be added to
*/
addUsersToRole: function addUsersToRole(usernames, rolename) {
if (_.isEmpty(usernames)) {
return Promise.reject(new Error('One or more usernames must be provided'));
}
if (!_.isArray(usernames)) {
usernames = [usernames];
}
return Role.findOne({
name: rolename
}).populate('users').then(function (role) {
return User.find({
username: usernames
}).then(function (users) {
role.users.add(_.pluck(users, 'id'));
return role.save();
});
});
},
/**
* remove one or more users from a particular role
* TODO should this work with multiple roles
* @params usernames {string or string array} - name or list of names of users
* @params rolename {string} - the name of the role that the users should be removed from
*/
removeUsersFromRole: function removeUsersFromRole(usernames, rolename) {
if (_.isEmpty(usernames)) {
return Promise.reject(new Error('One or more usernames must be provided'));
}
if (!_.isArray(usernames)) {
usernames = [usernames];
}
return Role.findOne({
name: rolename
}).populate('users').then(function (role) {
return User.find({
username: usernames
}, {
select: ['id']
}).then(function (users) {
users.map(function (user) {
role.users.remove(user.id);
});
return role.save();
});
});
},
/**
* revoke permission from role
* @param options
* @param options.role {string} - the name of the role related to the permission. This, or options.user should be set, but not both.
* @param options.user {string} - the name of the user related to the permission. This, or options.role should be set, but not both.
* @param options.model {string} - the name of the model for the permission
* @param options.action {string} - the name of the action for the permission
* @param options.relation {string} - the type of the relation (owner or role)
*/
revoke: function revoke(options) {
var findRole = options.role ? Role.findOne({
name: options.role
}) : null;
var findUser = options.user ? User.findOne({
username: options.user
}) : null;
var ok = Promise.all([findRole, findUser, Model.findOne({
name: options.model
})]);
ok = ok.then(function (_ref3) {
var _ref32 = _slicedToArray(_ref3, 3);
var role = _ref32[0];
var user = _ref32[1];
var model = _ref32[2];
var query = {
model: model.id,
action: options.action,
relation: options.relation
};
if (role && role.id) {
query.role = role.id;
} else if (user && user.id) {
query.user = user.id;
} else {
return Promise.reject(new Error('You must provide either a user or role to revoke the permission from'));
}
return Permission.destroy(query);
});
return ok;
},
/**
* Check if the user (out of role) is granted to perform action on given objects
* @param objects
* @param user
* @param action
* @param model
* @param body
* @returns {*}
*/
isAllowedToPerformAction: function isAllowedToPerformAction(objects, user, action, model, body) {
if (!_.isArray(objects)) {
return PermissionService.isAllowedToPerformSingle(user.id, action, model, body)(objects);
}
return Promise.all(objects.map(PermissionService.isAllowedToPerformSingle(user.id, action, model, body))).then(function (allowedArray) {
return allowedArray.every(function (allowed) {
return allowed === true;
});
});
},
/**
* Resolve if the user have the permission to perform this action
* @param user
* @param action
* @param model
* @param body
* @returns {Function}
*/
isAllowedToPerformSingle: function isAllowedToPerformSingle(user, action, model, body) {
return function (obj) {
return new Promise(function (resolve, reject) {
Model.findOne({
identity: model
}).then(function (model) {
return Permission.find({
model: model.id,
action: action,
relation: 'user',
user: user
}).populate('criteria').populate('objectFilters');
}).then(function (permission) {
if (permission.length > 0 && PermissionService.hasPassingCriteria(obj, permission, body)) {
resolve(true);
} else {
resolve(false);
}
})['catch'](reject);
});
};
}
};
;