Merge pull request #61 from strongloop/acl

Start to add ACL related models to LoopBack
This commit is contained in:
Raymond Feng 2013-11-14 21:20:52 -08:00
commit 965ff4e350
9 changed files with 845 additions and 45 deletions

View File

@ -90,7 +90,7 @@ loopback.errorHandler.title = 'Loopback';
*/
loopback.createDataSource = function (name, options) {
var ds = new DataSource(name, options);
var ds = new DataSource(name, options, loopback.Model.dataSource);
ds.createModel = function (name, properties, settings) {
var ModelCtor = loopback.createModel(name, properties, settings);
ModelCtor.attachTo(ds);

View File

@ -1,14 +1,14 @@
/**
Schema ACL options
Schema ACL options
Object level permissions, for example, an album owned by a user
Object level permissions, for example, an album owned by a user
Factors to be authorized against:
Factors to be authorized against:
* model name: Album
* model instance properties: userId of the album, friends, shared
* methods
* app and/or user ids/roles
* model name: Album
* model instance properties: userId of the album, friends, shared
* methods
* app and/or user ids/roles
** loggedIn
** roles
** userId
@ -17,35 +17,166 @@ Factors to be authorized against:
** everyone
** relations: owner/friend/granted
Class level permissions, for example, Album
Class level permissions, for example, Album
* model name: Album
* methods
URL/Route level permissions
URL/Route level permissions
* url pattern
* application id
* ip addresses
* http headers
Map to oAuth 2.0 scopes
Map to oAuth 2.0 scopes
*/
*/
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
};
/**
* 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|*}
*/
var Scope = loopback.createModel('Scope', ScopeSchema);
/**
* 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 model name
properties: [String], // A list of property names
methods: [String], // A list of methods
roles: [String], // A list of roles
permission: {type: String, enum: ['Allow', 'Deny']}, // Allow/Deny
status: String, // Enabled/disabled
created: Date,
modified: Date
model: String, // The name of the model
property: String, // The name of the property, method, scope, or relation
/**
* Name of the access type - READ/WRITE/EXEC
*/
accessType: String,
/**
* ALARM - Generate an alarm, in a system dependent way, the access specified in the permissions component of the ACL entry.
* ALLOW - Explicitly grants access to the resource.
* AUDIT - Log, in a system dependent way, the access specified in the permissions component of the ACL entry.
* DENY - Explicitly denies access to the resource.
*/
permission: String,
/**
* Type of the principal - Application/User/Role
*/
principalType: String,
/**
* Id of the principal - such as appId, userId or roleId
*/
principalId: String
};
var ACL = loopback.createModel('ACL', ACLSchema);
ACL.ALL = '*';
ACL.ALLOW = 'ALLOW';
ACL.ALARM = 'ALARM';
ACL.AUDIT = 'AUDIT';
ACL.DENY = 'DENY';
ACL.READ = 'READ';
ACL.WRITE = 'WRITE';
ACL.EXECUTE = 'EXECUTE';
ACL.USER = 'USER';
ACL.APP = ACL.APPLICATION = 'APP';
ACL.ROLE = 'ROLE';
ACL.SCOPE = 'SCOPE';
var permissionOrder = {
ALLOW: 1,
ALARM: 2,
AUDIT: 3,
DENY: 4
};
function overridePermission(p1, p2) {
p1 = permissionOrder[p1] ? p1 : ACL.ALLOW;
p2 = permissionOrder[p2] ? p2 : ACL.ALLOW;
var i1 = permissionOrder[p1];
var i2 = permissionOrder[p2];
return i1 > i2 ? p1 : p2;
}
// readAccess, writeAccess --> public, userId, role
/**
* 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) {
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]};
module.exports = function(dataSource) {
dataSource = dataSource || new require('loopback-datasource-juggler').ModelBuilder();
var ACL = dataSource.define('ACL', ACLSchema);
return ACL;
}
ACL.find({where: {principalType: principalType, principalId: principalId,
model: model, property: propertyQuery, accessType: accessTypeQuery}},
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 ACL.ALL (ALL)
if (previousValue.property === currentValue.property || (previousValue.property === ACL.ALL && currentValue.property)) {
previousValue.property = currentValue.property;
if (previousValue.accessType === currentValue.accessType || (previousValue.accessType === ACL.ALL && currentValue.accessType)) {
previousValue.accessType = currentValue.accessType;
}
previousValue.permission = overridePermission(previousValue.permission, currentValue.permission);
}
return previousValue;
}, {principalType: principalType, principalId: principalId, model: model, property: ACL.ALL, accessType: ACL.ALL, permission: 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
*/
Scope.checkPermission = function (scope, model, property, accessType, callback) {
Scope.findOne({where: {name: scope}}, function (err, scope) {
if (err) {
callback && callback(err);
} else {
ACL.checkPermission(ACL.SCOPE, scope.id, model, property, accessType, callback);
}
});
};
module.exports = {
ACL: ACL,
Scope: Scope
};

