From 48a02427113efcd0b48b019c39b00cee64f5ad82 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Mon, 11 Nov 2013 22:16:51 -0800 Subject: [PATCH] Define the models/relations for ACL --- lib/models/acl.js | 98 ++++++++++++++++++++++------ lib/models/role.js | 156 +++++++++++++++++++++++++++++++++++++++------ test/acl.test.js | 58 ++++++++++++++--- test/model.test.js | 33 ++++++++++ test/role.test.js | 43 +++++++++---- 5 files changed, 329 insertions(+), 59 deletions(-) diff --git a/lib/models/acl.js b/lib/models/acl.js index b639d31a..58282c4b 100644 --- a/lib/models/acl.js +++ b/lib/models/acl.js @@ -33,12 +33,15 @@ var loopback = require('../loopback'); +/** + * Schema for Scope which represents the permissions that are granted to client applications by the resource owner + */ var ScopeSchema = { name: {type: String, required: true}, description: String }; -var ScopeResourceAccessSchema = { +var ScopeACLSchema = { model: String, // The name of the model property: String, // The name of the property, method, scope, or relation @@ -57,7 +60,7 @@ var ScopeResourceAccessSchema = { scopeId: Number }; -var ScopeResourceAccess = loopback.createModel('ScopeResourceAccess', ScopeResourceAccessSchema, { +var ScopeACL = loopback.createModel('ScopeACL', ScopeACLSchema, { relations: { scope: { type: 'belongsTo', @@ -68,6 +71,10 @@ var ScopeResourceAccess = loopback.createModel('ScopeResourceAccess', ScopeResou }); /** + * Resource owner grants/delegates permissions to client applications + * + * For a protected resource, does the client application have the authorization from the resource owner (user or system)? + * * Scope has many resource access entries * @type {createModel|*} */ @@ -75,12 +82,21 @@ var Scope = loopback.createModel('Scope', ScopeSchema, { relations: { resources: { type: 'hasMany', - model: 'ScopeResourceAccess', + model: 'ScopeACL', foreignKey: 'scopeId' } } }); +/** + * System grants permissions to principals (users/applications, can be grouped into roles). + * + * Protected resource: the model data and operations (model/property/method/relation/…) + * + * For a given principal, such as client application and/or user, is it allowed to access (read/write/execute) + * the protected resource? + * + */ var ACLSchema = { model: String, // The name of the model property: String, // The name of the property, method, scope, or relation @@ -112,32 +128,72 @@ var ACL = loopback.createModel('ACL', ACLSchema); module.exports = { ACL: ACL, Scope: Scope, - ScopeResourceAccess: ScopeResourceAccess + ScopeACL: ScopeACL }; -Scope.isAllowed = function (scope, model, property, accessType, callback) { +/** + * Check if the given principal is allowed to access the model/property + * @param principalType + * @param principalId + * @param model + * @param property + * @param accessType + * @param callback + */ +ACL.checkPermission = function (principalType, principalId, model, property, accessType, callback) { + ACL.find({where: {principalType: principalType, principalId: principalId, + model: model, property: {inq: [property, '*']}, accessType: {inq: [accessType, '*']}}}, + function (err, acls) { + if (err) { + callback && callback(err); + return; + } + var resolvedPermission = acls.reduce(function (previousValue, currentValue, index, array) { + // If the property is the same or the previous one is '*' (ALL) + if (previousValue.property === currentValue.property || (previousValue.property === '*' && currentValue.property)) { + previousValue.property = currentValue.property; + if (previousValue.accessType === currentValue.accessType || (previousValue.accessType === '*' && currentValue.accessType)) { + previousValue.accessType = currentValue.accessType; + } + } + return previousValue; + }, {principalType: principalType, principalId: principalId, model: model, property: '*', accessType: '*', permission: 'Allow'}); + callback && callback(resolvedPermission); + }); +}; + +/** + * Check if the given scope is allowed to access the model/property + * @param scope + * @param model + * @param property + * @param accessType + * @param callback + */ +Scope.checkPermission = function (scope, model, property, accessType, callback) { Scope.findOne({where: {name: scope}}, function (err, scope) { if (err) { callback && callback(err); } else { - scope.resources({where: {model: model, property: {inq: [property, '*']}, accessType: {inq: [accessType, '*']}}}, function (err, resources) - { - if (err) { - callback && callback(err); - } else { - console.log('Resources: ', resources); - for (var r = 0; r < resources.length; r++) { - if (resources[r].permission === 'Allow') { - callback && callback(null, true); - return; - } + scope.resources({where: {model: model, property: {inq: [property, '*']}, accessType: {inq: [accessType, '*']}}}, function (err, resources) { + if (err) { + callback && callback(err); + return; } - callback && callback(null, false); + // Try to resolve the permission + var resolvedPermission = resources.reduce(function (previousValue, currentValue, index, array) { + // If the property is the same or the previous one is '*' (ALL) + if (previousValue.property === currentValue.property || (previousValue.property === '*' && currentValue.property)) { + previousValue.property = currentValue.property; + if (previousValue.accessType === currentValue.accessType || (previousValue.accessType === '*' && currentValue.accessType)) { + previousValue.accessType = currentValue.accessType; + } + } + return previousValue; + }, {model: model, property: '*', accessType: '*', permission: 'Allow'}); + callback && callback(resolvedPermission); } - - } - ) - ; + ); } }); }; \ No newline at end of file diff --git a/lib/models/role.js b/lib/models/role.js index 9c1170d9..b0aaf58f 100644 --- a/lib/models/role.js +++ b/lib/models/role.js @@ -5,36 +5,112 @@ var RoleSchema = { id: {type: String, id: true}, // Id name: {type: String, required: true}, // The name of a role description: String, // Description - // roles: [String], // A role can be an aggregate of other roles - // users: [String], // A role contains a list of user ids - parent: String, // Timestamps created: {type: Date, default: Date}, modified: {type: Date, default: Date} }; -var Role = loopback.createModel('Role', RoleSchema, { +/** + * Map principals to roles + */ +var RoleMappingSchema = { + id: {type: String, id: true}, // Id + roleId: String, // The role id + principalType: String, // The principal type, such as user, application, or role + principalId: String // The principal id +}; + +var RoleMapping = loopback.createModel('RoleMapping', RoleMappingSchema, { relations: { - roles: { - type: 'hasMany', + role: { + type: 'belongsTo', model: 'Role', - foreignKey: 'parent' - }, - users: { - type: 'hasAndBelongsToMany', - model: 'user', - foreignKey: 'userId' - }, - applications: { - type: 'hasAndBelongsToMany', - model: 'Application', - foreignKey: 'appId' + foreignKey: 'roleId' } } }); -module.exports = Role; +RoleMapping.prototype.application = function(callback) { + if(this.principalType === 'application') { + loopback.Application.findById(this.principalId, callback); + } else { + process.nextTick(function() { + callback && callback(null, null); + }); + } +}; + +RoleMapping.prototype.user = function(callback) { + if(this.principalType === 'user') { + loopback.User.findById(this.principalId, callback); + } else { + process.nextTick(function() { + callback && callback(null, null); + }); + } +}; + +RoleMapping.prototype.childRole = function(callback) { + if(this.principalType === 'role') { + loopback.User.findById(this.principalId, callback); + } else { + process.nextTick(function() { + callback && callback(null, null); + }); + } +}; + +var Role = loopback.createModel('Role', RoleSchema, { + relations: { + principals: { + type: 'hasMany', + model: 'RoleMapping', + foreignKey: 'roleId' + } + } +}); + + +Role.once('dataSourceAttached', function() { + Role.prototype.users = function(callback) { + RoleMapping.find({where: {roleId: this.id, principalType: 'user'}}, function(err, mappings) { + if(err) { + callback && callback(err); + return; + } + return mappings.map(function(m) { + return m.principalId; + }); + }); + }; + + Role.prototype.applications = function(callback) { + RoleMapping.find({where: {roleId: this.id, principalType: 'application'}}, function(err, mappings) { + if(err) { + callback && callback(err); + return; + } + return mappings.map(function(m) { + return m.principalId; + }); + }); + }; + + Role.prototype.roles = function(callback) { + RoleMapping.find({where: {roleId: this.id, principalType: 'role'}}, function(err, mappings) { + if(err) { + callback && callback(err); + return; + } + return mappings.map(function(m) { + return m.principalId; + }); + }); + }; + +}); + // Special roles Role.OWNER = '$owner'; // owner of the object @@ -42,3 +118,47 @@ Role.RELATED = "$related"; // any User with a relationship to the object Role.AUTHENTICATED = "$authenticated"; // authenticated user Role.EVERYONE = "$everyone"; // everyone +/** + * Check if a given principal is in the role + * + * @param principalType + * @param principalId + * @param role + * @param callback + */ +Role.isInRole = function(principalType, principalId, role, callback) { + Role.findOne({where: {name: role}}, function(err, role) { + if(err) { + callback && callback(err); + return; + } + RoleMapping.exists({where: {roleId: role.id, principalType: principalType, principalId: principalId}}, callback); + }); +}; + +/** + * List roles for a given principal + * @param principalType + * @param principalId + * @param role + * @param callback + */ +Role.getRoles = function(principalType, principalId, role, callback) { + RoleMapping.find({where: {principalType: principalType, principalId: principalId}},function(err, mappings) { + if(err) { + callback && callback(err); + return; + } + var roles = []; + mappings.forEach(function(m) { + roles.push(m.roleId); + }); + callback && callback(null, roles); + }); +}; + +module.exports = { + Role: Role, + RoleMapping: RoleMapping +}; + diff --git a/test/acl.test.js b/test/acl.test.js index 15ca5720..e5d0731a 100644 --- a/test/acl.test.js +++ b/test/acl.test.js @@ -1,27 +1,69 @@ var assert = require('assert'); var loopback = require('../index'); var acl = require('../lib/models/acl'); +var Scope = acl.Scope; +var ACL = acl.ACL; +var ScopeACL = acl.ScopeACL; var User = loopback.User; describe('security scopes', function () { - it("should allow access to models", function () { + it("should allow access to models for the given scope by wildcard", function () { var ds = loopback.createDataSource({connector: loopback.Memory}); - acl.Scope.attachTo(ds); - acl.ScopeResourceAccess.attachTo(ds); + Scope.attachTo(ds); + ScopeACL.attachTo(ds); - // console.log(acl.Scope.relations); + // console.log(Scope.relations); - acl.Scope.create({name: 'user', description: 'access user information'}, function (err, scope) { - console.log(scope); + Scope.create({name: 'user', description: 'access user information'}, function (err, scope) { + // console.log(scope); scope.resources.create({model: 'user', property: '*', accessType: '*', permission: 'Allow'}, function (err, resource) { - console.log(resource); - acl.Scope.isAllowed('user', 'user', '*', '*', console.log); + // console.log(resource); + Scope.checkPermission('user', 'user', '*', '*', console.log); + Scope.checkPermission('user', 'user', 'name', '*', console.log); + Scope.checkPermission('user', 'user', 'name', 'Read', console.log); }); }); }); + it("should allow access to models for the given scope", function () { + var ds = loopback.createDataSource({connector: loopback.Memory}); + Scope.attachTo(ds); + ScopeACL.attachTo(ds); + + // console.log(Scope.relations); + + Scope.create({name: 'user', description: 'access user information'}, function (err, scope) { + // console.log(scope); + scope.resources.create({model: 'user', property: 'name', accessType: 'Read', permission: 'Allow'}, function (err, resource) { + // console.log(resource); + Scope.checkPermission('user', 'user', '*', '*', console.log); + Scope.checkPermission('user', 'user', 'name', '*', console.log); + Scope.checkPermission('user', 'user', 'name', 'Read', console.log); + }); + }); + + }); + +}); + +describe('security ACLs', function () { + + it("should allow access to models for the given principal by wildcard", function () { + var ds = loopback.createDataSource({connector: loopback.Memory}); + ACL.attachTo(ds); + + // console.log(Scope.relations); + + ACL.create({principalType: 'user', principalId: 'u001', model: 'user', property: '*', accessType: '*', permission: 'Allow'}, function (err, acl) { + + ACL.checkPermission('user', 'u001', 'user', 'u001', 'Read', console.log); + + }); + + }); + }); diff --git a/test/model.test.js b/test/model.test.js index 1ac72441..d0686fee 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -512,6 +512,39 @@ describe('Model', function() { }); }); + describe('Model.extend() events', function() { + it('create isolated emitters for subclasses', function() { + var User1 = loopback.createModel('User1', { + 'first': String, + 'last': String + }); + + var User2 = loopback.createModel('User2', { + 'name': String + }); + + var user1Triggered = false; + User1.once('x', function(event) { + user1Triggered = true; + }); + + + var user2Triggered = false; + User2.once('x', function(event) { + user2Triggered = true; + }); + + assert(User1.once !== User2.once); + assert(User1.once !== loopback.Model.once); + + User1.emit('x', User1); + + assert(user1Triggered); + assert(!user2Triggered); + }); + + }); + // describe('Model.hasAndBelongsToMany()', function() { // it("TODO: implement / document", function(done) { // /* example - diff --git a/test/role.test.js b/test/role.test.js index 81cdeffd..93797e2f 100644 --- a/test/role.test.js +++ b/test/role.test.js @@ -1,39 +1,58 @@ var assert = require('assert'); var loopback = require('../index'); -var Role = require('../lib/models/role'); +var role = require('../lib/models/role'); +var Role = role.Role; +var RoleMapping = role.RoleMapping; var User = loopback.User; describe('security models', function () { describe('roles', function () { - it("Defines role/role relations", function () { + it("should define role/role relations", function () { var ds = loopback.createDataSource({connector: loopback.Memory}); Role.attachTo(ds); + RoleMapping.attachTo(ds); - Role.create({name: 'user'}, function (err, role) { - role.roles.create({name: 'admin'}, function (err, role2) { - Role.find(console.log); - role.roles(console.log); + Role.create({name: 'user'}, function (err, userRole) { + Role.create({name: 'admin'}, function (err, adminRole) { + userRole.principals.create({principalType: 'role', principalId: adminRole.id}, function (err, mapping) { + Role.find(function(err, roles) { + assert.equal(roles.length, 2); + }); + RoleMapping.find(function(err, mappings) { + assert.equal(mappings.length, 1); + }); + userRole.principals(function(err, principals) { + assert.equal(principals.length, 1); + }); + userRole.roles(function(err, roles) { + assert.equal(roles.length, 1); + }); + }); }); }); }); - it("Defines role/user relations", function () { + it("should define role/user relations", function () { var ds = loopback.createDataSource({connector: loopback.Memory}); User.attachTo(ds); Role.attachTo(ds); - Role.create({name: 'user'}, function (err, role) { - role.users.create({name: 'Raymond'}, function (err, user) { - console.log('User: ', user); - Role.find(console.log); - role.users(console.log); + User.create({name: 'Raymond', email: 'x@y.com', password: 'foobar'}, function (err, user) { + console.log('User: ', user.id); + Role.create({name: 'userRole'}, function (err, role) { + role.principals.create({principalType: 'user', principalId: user.id}, function (err, p) { + Role.find(console.log); + role.principals(console.log); + role.users(console.log); + }); }); }); }); + }); });