alsocan
Version:
Authorization libray, replaces cancan
411 lines (338 loc) • 12.2 kB
JavaScript
const colors = require('colors');
/* eslint-disable no-use-before-define */
function defaultUser(user) { return user; }
function modelCompare(instance, model) {
if (model === 'all') return true;
if ((typeof model !== 'string') && (instance instanceof model)) return true;
return instance === model;
}
const targetCompare = modelCompare;
const userCompare = modelCompare;
function actionCompare(action, abilityAction) {
if (abilityAction === 'manage' || abilityAction === 'ALL') return true;
if (abilityAction === action) return true;
if (abilityAction instanceof RegExp) {
return abilityAction.test(action);
}
return false;
}
function createError(user, action, record, message, extra) {
// eslint-disable-next-line no-param-reassign
message = message || 'You are not authorized to do that.';
const options = {
action,
model: toString(record),
user: toString(user),
extra,
};
if (user.token) options.roles = user.token.roles;
return new AuthorizationError(message, options);
}
function validateAllow(...args) {
if (args.length > 3 && !args[3]) {
throw new Error(`It looks like you passed 'undefined' instead of a function to the third argument to allow (arguments: ${args}). (passing 'undefined' will grant permission unconditionally, which is probably not what you want)`);
}
const names = ['user', 'action', 'model'];
args.forEach((arg, i) => {
const a = Array.isArray(arg) ? arg : [arg];
a.forEach((argument) => {
if (!argument) throw new Error(`undefined passed into allow (${arg}). (user: ${args[0]}, action: ${args[1]}, model: ${args[2]})`);
if (Array.isArray(argument)) throw new Error(`nested array passed into allow (${names[i]} argument), make sure you flatten the argument (user: ${args[0]}, action: ${args[1]}, model: ${args[2]})`);
});
});
}
class AlsoCan {
/**
* @param {function} options.defaultUser fn(user)
* @param {function} options.userCompare fn(currentUser, abilityUser)
* @param {function} options.targetCompare fn(currentTarget, abilityTarget)
* @param {function} options.actionCompare fn(currentAction, abilityAction)
* @param {function} options.createError fn(user, action, record, condition)
*/
constructor(options) {
// Save options to this, but don't clobber the debug function
Object.assign(this, {
defaultUser,
userCompare,
targetCompare,
actionCompare,
createError,
}, options);
this.options = options;
this.listeners = {};
this.abilities = [];
['authorize', 'can', 'allow', 'deny', '_can'].forEach((fn) => {
this[fn] = this[fn].bind(this);
});
}
on(name, fn) {
if (!this.listeners[name]) this.listeners[name] = [];
if (this.listeners[name].find(f => f === fn)) throw new Error(`Function ${fn.name} already registered for event ${name}`);
this.listeners[name].push(fn);
}
emit(event, ...args) {
if (this.listeners[event]) {
this.listeners[event].forEach(fn => fn(...args));
}
}
setAbilities(abilities) {
this.abilities = abilities.map(a => new Ability(this, a));
}
getAbilities() {
return this.abilities;
}
allow(user, action, target, condition) {
// eslint-disable-next-line prefer-rest-params
validateAllow(...arguments);
this.abilities.push(new Ability(this, { user, action, target, condition }));
}
deny(user, action, target, condition) {
// eslint-disable-next-line prefer-rest-params
validateAllow(...arguments);
this.abilities.push(new Ability(this, { user, action, target, condition, deny: true }));
}
authorize(user, action, target, ctx) {
console.log('authorizing...')
const performer = this.defaultUser(user);
const isAuthorized = this._can(performer, action, target, ctx);
this.emit('authorize', isAuthorized, performer, action, target, ctx)
if (!isAuthorized) {
const denied = (isAuthorized === false);
throw this.createError(performer, action, target, null, { denied });
}
}
_can(user, action, target, ctx, options) {
if (this.debug) debugHeader(user, action, target, options);
for (let i = 0; i < this.abilities.length; i++) {
const ability = this.abilities[i];
const isAuthorized = ability.can(user, action, target, ctx, options);
// if it is true, it's allowed, stop checking
// if it is === false then it's explicitly denied, stop immediately
if (isAuthorized === false || isAuthorized) {
if (this.debug) debugFooter(this, isAuthorized);
return isAuthorized;
}
}
if (this.debug) debugFooter(this, null);
return null;
}
can(user, action, target, ctx, options) {
const performer = this.defaultUser(user);
const result = this._can(user, action, target, ctx, options)
this.emit('can', result, performer, action, target, ctx)
return result;
}
/**
* Return a subset of the abilities for which user matches
* @param {object} user The same user you would pass to can or authorize
* @returns {object[]} Array of abilities matching that user
*/
getUserAbilities(user) {
return this.abilities
.filter(ability => ability.userCompare(user, ability.user))
.map(ability => ability.toJSON());
}
/**
* Return a subset of the abilities for which a target matches
* @param {string|RegExp} target String or regexp to match the target name
* @returns {object[]} Array of abilities matching that user
*/
getTargetAbilities(target) {
return this.abilities
.filter(ability => (Array.isArray(ability.target) ?
ability.target.find(t => matchTarget(targetName(t), target)) :
matchTarget(targetName(ability.target), target))
)
.map(ability => ability.toJSON())
.map((ability) => {
if (Array.isArray(ability.target)) {
ability.target = ability.target.filter(t => matchTarget(t, target));
}
return ability;
});
}
}
function matchTarget(abilityTarget, targetMatch) {
return (abilityTarget === 'all') ||
((targetMatch instanceof RegExp) ?
targetMatch.test(abilityTarget) : targetMatch === abilityTarget);
}
function targetName(target) {
if (typeof target === 'string') return target;
return target.$MockModel || target.name;
}
function singleAbility(fn) {
return function matchSingleAbility(instance, toMatch, options = {}, ...args) {
const match = fn(instance, toMatch, ...args);
return match && options.describeMatch ? [toMatch] : !!match;
};
}
function arrayAbility(fn) {
return function matchArrayAbility(instance, toMatch, options = {}, ...args) {
const matcher = ability => fn(instance, ability, ...args);
if (options.describeMatch) {
const matches = toMatch.filter(matcher);
return matches.length ? matches : false;
}
return !!toMatch.find(matcher);
};
}
class Ability {
/**
* @param {AlsoCan} alsoCan alsoCan instance
* @param {object} config.user
* @param {string} config.action
* @param {object} config.target
*/
constructor(alsoCan, config) {
Object.assign(this, config);
this.alsoCan = alsoCan;
// If action includes wildcards, turn them into regex's for easy
// matching at runtime
if (this.action.includes('*')) {
this.action = this.action.replace(/\*/g, '.*');
this.action = new RegExp(`${this.action}`);
}
// Set up shortcuts to this.userCompare, etc
// If the ability is an array, then set up an array matcher for that
// ability
['user', 'action', 'target'].forEach((type) => {
const fnName = `${type}Compare`;
this[fnName] = Array.isArray(config[type]) ?
arrayAbility(alsoCan[fnName]) :
singleAbility(alsoCan[fnName]);
});
}
/**
* @returns {boolean} true if this ability matches
* @see #can
* @note does not check if this is a deny rule, see #can to determine
* if this rule allows
*/
matches(user, action, target, ctx, options = {}) {
const isRole = colors.green('yes');
const isAction = this.action === 'manage' ? colors.yellow('manage') : colors.green(' yes ');
const isTarget = this.target === 'all' ? colors.yellow('(all)') : colors.green('yes');
const no = colors.red('no');
let debugStr = `is ${toString(this.user)}`;
const match = {};
let usersToMatch = this.user;
if (options.excludeUsers) {
if (Array.isArray(this.user)) {
usersToMatch = this.user.filter(u => !options.excludeUsers.includes(u));
} else {
usersToMatch = options.excludeUsers.includes(this.user) ? '__do_not_match__' : this.user;
}
}
match.user = this.user === 'all' ? ['ALL'] : this.userCompare(user, usersToMatch, options);
if (!match.user) {
if (this.alsoCan.debug === 'FULL') console.log(`${debugStr} ${no}`);
return false;
}
debugStr += ` ${isRole} can ${action}`;
match.action = this.action === 'manage' ? 'manage' : this.actionCompare(action, this.action, options);
if (!match.action) {
if (this.alsoCan.debug) console.log(`${debugStr} ${no}`);
return false;
}
if (this.alsoCan.debug) {
let targetStr;
if (Array.isArray(this.target)) {
targetStr = this.target.length ? `Array of ${this.target[0].toString()}` : '(empty array)';
} else {
targetStr = this.target.toString();
}
debugStr += ` ${isAction} on ${targetStr}`;
}
match.target = this.target === 'all' ? ['all'] : this.targetCompare(target, this.target, options);
if (!match.target) {
if (this.alsoCan.debug) console.log(`${debugStr} ${no}`);
return false;
}
debugStr += ` ${isTarget}`;
if (this.condition) {
match.condition = this.condition.name || this.condition.constructor.name;
const conditionIsFunction = typeof this.condition === 'function';
if (conditionIsFunction && !this.condition(user, target, ctx, action)) {
if (this.alsoCan.debug) console.log(`${debugStr} (${match.condition} ${no})`);
return false;
}
debugStr += `(${match.condition})`;
}
if (this.alsoCan.debug) {
let authorized = colors.green('* GRANT *');
if (this.deny) {
match.deny = true;
authorized = colors.red('* DENIED *');
}
console.log(`${debugStr} ${authorized}`);
}
return match;
}
/**
* Checks if this rule is a match, and if the ability should be allowed/disallowed
* @param {boolean} options.describeMatch if true, will return an object describing the match (even if deny)
* @warning If describeMatch is true, then you must examine the return value as it could be truthy
* but contain { deny: true }
* @returns {boolean} false if it is a deny rule, null for no match, true for allow
*/
can(user, action, target, ctx, options = {}) {
const match = this.matches(user, action, target, ctx, options);
if (match) {
return options.describeMatch ? match : !this.deny;
}
return null;
}
toString() {
const can = this.deny ? 'CANNOT' : 'can';
const condition = this.condition ? ` if ${this.condition.name}` : '';
return `${this.user} ${can} ${this.action} ${this.target}${condition}`;
}
toJSON() {
return {
user: this.user,
action: this.action,
target: Array.isArray(this.target) ?
this.target.map(t => targetName(t)) :
targetName(this.target),
condition: this.condition && this.condition.name,
deny: this.deny,
};
}
}
function debugHeader(user, action, target, options = {}) {
let targetObj = target;
if (Array.isArray(target) && target.length) targetObj = targetObj[0];
let question = `Can ${toString(user)} ${action} `;
if (Array.isArray(target)) question += '(array of) ';
question += toString(targetObj);
const authType = options.describeMatch ? '(match description requested)' : '';
console.log(`Debug Authorisation: ${authType}`);
console.log(question);
console.log('====');
}
function debugFooter(alsoCan, result) {
let outcome = 'not found';
if (result === false) outcome = 'DENIED';
if (result) outcome = 'GRANTED';
console.log(`Authorization? ${outcome}`);
if (alsoCan.debug !== 'FULL') {
console.log('(Some were not shown because user does not match, set alsoCan.debug = \'FULL\' to show)');
}
}
function toString(x) {
return (x && x.toString()) || '(null)';
}
class AuthorizationError extends Error {
constructor(message, details) {
super();
this.name = 'AuthorizationError';
this.message = message;
this.details = details;
Object.assign(this, details);
}
}
Object.assign(AlsoCan, {
AuthorizationError,
});
module.exports = AlsoCan;