Improve the ACL matching algorithm

See https://github.com/strongloop/loopback-example-access-control/issues/8
This commit is contained in:
Raymond Feng 2014-03-19 15:09:20 -07:00
parent 1512ef4561
commit 328a72ac91
6 changed files with 143 additions and 15 deletions

View File

@ -217,10 +217,19 @@ function AccessRequest(model, property, accessType, permission) {
if (!(this instanceof AccessRequest)) { if (!(this instanceof AccessRequest)) {
return new AccessRequest(model, property, accessType); return new AccessRequest(model, property, accessType);
} }
if (arguments.length === 1 && typeof model === 'object') {
// The argument is an object that contains all required properties
var obj = model || {};
this.model = obj.model || AccessContext.ALL;
this.property = obj.property || AccessContext.ALL;
this.accessType = obj.accessType || AccessContext.ALL;
this.permission = obj.permission || AccessContext.DEFAULT;
} else {
this.model = model || AccessContext.ALL; this.model = model || AccessContext.ALL;
this.property = property || AccessContext.ALL; this.property = property || AccessContext.ALL;
this.accessType = accessType || AccessContext.ALL; this.accessType = accessType || AccessContext.ALL;
this.permission = permission || AccessContext.DEFAULT; this.permission = permission || AccessContext.DEFAULT;
}
if(debug.enabled) { if(debug.enabled) {
debug('---AccessRequest---'); debug('---AccessRequest---');

View File

@ -137,6 +137,44 @@ ACL.getMatchingScore = function getMatchingScore(rule, req) {
return -1; return -1;
} }
} }
// Weigh agaist the principal type
score = score * 4;
switch(rule.principalType) {
case ACL.USER:
score += 4;
break;
case ACL.APP:
score += 3;
break;
case ACL.ROLE:
score += 2;
break;
default:
score +=1;
}
// Weigh against the roles
score = score * 8;
if(rule.principalType === ACL.ROLE) {
switch(rule.principalId) {
case Role.OWNER:
score += 4;
break;
case Role.RELATED:
score += 3;
break;
case Role.AUTHENTICATED:
case Role.UNAUTHENTICATED:
score += 2;
break;
case Role.EVERYONE:
score += 1;
break;
default:
score += 5;
}
}
score = score * 4; score = score * 4;
score += AccessContext.permissionOrder[rule.permission || ACL.ALLOW] - 1; score += AccessContext.permissionOrder[rule.permission || ACL.ALLOW] - 1;
return score; return score;
@ -149,10 +187,16 @@ ACL.getMatchingScore = function getMatchingScore(rule, req) {
* @returns {AccessRequest} result The effective ACL * @returns {AccessRequest} result The effective ACL
*/ */
ACL.resolvePermission = function resolvePermission(acls, req) { ACL.resolvePermission = function resolvePermission(acls, req) {
if(!(req instanceof AccessRequest)) {
req = new AccessRequest(req);
}
// Sort by the matching score in descending order // Sort by the matching score in descending order
acls = acls.sort(function (rule1, rule2) { acls = acls.sort(function (rule1, rule2) {
return ACL.getMatchingScore(rule2, req) - ACL.getMatchingScore(rule1, req); return ACL.getMatchingScore(rule2, req) - ACL.getMatchingScore(rule1, req);
}); });
if(debug.enabled) {
debug('ACLs by order: %j', acls);
}
var permission = ACL.DEFAULT; var permission = ACL.DEFAULT;
var score = 0; var score = 0;
for (var i = 0; i < acls.length; i++) { for (var i = 0; i < acls.length; i++) {
@ -351,6 +395,9 @@ ACL.checkAccess = function (context, callback) {
inRoleTasks.push(function (done) { inRoleTasks.push(function (done) {
roleModel.isInRole(acl.principalId, context, roleModel.isInRole(acl.principalId, context,
function (err, inRole) { function (err, inRole) {
if(debug.enabled) {
debug('In role %j: %j', acl.principalId, inRole);
}
if (!err && inRole) { if (!err && inRole) {
effectiveACLs.push(acl); effectiveACLs.push(acl);
} }

View File

@ -196,6 +196,21 @@ function isUserClass(modelClass) {
modelClass.prototype instanceof loopback.User; modelClass.prototype instanceof loopback.User;
} }
/*!
* Check if two user ids matches
* @param {*} id1
* @param {*} id2
* @returns {boolean}
*/
function matches(id1, id2) {
if (id1 === undefined || id1 === null || id1 ===''
|| id2 === undefined || id2 === null || id2 === '') {
return false;
}
// The id can be a MongoDB ObjectID
return id1 === id2 || id1.toString() === id2.toString();
}
/** /**
* Check if a given userId is the owner the model instance * Check if a given userId is the owner the model instance
* @param {Function} modelClass The model class * @param {Function} modelClass The model class
@ -205,7 +220,7 @@ function isUserClass(modelClass) {
*/ */
Role.isOwner = function isOwner(modelClass, modelId, userId, callback) { Role.isOwner = function isOwner(modelClass, modelId, userId, callback) {
assert(modelClass, 'Model class is required'); assert(modelClass, 'Model class is required');
debug('isOwner(): %s %s %s', modelClass && modelClass.modelName, modelId, userId); debug('isOwner(): %s %s userId: %s', modelClass && modelClass.modelName, modelId, userId);
// No userId is present // No userId is present
if(!userId) { if(!userId) {
process.nextTick(function() { process.nextTick(function() {
@ -217,19 +232,21 @@ Role.isOwner = function isOwner(modelClass, modelId, userId, callback) {
// Is the modelClass User or a subclass of User? // Is the modelClass User or a subclass of User?
if(isUserClass(modelClass)) { if(isUserClass(modelClass)) {
process.nextTick(function() { process.nextTick(function() {
callback(null, modelId == userId); callback(null, matches(modelId, userId));
}); });
return; return;
} }
modelClass.findById(modelId, function(err, inst) { modelClass.findById(modelId, function(err, inst) {
if(err || !inst) { if(err || !inst) {
debug('Model not found for id %j', modelId);
callback && callback(err, false); callback && callback(err, false);
return; return;
} }
debug('Model found: %j', inst); debug('Model found: %j', inst);
if(inst.userId || inst.owner) { var ownerId = inst.userId || inst.owner;
callback && callback(null, (inst.userId || inst.owner) === userId); if(ownerId) {
callback && callback(null, matches(ownerId, userId));
return; return;
} else { } else {
// Try to follow belongsTo // Try to follow belongsTo
@ -240,7 +257,7 @@ Role.isOwner = function isOwner(modelClass, modelId, userId, callback) {
inst[r](function(err, user) { inst[r](function(err, user) {
if(!err && user) { if(!err && user) {
debug('User found: %j', user.id); debug('User found: %j', user.id);
callback && callback(null, user.id === userId); callback && callback(null, matches(user.id, userId));
} else { } else {
callback && callback(err, false); callback && callback(err, false);
} }
@ -248,6 +265,7 @@ Role.isOwner = function isOwner(modelClass, modelId, userId, callback) {
return; return;
} }
} }
debug('No matching belongsTo relation found for model %j and user: %j', modelId, userId);
callback && callback(null, false); callback && callback(null, false);
} }
}); });
@ -401,6 +419,9 @@ Role.getRoles = function (context, callback) {
Object.keys(Role.resolvers).forEach(function (role) { Object.keys(Role.resolvers).forEach(function (role) {
inRoleTasks.push(function (done) { inRoleTasks.push(function (done) {
self.isInRole(role, context, function (err, inRole) { self.isInRole(role, context, function (err, inRole) {
if(debug.enabled) {
debug('In role %j: %j', role, inRole);
}
if (!err && inRole) { if (!err && inRole) {
addRole(role); addRole(role);
done(); done();

View File

@ -11,6 +11,7 @@ describe('access control - integration', function () {
lt.beforeEach.withApp(app); lt.beforeEach.withApp(app);
/*
describe('accessToken', function() { describe('accessToken', function() {
// it('should be a sublcass of AccessToken', function () { // it('should be a sublcass of AccessToken', function () {
// assert(app.models.accessToken.prototype instanceof loopback.AccessToken); // assert(app.models.accessToken.prototype instanceof loopback.AccessToken);
@ -135,6 +136,7 @@ describe('access control - integration', function () {
return '/api/banks/' + this.bank.id; return '/api/banks/' + this.bank.id;
} }
}); });
*/
describe('/accounts', function () { describe('/accounts', function () {
lt.beforeEach.givenModel('account'); lt.beforeEach.givenModel('account');
@ -147,6 +149,7 @@ describe('access control - integration', function () {
lt.it.shouldBeDeniedWhenCalledUnauthenticated('GET', urlForAccount); lt.it.shouldBeDeniedWhenCalledUnauthenticated('GET', urlForAccount);
lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'GET', urlForAccount); lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'GET', urlForAccount);
lt.it.shouldBeDeniedWhenCalledAnonymously('POST', '/api/accounts'); lt.it.shouldBeDeniedWhenCalledAnonymously('POST', '/api/accounts');
lt.it.shouldBeDeniedWhenCalledUnauthenticated('POST', '/api/accounts'); lt.it.shouldBeDeniedWhenCalledUnauthenticated('POST', '/api/accounts');
lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'POST', '/api/accounts'); lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'POST', '/api/accounts');
@ -156,12 +159,25 @@ describe('access control - integration', function () {
lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'PUT', urlForAccount); lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'PUT', urlForAccount);
lt.describe.whenLoggedInAsUser(CURRENT_USER, function() { lt.describe.whenLoggedInAsUser(CURRENT_USER, function() {
beforeEach(function() { beforeEach(function(done) {
this.url = '/api/accounts/' + this.user.accountId; var self = this;
// Create an account under the given user
app.models.account.create({
userId: self.user.id,
balance: 100
}, function(err, act) {
self.url = '/api/accounts/' + act.id;
done();
});
}); });
lt.describe.whenCalledRemotely('PUT', '/api/accounts/:id', function() { lt.describe.whenCalledRemotely('PUT', '/api/accounts/:id', function() {
lt.it.shouldBeAllowed(); lt.it.shouldBeAllowed();
}); });
lt.describe.whenCalledRemotely('GET', '/api/accounts/:id', function() {
lt.it.shouldBeAllowed();
});
lt.describe.whenCalledRemotely('DELETE', '/api/accounts/:id', function() { lt.describe.whenCalledRemotely('DELETE', '/api/accounts/:id', function() {
lt.it.shouldBeDenied(); lt.it.shouldBeDenied();
}); });

View File

@ -70,6 +70,40 @@ describe('security scopes', function () {
}); });
describe('security ACLs', function () { describe('security ACLs', function () {
it('should order ACL entries based on the matching score', function() {
var acls = [
{
"model": "account",
"accessType": "*",
"permission": "DENY",
"principalType": "ROLE",
"principalId": "$everyone"
},
{
"model": "account",
"accessType": "*",
"permission": "ALLOW",
"principalType": "ROLE",
"principalId": "$owner"
},
{
"model": "account",
"accessType": "READ",
"permission": "ALLOW",
"principalType": "ROLE",
"principalId": "$everyone"
}];
var req = {
model: 'account',
property: 'find',
accessType: 'WRITE'
};
var perm = ACL.resolvePermission(acls, req);
assert.deepEqual(perm, { model: 'account',
property: 'find',
accessType: 'WRITE',
permission: 'ALLOW' });
});
it("should allow access to models for the given principal by wildcard", function () { it("should allow access to models for the given principal by wildcard", function () {
ACL.create({principalType: ACL.USER, principalId: 'u001', model: 'User', property: ACL.ALL, ACL.create({principalType: ACL.USER, principalId: 'u001', model: 'User', property: ACL.ALL,

View File

@ -23,10 +23,6 @@
"type": "hasMany", "type": "hasMany",
"foreignKey": "userId" "foreignKey": "userId"
}, },
"account": {
"model": "account",
"type": "belongsTo"
},
"transactions": { "transactions": {
"model": "transaction", "model": "transaction",
"type": "hasMany" "type": "hasMany"
@ -103,6 +99,11 @@
"transactions": { "transactions": {
"model": "transaction", "model": "transaction",
"type": "hasMany" "type": "hasMany"
},
"user": {
"model": "user",
"type": "belongsTo",
"foreignKey": "userId"
} }
}, },
"acls": [ "acls": [