Merge pull request #94 from strongloop/feature/access-context

Refactor to the code use wrapper classes
This commit is contained in:
Raymond Feng 2013-12-11 16:26:01 -08:00
commit 0f4e9e1d1c
4 changed files with 404 additions and 157 deletions

View File

@ -0,0 +1,208 @@
var loopback = require('../loopback');
var AccessToken = require('./access-token');
/**
* 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 || AccessToken.ANONYMOUS;
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;

View File

@ -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;

View File

@ -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
};

View File

@ -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',