/* ACL System inspired on Zend_ACL. All functions accept strings, objects or arrays unless specified otherwise. '*' is used to express 'all' Database structure in Redis (using default prefix 'acl') Users: acl_roles_{userid} = set(roles) Roles: acl_roles = {roleNames} // Used to remove all the permissions associated to ONE resource. acl_parents_{roleName} = set(parents) acl_resources_{roleName} = set(resourceNames) Permissions: acl_allows_{resourceName}_{roleName} = set(permissions) Note: user ids, role names and resource names are all case sensitive. Roadmap: - Add support for locking resources. If a user has roles that gives him permissions to lock a resource, then he can get exclusive write operation on the locked resource. This lock should expire if the resource has not been accessed in some time. */ "use strict"; const _ = require("lodash"); const util = require("util"); const bluebird = require("bluebird"); const contract_1 = require("./contract"); contract_1.contract.debug = true; exports.Acl = function (backend, logger, options) { /* contract(arguments) .params('object') .params('object', 'object') .params('object', 'object', 'object') .end(); */ options = _.extend({ buckets: { meta: 'meta', parents: 'parents', permissions: 'permissions', resources: 'resources', roles: 'roles', users: 'users' } }, options); this.logger = logger; this.backend = backend; this.options = options; // Promisify async methods backend.endAsync = bluebird.promisify(backend.end); backend.getAsync = bluebird.promisify(backend.get); backend.cleanAsync = bluebird.promisify(backend.clean); backend.unionAsync = bluebird.promisify(backend.union); if (backend.unions) { backend.unionsAsync = bluebird.promisify(backend.unions); } }; exports.Acl.prototype.allow = function (roles, resources, permissions, cb) { contract_1.contract(arguments) .params('string|array', 'string|array', 'string|array', 'function') .params('string|array', 'string|array', 'string|array') .params('array', 'function') .params('array') .end(); if ((arguments.length === 1) || ((arguments.length === 2) && _.isObject(roles) && _.isFunction(resources))) { return this._allowEx(roles).nodeify(resources); } else { let _this = this; roles = makeArray(roles); resources = makeArray(resources); let backend = _this.backend; let transaction = _this.backend.begin(); backend.add(transaction, _this.options.buckets.meta, 'roles', roles); resources.forEach(function (resource) { _.each(roles, (role) => { backend.add(transaction, allowsBucket(resource), role, permissions); }); }); roles.forEach(function (role) { _this.backend.add(transaction, _this.options.buckets.resources, role, resources); }); return backend.endAsync(transaction).nodeify(cb); } }; /* addUserRoles( userId, roles, function(err) ) * Adds roles to a given user id. @param {String|Number} User id. @param {String|Array} Role(s) to add to the user id. @param {Function} Callback called when finished. @return {Promise} Promise resolved when finished */ exports.Acl.prototype.addUserRoles = function (userId, roles, cb) { contract_1.contract(arguments) .params('string|number', 'string|array', 'function') .params('string|number', 'string|array') .end(); let transaction = this.backend.begin(); this.backend.add(transaction, this.options.buckets.meta, 'users', userId); this.backend.add(transaction, this.options.buckets.users, userId, roles); if (Array.isArray(roles)) { let _this = this; roles.forEach(function (role) { _this.backend.add(transaction, _this.options.buckets.roles, role, userId); }); } else { this.backend.add(transaction, this.options.buckets.roles, roles, userId); } return this.backend.endAsync(transaction).nodeify(cb); }; /* removeUserRoles( userId, roles, function(err) ) Remove roles from a given user. @param {String|Number} User id. @param {String|Array} Role(s) to remove to the user id. @param {Function} Callback called when finished. @return {Promise} Promise resolved when finished */ exports.Acl.prototype.removeUserRoles = function (userId, roles, cb) { contract_1.contract(arguments) .params('string|number', 'string|array', 'function') .params('string|number', 'string|array') .end(); let transaction = this.backend.begin(); this.backend.remove(transaction, this.options.buckets.users, userId, roles); if (Array.isArray(roles)) { let _this = this; roles.forEach(function (role) { _this.backend.remove(transaction, _this.options.buckets.roles, role, userId); }); } else { this.backend.remove(transaction, this.options.buckets.roles, roles, userId); } return this.backend.endAsync(transaction).nodeify(cb); }; /* userRoles( userId, function(err, roles) ) Return all the roles from a given user. @param {String|Number} User id. @param {Function} Callback called when finished. @return {Promise} Promise resolved with an array of user roles */ exports.Acl.prototype.userRoles = function (userId, cb) { return this.backend.getAsync(this.options.buckets.users, userId).nodeify(cb); }; /* roleUsers( roleName, function(err, users) ) Return all users who has a given role. @param {String|Number} rolename. @param {Function} Callback called when finished. @return {Promise} Promise resolved with an array of users */ exports.Acl.prototype.roleUsers = function (role, cb) { return this.backend.getAsync(this.options.buckets.roles, role).nodeify(cb); }; /* hasRole( userId, rolename, function(err, is_in_role) ) Return boolean whether user is in the role @param {String|Number} User id. @param {String|Number} rolename. @param {Function} Callback called when finished. @return {Promise} Promise resolved with boolean of whether user is in role */ exports.Acl.prototype.hasRole = function (userId, role, cb) { return this.userRoles(userId).then(function (roles) { return roles.indexOf(role) !== -1; }).nodeify(cb); }; /* addRoleParents( role, parents, function(err) ) Adds a parent or parent list to role. @param {String} Child role. @param {String|Array} Parent role(s) to be added. @param {Function} Callback called when finished. @return {Promise} Promise resolved when finished */ exports.Acl.prototype.addRoleParents = function (role, parents, cb) { contract_1.contract(arguments) .params('string|number', 'string|array', 'function') .params('string|number', 'string|array') .end(); let transaction = this.backend.begin(); this.backend.add(transaction, this.options.buckets.meta, 'roles', role); this.backend.add(transaction, this.options.buckets.parents, role, parents); return this.backend.endAsync(transaction).nodeify(cb); }; /* removeRoleParents( role, parents, function(err) ) Removes a parent or parent list from role. If `parents` is not specified, removes all parents. @param {String} Child role. @param {String|Array} Parent role(s) to be removed [optional]. @param {Function} Callback called when finished [optional]. @return {Promise} Promise resolved when finished. */ exports.Acl.prototype.removeRoleParents = function (role, parents, cb) { contract_1.contract(arguments) .params('string', 'string|array', 'function') .params('string', 'string|array') .params('string', 'function') .params('string') .end(); if (!cb && _.isFunction(parents)) { cb = parents; parents = null; } let transaction = this.backend.begin(); if (parents) { this.backend.remove(transaction, this.options.buckets.parents, role, parents); } else { this.backend.del(transaction, this.options.buckets.parents, role); } return this.backend.endAsync(transaction).nodeify(cb); }; /* removeRole( role, function(err) ) Removes a role from the system. @param {String} Role to be removed @param {Function} Callback called when finished. */ exports.Acl.prototype.removeRole = function (role, cb) { contract_1.contract(arguments) .params('string', 'function') .params('string').end(); let _this = this; // Note that this is not fully transactional. return this.backend.getAsync(this.options.buckets.resources, role).then(function (resources) { let transaction = _this.backend.begin(); resources.forEach(function (resource) { let bucket = allowsBucket(resource); _this.backend.del(transaction, bucket, role); }); _this.backend.del(transaction, _this.options.buckets.resources, role); _this.backend.del(transaction, _this.options.buckets.parents, role); _this.backend.del(transaction, _this.options.buckets.roles, role); _this.backend.remove(transaction, _this.options.buckets.meta, 'roles', role); // `users` collection keeps the removed role // because we don't know what users have `role` assigned. return _this.backend.endAsync(transaction); }).nodeify(cb); }; /* removeResource( resource, function(err) ) Removes a resource from the system @param {String} Resource to be removed @param {Function} Callback called when finished. @return {Promise} Promise resolved when finished */ exports.Acl.prototype.removeResource = function (resource, cb) { contract_1.contract(arguments) .params('string', 'function') .params('string') .end(); let _this = this; return this.backend.getAsync(this.options.buckets.meta, 'roles').then(function (roles) { let transaction = _this.backend.begin(); _this.backend.del(transaction, allowsBucket(resource), roles); roles.forEach(function (role) { _this.backend.remove(transaction, _this.options.buckets.resources, role, resource); }); return _this.backend.endAsync(transaction); }).nodeify(cb); }; /* allow( roles, resources, permissions, function(err) ) Adds the given permissions to the given roles over the given resources. @param {String|Array} role(s) to add permissions to. @param {String|Array} resource(s) to add permisisons to. @param {String|Array} permission(s) to add to the roles over the resources. @param {Function} Callback called when finished. allow( permissionsArray, function(err) ) @param {Array} Array with objects expressing what permissions to give. [{roles:{String|Array}, allows:[{resources:{String|Array}, permissions:{String|Array}]] @param {Function} Callback called when finished. @return {Promise} Promise resolved when finished */ exports.Acl.prototype.removeAllow = function (role, resources, permissions, cb) { contract_1.contract(arguments) .params('string', 'string|array', 'string|array', 'function') .params('string', 'string|array', 'string|array') .params('string', 'string|array', 'function') .params('string', 'string|array') .end(); resources = makeArray(resources); if (cb || (permissions && !_.isFunction(permissions))) { permissions = makeArray(permissions); } else { cb = permissions; permissions = null; } return this.removePermissions(role, resources, permissions, cb); }; /* removePermissions( role, resources, permissions) Remove permissions from the given roles owned by the given role. Note: we loose atomicity when removing empty role_resources. @param {String} @param {String|Array} @param {String|Array} */ exports.Acl.prototype.removePermissions = function (role, resources, permissions, cb) { let _this = this; let transaction = _this.backend.begin(); _.each(resources, resource => { let bucket = allowsBucket(resource); if (permissions) { _this.backend.remove(transaction, bucket, role, permissions); } else { _this.backend.del(transaction, bucket, role); _this.backend.remove(transaction, _this.options.buckets.resources, role, resource); } }); // Remove resource from role if no rights for that role exists. // Not fully atomic... return _this.backend.endAsync(transaction).then(function () { let transaction = _this.backend.begin(); return bluebird.all(_.map(resources, resource => { let bucket = allowsBucket(resource); return _this.backend.getAsync(bucket, role).then(function (permissions) { if (permissions.length === 0) { _this.backend.remove(transaction, _this.options.buckets.resources, role, resource); } }); })).then(function () { return _this.backend.endAsync(transaction); }); }).nodeify(cb); }; /* allowedPermissions( userId, resources, function(err, obj) ) Returns all the allowable permissions a given user have to access the given resources. It returns an array of objects where every object maps a resource name to a list of permissions for that resource. @param {String|Number} User id. @param {String|Array} resource(s) to ask permissions for. @param {Function} Callback called when finished. */ exports.Acl.prototype.allowedPermissions = function (userId, resources, cb) { if (!userId) { return cb(null, {}); } contract_1.contract(arguments) .params('string|number', 'string|array', 'function') .params('string|number', 'string|array') .end(); if (this.backend.unionsAsync) { return this.optimizedAllowedPermissions(userId, resources, cb); } let _this = this; resources = makeArray(resources); return _this.userRoles(userId).then(function (roles) { let result = {}; return bluebird.all(_.map(resources, resource => { return _this._resourcePermissions(roles, resource).then(function (permissions) { result[resource] = permissions; }); })).then(function () { return result; }); }).nodeify(cb); }; /* optimizedAllowedPermissions( userId, resources, function(err, obj) ) Returns all the allowable permissions a given user have to access the given resources. It returns a map of resource name to a list of permissions for that resource. This is the same as allowedPermissions, it just takes advantage of the unions function if available to reduce the number of backend queries. @param {String|Number} User id. @param {String|Array} resource(s) to ask permissions for. @param {Function} Callback called when finished. */ exports.Acl.prototype.optimizedAllowedPermissions = function (userId, resources, cb) { if (!userId) { return cb(null, {}); } contract_1.contract(arguments) .params('string|number', 'string|array', 'function|undefined') .params('string|number', 'string|array') .end(); resources = makeArray(resources); let self = this; return this._allUserRoles(userId).then(function (roles) { let buckets = resources.map(allowsBucket); if (roles.length === 0) { let emptyResult = {}; buckets.forEach(function (bucket) { emptyResult[bucket] = []; }); return bluebird.resolve(emptyResult); } return self.backend.unionsAsync(buckets, roles); }).then(function (response) { let result = {}; Object.keys(response).forEach(function (bucket) { result[keyFromAllowsBucket(bucket)] = response[bucket]; }); return result; }).nodeify(cb); }; /* isAllowed( userId, resource, permissions, function(err, allowed) ) Checks if the given user is allowed to access the resource for the given permissions (note: it must fulfill all the permissions). @param {String|Number} User id. @param {String|Array} resource(s) to ask permissions for. @param {String|Array} asked permissions. @param {Function} Callback called wish the result. */ exports.Acl.prototype.isAllowed = function (userId, resource, permissions, cb) { contract_1.contract(arguments) .params('string|number', 'string', 'string|array', 'function') .params('string|number', 'string', 'string|array') .end(); let _this = this; return this.backend.getAsync(this.options.buckets.users, userId).then(function (roles) { if (roles.length) { return _this.areAnyRolesAllowed(roles, resource, permissions); } else { return false; } }).nodeify(cb); }; /* areAnyRolesAllowed( roles, resource, permissions, function(err, allowed) ) Returns true if any of the given roles have the right permissions. @param {String|Array} Role(s) to check the permissions for. @param {String} resource(s) to ask permissions for. @param {String|Array} asked permissions. @param {Function} Callback called with the result. */ exports.Acl.prototype.areAnyRolesAllowed = function (roles, resource, permissions, cb) { contract_1.contract(arguments) .params('string|array', 'string', 'string|array', 'function') .params('string|array', 'string', 'string|array') .end(); roles = makeArray(roles); permissions = makeArray(permissions); if (roles.length === 0) { return bluebird.resolve(false).nodeify(cb); } else { return this._checkPermissions(roles, resource, permissions).nodeify(cb); } }; /* whatResources(role, function(err, {resourceName: [permissions]}) Returns what resources a given role or roles have permissions over. whatResources(role, permissions, function(err, resources) ) Returns what resources a role has the given permissions over. @param {String|Array} Roles @param {String[Array} Permissions @param {Function} Callback called wish the result. */ exports.Acl.prototype.whatResources = function (roles, permissions, cb) { contract_1.contract(arguments) .params('string|array') .params('string|array', 'string|array') .params('string|array', 'function') .params('string|array', 'string|array', 'function') .end(); roles = makeArray(roles); if (_.isFunction(permissions)) { cb = permissions; permissions = undefined; } else if (permissions) { permissions = makeArray(permissions); } return this.permittedResources(roles, permissions, cb); }; exports.Acl.prototype.permittedResources = function (roles, permissions, cb) { let _this = this; let result = _.isUndefined(permissions) ? {} : []; return this._rolesResources(roles).then(function (resources) { return bluebird.all(resources.map(function (resource) { return _this._resourcePermissions(roles, resource).then(function (p) { if (permissions) { let commonPermissions = _.intersection(permissions, p); if (commonPermissions.length > 0) { result.push(resource); } } else { result[resource] = p; } }); })).then(function () { return result; }); }).nodeify(cb); }; /* clean () Cleans all the keys with the given prefix from redis. Note: this operation is not reversible!. */ /* Acl.prototype.clean = function(callback){ var acl = this; this.redis.keys(this.prefix+'*', function(err, keys){ if(keys.length){ acl.redis.del(keys, function(err){ callback(err); }); }else{ callback(); } }); }; */ /* Express Middleware */ exports.Acl.prototype.middleware = function (numPathComponents, userId, actions) { contract_1.contract(arguments) .params() .params('number') .params('number', 'string|number|function') .params('number', 'string|number|function', 'string|array') .end(); let acl = this; function HttpError(errorCode, msg) { this.errorCode = errorCode; this.message = msg; this.name = this.constructor.name; // Error.captureStackTrace(this, this.constructor); this.constructor.prototype.__proto__ = Error.prototype; } return function (req, res, next) { let _userId = userId, _actions = actions, resource, url; // call function to fetch userId if (typeof userId === 'function') { _userId = userId(req, res); } if (!userId) { if ((req.session) && (req.session.userId)) { _userId = req.session.userId; } else if ((req.user) && (req.user.id)) { _userId = req.user.id; } else { next(new HttpError(401, 'User not authenticated')); return; } } // Issue #80 - Additional check if (!_userId) { next(new HttpError(401, 'User not authenticated')); return; } url = req.originalUrl.split('?')[0]; if (!numPathComponents) { resource = url; } else { resource = url.split('/').slice(0, numPathComponents + 1).join('/'); } if (!_actions) { _actions = req.method.toLowerCase(); } acl.logger ? acl.logger.debug('Requesting ' + _actions + ' on ' + resource + ' by user ' + _userId) : null; acl.isAllowed(_userId, resource, _actions, function (err, allowed) { if (err) { next(new Error('Error checking permissions to access resource')); } else if (allowed === false) { if (acl.logger) { acl.logger.debug('Not allowed ' + _actions + ' on ' + resource + ' by user ' + _userId); acl.allowedPermissions(_userId, resource, function (err, obj) { acl.logger.debug('Allowed permissions: ' + util.inspect(obj)); }); } next(new HttpError(403, 'Insufficient permissions to access resource')); } else { acl.logger ? acl.logger.debug('Allowed ' + _actions + ' on ' + resource + ' by user ' + _userId) : null; next(); } }); }; }; /* Error handler for the Express middleware @param {String} [contentType] (html|json) defaults to plain text */ exports.Acl.prototype.middleware.errorHandler = function (contentType) { let method = 'end'; if (contentType) { switch (contentType) { case 'json': method = 'json'; break; case 'html': method = 'send'; break; } } return function (err, req, res, next) { if (err.name !== 'HttpError' || !err.errorCode) { return next(err); } ; res.status(err.errorCode)[method](err.message); }; }; // ----------------------------------------------------------------------------- // // Private methods // // ----------------------------------------------------------------------------- // // Same as allow but accepts a more compact input. // exports.Acl.prototype._allowEx = function (objs) { let _this = this; objs = makeArray(objs); let demuxed = []; objs.forEach(function (obj) { let roles = obj.roles; obj.allows.forEach(function (allow) { demuxed.push({ roles: roles, resources: allow.resources, permissions: allow.permissions }); }); }); return bluebird.reduce(demuxed, function (values, obj) { return _this.allow(obj.roles, obj.resources, obj.permissions); }, null); }; // // Returns the parents of the given roles // exports.Acl.prototype._rolesParents = function (roles) { return this.backend.unionAsync(this.options.buckets.parents, roles); }; // // Return all roles in the hierarchy including the given roles. // /* Acl.prototype._allRoles = function(roleNames, cb){ var _this = this, roles; _this._rolesParents(roleNames, function(err, parents){ roles = _.union(roleNames, parents); async.whilst( function (){ return parents.length >0; }, function (cb) { _this._rolesParents(parents, function(err, result){ if(!err){ roles = _.union(roles, parents); parents = result; } cb(err); }); }, function(err){ cb(err, roles); } ); }); }; */ // // Return all roles in the hierarchy including the given roles. // exports.Acl.prototype._allRoles = function (roleNames) { let _this = this; return this._rolesParents(roleNames).then(function (parents) { if (parents.length > 0) { return _this._allRoles(parents).then(function (parentRoles) { return _.union(roleNames, parentRoles); }); } else { return roleNames; } }); }; // // Return all roles in the hierarchy of the given user. // exports.Acl.prototype._allUserRoles = function (userId) { let _this = this; return this.userRoles(userId).then(function (roles) { if (roles && roles.length > 0) { return _this._allRoles(roles); } else { return []; } }); }; // // Returns an array with resources for the given roles. // exports.Acl.prototype._rolesResources = function (roles) { let _this = this; roles = makeArray(roles); return this._allRoles(roles).then(function (allRoles) { let result = []; // check if bluebird.map simplifies this code return bluebird.all(allRoles.map(function (role) { return _this.backend.getAsync(_this.options.buckets.resources, role).then(function (resources) { result = result.concat(resources); }); })).then(function () { return result; }); }); }; // // Returns the permissions for the given resource and set of roles // exports.Acl.prototype._resourcePermissions = function (roles, resource) { let _this = this; if (roles.length === 0) { return bluebird.resolve([]); } else { return this.backend.unionAsync(allowsBucket(resource), roles).then(function (resourcePermissions) { return _this._rolesParents(roles).then(function (parents) { if (parents && parents.length) { return _this._resourcePermissions(parents, resource).then(function (morePermissions) { return _.union(resourcePermissions, morePermissions); }); } else { return resourcePermissions; } }); }); } }; // // NOTE: This function will not handle circular dependencies and result in a crash. // exports.Acl.prototype._checkPermissions = function (roles, resource, permissions) { let _this = this; return this.backend.unionAsync(allowsBucket(resource), roles).then(function (resourcePermissions) { if (resourcePermissions.indexOf('*') !== -1) { return true; } else { permissions = permissions.filter(function (p) { return resourcePermissions.indexOf(p) === -1; }); if (permissions.length === 0) { return true; } else { return _this.backend.unionAsync(_this.options.buckets.parents, roles).then(function (parents) { if (parents && parents.length) { return _this._checkPermissions(parents, resource, permissions); } else { return false; } }); } } }); }; // ----------------------------------------------------------------------------- // // Helpers // // ----------------------------------------------------------------------------- function makeArray(arr) { return Array.isArray(arr) ? arr : [arr]; } function allowsBucket(role) { return 'allows_' + role; } function keyFromAllowsBucket(str) { return str.replace(/^allows_/, ''); } // ----------------------------------------------------------------------------------- // exports = module.exports = Acl; //# sourceMappingURL=acl.js.map