222
lib/models/oauth2.js Normal file
View File

@ -0,0 +1,222 @@
var loopback = require('../loopback');
// "OAuth token"
var OAuthToken = loopback.createModel({
// "access token"
accessToken: {
type: String,
index: {
unique: true
}
}, // key, The string token
clientId: {
type: String,
index: true
}, // The client id
resourceOwner: {
type: String,
index: true
}, // The resource owner (user) id
realm: {
type: String,
index: true
}, // The resource owner realm
issuedAt: {
type: Date,
index: true
}, // The timestamp when the token is issued
expiresIn: Number, // Expiration time in seconds
expiredAt: {
type: Date,
index: {
expires: "1d"
}
}, // The timestamp when the token is expired
scopes: [ String ], // oAuth scopes
parameters: [
{
name: String,
value: String
}
],
authorizationCode: {
type: String,
index: true
}, // The corresponding authorization code that is used to request the
// access token
refreshToken: {
type: String,
index: true
}, // The corresponding refresh token if issued
tokenType: {
type: String,
enum: [ "Bearer", "MAC" ]
}, // The token type, such as Bearer:
// http://tools.ietf.org/html/draft-ietf-oauth-v2-bearer-16
// or MAC: http://tools.ietf.org/html/draft-hammer-oauth-v2-mac-token-05
authenticationScheme: String, // HTTP authenticationScheme
hash: String // The SHA-1 hash for
// client-secret/resource-owner-secret-key
});
// "OAuth authorization code"
var OAuthAuthorizationCode = loopback.createModel({
code: {
type: String,
index: {
unique: true
}
}, // key // The string code
clientId: {
type: String,
index: true
}, // The client id
resourceOwner: {
type: String,
index: true
}, // The resource owner (user) id
realm: {
type: String,
index: true
}, // The resource owner realm
issuedAt: {
type: Date,
index: true
}, // The timestamp when the token is issued
expiresIn: Number, // Expiration time in seconds
expiredAt: {
type: Date,
index: {
expires: "1d"
}
}, // The timestamp when the token is expired
scopes: [ String ], // oAuth scopes
parameters: [
{
name: String,
value: String
}
],
used: Boolean, // Is it ever used
redirectURI: String, // The redirectURI from the request, we need to
// check if it's identical to the one used for
// access token
hash: String // The SHA-1 hash for
// client-secret/resource-owner-secret-key
});
// "OAuth client registration record"
var ClientRegistration = loopback.createModel({
id: {
type: String,
index: {
unique: true
}
},
clientId: {
type: String,
index: {
unique: true
}
}, // key; // The client id
clientSecret: String, // The generated client secret
defaultTokenType: String,
accessLevel: Number, // The access level to scopes, -1: disabled, 0:
// basic, 1..N
disabled: Boolean,
name: {
type: String,
index: true
},
email: String,
description: String,
url: String,
iconURL: String,
redirectURIs: [ String ],
type: {
type: String,
enum: [ "CONFIDENTIAL", "PUBLIC" ]
},
userId: {
type: String,
index: true
} // The registered developer
});
// "OAuth permission"
var OAuthPermission = loopback.createModel({
clientId: {
type: String,
index: true
}, // The client id
resourceOwner: {
type: String,
index: true
}, // The resource owner (user) id
realm: {
type: String,
index: true
}, // The resource owner realm
issuedAt: {
type: Date,
index: true
}, // The timestamp when the permission is issued
expiresIn: Number, // Expiration time in seconds
expiredAt: {
type: Date,
index: {
expires: "1d"
}
}, // The timestamp when the permission is expired
scopes: [ String ]
});
// "OAuth scope"
var OAuthScope = loopback.createModel({
scope: {
type: String,
index: {
unique: true
}
}, // key; // The scope name
description: String, // Description of the scope
iconURL: String, // The icon to be displayed on the "Request Permission"
// dialog
expiresIn: Number, // The default maximum lifetime of access token that
// carries the scope
requiredAccessLevel: Number, // The minimum access level required
resourceOwnerAuthorizationRequired: Boolean
// The scope requires authorization from the resource owner
});
// "OAuth protected resource"
var OAuthResource = loopback.createModel({
operations: [
{
type: String,
enum: [ "ALL", "GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "PATCH" ]
}
], // A list of operations, by default ALL
path: String, // The resource URI path
scopes: [ String ]
// Allowd scopes
});
// Use the schema to register a model
exports.OAuthToken = OAuthToken;
exports.OAuthAuthorizationCode = OAuthAuthorizationCode;
exports.ClientRegistration = ClientRegistration;
exports.OAuthPermission = OAuthPermission;
exports.OAuthScope = OAuthScope;
exports.OAuthResource = OAuthResource;

View File

@ -1,18 +1,208 @@
var loopback = require('../loopback');
// Role model
var RoleSchema = {
id: {type: String, required: 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
id: {type: String, id: true}, // Id
name: {type: String, required: true}, // The name of a role
description: String, // Description
// Timestamps
created: {type: Date, default: Date},
modified: {type: Date, default: Date}
}
// Timestamps
created: {type: Date, default: Date},
modified: {type: Date, default: Date}
};
/**
* 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: {
role: {
type: 'belongsTo',
model: 'Role',
foreignKey: 'roleId'
}
}
});
// Principal types
RoleMapping.USER = 'USER';
RoleMapping.APP = RoleMapping.APPLICATION = 'APP';
RoleMapping.ROLE = 'ROLE';
/**
* Get the application principal
* @param callback
*/
RoleMapping.prototype.application = function (callback) {
if (this.principalType === RoleMapping.APPLICATION) {
loopback.Application.findById(this.principalId, callback);
} else {
process.nextTick(function () {
callback && callback(null, null);
});
}
};
/**
* Get the user principal
* @param callback
*/
RoleMapping.prototype.user = function (callback) {
if (this.principalType === RoleMapping.USER) {
loopback.User.findById(this.principalId, callback);
} else {
process.nextTick(function () {
callback && callback(null, null);
});
}
};
/**
* Get the child role principal
* @param callback
*/
RoleMapping.prototype.childRole = function (callback) {
if (this.principalType === RoleMapping.ROLE) {
loopback.User.findById(this.principalId, callback);
} else {
process.nextTick(function () {
callback && callback(null, null);
});
}
};
/**
* Define the Role model with `hasMany` relation to RoleMapping
*/
var Role = loopback.createModel('Role', RoleSchema, {
relations: {
principals: {
type: 'hasMany',
model: 'RoleMapping',
foreignKey: 'roleId'
}
}
});
// Set up the connection to users/applications/roles once the model
Role.once('dataSourceAttached', function () {
Role.prototype.users = function (callback) {
RoleMapping.find({where: {roleId: this.id, principalType: RoleMapping.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: RoleMapping.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: RoleMapping.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
Role.RELATED = "$related"; // any User with a relationship to the object
Role.AUTHENTICATED = "$authenticated"; // authenticated user
Role.EVERYONE = "$everyone"; // everyone
/**
* Add custom handler for roles
* @param role
* @param resolver The resolver function decides if a principal is in the role dynamically
*
* isInRole(role, context, callback)
*/
Role.registerResolver = function(role, resolver) {
Role.resolvers[role] = resolver;
};
/**
* Check if a given principal is in the role
*
* @param role
* @param principalType
* @param principalId
* @param callback
*/
Role.isInRole = function (role, principalType, principalId, callback) {
Role.findOne({where: {name: role}}, function (err, result) {
if (err) {
callback && callback(err);
return;
}
if(!result) {
callback && callback(null, false);
return;
}
RoleMapping.findOne({where: {roleId: result.id, principalType: principalType, principalId: principalId}},
function (err, result) {
if (err) {
callback && callback(err);
return;
}
callback && callback(null, !!result);
});
});
};
/**
* List roles for a given principal
* @param {String} principalType
* @param {String|Number} principalId
* @param {Function} callback
*
* @callback callback
* @param err
* @param {String[]} An array of role ids
*/
Role.getRoles = function (principalType, principalId, 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
};
module.exports = function(dataSource) {
dataSource = dataSource || new require('loopback-datasource-juggler').ModelBuilder();
var Role = dataSource.define('Role', RoleSchema);
return Role;
}

View File

@ -21,11 +21,11 @@
"debug": "~0.7.2",
"express": "~3.4.0",
"loopback-datasource-juggler": "~1.2.0",
"strong-remoting": "~1.0.0",
"strong-remoting": "~1.1.0",
"inflection": "~1.2.5",
"passport": "~0.1.17",
"passport-local": "~0.1.6",
"nodemailer": "~0.4.4",
"nodemailer": "~0.5.5",
"ejs": "~0.8.4",
"bcryptjs": "~0.7.10",
"underscore.string": "~2.3.3",
@ -34,9 +34,9 @@
},
"devDependencies": {
"blanket": "~1.1.5",
"mocha": "~1.12.1",
"mocha": "~1.14.0",
"strong-task-emitter": "0.0.x",
"supertest": "~0.7.1"
"supertest": "~0.8.1"
},
"repository": {
"type": "git",

102
test/acl.test.js Normal file
View File

@ -0,0 +1,102 @@
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;
function checkResult(err, result) {
// console.log(err, result);
assert(!err);
}
describe('security scopes', function () {
it("should allow access to models for the given scope by wildcard", function () {
var ds = loopback.createDataSource({connector: loopback.Memory});
Scope.attachTo(ds);
ACL.attachTo(ds);
// console.log(Scope.relations);
Scope.create({name: 'user', description: 'access user information'}, function (err, scope) {
// console.log(scope);
ACL.create({principalType: ACL.SCOPE, principalId: scope.id, model: 'user', property: ACL.ALL,
accessType: ACL.ALL, permission: ACL.ALLOW},
function (err, resource) {
// console.log(resource);
Scope.checkPermission('user', 'user', ACL.ALL, ACL.ALL, checkResult);
Scope.checkPermission('user', 'user', 'name', ACL.ALL, checkResult);
Scope.checkPermission('user', 'user', 'name', ACL.READ, checkResult);
});
});
});
it("should allow access to models for the given scope", function () {
var ds = loopback.createDataSource({connector: loopback.Memory});
Scope.attachTo(ds);
ACL.attachTo(ds);
// console.log(Scope.relations);
Scope.create({name: 'user', description: 'access user information'}, function (err, scope) {
// console.log(scope);
ACL.create({principalType: ACL.SCOPE, principalId: scope.id,
model: 'user', property: 'name', accessType: ACL.READ, permission: ACL.ALLOW},
function (err, resource) {
ACL.create({principalType: ACL.SCOPE, principalId: scope.id,
model: 'user', property: 'name', accessType: ACL.WRITE, permission: ACL.DENY},
function (err, resource) {
// console.log(resource);
Scope.checkPermission('user', 'user', ACL.ALL, ACL.ALL, function (err, perm) {
assert(perm.permission === ACL.DENY); // because name.WRITE == DENY
});
Scope.checkPermission('user', 'user', 'name', ACL.ALL, function (err, perm) {
assert(perm.permission === ACL.DENY); // because name.WRITE == DENY
});
Scope.checkPermission('user', 'user', 'name', ACL.READ, function (err, perm) {
assert(perm.permission === ACL.ALLOW);
});
Scope.checkPermission('user', 'user', 'name', ACL.WRITE, function (err, perm) {
assert(perm.permission === ACL.DENY);
});
});
});
});
});
});
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);
ACL.create({principalType: 'user', principalId: 'u001', model: 'user', property: ACL.ALL,
accessType: ACL.ALL, permission: ACL.ALLOW}, function (err, acl) {
ACL.create({principalType: 'user', principalId: 'u001', model: 'user', property: ACL.ALL,
accessType: ACL.READ, permission: ACL.DENY}, function (err, acl) {
ACL.checkPermission('user', 'u001', 'user', 'name', ACL.READ, function (err, perm) {
assert(perm.permission === ACL.DENY);
});
ACL.checkPermission('user', 'u001', 'user', 'name', ACL.ALL, function (err, perm) {
assert(perm.permission === ACL.DENY);
});
});
});
});
});

View File

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

122
test/role.test.js Normal file
View File

@ -0,0 +1,122 @@
var assert = require('assert');
var loopback = require('../index');
var role = require('../lib/models/role');
var Role = role.Role;
var RoleMapping = role.RoleMapping;
var User = loopback.User;
function checkResult(err, result) {
// console.log(err, result);
assert(!err);
}
describe('role model', function () {
it("should define role/role relations", function () {
var ds = loopback.createDataSource({connector: 'memory'});
Role.attachTo(ds);
RoleMapping.attachTo(ds);
Role.create({name: 'user'}, function (err, userRole) {
Role.create({name: 'admin'}, function (err, adminRole) {
userRole.principals.create({principalType: RoleMapping.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);
assert.equal(mappings[0].principalType, RoleMapping.ROLE);
assert.equal(mappings[0].principalId, adminRole.id);
});
userRole.principals(function (err, principals) {
assert.equal(principals.length, 1);
});
userRole.roles(function (err, roles) {
assert.equal(roles.length, 1);
});
});
});
});
});
it("should define role/user relations", function () {
var ds = loopback.createDataSource({connector: 'memory'});
User.attachTo(ds);
Role.attachTo(ds);
RoleMapping.attachTo(ds);
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: RoleMapping.USER, principalId: user.id}, function (err, p) {
Role.find(function(err, roles) {
assert(!err);
assert.equal(roles.length, 1);
assert.equal(roles[0].name, 'userRole');
});
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) {
assert(!err);
assert.equal(users.length, 1);
assert.equal(users[0].principalType, RoleMapping.USER);
assert.equal(users[0].principalId, user.id);
});
});
});
});
});
it("should support getRoles() and isInRole()", function () {
var ds = loopback.createDataSource({connector: 'memory'});
User.attachTo(ds);
Role.attachTo(ds);
RoleMapping.attachTo(ds);
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: 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) {
assert(!err && exists === true);
});
Role.isInRole('userRole', RoleMapping.APP, user.id, function(err, exists) {
assert(!err && exists === false);
});
Role.isInRole('userRole', RoleMapping.USER, 100, function(err, exists) {
assert(!err && exists === false);
});
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) {
assert.equal(roles.length, 0);
});
Role.getRoles(RoleMapping.USER, 100, function(err, roles) {
assert.equal(roles.length, 0);
});
});
});
});
});
});

View File

@ -264,7 +264,7 @@ describe('User', function(){
var lines = result.email.message.split('\n');
assert(lines[4].indexOf('To: bar@bat.com') === 0);
assert(lines[3].indexOf('To: bar@bat.com') === 0);
done();
});
});