diff --git a/lib/models/acl.js b/lib/models/acl.js index 028c1ead..d2b99be4 100644 --- a/lib/models/acl.js +++ b/lib/models/acl.js @@ -32,6 +32,11 @@ */ var loopback = require('../loopback'); +var async = require('async'); +var assert = require('assert'); + +var role = require('./role'); +var Role = role.Role; /** * Schema for Scope which represents the permissions that are granted to client applications by the resource owner @@ -91,6 +96,7 @@ var ACL = loopback.createModel('ACL', ACLSchema); ACL.ALL = '*'; +ACL.DEFAULT = 'DEFAULT'; ACL.ALLOW = 'ALLOW'; ACL.ALARM = 'ALARM'; ACL.AUDIT = 'AUDIT'; @@ -106,6 +112,7 @@ ACL.ROLE = 'ROLE'; ACL.SCOPE = 'SCOPE'; var permissionOrder = { + DEFAULT: 0, ALLOW: 1, ALARM: 2, AUDIT: 3, @@ -144,62 +151,80 @@ function resolvePermission(acls, defaultPermission) { return resolvedPermission; } -/** - * Check if the given principal is allowed to access the model/property +/*! + * Check the LDL ACLs * @param principalType * @param principalId - * @param model - * @param property - * @param accessType - * @param callback + * @param {String} model The model name + * @param {String} property The property/method/relation name + * @param {String} accessType The access type + * + * @returns {{principalType: *, principalId: *, model: *, property: string, accessType: *, permission: string}} */ -ACL.checkPermission = function (principalType, principalId, model, property, accessType, callback) { - 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]}; - +function getStaticPermission(principalType, principalId, model, property, accessType) { + var modelClass = loopback.getModel(model); var staticACLs = []; - var modelClass = loopback.getModel(model); { - if(modelClass && modelClass.settings.acls) { - modelClass.settings.acls.forEach(function(acl) { - staticACLs.push({ - 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 - }); + if (modelClass && modelClass.settings.acls) { + modelClass.settings.acls.forEach(function (acl) { + staticACLs.push({ + 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 && - (modelClass.definition.properties[property] // regular property + }); + } + var prop = modelClass && + (modelClass.definition.properties[property] // regular property || (modelClass._scopeMeta && modelClass._scopeMeta[property]) // relation/scope || modelClass[property] // static method || modelClass.prototype[property]); // prototype method - if(prop && prop.acls) { - prop.acls.forEach(function(acl) { - staticACLs.push({ - model: model, - property: property, - principalType: acl.principalType, - principalId: acl.principalId, - accessType: acl.accessType, - permission: acl.permission - }); + if (prop && prop.acls) { + prop.acls.forEach(function (acl) { + staticACLs.push({ + model: modelClass.modelName, + property: property, + principalType: acl.principalType, + principalId: acl.principalId, + accessType: acl.accessType, + permission: acl.permission }); - } + }); } var defaultPermission = {principalType: principalType, principalId: principalId, model: model, property: ACL.ALL, accessType: accessType, permission: ACL.ALLOW}; defaultPermission = resolvePermission(staticACLs, defaultPermission); + return defaultPermission; +} +/** + * Check if the given principal is allowed to access the model/property + * @param {String} principalType The principal type + * @param {String} principalId The principal id + * @param {String} model The model name + * @param {String} property The property/method/relation name + * @param {String} accessType The access type + * @param {Function} callback The callback function + * + * @callback callback + * @param {String|Error} err The error object + * @param {Object} the access permission + */ +ACL.checkPermission = function (principalType, principalId, model, property, accessType, callback) { + 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 defaultPermission = getStaticPermission(principalType, principalId, model, property, accessType); if(defaultPermission.permission === ACL.DENY) { // Fail fast - callback && callback(null, defaultPermission); + process.nextTick(function() { + callback && callback(null, defaultPermission); + }); return; } @@ -211,17 +236,25 @@ ACL.checkPermission = function (principalType, principalId, model, property, acc return; } var resolvedPermission = resolvePermission(acls, defaultPermission); + if(resolvedPermission.permission === ACL.DEFAULT) { + var modelClass = loopback.getModel(model); + resolvedPermission.permission = (modelClass && modelClass.settings.defaultPermission) || ACL.ALLOW; + } callback && callback(null, resolvedPermission); }); }; /** * Check if the given scope is allowed to access the model/property - * @param scope - * @param model - * @param property - * @param accessType - * @param callback + * @param {String} scope The scope name + * @param {String} model The model name + * @param {String} property The property/method/relation name + * @param {String} accessType The access type + * @param {Function} callback The callback function + * + * @callback callback + * @param {String|Error} err The error object + * @param {Object} the access permission */ Scope.checkPermission = function (scope, model, property, accessType, callback) { Scope.findOne({where: {name: scope}}, function (err, scope) { @@ -233,6 +266,113 @@ Scope.checkPermission = function (scope, model, property, accessType, callback) }); }; +/** + * Check if the request has the permission to access + * @param {Object} context + * @param {Function} callback + */ +ACL.checkAccess = function (context, callback) { + context = context || {}; + var principals = context.principals || []; + 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 defaultPermission = {principalType: null, principalId: null, + model: model.modelName, property: ACL.ALL, accessType: accessType, permission: ACL.ALLOW}; + + // Check the LDL ACLs + principals.forEach(function(p) { + var perm = getStaticPermission(p.principalType, p.principalId, model.modelName, property, accessType); + defaultPermission = resolvePermission([perm], defaultPermission); + }); + + if(defaultPermission.permission === ACL.DENY) { + // Fail fast + process.nextTick(function() { + callback && callback(null, defaultPermission); + }); + return; + } + + ACL.find({where: {model: model.modelName, property: propertyQuery, accessType: accessTypeQuery}}, function (err, acls) { + if (err) { + callback && callback(err); + return; + } + var effectiveACLs = []; + var inRoleTasks = []; + acls.forEach(function (acl) { + principals.forEach(function (principal) { + if (principal.principalType === acl.pricipalType && principal.principalId === acl.principalId) { + effectiveACLs.push(acl); + } else if (acl.principalType === ACL.ROLE) { + inRoleTasks.push(function (done) { + Role.isInRole(acl.principalId, + {principalType: principal.principalType, principalId: acl.principalId, model: model, id: id, property: property}, + function (err, inRole) { + if(!err) { + effectiveACLs.push(acl); + } + done(err, acl); + }); + }); + } + }); + }); + + async.parallel(inRoleTasks, function(err, results) { + defaultPermission = resolvePermission(effectiveACLs, defaultPermission); + callback && callback(null, defaultPermission); + }); + }); +}; + + +/** + * Check if the given access token can invoke the method + * @param {AccessToken} token The access token + * @param {String} model The model name + * @param {*} modelId The model id + * @param {String} method The method name + * @param callback The callback function + * + * @callback callback + * @param {String|Error} err The error object + * @param {Boolean} allowed is the request allowed + */ +ACL.checkAccessForToken = function(token, model, modelId, method, callback) { + 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 context = { + principals: principals, + model: model, + property: method, + accessType: ACL.EXECUTE, + id: modelId + }; + ACL.checkAccess(context, function(err, access) { + if(err) { + callback && callback(err); + return; + } + callback && callback(access.permission !== ACL.DENY); + }); +}; + module.exports = { ACL: ACL, diff --git a/lib/models/model.js b/lib/models/model.js index 04f2372c..72a22799 100644 --- a/lib/models/model.js +++ b/lib/models/model.js @@ -79,7 +79,7 @@ Model.setup = function () { self.beforeRemote.apply(self, args); }); } - } + }; // after remote hook ModelCtor.afterRemote = function (name, fn) { @@ -95,7 +95,7 @@ Model.setup = function () { self.afterRemote.apply(self, args); }); } - } + }; // Map the prototype method to /:id with data in the body ModelCtor.sharedCtor.accepts = [ @@ -110,7 +110,34 @@ Model.setup = function () { ModelCtor.sharedCtor.returns = {root: true}; return ModelCtor; +}; + +/*! + * Get the reference to ACL in a lazy fashion to avoid race condition in require + */ +var ACL = null; +function getACL() { + return ACL || (ACL = require('./acl').ACL); } +/** + * Check if the given access token can invoke the method + * + * @param {AccessToken} token The access token + * @param {*} modelId The model id + * @param {String} method The method name + * @param callback The callback function + * + * @callback callback + * @param {String|Error} err The error object + * @param {Boolean} allowed is the request allowed + */ +Model.checkAccess = function(token, modelId, method, callback) { + var ACL = getACL(); + var methodName = 'string' === typeof method? method: method && method.name; + ACL.checkAccessForToken(token, this.modelName, modelId, methodName, callback); +}; + // setup the initial model Model.setup(); + diff --git a/lib/models/role.js b/lib/models/role.js index 5522340b..56284e43 100644 --- a/lib/models/role.js +++ b/lib/models/role.js @@ -135,6 +135,7 @@ Role.once('dataSourceAttached', function () { Role.OWNER = '$owner'; // owner of the object Role.RELATED = "$related"; // any User with a relationship to the object Role.AUTHENTICATED = "$authenticated"; // authenticated user +Role.UNAUTHENTICATED = "$unauthenticated"; // authenticated user Role.EVERYONE = "$everyone"; // everyone /** @@ -142,21 +143,104 @@ Role.EVERYONE = "$everyone"; // everyone * @param role * @param resolver The resolver function decides if a principal is in the role dynamically * - * isInRole(role, context, callback) + * function(role, context, callback) */ Role.registerResolver = function(role, resolver) { + if(!Role.resolvers) { + Role.resolvers = {}; + } Role.resolvers[role] = resolver; }; +Role.registerResolver(Role.OWNER, function(role, context, callback) { + if(!context || !context.model || !context.id) { + process.nextTick(function() { + callback && callback(null, false); + }); + return; + } + var modelClass = context.model; + var id = context.id; + var userId = context.principalId; + isOwner(modelClass, id, userId, callback); +}); + +function isOwner(modelClass, id, userId, callback) { + modelClass.findById(id, function(err, inst) { + if(err) { + callback && callback(err); + return; + } + if(inst.userId || inst.owner) { + callback && callback(null, (inst.userId || inst.owner) === userId); + return; + } else { + for(var r in modelClass.relations) { + var rel = modelClass.relations[r]; + if(rel.type === 'belongsTo' && rel.model && rel.model.prototype instanceof loopback.User) { + callback && callback(null, rel.foreignKey === userId); + return; + } + } + callback && callback(null, false); + } + }); +} + +Role.registerResolver(Role.AUTHENTICATED, function(role, context, callback) { + if(!context) { + process.nextTick(function() { + callback && callback(null, false); + }); + return; + } + var userId = context.principalId; + isAuthenticated(userId, callback); +}); + +function isAuthenticated(userId, callback) { + process.nextTick(function() { + callback && callback(null, !!userId); + }); +} + +Role.registerResolver(Role.UNAUTHENTICATED, function(role, context, callback) { + process.nextTick(function() { + callback && callback(null, !context || !context.principalId); + }); +}); + +Role.registerResolver(Role.EVERYONE, function (role, context, callback) { + process.nextTick(function () { + callback && callback(null, true); // Always true + }); +}); + /** * Check if a given principal is in the role * - * @param role - * @param principalType - * @param principalId - * @param callback + * @param {String} role The role name + * @param {Object} context The context object + * @param {Function} callback */ -Role.isInRole = function (role, principalType, principalId, callback) { +Role.isInRole = function (role, context, callback) { + var resolver = Role.resolvers[role]; + if(resolver) { + resolver(role, context, callback); + return; + } + + var principalType = context.principalType; + var principalId = context.principalId; + + // Check if it's the same role + if(principalType === RoleMapping.ROLE && principalId === role) { + process.nextTick(function() { + callback && callback(null, true); + }); + return; + } + Role.findOne({where: {name: role}}, function (err, result) { if (err) { callback && callback(err); diff --git a/package.json b/package.json index 413fc9d7..687ee09a 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "bcryptjs": "~0.7.10", "underscore.string": "~2.3.3", "underscore": "~1.5.2", - "uid2": "0.0.3" + "uid2": "0.0.3", + "async": "~0.2.9" }, "devDependencies": { "mocha": "~1.14.0", diff --git a/test/acl.test.js b/test/acl.test.js index 2df75544..46035992 100644 --- a/test/acl.test.js +++ b/test/acl.test.js @@ -3,7 +3,9 @@ var loopback = require('../index'); var acl = require('../lib/models/acl'); var Scope = acl.Scope; var ACL = acl.ACL; -var ScopeACL = acl.ScopeACL; +var role = require('../lib/models/role'); +var Role = role.Role; +var RoleMapping = role.RoleMapping; var User = loopback.User; function checkResult(err, result) { @@ -81,6 +83,39 @@ describe('security ACLs', function () { }); + it("should honor defaultPermission from the model", function () { + var ds = loopback.createDataSource({connector: loopback.Memory}); + ACL.attachTo(ds); + var Customer = ds.createModel('Customer', { + name: { + type: String, + acls: [ + {principalType: ACL.USER, principalId: 'u001', accessType: ACL.WRITE, permission: ACL.DENY}, + {principalType: ACL.USER, principalId: 'u001', accessType: ACL.ALL, permission: ACL.ALLOW} + ] + } + }, { + acls: [ + {principalType: ACL.USER, principalId: 'u001', accessType: ACL.ALL, permission: ACL.ALLOW} + ] + }); + + Customer.settings.defaultPermission = ACL.DENY; + + ACL.checkPermission(ACL.USER, 'u001', 'Customer', 'name', ACL.WRITE, function (err, perm) { + assert(perm.permission === ACL.DENY); + }); + + ACL.checkPermission(ACL.USER, 'u001', 'Customer', 'name', ACL.READ, function (err, perm) { + assert(perm.permission === ACL.ALLOW); + }); + + ACL.checkPermission(ACL.USER, 'u002', 'Customer', 'name', ACL.WRITE, function (err, perm) { + assert(perm.permission === ACL.DENY); + }); + + }); + it("should honor static ACLs from the model", function () { var ds = loopback.createDataSource({connector: loopback.Memory}); var Customer = ds.createModel('Customer', { @@ -117,6 +152,81 @@ describe('security ACLs', function () { }); + it("should check access against LDL, ACL, and Role", function () { + var ds = loopback.createDataSource({connector: loopback.Memory}); + ACL.attachTo(ds); + Role.attachTo(ds); + RoleMapping.attachTo(ds); + User.attachTo(ds); + + // var log = console.log; + var log = function() {}; + + // Create + User.create({name: 'Raymond', email: 'x@y.com', password: 'foobar'}, function (err, user) { + + log('User: ', user.toObject()); + + // Define a model with static ACLs + var Customer = ds.createModel('Customer', { + name: { + type: String, + acls: [ + {principalType: ACL.USER, principalId: 'u001', accessType: ACL.WRITE, permission: ACL.DENY}, + {principalType: ACL.USER, principalId: 'u001', accessType: ACL.ALL, permission: ACL.ALLOW} + ] + } + }, { + acls: [ + {principalType: ACL.USER, principalId: 'u001', accessType: ACL.ALL, permission: ACL.ALLOW} + ] + }); + + ACL.create({principalType: ACL.USER, principalId: 'u001', model: 'Customer', property: ACL.ALL, + accessType: ACL.ALL, permission: ACL.ALLOW}, function (err, acl) { + + log('ACL 1: ', acl.toObject()); + + Role.create({name: 'MyRole'}, function (err, myRole) { + log('Role: ', myRole.toObject()); + + myRole.principals.create({principalType: RoleMapping.USER, principalId: user.id}, function (err, p) { + + log('Principal added to role: ', p.toObject()); + + ACL.create({principalType: ACL.ROLE, principalId: myRole.id, model: 'Customer', property: ACL.ALL, + accessType: ACL.READ, permission: ACL.DENY}, function (err, acl) { + + log('ACL 2: ', acl.toObject()); + + ACL.checkAccess({ + principals: [ + {principalType: ACL.USER, principalId: 'u001'} + ], + model: 'Customer', + property: 'name', + accessType: ACL.READ + }, function(err, access) { + assert(!err && access.permission === ACL.ALLOW); + }); + + ACL.checkAccess({ + principals: [ + {principalType: ACL.USER, principalId: 'u001'} + ], + model: 'Customer', + accessType: ACL.READ + }, function(err, access) { + assert(!err && access.permission === ACL.DENY); + }); + + }); + + }); + }); + }); + }); + }); }); diff --git a/test/role.test.js b/test/role.test.js index 45d7385e..c597c365 100644 --- a/test/role.test.js +++ b/test/role.test.js @@ -4,6 +4,7 @@ var role = require('../lib/models/role'); var Role = role.Role; var RoleMapping = role.RoleMapping; var User = loopback.User; +var ACL = require('../lib/models/acl'); function checkResult(err, result) { // console.log(err, result); @@ -41,19 +42,19 @@ describe('role model', function () { // console.log('User: ', user.id); Role.create({name: 'userRole'}, function (err, role) { role.principals.create({principalType: RoleMapping.USER, principalId: user.id}, function (err, p) { - Role.find(function(err, roles) { + Role.find(function (err, roles) { assert(!err); assert.equal(roles.length, 1); assert.equal(roles[0].name, 'userRole'); }); - role.principals(function(err, principals) { + role.principals(function (err, principals) { assert(!err); // console.log(principals); assert.equal(principals.length, 1); assert.equal(principals[0].principalType, RoleMapping.USER); assert.equal(principals[0].principalId, user.id); }); - role.users(function(err, users) { + role.users(function (err, users) { assert(!err); assert.equal(users.length, 1); assert.equal(users[0].principalType, RoleMapping.USER); @@ -72,26 +73,26 @@ describe('role model', function () { role.principals.create({principalType: RoleMapping.USER, principalId: user.id}, function (err, p) { // Role.find(console.log); // role.principals(console.log); - Role.isInRole('userRole', RoleMapping.USER, user.id, function(err, exists) { + Role.isInRole('userRole', {principalType: RoleMapping.USER, principalId: user.id}, function (err, exists) { assert(!err && exists === true); }); - Role.isInRole('userRole', RoleMapping.APP, user.id, function(err, exists) { + Role.isInRole('userRole', {principalType: RoleMapping.APP, principalId: user.id}, function (err, exists) { assert(!err && exists === false); }); - Role.isInRole('userRole', RoleMapping.USER, 100, function(err, exists) { + Role.isInRole('userRole', {principalType: RoleMapping.USER, principalId: 100}, function (err, exists) { assert(!err && exists === false); }); - Role.getRoles(RoleMapping.USER, user.id, function(err, roles) { + Role.getRoles(RoleMapping.USER, user.id, function (err, roles) { assert.equal(roles.length, 1); assert.equal(roles[0], role.id); }); - Role.getRoles(RoleMapping.APP, user.id, function(err, roles) { + Role.getRoles(RoleMapping.APP, user.id, function (err, roles) { assert.equal(roles.length, 0); }); - Role.getRoles(RoleMapping.USER, 100, function(err, roles) { + Role.getRoles(RoleMapping.USER, 100, function (err, roles) { assert.equal(roles.length, 0); }); @@ -101,6 +102,63 @@ describe('role model', function () { }); + it("should support owner role resolver", function () { + var ds = loopback.createDataSource({connector: 'memory'}); + User.attachTo(ds); + Role.attachTo(ds); + RoleMapping.attachTo(ds); + + var Album = ds.createModel('Album', { + name: String, + userId: Number + }, { + relations: { + user: { + type: 'belongsTo', + model: 'User', + foreignKey: 'userId' + } + } + }); + + User.create({name: 'Raymond', email: 'x@y.com', password: 'foobar'}, function (err, user) { + Role.isInRole(Role.AUTHENTICATED, {principalType: ACL.USER, principalId: user.id}, function (err, yes) { + assert(!err && yes); + }); + Role.isInRole(Role.AUTHENTICATED, {principalType: ACL.USER, principalId: null}, function (err, yes) { + assert(!err && !yes); + }); + + Role.isInRole(Role.UNAUTHENTICATED, {principalType: ACL.USER, principalId: user.id}, function (err, yes) { + assert(!err && !yes); + }); + Role.isInRole(Role.UNAUTHENTICATED, {principalType: ACL.USER, principalId: null}, function (err, yes) { + assert(!err && yes); + }); + + Role.isInRole(Role.EVERYONE, {principalType: ACL.USER, principalId: user.id}, function (err, yes) { + assert(!err && yes); + }); + + Role.isInRole(Role.EVERYONE, {principalType: ACL.USER, principalId: null}, function (err, yes) { + assert(!err && yes); + }); + + // console.log('User: ', user.id); + Album.create({name: 'Album 1', userId: user.id}, function (err, album1) { + Role.isInRole(Role.OWNER, {principalType: ACL.USER, principalId: user.id, model: Album, id: album1.id}, function (err, yes) { + assert(!err && yes); + }); + Album.create({name: 'Album 2'}, function (err, album2) { + Role.isInRole(Role.OWNER, {principalType: ACL.USER, principalId: user.id, model: Album, id: album2.id}, function (err, yes) { + assert(!err && !yes); + }); + }); + }); + }); + + }); + });