Source: cabr.js

/**
 * @module module:cabr
 * @description
 * <pre>$ <kbd>NODE_DEBUG=cabr</kbd></pre>
 *
 * The cabr module, which holds the CABR class.
 *
 * The {@link module:cabr~CABR CABR class} provides rbac-a support for express/connect
 * applications.
 */

'use strict';

const log = require('util').debuglog('cabr');
const _ = require('lodash');
const Promise = require('bluebird');
const mung = require('express-mung');
const httpMethods = require('./http-methods');

class CABR {

	/**
	 * Constructs a new instance of CABR.
	 * @class CABR
	 * @param rbac {Object} The {@link https://github.com/yanickrochon/rbac-a#rbac-a RBAC-A} instance to use
	 * @param [options] {Object} The options to setup the class
	 * @param [options.userProvider] {Object} An RBAC-A provider which get method will be called with the current
	 * request to determine the current user. Defaults to the provider of the passed rbac instance.
	 * @param [options.routes] {Object} An object of regular expression strings mapped to a string or array of strings
	 * (see the {@link https://github.com/yanickrochon/rbac-a#grouped-permissions RBAC-A Grouped permissions syntax}),
	 * or an object with keys defining HTTP methods (upper or lowercase) mapped to a permission syntax string or array.
	 * The keys of the route object are used as regular expressions to determine if a route configuration applies for
	 * the current request.
 	 * @param [options.unauthorizedHandler] {Function} A middleware function that is called if a permission or attribute
	 * validation failed. Defaults to a simple function sending a 401 status and calling the next handler with an error
	 * message. The failed permission or attribute is attached as rbacFailed to the request object.
	 * @example
	 * const rbac = require('rbac-a');
	 * const CABR = require('cabr');
	 *
	 * // init the rbac instance ...
	 *
	 * const routes = {
	 *	// every route, every HTTP method needs the awesome permission
	 *	'.*': 'awesome',
	 *
	 * 	// every route, every HTTP method needs the 'awesome', yolo' and 'funky' permission
	 *	'^\\/funky$': ['yolo', 'funky'],
	 *
	 *	// every route, every HEAD request needs the 'clever' and 'smart' permission
	 * 	// plus the 'awesome' permission
	 *	'.*': {HEAD: ['clever', 'smart']}, // or 'clever && smart'
	 *
	 *	// every route, every COPY request needs the either the 'clever' or 'smart' permission
	 * 	// plus the 'awesome' permission
	 *	'.*': {COPY: 'clever || smart']},
	 *
	 *	// ALL HTTP methods for '/pets' will be checked with the 'pets.read'
	 *	// permission and 'awesome' permissions
	 *	'^\\/pets$': 'pets.read',
	 *
	 *	// Custom config for '/cats', different HTTP methods
	 *	// will apply different permissions
	 *	'^\\/pets\\/cats$': {GET: 'pets.read', POST: 'cats.create', DELETE: ['pets.create', 'pets.delete']},
	 * };
	 *
	 * // init the cabr instance
	 * const cabr = new CABR(rbac, {routes});
	 *
	 * // use a custom user provider
	 * const get = (req) => Promise.resolve(req.user);
	 * cabr = new CABR(rbac, {routes, userProvider: {get}});
	 */
	constructor(rbac, options) {
		if (!rbac || !(typeof rbac.check === 'function')) {
			throw new Error('Invalid options or rbac-a instance!');
		}

		this.rbac = rbac;
		this.map = [];

		const defaultOptions = {
			userProvider: this.rbac.provider,
			routes: {},
			unauthorizedHandler
		};

		this.options = _.defaults(options || {}, defaultOptions);
		// setup the route mapping with the initial options map
		_.map(this.options.routes, (v, k) => this.registerRoute(k, v));
	}

	/**
	 * Register an {@link http://expressjs.com/en/4x.html#app express app}
	 * on this CABR instance. All mapped requests will be validated with the configured
	 * RBAC-A permissions. For all attributes of a role, the RBAC-A attribute function
	 * will be called with params.permissions: permissions object, params.req: request
	 * and params.res: response for request validation, and additionally params.body for response
	 * validation and manipulation, after all other middleware has been called.
	 * The registerApp method must be called before any route handling middleware is registered
	 * that modifies the response body, also note that it may cause errors if the response body
	 * object is dereferenced in an attribute function!
	 * @param app {Object} The express app to register.
	 * @example
	 * const express = require('express');
	 * const cabr = new CABR(...);
	 * const app = express();
	 *
	 * cabr.registerApp(app).use(...);
	 *
	 * // or
	 * const cabredApp = cabr.registerApp(express());
	 */
	registerApp(app) {
		app.use(pre.bind(this));
		app.use(mung.jsonAsync(post.bind(this)));
		return app;
	}

	/**
	 * Add a route configuration at runtime. CABR supports dynamically building the route
	 * configuration.
	 * @param route {String} String used as a regular expression. The route the permissions should be applied to
	 * @param permissions {Array[]|Array|String|Object} The permission object. The same formats as for the route
	 * options are supported. Also see the
	 * {@link https://github.com/yanickrochon/rbac-a#grouped-permissions RBAC-A Grouped permissions syntax}.
	 * @example
	 * cabr.registerRoute('^\/api$', {GET: 'read', POST: 'create'});
	 */
	registerRoute(route, permissions) {
		if (!route || !permissions) {
			return;
		}

		const o = {route, regExp: new RegExp(route)};
		_.map(parsePermission(permissions, true), (p, m) => {
			m = m.toUpperCase();
			o[m] = o[m] || [];
			o[m] = _.concat(o[m], parsePermission(p));
		});
		this.map.push(o);
	}

