From 97dc0aa441f56d598ca02a1a82c4b30f255126a5 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Wed, 11 Dec 2013 16:03:48 -0800 Subject: [PATCH] Refactor to the code use wrapper classes Add AccessContext, Principal, and AccessRequest Add debug information --- lib/models/access-context.js | 208 ++++++++++++++++++++++++++++++++++ lib/models/acl.js | 213 +++++++++++++++++------------------ lib/models/role.js | 138 +++++++++++++++-------- test/acl.test.js | 2 +- 4 files changed, 404 insertions(+), 157 deletions(-) create mode 100644 lib/models/access-context.js diff --git a/lib/models/access-context.js b/lib/models/access-context.js new file mode 100644 index 00000000..6d5bad49 --- /dev/null +++ b/lib/models/access-context.js @@ -0,0 +1,208 @@ +var loopback = require('../loopback'); + +/** + * Access context represents the context for a request to access protected + * resources + * + * The AccessContext instance contains the following properties: + * @property {Principal[]} principals An array of principals + * @property {Function} model The model class + * @property {String} modelName The model name + * @property {String} modelId The model id + * @property {String} property The model property/method/relation name + * @property {String} method The model method to be invoked + * @property {String} accessType The access type + * @property {AccessToken} accessToken The access token + * + * @param {Object} context The context object + * @returns {AccessContext} + * @constructor + */ +function AccessContext(context) { + if (!(this instanceof AccessContext)) { + return new AccessContext(context); + } + context = context || {}; + + // + this.principals = context.principals || []; + var model = context.model; + model = ('string' === typeof model) ? loopback.getModel(model) : model; + this.model = model; + this.modelName = model && model.modelName; + + this.modelId = context.id || context.modelId; + this.property = context.property || AccessContext.ALL; + + this.method = context.method; + + this.accessType = context.accessType || AccessContext.ALL; + this.accessToken = context.accessToken; + + var principalType = context.principalType || Principal.USER; + var principalId = context.principalId || undefined; + var principalName = context.principalName || undefined; + if (principalId) { + this.addPrincipal(principalType, principalId, principalName); + } + + var token = this.accessToken || {}; + + if (token.userId) { + this.addPrincipal(Principal.USER, token.userId); + } + if (token.appId) { + this.addPrincipal(Principal.APPLICATION, token.appId); + } +} + +// Define constant for the wildcard +AccessContext.ALL = '*'; + +// Define constants for access types +AccessContext.READ = 'READ'; // Read operation +AccessContext.WRITE = 'WRITE'; // Write operation +AccessContext.EXECUTE = 'EXECUTE'; // Execute operation + +AccessContext.DEFAULT = 'DEFAULT'; // Not specified +AccessContext.ALLOW = 'ALLOW'; // Allow +AccessContext.ALARM = 'ALARM'; // Warn - send an alarm +AccessContext.AUDIT = 'AUDIT'; // Audit - record the access +AccessContext.DENY = 'DENY'; // Deny + +AccessContext.permissionOrder = { + DEFAULT: 0, + ALLOW: 1, + ALARM: 2, + AUDIT: 3, + DENY: 4 +}; + + +/** + * Add a principal to the context + * @param {String} principalType The principal type + * @param {*} principalId The principal id + * @param {String} [principalName] The principal name + * @returns {boolean} + */ +AccessContext.prototype.addPrincipal = function (principalType, principalId, principalName) { + var principal = new Principal(principalType, principalId, principalName); + for (var i = 0; i < this.principals.length; i++) { + var p = this.principals[i]; + if (p.equals(principal)) { + return false; + } + } + this.principals.push(principal); + return true; +}; + +/** + * Get the user id + * @returns {*} + */ +AccessContext.prototype.getUserId = function() { + for (var i = 0; i < this.principals.length; i++) { + var p = this.principals[i]; + if (p.type === Principal.USER) { + return p.id; + } + } + return null; +}; + + +/** + * Get the application id + * @returns {*} + */ +AccessContext.prototype.getAppId = function() { + for (var i = 0; i < this.principals.length; i++) { + var p = this.principals[i]; + if (p.type === Principal.APPLICATION) { + return p.id; + } + } + return null; +}; + +/** + * Check if the access context has authenticated principals + * @returns {boolean} + */ +AccessContext.prototype.isAuthenticated = function() { + return !!(this.getUserId() || this.getAppId()); +}; + +/** + * This class represents the abstract notion of a principal, which can be used + * to represent any entity, such as an individual, a corporation, and a login id + * @param {String} type The principal type + * @param {*} id The princiapl id + * @param {String} [name] The principal name + * @returns {Principal} + * @constructor + */ +function Principal(type, id, name) { + if (!(this instanceof Principal)) { + return new Principal(type, id, name); + } + this.type = type; + this.id = id; + this.name = name; +} + +// Define constants for principal types +Principal.USER = 'USER'; +Principal.APP = Principal.APPLICATION = 'APP'; +Principal.ROLE = 'ROLE'; +Principal.SCOPE = 'SCOPE'; + +/** + * Compare if two principals are equal + * @param p The other principal + * @returns {boolean} + */ +Principal.prototype.equals = function (p) { + if (p instanceof Principal) { + return this.type === p.type && String(this.id) === String(p.id); + } + return false; +}; + +/** + * A request to access protected resources + * @param {String} model The model name + * @param {String} property + * @param {String} accessType The access type + * @param {String} permission The permission + * @returns {AccessRequest} + * @constructor + */ +function AccessRequest(model, property, accessType, permission) { + if (!(this instanceof AccessRequest)) { + return new AccessRequest(model, property, accessType); + } + this.model = model || AccessContext.ALL; + this.property = property || AccessContext.ALL; + this.accessType = accessType || AccessContext.ALL; + this.permission = permission || AccessContext.DEFAULT; +} + +/** + * Is the request a wildcard + * @returns {boolean} + */ +AccessRequest.prototype.isWildcard = function () { + return this.model === AccessContext.ALL || + this.property === AccessContext.ALL || + this.accessType === AccessContext.ALL; +}; + +module.exports.AccessContext = AccessContext; +module.exports.Principal = Principal; +module.exports.AccessRequest = AccessRequest; + + + diff --git a/lib/models/acl.js b/lib/models/acl.js index 5ff73133..323d44ab 100644 --- a/lib/models/acl.js +++ b/lib/models/acl.js @@ -36,6 +36,11 @@ var async = require('async'); var assert = require('assert'); var debug = require('debug')('acl'); +var ctx = require('./access-context'); +var AccessContext = ctx.AccessContext; +var Principal = ctx.Principal; +var AccessRequest = ctx.AccessRequest; + var role = require('./role'); var Role = role.Role; @@ -102,38 +107,30 @@ var ACLSchema = { var ACL = loopback.createModel('ACL', ACLSchema); -ACL.ALL = '*'; +ACL.ALL = AccessContext.ALL; -ACL.DEFAULT = 'DEFAULT'; // Not specified -ACL.ALLOW = 'ALLOW'; // Allow -ACL.ALARM = 'ALARM'; // Warn - send an alarm -ACL.AUDIT = 'AUDIT'; // Audit - record the access -ACL.DENY = 'DENY'; // Deny +ACL.DEFAULT = AccessContext.DEFAULT; // Not specified +ACL.ALLOW = AccessContext.ALLOW; // Allow +ACL.ALARM = AccessContext.ALARM; // Warn - send an alarm +ACL.AUDIT = AccessContext.AUDIT; // Audit - record the access +ACL.DENY = AccessContext.DENY; // Deny -ACL.READ = 'READ'; // Read operation -ACL.WRITE = 'WRITE'; // Write operation -ACL.EXECUTE = 'EXECUTE'; // Execute operation +ACL.READ = AccessContext.READ; // Read operation +ACL.WRITE = AccessContext.WRITE; // Write operation +ACL.EXECUTE = AccessContext.EXECUTE; // Execute operation -ACL.USER = 'USER'; -ACL.APP = ACL.APPLICATION = 'APP'; -ACL.ROLE = 'ROLE'; -ACL.SCOPE = 'SCOPE'; - -var permissionOrder = { - DEFAULT: 0, - ALLOW: 1, - ALARM: 2, - AUDIT: 3, - DENY: 4 -}; +ACL.USER = Principal.USER; +ACL.APP = ACL.APPLICATION = Principal.APPLICATION; +ACL.ROLE = Principal.ROLE; +ACL.SCOPE = Principal.SCOPE; /** * Calculate the matching score for the given rule and request - * @param {Object} rule The ACL entry - * @param {Object} req The request + * @param {ACL} rule The ACL entry + * @param {AccessRequest} req The request * @returns {number} */ -function getMatchingScore(rule, req) { +ACL.getMatchingScore = function getMatchingScore(rule, req) { var props = ['model', 'property', 'accessType']; var score = 0; for (var i = 0; i < props.length; i++) { @@ -155,9 +152,9 @@ function getMatchingScore(rule, req) { } } score = score * 4; - score += permissionOrder[rule.permission || ACL.ALLOW] - 1; + score += AccessContext.permissionOrder[rule.permission || ACL.ALLOW] - 1; return score; -} +}; /*! * Resolve permission from the ACLs @@ -165,21 +162,20 @@ function getMatchingScore(rule, req) { * @param {Object} req The request * @returns {Object} The effective ACL */ -function resolvePermission(acls, req) { +ACL.resolvePermission = function resolvePermission(acls, req) { + debug('resolvePermission(): %j %j', acls, req); // Sort by the matching score in descending order acls = acls.sort(function (rule1, rule2) { - return getMatchingScore(rule2, req) - getMatchingScore(rule1, req); + return ACL.getMatchingScore(rule2, req) - ACL.getMatchingScore(rule1, req); }); var permission = ACL.DEFAULT; var score = 0; for (var i = 0; i < acls.length; i++) { - score = getMatchingScore(acls[i], req); + score = ACL.getMatchingScore(acls[i], req); if (score < 0) { break; } - if (req.model !== ACL.ALL && - req.property !== ACL.ALL && - req.accessType !== ACL.ALL) { + if (!req.isWildcard()) { // We should stop from the first match for non-wildcard permission = acls[i].permission; break; @@ -193,19 +189,18 @@ function resolvePermission(acls, req) { break; } // For wildcard match, find the strongest permission - if(permissionOrder[acls[i].permission] > permissionOrder[permission]) { + if(AccessContext.permissionOrder[acls[i].permission] + > AccessContext.permissionOrder[permission]) { permission = acls[i].permission; } } } - return { - model: req.model, - property: req.property, - accessType: req.accessType, - permission: permission || ACL.DEFAULT - }; -} + var res = new AccessRequest(req.model, req.property, req.accessType, + permission || ACL.DEFAULT); + debug('resolvePermission() returns: %j', res); + return res; +}; /*! * Get the static ACLs from the model definition @@ -214,19 +209,20 @@ function resolvePermission(acls, req) { * * @return {Object[]} An array of ACLs */ -function getStaticACLs(model, property) { +ACL.getStaticACLs = function getStaticACLs(model, property) { + debug('getStaticACLs(): %s %s', model, property); var modelClass = loopback.getModel(model); var staticACLs = []; if (modelClass && modelClass.settings.acls) { modelClass.settings.acls.forEach(function (acl) { - staticACLs.push({ + staticACLs.push(new ACL({ model: model, property: acl.property || ACL.ALL, principalType: acl.principalType, principalId: acl.principalId, // TODO: Should it be a name? accessType: acl.accessType, permission: acl.permission - }); + })); }); } var prop = modelClass && @@ -236,19 +232,19 @@ function getStaticACLs(model, property) { || modelClass.prototype[property]); // prototype method if (prop && prop.acls) { prop.acls.forEach(function (acl) { - staticACLs.push({ + staticACLs.push(new ACL({ model: modelClass.modelName, property: property, principalType: acl.principalType, principalId: acl.principalId, accessType: acl.accessType, permission: acl.permission - }); + })); }); } - + debug('getStaticACLs() returns: %s', staticACLs); return staticACLs; -} +}; /** * Check if the given principal is allowed to access the model/property @@ -263,24 +259,25 @@ function getStaticACLs(model, property) { * @param {String|Error} err The error object * @param {Object} the access permission */ -ACL.checkPermission = function (principalType, principalId, model, property, accessType, callback) { +ACL.checkPermission = function checkPermission(principalType, principalId, + model, property, accessType, + callback) { + debug('checkPermission(): %s %s %s %s %s', principalType, principalId, model, + property, accessType); property = property || ACL.ALL; var propertyQuery = (property === ACL.ALL) ? undefined : {inq: [property, ACL.ALL]}; accessType = accessType || ACL.ALL; var accessTypeQuery = (accessType === ACL.ALL) ? undefined : {inq: [accessType, ACL.ALL]}; - var req = { - model: model, - property: property, - accessType: accessType - }; + var req = new AccessRequest(model, property, accessType); - var acls = getStaticACLs(model, property); + var acls = ACL.getStaticACLs(model, property); - var resolved = resolvePermission(acls, req); + var resolved = ACL.resolvePermission(acls, req); if(resolved && resolved.permission === ACL.DENY) { // Fail fast + debug('checkPermission(): %j', resolved); process.nextTick(function() { callback && callback(null, resolved); }); @@ -295,11 +292,12 @@ ACL.checkPermission = function (principalType, principalId, model, property, acc return; } acls = acls.concat(dynACLs); - resolved = resolvePermission(acls, req); + resolved = ACL.resolvePermission(acls, req); if(resolved && resolved.permission === ACL.DEFAULT) { var modelClass = loopback.getModel(model); resolved.permission = (modelClass && modelClass.settings.defaultPermission) || ACL.ALLOW; } + debug('checkPermission(): %j', resolved); callback && callback(null, resolved); }); }; @@ -337,31 +335,23 @@ Scope.checkPermission = function (scope, model, property, accessType, callback) * @param {Function} callback */ ACL.checkAccess = function (context, callback) { - context = context || {}; - var principals = context.principals || []; + debug('checkAccess(): %j', context); - // add ROLE.EVERYONE - principals.unshift({principalType: ACL.ROLE, principalId: Role.EVERYONE}); + if(!(context instanceof AccessContext)) { + context = new AccessContext(context); + } var model = context.model; - model = ('string' === typeof model) ? loopback.getModel(model) : model; - var id = context.id; var property = context.property; var accessType = context.accessType; - property = property || ACL.ALL; var propertyQuery = (property === ACL.ALL) ? undefined : {inq: [property, ACL.ALL]}; - accessType = accessType || ACL.ALL; var accessTypeQuery = (accessType === ACL.ALL) ? undefined : {inq: [accessType, ACL.ALL]}; - var req = { - model: model.modelName, - property: property, - accessType: accessType - }; + var req = new AccessRequest(model.modelName, property, accessType); var effectiveACLs = []; - var staticACLs = getStaticACLs(model.modelName, property); + var staticACLs = ACL.getStaticACLs(model.modelName, property); ACL.find({where: {model: model.modelName, property: propertyQuery, accessType: accessTypeQuery}}, function (err, acls) { @@ -374,30 +364,37 @@ ACL.checkAccess = function (context, callback) { acls = acls.concat(staticACLs); acls.forEach(function (acl) { - principals.forEach(function (principal) { - if (principal.principalType === acl.principalType - && String(principal.principalId) === String(acl.principalId)) { + // Check exact matches + for (var i = 0; i < context.principals.length; i++) { + var p = context.principals[i]; + if (p.type === acl.principalType + && String(p.id) === String(acl.principalId)) { effectiveACLs.push(acl); - } else if (acl.principalType === ACL.ROLE) { - inRoleTasks.push(function (done) { - Role.isInRole(acl.principalId, - {principalType: principal.principalType, - principalId: principal.principalId, - userId: context.userId, - model: model, id: id, property: property}, - function (err, inRole) { - if(!err && inRole) { - effectiveACLs.push(acl); - } - done(err, acl); - }); - }); + return; } - }); + } + + // Check role matches + if (acl.principalType === ACL.ROLE) { + inRoleTasks.push(function (done) { + Role.isInRole(acl.principalId, context, + function (err, inRole) { + if (!err && inRole) { + effectiveACLs.push(acl); + } + done(err, acl); + }); + }); + } }); - async.parallel(inRoleTasks, function(err, results) { - resolved = resolvePermission(effectiveACLs, req); + async.parallel(inRoleTasks, function (err, results) { + if(err) { + callback && callback(err, null); + return; + } + var resolved = ACL.resolvePermission(effectiveACLs, req); + debug('checkAccess() returns: %j', resolved); callback && callback(null, resolved); }); }); @@ -416,38 +413,30 @@ ACL.checkAccess = function (context, callback) { * @param {String|Error} err The error object * @param {Boolean} allowed is the request allowed */ -ACL.checkAccessForToken = function(token, model, modelId, method, callback) { +ACL.checkAccessForToken = function (token, model, modelId, method, callback) { + debug('checkAccessForToken(): %j %s %s %s', token, model, modelId, method); assert(token, 'Access token is required'); - var principals = []; - if(token.userId) { - principals.push({principalType: ACL.USER, principalId: token.userId}); - } - if(token.appId) { - principals.push({principalType: ACL.APPLICATION, principalId: token.appId}); - } - var modelCtor = loopback.getModel(model); - - var context = { - userId: token.userId, - principals: principals, + var context = new AccessContext({ + accessToken: token, model: model, property: method, - accessType: modelCtor._getAccessTypeForMethod(method), - id: modelId - }; + method: method, + modelId: modelId + }); - ACL.checkAccess(context, function(err, access) { - if(err) { + context.accessType = context.model._getAccessTypeForMethod(method); + + ACL.checkAccess(context, function (err, access) { + if (err) { callback && callback(err); return; } + debug('checkAccessForToken(): %j', access); callback && callback(null, access.permission !== ACL.DENY); }); }; -module.exports = { - ACL: ACL, - Scope: Scope -}; +module.exports.ACL = ACL; +module.exports.Scope = Scope; diff --git a/lib/models/role.js b/lib/models/role.js index 1bd4447f..c2874d5a 100644 --- a/lib/models/role.js +++ b/lib/models/role.js @@ -3,6 +3,8 @@ var debug = require('debug')('role'); var assert = require('assert'); var async = require('async'); +var AccessContext = require('./access-context').AccessContext; + // Role model var RoleSchema = { id: {type: String, id: true}, // Id @@ -160,16 +162,16 @@ Role.registerResolver = function(role, resolver) { }; Role.registerResolver(Role.OWNER, function(role, context, callback) { - if(!context || !context.model || !context.id) { + if(!context || !context.model || !context.modelId) { process.nextTick(function() { callback && callback(null, false); }); return; } var modelClass = context.model; - var id = context.id; - var userId = context.userId || context.principalId; - Role.isOwner(modelClass, id, userId, callback); + var modelId = context.modelId; + var userId = context.getUserId(); + Role.isOwner(modelClass, modelId, userId, callback); }); function isUserClass(modelClass) { @@ -251,13 +253,13 @@ Role.registerResolver(Role.AUTHENTICATED, function(role, context, callback) { */ Role.isAuthenticated = function isAuthenticated(context, callback) { process.nextTick(function() { - callback && callback(null, !!context.principalId); + callback && callback(null, context.isAuthenticated()); }); }; Role.registerResolver(Role.UNAUTHENTICATED, function(role, context, callback) { process.nextTick(function() { - callback && callback(null, !context || !context.principalId); + callback && callback(null, !context || !context.isAuthenticated()); }); }); @@ -276,19 +278,38 @@ Role.registerResolver(Role.EVERYONE, function (role, context, callback) { */ Role.isInRole = function (role, context, callback) { debug('isInRole(): %s %j', role, context); + + if (!(context instanceof AccessContext)) { + context = new AccessContext(context); + } + var resolver = Role.resolvers[role]; - if(resolver) { + if (resolver) { debug('Custom resolver found for role %s', role); resolver(role, context, callback); return; } - var principalType = context.principalType; - var principalId = context.principalId; + if (context.principals.length === 0) { + debug('isInRole() returns: false'); + process.nextTick(function () { + callback && callback(null, false); + }); + return; + } - // Check if it's the same role - if(principalType === RoleMapping.ROLE && principalId === role) { - process.nextTick(function() { + var inRole = context.principals.some(function (p) { + + var principalType = p.type || undefined; + var principalId = p.id || undefined; + + // Check if it's the same role + return principalType === RoleMapping.ROLE && principalId === role; + }); + + if (inRole) { + debug('isInRole() returns: %j', inRole); + process.nextTick(function () { callback && callback(null, true); }); return; @@ -299,21 +320,34 @@ Role.isInRole = function (role, context, callback) { callback && callback(err); return; } - if(!result) { + if (!result) { callback && callback(null, false); return; } debug('Role found: %j', result); - RoleMapping.findOne({where: {roleId: result.id, principalType: principalType, principalId: principalId}}, - function (err, result) { - if (err) { - callback && callback(err); - return; - } - debug('Role mapping found: %j', result); - callback && callback(null, !!result); - }); + + // Iterate through the list of principals + async.some(context.principals, function (p, done) { + var principalType = p.type || undefined; + var principalId = p.id || undefined; + if (principalType && principalId) { + RoleMapping.findOne({where: {roleId: result.id, + principalType: principalType, principalId: principalId}}, + function (err, result) { + debug('Role mapping found: %j', result); + done(!err && result); // The only arg is the result + }); + } else { + process.nextTick(function () { + done(false); + }); + } + }, function (inRole) { + debug('isInRole() returns: %j', inRole); + callback && callback(null, inRole); + }); }); + }; /** @@ -327,17 +361,25 @@ Role.isInRole = function (role, context, callback) { */ Role.getRoles = function (context, callback) { debug('getRoles(): %j', context); + + if(!(context instanceof AccessContext)) { + context = new AccessContext(context); + } var roles = []; + var addRole = function (role) { + if (role && roles.indexOf(role) === -1) { + roles.push(role); + } + }; + // Check against the smart roles var inRoleTasks = []; Object.keys(Role.resolvers).forEach(function (role) { inRoleTasks.push(function (done) { Role.isInRole(role, context, function (err, inRole) { if (!err && inRole) { - if (roles.indexOf(role) === -1) { - roles.push(role); - } + addRole(role); done(); } else { done(err, null); @@ -346,31 +388,37 @@ Role.getRoles = function (context, callback) { }); }); - // Check against the role mappings - var principalType = context.principalType || undefined; - var principalId = context.principalId || undefined; + context.principals.forEach(function (p) { + // Check against the role mappings + var principalType = p.type || undefined; + var principalId = p.id || undefined; - if (principalType && principalId) { - // Please find() treat undefined matches all values - inRoleTasks.push(function (done) { - RoleMapping.find({where: {principalType: principalType, - principalId: principalId}}, function (err, mappings) { - if (err) { - done && done(err); - return; - } - mappings.forEach(function (m) { - if (roles.indexOf(m.roleId) === -1) { - roles.push(m.roleId); + // Add the role itself + if (principalType === RoleMapping.ROLE && principalId) { + addRole(principalId); + } + + if (principalType && principalId) { + // Please find() treat undefined matches all values + inRoleTasks.push(function (done) { + RoleMapping.find({where: {principalType: principalType, + principalId: principalId}}, function (err, mappings) { + debug('Role mappings found: %s %j', err, mappings); + if (err) { + done && done(err); + return; } + mappings.forEach(function (m) { + addRole(m.roleId); + }); + done && done(); }); - done && done(); }); - }); - } + } + }); async.parallel(inRoleTasks, function (err, results) { - debug('getRoles() return: %j %j', err, results); + debug('getRoles() returns: %j %j', err, roles); callback && callback(err, roles); }); }; @@ -380,3 +428,5 @@ module.exports = { RoleMapping: RoleMapping }; + + diff --git a/test/acl.test.js b/test/acl.test.js index 00a8fcae..7df37a3f 100644 --- a/test/acl.test.js +++ b/test/acl.test.js @@ -238,7 +238,7 @@ describe('security ACLs', function () { ACL.checkAccess({ principals: [ - {principalType: ACL.USER, principalId: userId} + {type: ACL.USER, id: userId} ], model: 'Customer', property: 'name',