UNPKG

access-controls

Version:

rule based access-controls engine for node.js (browser compatible)

535 lines (464 loc) 16.7 kB
var setImmediateShim = setTimeout // browser shim if(typeof setImmediate !== 'undefined') { setImmediateShim = setImmediate } // var perm = { // acl: { // roles: ['emea'], // control: 'required', // actions: 'rw', // conditions: [{ // attributes: { // 'region': 'emea' // } // } // ] // } // } var _ = require('lodash') var OTParser = require('object-tree') function AccessControlList(conf) { if(!conf) { throw new Error ('missing configuration') } if(!conf.roles) { throw new Error('roles is required') } if(!_.isArray(conf.roles)) { throw new Error('roles should be a string array') } this._roles = conf.roles this._name = conf.name || JSON.stringify(conf.roles) this._hard = conf.hard || false if(!conf.control) { throw new Error('control is required') } this._control = conf.control // create a map for O(1) speed this._actions = {} if(conf.actions && _.isArray(conf.actions)) { for(var i = 0 ; i < conf.actions.length ; i++) { this._actions[conf.actions[i]] = true } } if(conf.filters) { this._filters = conf.filters } this._conditions = conf.conditions || [] if (conf.inherit) { this._inherit = conf.inherit } this._objectParser = new OTParser() } AccessControlList.prototype.shouldApplyInherited = function(obj, action, roles, context) { var inheritApply = { ok: true } var self = this if (self._inherit && context.inherit$) { var skip = _.any(self._inherit.allow, function(allow) { var rolesMatch = self._rolesMatch(allow.roles, roles) if (rolesMatch.ok) { var entityMatch = _.findWhere(allow.entities, context.inherit$.entityDef) if (entityMatch) { return _.all(allow.conditions, function(condition) { var match = self._conditionMatch(condition, context.inherit$.entity, action, context) return (match.ok === true) }) } else { return false } } else { return false } }) if (skip) { inheritApply.ok = false inheritApply.reason = 'inherit pass through' } } return inheritApply } AccessControlList.prototype.shouldApply = function(obj, action) { var shouldApply = { ok: true } if(!this._actionMatch(action)) { shouldApply.ok = false shouldApply.reason = 'action does not match' } return shouldApply } AccessControlList.prototype._actionMatch = function(intendedAction) { return this._actions[intendedAction] === true } /** * returns: * - obj.ok=true if the rule should apply and the conditions match the context * - obj.ok=false if the rule should apply and the conditions don't match the context (access denied) * - obj.ok=undefined if the conditions don't match the object (rule should not apply) */ AccessControlList.prototype._conditionsMatch = function(obj, action, context) { var totalMatch = { ok: true } for(var i = 0 ; i < this._conditions.length ; i++) { var condition = this._conditions[i] var match = this._conditionMatch(condition, obj, action, context) if(match.ok === undefined) { totalMatch.ok = undefined } else if(match.ok === false && totalMatch.ok !== undefined) { totalMatch.ok = false if(match.reason) { totalMatch.reason = match.reason } else { totalMatch.reason = 'Condition #'+i+' does not match ==> '+ JSON.stringify(condition) } } if(match.inherit) { totalMatch.inherit = totalMatch.inherit || [] totalMatch.inherit = totalMatch.inherit.concat(match.inherit) } } return totalMatch } AccessControlList.prototype._filter = function(obj) { if(this._filters) { var filters = [] for(var attr in this._filters) { var filter = this._applyFilter(this._filters[attr], obj, attr) filters.push(filter) } return filters } } AccessControlList.prototype._applyFilter = function(filter, obj, attribute) { var filterResult = {} filterResult.attribute = attribute // foo: false => foo denied if(!filter) { filterResult.access = 'denied' filterResult.originalValue = obj[attribute] } else // foo: [...] => foo denied if (new) value is in specified array of values // used with write operations to disallow certain values to be set on a field // doesn't make much sense for read operations, but if used with reads it will // stop certain values from being returned if(_.isArray(filter)) { filterResult.originalValue = obj[attribute] if (~filter.indexOf(obj[attribute])) { filterResult.access = 'denied' } else { // if value is not in specified array of values, allow it through } } else // custom filter function // only works for read operations, replaces original field value with // the value returned from the custom filtration function // when used with write operations the returned value has no effect // and access to field will be always denied if(_.isFunction(filter)) { filterResult.access = 'partial' filterResult.originalValue = obj[attribute] filterResult.filteredValue = filter(obj[attribute]) } else // "mask" filter for read operations // if positive will replace first N characters with * // if negative will replace last N characters with * if(_.isNumber(filter)) { // only works with strings, for non-string values simply denies access if(_.isString(obj[attribute])) { filterResult.access = 'partial' filterResult.originalValue = obj[attribute] var fullMask = filterResult.originalValue.replace(/./g, '*') var maskedValue if(filter > 0) { // N = filter // mask all but the 'N' first characters maskedValue = filterResult.originalValue.substr(0, filter) if(fullMask.length > filter) { maskedValue += fullMask.substr(filter) } } else if(filter < 0) { // N = filter // mask all but the 'N' last characters maskedValue = filterResult.originalValue.substr(filter) if(fullMask.length > (-filter)) { maskedValue = fullMask.substr(0, fullMask.length + filter) + maskedValue } } filterResult.filteredValue = maskedValue } else { filterResult.access = 'denied' if(!filter) { filterResult.reason = 'trying to apply a replace filter on a falsy value' } else { filterResult.reason = 'trying to apply a replace filter on a value that is not a string' } filterResult.originalValue = obj[attribute] if (typeof console !== 'undefined') { console.warn('Denying access to field ['+attribute+'].', filterResult.reason) } } } else { throw new Error('unsupported filter', filter) } return filterResult } AccessControlList.prototype._conditionMatch = function(condition, obj, action, context) { var match = {ok: true} // at least one common element between expected and actual var oneMatch = function(expected, actual) { if (_.isUndefined(expected) || _.isUndefined(actual)) { return false } expected = _.isArray(expected) ? expected : [expected] actual = _.isArray(actual) ? actual : [actual] for (var i=0; i<expected.length; i++) { for (var j=0; j<actual.length; j++) { if (expected[i] === actual[j]) { return true } } } return false } if(condition.attributes) { for(var attr in condition.attributes) { if(condition.attributes.hasOwnProperty(attr)) { var areEqual var invertedCondition = false var expectedValue = condition.attributes[attr] if(attr.indexOf('!') === 0) { attr = attr.slice(1) invertedCondition = true } var actualValue if (obj.original$) { actualValue = this._objectParser.lookup(attr, obj.original$) } else { actualValue = this._objectParser.lookup(attr, obj) } if(this._objectParser.isTemplate(expectedValue)) { // Check to see if this is a NOT template. This will happen if it starts with {! // In order for the template to work replace the {! with { and set a flag that // we are inverting the following logic if(expectedValue.indexOf('{!') === 0) { invertedCondition = true expectedValue = '{' + expectedValue.substr(2) } expectedValue = this._objectParser.lookupTemplate(expectedValue, context) areEqual = oneMatch(expectedValue, actualValue) if(invertedCondition) { if (areEqual) { match.ok = false match.reason = 'Attr [' + attr + '] should not be [' + actualValue + '] but is in [' + expectedValue + ']' } } else { if (!areEqual) { match.ok = false match.reason = 'Attr [' + attr + '] should be [' + actualValue + '] but is not in [' + expectedValue + ']' } } } else { if (expectedValue === null) { // special handling when expecting value null (literal) var bothNull = (actualValue === undefined || actualValue === null) if (invertedCondition) { // inverted if (bothNull) { // left null, right null // TODO: review // irregular behaviour here when using the bang on the attr name and literal null as the expectected value // returns false here if actual value is null when normally it should return undefined match.ok = false match.reason = 'Condition do not apply. Truthy value expected for attr ['+attr+'] but got ['+actualValue+']' return match } else { // if the condition matches then match.ok should preserve its current value } } else { // normal if (!bothNull) { // left null, right !null // TODO: review // figure out why we're returning false on the inverted path above // might need to return false here as well match.ok = undefined // this ACL should not apply to this object match.reason = 'Condition do not apply. Attr ['+attr+'] should be ['+expectedValue+'] but is ['+actualValue+']' return match } else { // left null, right null // nothing to do here. condition matches, match.ok preserves its current value // left overs from v0.5.2: //match.reason = 'falsy value expected' } } } else { areEqual = oneMatch(expectedValue, actualValue) if (invertedCondition) { // inverted if (areEqual) { // !!! not handled in v0.5.2 match.ok = undefined match.reason = 'Condition do not apply. Attr ['+attr+'] should *NOT* be ['+expectedValue+'] but is ['+actualValue+']' return match } else { // if the condition matches then match.ok should preserve its current value // !!! removed: //match.ok = true // as match.ok should is initialized with true and should only be set to false or undefined while evaluating attrs // more left overs from v0.5.2: //match.reason = 'Condition match. Attr ['+attr+'] should *NOT* be ['+expectedValue+'] and is ['+actualValue+']' } } else { // normal if (!areEqual) { match.ok = undefined // this ACL should not apply to this object match.reason = 'Condition do not apply. Attr ['+attr+'] should be ['+expectedValue+'] but is ['+actualValue+']' return match } else { // nothing to do here. condition matches, match.ok preserves its current value } } } } } } } else if (condition.fn) { var result = condition.fn(obj, context) if (result.ok !== true) { match.ok = result.ok match.reason = result.reason } } else if(/^\{(.+\/){0,2}.*::.*\}$/.test(condition)) { // match {-/-/foobar::path.to.attr} or {-/foobar::path.to.attr} etc. var data = condition.slice(1, condition.length-1).split('::') var referencedId = this._objectParser.lookup(data[1], obj) var typeData = data[0].split('/') if(!referencedId) { // shortcut to denial if the reference does not exist, we cannot inherit its permissions match.ok = false match.reason = 'Authorization should be inherited from field ['+data[1]+'] but the field is falsy' return match } else { match.inherit = match.inherit || [] var inheritance = { entity: { }, id: referencedId } if(typeData[2]) { inheritance.entity.zone = typeData[0] inheritance.entity.base = typeData[1] inheritance.entity.name = typeData[2] } else if(typeData[1] && typeData[0] !== '-') { inheritance.entity.base = typeData[0] inheritance.entity.name = typeData[1] } else { inheritance.entity.name = typeData[0] } match.inherit.push(inheritance) } } return match } AccessControlList.prototype.authorize = function(obj, action, roles, context, callback) { var authorize = false var reason = '' var inherit = [] var shouldApply = this.shouldApply(obj, action) var filters = null var hard = this._hard var missing = null if(shouldApply.ok) { var conditionsMatch = this._conditionsMatch(obj, action, context) if(conditionsMatch.inherit) { inherit = inherit.concat(conditionsMatch.inherit) } if(conditionsMatch.ok === false) { reason = conditionsMatch.reason if(this.control() === 'filter') { reason = 'skipping filter because the conditions do not match' authorize = true } else { authorize = false } } else if(conditionsMatch.ok === true) { var rolesMatch = this._rolesMatch(this._roles, roles) reason = rolesMatch.reason missing = rolesMatch.missing if(!rolesMatch.ok && this.control() === 'filter') { reason = 'applying filter because the roles do not match' filters = this._filter(obj) authorize = true } else { authorize = rolesMatch.ok } } else { // conditions say this ACL does not apply if(this.control() === 'sufficient') { authorize = false } else { authorize = true } reason = conditionsMatch.reason || 'ACL conditions do not apply' } } else { reason = shouldApply.reason authorize = true } setImmediateShim(function() { callback(undefined, { authorize: authorize, reason: reason, inherit: inherit, filters: filters, hard: hard, missingRoles: missing }) }) } AccessControlList.prototype._rolesMatch = function(expectedRoles, actualRoles) { var rolesMatch = {ok: true} var missingRoles = [] if(expectedRoles && expectedRoles.length > 0) { // TODO: optimize this O(N square) into at least a O(N) for(var i = 0 ; i < expectedRoles.length ; i++) { var match = false if(actualRoles) { for(var j = 0 ; j < actualRoles.length ; j++) { if(actualRoles[j] === expectedRoles[i]) { match = true break } } } if(!match) { missingRoles.push(expectedRoles[i]) } } } if(missingRoles.length > 0) { rolesMatch.ok = false rolesMatch.missing = missingRoles rolesMatch.reason = 'expected roles ' + JSON.stringify(expectedRoles) + ' but got roles ' + JSON.stringify(actualRoles) + '. missing roles ' + JSON.stringify(missingRoles) } else if(rolesMatch.ok) { rolesMatch.reason = 'roles match as expected: ' + expectedRoles.join(',') } return rolesMatch } AccessControlList.prototype.roles = function() { return this._roles } AccessControlList.prototype.control = function() { return this._control } AccessControlList.prototype.name = function() { return this._name } AccessControlList.prototype.hard = function() { return this._hard } AccessControlList.prototype.toString = function() { return 'ACL::' + this._name } module.exports = AccessControlList