	/**
	 * Return a middleware function checking access based on the given permissions.
	 * The rbac check function is called with the request as req param, the response as
	 * res param, any additional params can be feed with the params parameter.
	 *
	 * @param permissions {Array} Array of permissions or permission syntax strings that
	 * should be checked for this route.
	 * @param [params] Additional params to be passed to the attribute validation, beside req and res.
	 * @returns {Function} A middleware function calling next if the rbac check succeeded,
	 * calls the options unauthorizedHandler otherwise.
	 */
	guard(permissions, params) {
		return (req, res, next) => {
			params = _.defaults(params, {permissions, req, res});
			return Promise.resolve(this.options.userProvider.get(req))
				.then(user => this.rbac.check(user, permissions, params))
				.then(can => {
					if (can) {
						log('allow request for %s with permissions %s', req.originalUrl, permissions);
						return next();
					}

					log('deny request for %s with permissions %s', req.originalUrl, permissions);
					req.rbacFailed = permissions;
					return this.options.unauthorizedHandler(req, res, next);
				})
				.catch(next);
		};
	}
}

/**
 * @private
 * Get all needed permissions for a route
 */
function getPermissions(req, map) {
	let perm = map
		.filter(v => v.regExp.test(req.originalUrl))
		.map(v => v[req.method])
		.reduce((a, b) => a.concat(b), []);
	return _.compact(perm);
}

/**
 * @private
 * Pre request handler
 * @param req {Object} The request object
 * @param res {Object} The response object
 * @param next {Function} The next middleware handler
 */
function pre(req, res, next) {
	log('handle request for %s', req.originalUrl);
	const permissions = getPermissions(req, this.map);
	req.cabr = true;

	if (permissions && permissions.length) {
		return this.guard(permissions)(req, res, next);
	}

	log('No permissions found for route %s', req.originalUrl);
	return next();
}

/**
 * @private
 * Post request handler, gets all attributes for the given user and calls the
 * attribute validation with the parameters req, res, body, permissions.
 * @param body {Object} The response body
 * @param req {Object} The request object
 * @param res {Object} The response object
 */
function post(json, req, res) {
	log('handle response for %s', req.originalUrl);
	const permissions = getPermissions(req, this.map);
	const p = Promise.resolve;
	delete req.cabr;
	res.cabr = true;

	if (!permissions || !permissions.length) {
		log('No permissions found for route %s', req.originalUrl);
		return p(json);
	}

	return new Promise((resolve, reject) => {
		return p(this.options.userProvider.get(req))
			.then(user => p(this.rbac.provider.getRoles(user))
			.then(roles => Promise.mapSeries(_.keys(roles), r => p(this.rbac.provider.getAttributes(r)))
				.then(attrs => _.flatten(attrs))
				.reduce((body, attr) => p(this.rbac.attributes.validate(attr, user, roles, {req, res, body, permissions}))
					.then(can => {
						if (!can) {
							log('deny in response handler for %s: %s', req.originalUrl, attr);
							req.rbacFailed = attr;
							return new Error('Unauthorized');
						}
						return body;
					}), json)
					.then(body => resolve(body))
					.catch(err => {
						log('error in response handling for %s: %s', req.originalUrl, err);
						return this.options.unauthorizedHandler(req, res, reject);
					})
			)
		).catch(err => {
			log('error while getting user for request on route %s: %s', req.originalUrl, err);
			return reject(err);
		});
	});
}

/**
 * @private
 * Parse a given route permission configuration.
 * @param p {Object|Array|String} The permission object to parse
 * @param [extend] {Boolean} Applies only if p is not an object.
 * If set, the permission will be registered for all http methods.
 * @return {Array} The permission array
 */
function parsePermission(p, extend) {
	// check for an object first
	if (!_.isArray(p) && p === Object(p)) {
		return p;
	}
	// support simple permission strings or rbac expressions
	if (_.isString(p)) {
		p = [p];
	}
	// apply every http method
	if (extend && _.isArray(p)) {
		p = _.zipObject(httpMethods, _.map(httpMethods, _.constant(p)));
	}
	return p;
}

/**
 * @private
 * Unauthorized handler, simply send status 401 and call next
 * with an 'Unauthorized' error.
 * @param req {Object} The request object
 * @param res {Object} The response object
 * @param next {Function} The middleware handler
 * @return {*}
 */
function unauthorizedHandler(req, res, next) {
	if (!res.headersSent) {
		res.sendStatus(401);
	}
	const permission = req.rbacFailed ? req.rbacFailed : 'a permission';
	return next(new Error(`Unauthorized: Permission validation for ${req.originalUrl} failed for ${permission}`));
}

/**
 * Only exports the {@link module:cabr~CABR CABR class}.
 * @example
 * const CABR = require('cabr');
 */
module.exports = CABR;