From df9fe90d35e3fee13145c69362d29ba22787ee76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Thu, 9 Oct 2014 17:46:36 +0200 Subject: [PATCH 01/14] Auto-load and register built-in `Checkpoint` model --- common/models/change.js | 2 +- lib/builtin-models.js | 1 + test/checkpoint.test.js | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/common/models/change.js b/common/models/change.js index ed347eeb..bedfc1cc 100644 --- a/common/models/change.js +++ b/common/models/change.js @@ -441,7 +441,7 @@ Change.rectifyAll = function(cb) { Change.getCheckpointModel = function() { var checkpointModel = this.Checkpoint; if(checkpointModel) return checkpointModel; - this.checkpoint = checkpointModel = require('./checkpoint').extend('checkpoint'); + this.checkpoint = checkpointModel = loopback.Checkpoint.extend('checkpoint'); assert(this.dataSource, 'Cannot getCheckpointModel(): ' + this.modelName + ' is not attached to a dataSource'); checkpointModel.attachTo(this.dataSource); diff --git a/lib/builtin-models.js b/lib/builtin-models.js index f2df14c9..52bb2719 100644 --- a/lib/builtin-models.js +++ b/lib/builtin-models.js @@ -8,6 +8,7 @@ module.exports = function(loopback) { loopback.ACL = require('../common/models/acl').ACL; loopback.Scope = require('../common/models/acl').Scope; loopback.Change = require('../common/models/change'); + loopback.Checkpoint = require('../common/models/checkpoint'); /*! * Automatically attach these models to dataSources diff --git a/test/checkpoint.test.js b/test/checkpoint.test.js index c999729f..0b7aa3f2 100644 --- a/test/checkpoint.test.js +++ b/test/checkpoint.test.js @@ -2,7 +2,7 @@ var async = require('async'); var loopback = require('../'); // create a unique Checkpoint model -var Checkpoint = require('../common/models/checkpoint').extend('TestCheckpoint'); +var Checkpoint = loopback.Checkpoint.extend('TestCheckpoint'); Checkpoint.attachTo(loopback.memory()); describe('Checkpoint', function() { From b8e877c5e5a61cb5ab145063b0260dd8fdf91418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Thu, 9 Oct 2014 20:09:44 +0200 Subject: [PATCH 02/14] test: remove infinite timeout The infinite timeout was useful when debugging, which is not a good reason for keeping it around when not debugging. --- test/access-token.test.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/access-token.test.js b/test/access-token.test.js index 94260b8d..093e4dc9 100644 --- a/test/access-token.test.js +++ b/test/access-token.test.js @@ -109,8 +109,6 @@ describe('AccessToken', function () { }); describe('app.enableAuth()', function() { - this.timeout(0); - beforeEach(createTestingToken); it('prevents remote call with 401 status on denied ACL', function (done) { From b1e0edb22b308b395681c80023d411de9ba662ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 10 Oct 2014 11:21:15 +0200 Subject: [PATCH 03/14] test: verify exported models --- test/loopback.test.js | 24 ++++++++++++++++++++++++ test/user.test.js | 12 ++++++++++++ 2 files changed, 36 insertions(+) diff --git a/test/loopback.test.js b/test/loopback.test.js index 6529d69c..d03e01e6 100644 --- a/test/loopback.test.js +++ b/test/loopback.test.js @@ -241,4 +241,28 @@ describe('loopback', function() { expect(owner._targetClass).to.equal('User'); }); }); + + describe('loopback object', function() { + it('exports all built-in models', function() { + var expectedModelNames = [ + 'Email', + 'User', + 'Application', + 'AccessToken', + 'Role', + 'RoleMapping', + 'ACL', + 'Scope', + 'Change', + 'Checkpoint' + ]; + + expect(Object.keys(loopback)).to.include.members(expectedModelNames); + + expectedModelNames.forEach(function(name) { + expect(loopback[name], name).to.be.a('function'); + expect(loopback[name].modelName, name + '.modelName').to.eql(name); + }); + }); + }); }); diff --git a/test/user.test.js b/test/user.test.js index c6680fff..c08465a0 100644 --- a/test/user.test.js +++ b/test/user.test.js @@ -600,4 +600,16 @@ describe('User', function(){ }); }); }); + + describe('ctor', function() { + it('exports default Email model', function() { + expect(User.email, 'User.email').to.be.a('function'); + expect(User.email.modelName, 'modelName').to.eql('email'); + }); + + it('exports default AccessToken model', function() { + expect(User.accessToken, 'User.accessToken').to.be.a('function'); + expect(User.accessToken.modelName, 'modelName').to.eql('AccessToken'); + }); + }); }); From 01d17e636a07e9f7845eb402194ca5871a4756dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 13 Oct 2014 09:34:44 +0200 Subject: [PATCH 04/14] test: run more tests in the browser Add two more test files to `test/karma.conf.js`: - test/loopback.test.js - test/model.application.test.js --- test/karma.conf.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/karma.conf.js b/test/karma.conf.js index 6b712904..0e18c6ca 100644 --- a/test/karma.conf.js +++ b/test/karma.conf.js @@ -16,7 +16,9 @@ module.exports = function(config) { files: [ 'node_modules/es5-shim/es5-shim.js', 'test/support.js', + 'test/loopback.test.js', 'test/model.test.js', + 'test/model.application.test.js', 'test/geo-point.test.js', 'test/app.test.js' ], From 920d3be6a3e523a3b36b63d7763f1076d185cbfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 10 Oct 2014 11:53:22 +0200 Subject: [PATCH 05/14] models: move User LDL def into `user.json` --- common/models/user.js | 248 +++++++++++++--------------------------- common/models/user.json | 96 ++++++++++++++++ lib/builtin-models.js | 15 ++- 3 files changed, 190 insertions(+), 169 deletions(-) create mode 100644 common/models/user.json diff --git a/common/models/user.js b/common/models/user.js index d4a3ef04..0aec1e82 100644 --- a/common/models/user.js +++ b/common/models/user.js @@ -2,107 +2,18 @@ * Module Dependencies. */ -var PersistedModel = require('../../lib/loopback').PersistedModel - , loopback = require('../../lib/loopback') +var loopback = require('../../lib/loopback') , path = require('path') , SALT_WORK_FACTOR = 10 , crypto = require('crypto') , bcrypt = require('bcryptjs') - , BaseAccessToken = require('./access-token') , DEFAULT_TTL = 1209600 // 2 weeks in seconds , DEFAULT_RESET_PW_TTL = 15 * 60 // 15 mins in seconds , DEFAULT_MAX_TTL = 31556926 // 1 year in seconds - , Role = require('./role').Role - , ACL = require('./acl').ACL , assert = require('assert'); var debug = require('debug')('loopback:user'); -/*! - * Default User properties. - */ - -var properties = { - realm: {type: String}, - username: {type: String}, - password: {type: String, required: true}, - credentials: Object, // deprecated, to be removed in 2.x - challenges: Object, // deprecated, to be removed in 2.x - email: {type: String, required: true}, - emailVerified: Boolean, - verificationToken: String, - status: String, - created: Date, - lastUpdated: Date -}; - -var options = { - hidden: ['password'], - acls: [ - { - principalType: ACL.ROLE, - principalId: Role.EVERYONE, - permission: ACL.DENY - }, - { - principalType: ACL.ROLE, - principalId: Role.EVERYONE, - permission: ACL.ALLOW, - property: 'create' - }, - { - principalType: ACL.ROLE, - principalId: Role.OWNER, - permission: ACL.ALLOW, - property: 'deleteById' - }, - { - principalType: ACL.ROLE, - principalId: Role.EVERYONE, - permission: ACL.ALLOW, - property: "login" - }, - { - principalType: ACL.ROLE, - principalId: Role.EVERYONE, - permission: ACL.ALLOW, - property: "logout" - }, - { - principalType: ACL.ROLE, - principalId: Role.OWNER, - permission: ACL.ALLOW, - property: "findById" - }, - { - principalType: ACL.ROLE, - principalId: Role.OWNER, - permission: ACL.ALLOW, - property: "updateAttributes" - }, - { - principalType: ACL.ROLE, - principalId: Role.EVERYONE, - permission: ACL.ALLOW, - property: "confirm" - }, - { - principalType: ACL.ROLE, - principalId: Role.EVERYONE, - permission: ACL.ALLOW, - property: "resetPassword", - accessType: ACL.EXECUTE - } - ], - relations: { - accessTokens: { - type: 'hasMany', - model: 'AccessToken', - foreignKey: 'userId' - } - } -}; - /** * Extends from the built in `loopback.Model` type. * @@ -122,11 +33,11 @@ var options = { * @property {Boolean} emailVerified Set when a user's email has been verified via `confirm()` * @property {String} verificationToken Set when `verify()` is called * - * @class - * @inherits {Model} + * @class User + * @inherits {PersistedModel} */ -var User = module.exports = PersistedModel.extend('User', properties, options); +module.exports = function(User) { /** * Create access token for the logged in user. This method can be overridden to @@ -150,8 +61,8 @@ User.prototype.createAccessToken = function(ttl, cb) { * * ```js * User.login({username: 'foo', password: 'bar'}, function (err, token) { - * console.log(token.id); - * }); +* console.log(token.id); +* }); * ``` * * @param {Object} credentials @@ -160,7 +71,7 @@ User.prototype.createAccessToken = function(ttl, cb) { * @param {AccessToken} token */ -User.login = function (credentials, include, fn) { +User.login = function(credentials, include, fn) { var self = this; if (typeof include === 'function') { fn = include; @@ -169,19 +80,18 @@ User.login = function (credentials, include, fn) { include = (include || ''); if (Array.isArray(include)) { - include = include.map(function ( val ) { + include = include.map(function(val) { return val.toLowerCase(); }); - }else { + } else { include = include.toLowerCase(); } - var query = {}; - if(credentials.email) { + if (credentials.email) { query.email = credentials.email; - } else if(credentials.username) { + } else if (credentials.username) { query.username = credentials.username; } else { var err = new Error('username or email is required'); @@ -193,10 +103,10 @@ User.login = function (credentials, include, fn) { var defaultError = new Error('login failed'); defaultError.statusCode = 401; - if(err) { + if (err) { debug('An error is reported from User.findOne: %j', err); fn(defaultError); - } else if(user) { + } else if (user) { if (self.settings.emailVerificationRequired) { if (!user.emailVerified) { // Fail to log in if email verification is not done yet @@ -207,10 +117,10 @@ User.login = function (credentials, include, fn) { } } user.hasPassword(credentials.password, function(err, isMatch) { - if(err) { + if (err) { debug('An error is reported from User.hasPassword: %j', err); fn(defaultError); - } else if(isMatch) { + } else if (isMatch) { user.createAccessToken(credentials.ttl, function(err, token) { if (err) return fn(err); if (Array.isArray(include) ? include.indexOf('user') !== -1 : include === 'user') { @@ -241,8 +151,8 @@ User.login = function (credentials, include, fn) { * * ```js * User.logout('asd0a9f8dsj9s0s3223mk', function (err) { - * console.log(err || 'Logged out'); - * }); +* console.log(err || 'Logged out'); +* }); * ``` * * @param {String} accessTokenID @@ -250,11 +160,11 @@ User.login = function (credentials, include, fn) { * @param {Error} err */ -User.logout = function (tokenId, fn) { - this.relations.accessTokens.modelTo.findById(tokenId, function (err, accessToken) { - if(err) { +User.logout = function(tokenId, fn) { + this.relations.accessTokens.modelTo.findById(tokenId, function(err, accessToken) { + if (err) { fn(err); - } else if(accessToken) { + } else if (accessToken) { accessToken.destroy(fn); } else { fn(new Error('could not find accessToken')); @@ -269,10 +179,10 @@ User.logout = function (tokenId, fn) { * @returns {Boolean} */ -User.prototype.hasPassword = function (plain, fn) { - if(this.password && plain) { +User.prototype.hasPassword = function(plain, fn) { + if (this.password && plain) { bcrypt.compare(plain, this.password, function(err, isMatch) { - if(err) return fn(err); + if (err) return fn(err); fn(null, isMatch); }); } else { @@ -285,11 +195,11 @@ User.prototype.hasPassword = function (plain, fn) { * * ```js * var options = { - * type: 'email', - * to: user.email, - * template: 'verify.ejs', - * redirect: '/' - * }; +* type: 'email', +* to: user.email, +* template: 'verify.ejs', +* redirect: '/' +* }; * * user.verify(options, next); * ``` @@ -297,7 +207,7 @@ User.prototype.hasPassword = function (plain, fn) { * @param {Object} options */ -User.prototype.verify = function (options, fn) { +User.prototype.verify = function(options, fn) { var user = this; var userModel = this.constructor; assert(typeof options === 'object', 'options required when calling user.verify()'); @@ -314,32 +224,32 @@ User.prototype.verify = function (options, fn) { var app = userModel.app; options.host = options.host || (app && app.get('host')) || 'localhost'; options.port = options.port || (app && app.get('port')) || 3000; - options.restApiRoot = options.restApiRoot || (app && app.get('restApiRoot')) || '/api'; + options.restApiRoot = options.restApiRoot || (app && app.get('restApiRoot')) || '/api'; options.verifyHref = options.verifyHref || - options.protocol - + '://' - + options.host - + ':' - + options.port - + options.restApiRoot - + userModel.http.path - + userModel.confirm.http.path - + '?uid=' - + options.user.id - + '&redirect=' - + options.redirect; + options.protocol + + '://' + + options.host + + ':' + + options.port + + options.restApiRoot + + userModel.http.path + + userModel.confirm.http.path + + '?uid=' + + options.user.id + + '&redirect=' + + options.redirect; // Email model var Email = options.mailer || this.constructor.email || loopback.getModelByType(loopback.Email); crypto.randomBytes(64, function(err, buf) { - if(err) { + if (err) { fn(err); } else { user.verificationToken = buf.toString('hex'); - user.save(function (err) { - if(err) { + user.save(function(err) { + if (err) { fn(err); } else { sendEmail(user); @@ -363,8 +273,8 @@ User.prototype.verify = function (options, fn) { subject: options.subject || 'Thanks for Registering', text: options.text, html: template(options) - }, function (err, email) { - if(err) { + }, function(err, email) { + if (err) { fn(err); } else { fn(null, {email: email, token: user.verificationToken, uid: user.id}); @@ -383,16 +293,16 @@ User.prototype.verify = function (options, fn) { * @callback {Function} callback * @param {Error} err */ -User.confirm = function (uid, token, redirect, fn) { - this.findById(uid, function (err, user) { - if(err) { +User.confirm = function(uid, token, redirect, fn) { + this.findById(uid, function(err, user) { + if (err) { fn(err); } else { - if(user && user.verificationToken === token) { + if (user && user.verificationToken === token) { user.verificationToken = undefined; user.emailVerified = true; - user.save(function (err) { - if(err) { + user.save(function(err) { + if (err) { fn(err); } else { fn(); @@ -427,15 +337,15 @@ User.resetPassword = function(options, cb) { var ttl = UserModel.settings.resetPasswordTokenTTL || DEFAULT_RESET_PW_TTL; options = options || {}; - if(typeof options.email === 'string') { + if (typeof options.email === 'string') { UserModel.findOne({ where: {email: options.email} }, function(err, user) { - if(err) { + if (err) { cb(err); - } else if(user) { + } else if (user) { // create a short lived access token for temp login to change password // TODO(ritch) - eventually this should only allow password change user.accessTokens.create({ttl: ttl}, function(err, accessToken) { - if(err) { + if (err) { cb(err); } else { cb(); @@ -462,16 +372,16 @@ User.resetPassword = function(options, cb) { * Setup an extended user model. */ -User.setup = function () { +User.setup = function() { // We need to call the base class's setup method - PersistedModel.setup.call(this); + User.base.setup.call(this); var UserModel = this; // max ttl this.settings.maxTTL = this.settings.maxTTL || DEFAULT_MAX_TTL; this.settings.ttl = DEFAULT_TTL; - UserModel.setter.password = function (plain) { + UserModel.setter.password = function(plain) { var salt = bcrypt.genSaltSync(this.constructor.settings.saltWorkFactor || SALT_WORK_FACTOR); this.$password = bcrypt.hashSync(plain, salt); } @@ -491,16 +401,14 @@ User.setup = function () { description: 'Login a user with username/email and password', accepts: [ {arg: 'credentials', type: 'object', required: true, http: {source: 'body'}}, - {arg: 'include', type: 'string', http: {source: 'query' }, description: - 'Related objects to include in the response. ' + - 'See the description of return value for more details.'} + {arg: 'include', type: 'string', http: {source: 'query' }, description: 'Related objects to include in the response. ' + + 'See the description of return value for more details.'} ], returns: { - arg: 'accessToken', type: 'object', root: true, description: - 'The response body contains properties of the AccessToken created on login.\n' + - 'Depending on the value of `include` parameter, the body may contain ' + - 'additional properties:\n\n' + - ' - `user` - `{User}` - Data of the currently logged in user. (`include=user`)\n\n' + arg: 'accessToken', type: 'object', root: true, description: 'The response body contains properties of the AccessToken created on login.\n' + + 'Depending on the value of `include` parameter, the body may contain ' + + 'additional properties:\n\n' + + ' - `user` - `{User}` - Data of the currently logged in user. (`include=user`)\n\n' }, http: {verb: 'post'} } @@ -517,9 +425,8 @@ User.setup = function () { var tokenID = accessToken && accessToken.id; return tokenID; - }, description: - 'Do not supply this argument, it is automatically extracted ' + - 'from request headers.' + }, description: 'Do not supply this argument, it is automatically extracted ' + + 'from request headers.' } ], http: {verb: 'all'} @@ -550,9 +457,9 @@ User.setup = function () { } ); - UserModel.on('attached', function () { - UserModel.afterRemote('confirm', function (ctx, inst, next) { - if(ctx.req) { + UserModel.on('attached', function() { + UserModel.afterRemote('confirm', function(ctx, inst, next) { + if (ctx.req) { ctx.res.redirect(ctx.req.param('redirect')); } else { fn(new Error('transport unsupported')); @@ -561,15 +468,18 @@ User.setup = function () { }); // default models - UserModel.email = require('./email'); - UserModel.accessToken = require('./access-token'); + assert(loopback.Email, 'Email model must be defined before User model'); + UserModel.email = loopback.Email; + + assert(loopback.AccessToken, 'AccessToken model must be defined before User model'); + UserModel.accessToken = loopback.AccessToken; // email validation regex var re = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; UserModel.validatesUniquenessOf('email', {message: 'Email already exists'}); UserModel.validatesFormatOf('email', {with: re, message: 'Must provide a valid email'}); - UserModel.validatesUniquenessOf('username', {message: 'User already exists'}); + UserModel.validatesUniquenessOf('username', {message: 'User already exists'}); return UserModel; } @@ -579,3 +489,5 @@ User.setup = function () { */ User.setup(); + +}; diff --git a/common/models/user.json b/common/models/user.json new file mode 100644 index 00000000..f3280c99 --- /dev/null +++ b/common/models/user.json @@ -0,0 +1,96 @@ +{ + "name": "User", + "properties": { + "realm": { + "type": "string" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string", + "required": true + }, + "credentials": { + "type": "object", + "deprecated": true + }, + "challenges": { + "type": "object", + "deprecated": true + }, + "email": { + "type": "string", + "required": true + }, + "emailVerified": "boolean", + "verificationToken": "string", + "status": "string", + "created": "date", + "lastUpdated": "date" + }, + "hidden": ["password"], + "acls": [ + { + "principalType": "ROLE", + "principalId": "$everyone", + "permission": "DENY" + }, + { + "principalType": "ROLE", + "principalId": "$everyone", + "permission": "ALLOW", + "property": "create" + }, + { + "principalType": "ROLE", + "principalId": "$owner", + "permission": "ALLOW", + "property": "deleteById" + }, + { + "principalType": "ROLE", + "principalId": "$everyone", + "permission": "ALLOW", + "property": "login" + }, + { + "principalType": "ROLE", + "principalId": "$everyone", + "permission": "ALLOW", + "property": "logout" + }, + { + "principalType": "ROLE", + "principalId": "$owner", + "permission": "ALLOW", + "property": "findById" + }, + { + "principalType": "ROLE", + "principalId": "$owner", + "permission": "ALLOW", + "property": "updateAttributes" + }, + { + "principalType": "ROLE", + "principalId": "$everyone", + "permission": "ACL.ALLOW", + "property": "confirm" + }, + { + "principalType": "ROLE", + "principalId": "$everyone", + "permission": "ALLOW", + "property": "resetPassword", + "accessType": "EXECUTE" + } + ], + "relations": { + "accessTokens": { + "type": "hasMany", + "model": "AccessToken", + "foreignKey": "userId" + } + } +} diff --git a/lib/builtin-models.js b/lib/builtin-models.js index 52bb2719..c57351ad 100644 --- a/lib/builtin-models.js +++ b/lib/builtin-models.js @@ -1,12 +1,19 @@ module.exports = function(loopback) { + // NOTE(bajtos) we must use static require() due to browserify limitations + loopback.Email = require('../common/models/email'); - loopback.User = require('../common/models/user'); + loopback.Application = require('../common/models/application'); loopback.AccessToken = require('../common/models/access-token'); loopback.Role = require('../common/models/role').Role; loopback.RoleMapping = require('../common/models/role').RoleMapping; loopback.ACL = require('../common/models/acl').ACL; loopback.Scope = require('../common/models/acl').Scope; + + loopback.User = createModel( + require('../common/models/user.json'), + require('../common/models/user.js')); + loopback.Change = require('../common/models/change'); loopback.Checkpoint = require('../common/models/checkpoint'); @@ -28,4 +35,10 @@ module.exports = function(loopback) { loopback.ACL.autoAttach = dataSourceTypes.DB; loopback.Scope.autoAttach = dataSourceTypes.DB; loopback.Application.autoAttach = dataSourceTypes.DB; + + function createModel(definitionJson, customizeFn) { + var Model = loopback.createModel(definitionJson); + customizeFn(Model); + return Model; + } }; From 551d109a201a7c94bd44cc4f33e75ce630dbd961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 10 Oct 2014 13:49:21 +0200 Subject: [PATCH 06/14] models: move Email LDL def into `email.json` --- common/models/email.js | 87 ++++++++++++++++++---------------------- common/models/email.json | 11 +++++ lib/builtin-models.js | 4 +- 3 files changed, 54 insertions(+), 48 deletions(-) create mode 100644 common/models/email.json diff --git a/common/models/email.js b/common/models/email.js index 0d0e7993..7c5c44f6 100644 --- a/common/models/email.js +++ b/common/models/email.js @@ -1,57 +1,50 @@ -/*! - * Module Dependencies. - */ - -var Model = require('../../lib/loopback').Model - , loopback = require('../../lib/loopback'); - -var properties = { - to: {type: String, required: true}, - from: {type: String, required: true}, - subject: {type: String, required: true}, - text: {type: String}, - html: {type: String} -}; - /** * @property {String} to Email addressee. Required. * @property {String} from Email sender address. Required. * @property {String} subject Email subject string. Required. - * @property {String} text Text body of email. + * @property {String} text Text body of email. * @property {String} html HTML body of email. - * - * @class + * + * @class Email * @inherits {Model} */ -var Email = module.exports = Model.extend('Email', properties); +module.exports = function(Email) { -/** - * Send an email with the given `options`. - * - * Example Options: - * - * ```js - * { - * from: "Fred Foo ", // sender address - * to: "bar@blurdybloop.com, baz@blurdybloop.com", // list of receivers - * subject: "Hello", // Subject line - * text: "Hello world", // plaintext body - * html: "Hello world" // html body - * } - * ``` - * - * See https://github.com/andris9/Nodemailer for other supported options. - * - * @options {Object} options See below - * @prop {String} from Senders's email address - * @prop {String} to List of one or more recipient email addresses (comma-delimited) - * @prop {String} subject Subject line - * @prop {String} text Body text - * @prop {String} html Body HTML (optional) - * @param {Function} callback Called after the e-mail is sent or the sending failed - */ + /** + * Send an email with the given `options`. + * + * Example Options: + * + * ```js + * { + * from: "Fred Foo ", // sender address + * to: "bar@blurdybloop.com, baz@blurdybloop.com", // list of receivers + * subject: "Hello", // Subject line + * text: "Hello world", // plaintext body + * html: "Hello world" // html body + * } + * ``` + * + * See https://github.com/andris9/Nodemailer for other supported options. + * + * @options {Object} options See below + * @prop {String} from Senders's email address + * @prop {String} to List of one or more recipient email addresses (comma-delimited) + * @prop {String} subject Subject line + * @prop {String} text Body text + * @prop {String} html Body HTML (optional) + * @param {Function} callback Called after the e-mail is sent or the sending failed + */ -Email.prototype.send = function() { - throw new Error('You must connect the Email Model to a Mail connector'); -} + Email.send = function() { + throw new Error('You must connect the Email Model to a Mail connector'); + }; + + /** + * A shortcut for Email.send(this). + */ + Email.prototype.send = function() { + throw new Error('You must connect the Email Model to a Mail connector'); + }; +}; diff --git a/common/models/email.json b/common/models/email.json new file mode 100644 index 00000000..6def3834 --- /dev/null +++ b/common/models/email.json @@ -0,0 +1,11 @@ +{ + "name": "Email", + "base": "Model", + "properties": { + "to": {"type": "String", "required": true}, + "from": {"type": "String", "required": true}, + "subject": {"type": "String", "required": true}, + "text": {"type": "String"}, + "html": {"type": "String"} + } +} diff --git a/lib/builtin-models.js b/lib/builtin-models.js index c57351ad..f95433b7 100644 --- a/lib/builtin-models.js +++ b/lib/builtin-models.js @@ -1,7 +1,9 @@ module.exports = function(loopback) { // NOTE(bajtos) we must use static require() due to browserify limitations - loopback.Email = require('../common/models/email'); + loopback.Email = createModel( + require('../common/models/email.json'), + require('../common/models/email.js')); loopback.Application = require('../common/models/application'); loopback.AccessToken = require('../common/models/access-token'); From 1e6beabbd272c665cb22138a1903f1b7dd51b5de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 13 Oct 2014 08:41:31 +0200 Subject: [PATCH 07/14] models: move Application LDL def into a json file Move some of the comments describing properties into jsdoc. --- common/models/application.js | 339 +++++++++++++-------------------- common/models/application.json | 121 ++++++++++++ lib/builtin-models.js | 5 +- 3 files changed, 261 insertions(+), 204 deletions(-) create mode 100644 common/models/application.json diff --git a/common/models/application.js b/common/models/application.js index 4f3a066d..22892eb1 100644 --- a/common/models/application.js +++ b/common/models/application.js @@ -1,95 +1,5 @@ -var loopback = require('../../lib/loopback'); var assert = require('assert'); -// Authentication schemes -var AuthenticationSchemeSchema = { - scheme: String, // local, facebook, google, twitter, linkedin, github - credential: Object // Scheme-specific credentials -}; - -// See https://github.com/argon/node-apn/blob/master/doc/apn.markdown -var APNSSettingSchema = { - /** - * production or development mode. It denotes what default APNS servers to be - * used to send notifications - * - true (production mode) - * - push: gateway.push.apple.com:2195 - * - feedback: feedback.push.apple.com:2196 - * - false (development mode, the default) - * - push: gateway.sandbox.push.apple.com:2195 - * - feedback: feedback.sandbox.push.apple.com:2196 - */ - production: Boolean, - certData: String, // The certificate data loaded from the cert.pem file - keyData: String, // The key data loaded from the key.pem file - - pushOptions: {type: { - gateway: String, - port: Number - }}, - - feedbackOptions: {type: { - gateway: String, - port: Number, - batchFeedback: Boolean, - interval: Number - }} -}; - -var GcmSettingsSchema = { - serverApiKey: String -}; - -// Push notification settings -var PushNotificationSettingSchema = { - apns: APNSSettingSchema, - gcm: GcmSettingsSchema -}; - -/*! - * Data model for Application - */ -var ApplicationSchema = { - id: {type: String, id: true}, - // Basic information - name: {type: String, required: true}, // The name - description: String, // The description - icon: String, // The icon image url - - owner: String, // The user id of the developer who registers the application - collaborators: [String], // A list of users ids who have permissions to work on this app - - // EMail - email: String, // e-mail address - emailVerified: Boolean, // Is the e-mail verified - - // oAuth 2.0 settings - url: String, // The application url - callbackUrls: [String], // oAuth 2.0 code/token callback url - permissions: [String], // A list of permissions required by the application - - // Keys - clientKey: String, - javaScriptKey: String, - restApiKey: String, - windowsKey: String, - masterKey: String, - - // Push notification - pushSettings: PushNotificationSettingSchema, - - // User Authentication - authenticationEnabled: {type: Boolean, default: true}, - anonymousAllowed: {type: Boolean, default: true}, - authenticationSchemes: [AuthenticationSchemeSchema], - - status: {type: String, default: 'sandbox'}, // Status of the application, production/sandbox/disabled - - // Timestamps - created: {type: Date, default: Date}, - modified: {type: Date, default: Date} -}; - /*! * Application management functions */ @@ -109,7 +19,7 @@ function generateKey(hmacKey, algorithm, encoding) { /** * Manage client applications and organize their users. - * + * * @property {String} id Generated ID. * @property {String} name Name; required. * @property {String} description Text description @@ -122,7 +32,10 @@ function generateKey(hmacKey, algorithm, encoding) { * @property {String} status Status of the application; Either `production`, `sandbox` (default), or `disabled`. * @property {Date} created Date Application object was created. Default: current date. * @property {Date} modified Date Application object was modified. Default: current date. - * + * + * @property {Object} pushSettings.apns APNS configuration, see the options + * below and also + * https://github.com/argon/node-apn/blob/master/doc/apn.markdown * @property {Boolean} pushSettings.apns.production Whether to use production Apple Push Notification Service (APNS) servers to send push notifications. * If true, uses `gateway.push.apple.com:2195` and `feedback.push.apple.com:2196`. * If false, uses `gateway.sandbox.push.apple.com:2195` and `feedback.sandbox.push.apple.com:2196` @@ -135,120 +48,140 @@ function generateKey(hmacKey, algorithm, encoding) { * @property {Boolean} pushSettings.apns.feedbackOptions.batchFeedback (APNS). * @property {Number} pushSettings.apns.feedbackOptions.interval (APNS). * @property {String} pushSettings.gcm.serverApiKey: Google Cloud Messaging API key. - * - * @class - * @inherits {Model} - */ - -var Application = loopback.PersistedModel.extend('Application', ApplicationSchema); - -/*! - * A hook to generate keys before creation - * @param next - */ -Application.beforeCreate = function (next) { - var app = this; - app.created = app.modified = new Date(); - app.id = generateKey('id', 'md5'); - app.clientKey = generateKey('client'); - app.javaScriptKey = generateKey('javaScript'); - app.restApiKey = generateKey('restApi'); - app.windowsKey = generateKey('windows'); - app.masterKey = generateKey('master'); - next(); -}; - -/** - * Register a new application - * @param {String} owner Owner's user ID. - * @param {String} name Name of the application - * @param {Object} options Other options - * @param {Function} callback Callback function - */ -Application.register = function (owner, name, options, cb) { - assert(owner, 'owner is required'); - assert(name, 'name is required'); - - if (typeof options === 'function' && !cb) { - cb = options; - options = {}; - } - var props = {owner: owner, name: name}; - for (var p in options) { - if (!(p in props)) { - props[p] = options[p]; - } - } - this.create(props, cb); -}; - -/** - * Reset keys for the application instance - * @callback {Function} callback - * @param {Error} err - */ -Application.prototype.resetKeys = function (cb) { - this.clientKey = generateKey('client'); - this.javaScriptKey = generateKey('javaScript'); - this.restApiKey = generateKey('restApi'); - this.windowsKey = generateKey('windows'); - this.masterKey = generateKey('master'); - this.modified = new Date(); - this.save(cb); -}; - -/** - * Reset keys for a given application by the appId - * @param {Any} appId - * @callback {Function} callback - * @param {Error} err - */ -Application.resetKeys = function (appId, cb) { - this.findById(appId, function (err, app) { - if (err) { - cb && cb(err, app); - return; - } - app.resetKeys(cb); - }); -}; - -/** - * Authenticate the application id and key. - * - * `matched` parameter is one of: - * - clientKey - * - javaScriptKey - * - restApiKey - * - windowsKey - * - masterKey * - * @param {Any} appId - * @param {String} key - * @callback {Function} callback - * @param {Error} err - * @param {String} matched The matching key + * @property {Boolean} authenticationEnabled + * @property {Boolean} anonymousAllowed + * @property {Array} authenticationSchemes List of authentication schemes + * (see below). + * @property {String} authenticationSchemes.scheme Scheme name. + * Supported values: `local`, `facebook`, `google`, + * `twitter`, `linkedin`, `github`. + * @property {Object} authenticationSchemes.credential + * Scheme-specific credentials. + * + * @class Application + * @inherits {PersistedModel} */ -Application.authenticate = function (appId, key, cb) { - this.findById(appId, function (err, app) { - if (err || !app) { - cb && cb(err, null); - return; + +module.exports = function(Application) { + + // Workaround for https://github.com/strongloop/loopback/issues/292 + Application.definition.rawProperties.created.default = + Application.definition.properties.created.default = function() { + return new Date(); + }; + + // Workaround for https://github.com/strongloop/loopback/issues/292 + Application.definition.rawProperties.modified.default = + Application.definition.properties.modified.default = function() { + return new Date(); + }; + + /*! + * A hook to generate keys before creation + * @param next + */ + Application.beforeCreate = function(next) { + var app = this; + app.created = app.modified = new Date(); + app.id = generateKey('id', 'md5'); + app.clientKey = generateKey('client'); + app.javaScriptKey = generateKey('javaScript'); + app.restApiKey = generateKey('restApi'); + app.windowsKey = generateKey('windows'); + app.masterKey = generateKey('master'); + next(); + }; + + /** + * Register a new application + * @param {String} owner Owner's user ID. + * @param {String} name Name of the application + * @param {Object} options Other options + * @param {Function} callback Callback function + */ + Application.register = function(owner, name, options, cb) { + assert(owner, 'owner is required'); + assert(name, 'name is required'); + + if (typeof options === 'function' && !cb) { + cb = options; + options = {}; } - var result = null; - var keyNames = ['clientKey', 'javaScriptKey', 'restApiKey', 'windowsKey', 'masterKey']; - for (var i = 0; i < keyNames.length; i++) { - if (app[keyNames[i]] === key) { - result = { - application: app, - keyType: keyNames[i] - }; - break; + var props = {owner: owner, name: name}; + for (var p in options) { + if (!(p in props)) { + props[p] = options[p]; } } - cb && cb(null, result); - }); + this.create(props, cb); + }; + + /** + * Reset keys for the application instance + * @callback {Function} callback + * @param {Error} err + */ + Application.prototype.resetKeys = function(cb) { + this.clientKey = generateKey('client'); + this.javaScriptKey = generateKey('javaScript'); + this.restApiKey = generateKey('restApi'); + this.windowsKey = generateKey('windows'); + this.masterKey = generateKey('master'); + this.modified = new Date(); + this.save(cb); + }; + + /** + * Reset keys for a given application by the appId + * @param {Any} appId + * @callback {Function} callback + * @param {Error} err + */ + Application.resetKeys = function(appId, cb) { + this.findById(appId, function(err, app) { + if (err) { + cb && cb(err, app); + return; + } + app.resetKeys(cb); + }); + }; + + /** + * Authenticate the application id and key. + * + * `matched` parameter is one of: + * - clientKey + * - javaScriptKey + * - restApiKey + * - windowsKey + * - masterKey + * + * @param {Any} appId + * @param {String} key + * @callback {Function} callback + * @param {Error} err + * @param {String} matched The matching key + */ + Application.authenticate = function(appId, key, cb) { + this.findById(appId, function(err, app) { + if (err || !app) { + cb && cb(err, null); + return; + } + var result = null; + var keyNames = ['clientKey', 'javaScriptKey', 'restApiKey', 'windowsKey', 'masterKey']; + for (var i = 0; i < keyNames.length; i++) { + if (app[keyNames[i]] === key) { + result = { + application: app, + keyType: keyNames[i] + }; + break; + } + } + cb && cb(null, result); + }); + }; }; - -module.exports = Application; - diff --git a/common/models/application.json b/common/models/application.json new file mode 100644 index 00000000..8b053ced --- /dev/null +++ b/common/models/application.json @@ -0,0 +1,121 @@ +{ + "name": "Application", + "properties": { + "id": { + "type": "string", + "id": true + }, + "name": { + "type": "string", + "required": true + }, + "description": "string", + "icon": { + "type": "string", + "description": "The icon image url" + }, + + "owner": { + "type": "string", + "description": "The user id of the developer who registers the application" + }, + "collaborators": { + "type": ["string"], + "description": "A list of users ids who have permissions to work on this app" + }, + + "email": "string", + "emailVerified": "boolean", + + "url": { + "type": "string", + "description": "The application URL for OAuth 2.0" + }, + "callbackUrls": { + "type": ["string"], + "description": "OAuth 2.0 code/token callback URLs" + }, + "permissions": { + "type": ["string"], + "description": "A list of permissions required by the application" + }, + + "clientKey": "string", + "javaScriptKey": "string", + "restApiKey": "string", + "windowsKey": "string", + "masterKey": "string", + + "pushSettings": { + "apns": { + "production": { + "type": "boolean", + "description": [ + "Production or development mode. It denotes what default APNS", + "servers to be used to send notifications.", + "See API documentation for more details." + ] + }, + + "certData": { + "type": "string", + "description": "The certificate data loaded from the cert.pem file" + }, + "keyData": { + "type": "string", + "description": "The key data loaded from the key.pem file" + }, + + "pushOptions": { + "type": { + "gateway": "string", + "port": "number" + } + }, + + "feedbackOptions": { + "type": { + "gateway": "string", + "port": "number", + "batchFeedback": "boolean", + "interval": "number" + } + } + }, + + "gcm": { + "serverApiKey": "string" + } + }, + + "authenticationEnabled": { + "type": "boolean", + "default": true + }, + "anonymousAllowed": { + "type": "boolean", + "default": true + }, + "authenticationSchemes": [ + { + "scheme": { + "type": "string", + "description": "See the API docs for the list of supported values." + }, + "credential": { + "type": "object", + "description": "Scheme-specific credentials" + } + } + ], + + "status": { + "type": "string", + "default": "sandbox", + "description": "Status of the application, production/sandbox/disabled" + }, + + "created": "date", + "modified": "date" + } +} diff --git a/lib/builtin-models.js b/lib/builtin-models.js index f95433b7..ef847a07 100644 --- a/lib/builtin-models.js +++ b/lib/builtin-models.js @@ -5,7 +5,10 @@ module.exports = function(loopback) { require('../common/models/email.json'), require('../common/models/email.js')); - loopback.Application = require('../common/models/application'); + loopback.Application = createModel( + require('../common/models/application.json'), + require('../common/models/application.js')); + loopback.AccessToken = require('../common/models/access-token'); loopback.Role = require('../common/models/role').Role; loopback.RoleMapping = require('../common/models/role').RoleMapping; From 5f20652241145db44b186eb95e1e083acb34f77f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 13 Oct 2014 10:23:35 +0200 Subject: [PATCH 08/14] models: move AccessToken LDL def into a json file --- common/models/access-token.js | 337 +++++++++++++++----------------- common/models/access-token.json | 38 ++++ lib/access-context.js | 5 +- lib/builtin-models.js | 5 +- 4 files changed, 199 insertions(+), 186 deletions(-) create mode 100644 common/models/access-token.json diff --git a/common/models/access-token.js b/common/models/access-token.js index 8243747d..bfb9cb6a 100644 --- a/common/models/access-token.js +++ b/common/models/access-token.js @@ -4,231 +4,202 @@ var loopback = require('../../lib/loopback') , assert = require('assert') - , crypto = require('crypto') , uid = require('uid2') - , DEFAULT_TTL = 1209600 // 2 weeks in seconds , DEFAULT_TOKEN_LEN = 64 , Role = require('./role').Role , ACL = require('./acl').ACL; -/*! - * Default AccessToken properties. - */ - -var properties = { - id: {type: String, id: true}, - ttl: {type: Number, ttl: true, default: DEFAULT_TTL}, // time to live in seconds - created: {type: Date, default: function() { - return new Date(); - }} -}; - /** * Token based authentication and access control. * * **Default ACLs** - * + * * - DENY EVERYONE `*` * - ALLOW EVERYONE create * * @property {String} id Generated token ID - * @property {Number} ttl Time to live in seconds + * @property {Number} ttl Time to live in seconds, 2 weeks by default. * @property {Date} created When the token was created - * - * @class + * + * @class AccessToken * @inherits {PersistedModel} */ -var AccessToken = module.exports = - loopback.PersistedModel.extend('AccessToken', properties, { - acls: [ - { - principalType: ACL.ROLE, - principalId: Role.EVERYONE, - permission: 'DENY' - }, - { - principalType: ACL.ROLE, - principalId: Role.EVERYONE, - property: 'create', - permission: 'ALLOW' - } - ], - relations: { - user: { - type: 'belongsTo', - model: 'User', - foreignKey: 'userId' - } - } -}); +module.exports = function(AccessToken) { -/** - * Anonymous Token - * - * ```js - * assert(AccessToken.ANONYMOUS.id === '$anonymous'); - * ``` - */ + // Workaround for https://github.com/strongloop/loopback/issues/292 + AccessToken.definition.rawProperties.created.default = + AccessToken.definition.properties.created.default = function() { + return new Date(); + }; -AccessToken.ANONYMOUS = new AccessToken({id: '$anonymous'}); + /** + * Anonymous Token + * + * ```js + * assert(AccessToken.ANONYMOUS.id === '$anonymous'); + * ``` + */ -/** - * Create a cryptographically random access token id. - * - * @callback {Function} callback - * @param {Error} err - * @param {String} token - */ + AccessToken.ANONYMOUS = new AccessToken({id: '$anonymous'}); -AccessToken.createAccessTokenId = function (fn) { - uid(this.settings.accessTokenIdLength || DEFAULT_TOKEN_LEN, function(err, guid) { - if(err) { - fn(err); - } else { - fn(null, guid); - } - }); -} + /** + * Create a cryptographically random access token id. + * + * @callback {Function} callback + * @param {Error} err + * @param {String} token + */ -/*! - * Hook to create accessToken id. - */ - -AccessToken.beforeCreate = function (next, data) { - data = data || {}; - - AccessToken.createAccessTokenId(function (err, id) { - if(err) { - next(err); - } else { - data.id = id; - - next(); - } - }); -} - -/** - * Find a token for the given `ServerRequest`. - * - * @param {ServerRequest} req - * @param {Object} [options] Options for finding the token - * @callback {Function} callback - * @param {Error} err - * @param {AccessToken} token - */ - -AccessToken.findForRequest = function(req, options, cb) { - var id = tokenIdForRequest(req, options); - - if(id) { - this.findById(id, function(err, token) { - if(err) { - cb(err); - } else if(token) { - token.validate(function(err, isValid) { - if(err) { - cb(err); - } else if(isValid) { - cb(null, token); - } else { - var e = new Error('Invalid Access Token'); - e.status = e.statusCode = 401; - cb(e); - } - }); + AccessToken.createAccessTokenId = function(fn) { + uid(this.settings.accessTokenIdLength || DEFAULT_TOKEN_LEN, function(err, guid) { + if (err) { + fn(err); } else { - cb(); + fn(null, guid); } }); - } else { - process.nextTick(function() { - cb(); + } + + /*! + * Hook to create accessToken id. + */ + + AccessToken.beforeCreate = function(next, data) { + data = data || {}; + + AccessToken.createAccessTokenId(function(err, id) { + if (err) { + next(err); + } else { + data.id = id; + + next(); + } }); } -} -/** - * Validate the token. - * - * @callback {Function} callback - * @param {Error} err - * @param {Boolean} isValid - */ + /** + * Find a token for the given `ServerRequest`. + * + * @param {ServerRequest} req + * @param {Object} [options] Options for finding the token + * @callback {Function} callback + * @param {Error} err + * @param {AccessToken} token + */ -AccessToken.prototype.validate = function(cb) { - try { - assert( - this.created && typeof this.created.getTime === 'function', - 'token.created must be a valid Date' - ); - assert(this.ttl !== 0, 'token.ttl must be not be 0'); - assert(this.ttl, 'token.ttl must exist'); - assert(this.ttl >= -1, 'token.ttl must be >= -1'); + AccessToken.findForRequest = function(req, options, cb) { + var id = tokenIdForRequest(req, options); - var now = Date.now(); - var created = this.created.getTime(); - var elapsedSeconds = (now - created) / 1000; - var secondsToLive = this.ttl; - var isValid = elapsedSeconds < secondsToLive; - - if(isValid) { - cb(null, isValid); + if (id) { + this.findById(id, function(err, token) { + if (err) { + cb(err); + } else if (token) { + token.validate(function(err, isValid) { + if (err) { + cb(err); + } else if (isValid) { + cb(null, token); + } else { + var e = new Error('Invalid Access Token'); + e.status = e.statusCode = 401; + cb(e); + } + }); + } else { + cb(); + } + }); } else { - this.destroy(function(err) { - cb(err, isValid); + process.nextTick(function() { + cb(); }); } - } catch(e) { - cb(e); - } -} - -function tokenIdForRequest(req, options) { - var params = options.params || []; - var headers = options.headers || []; - var cookies = options.cookies || []; - var i = 0; - var length; - var id; - - params = params.concat(['access_token']); - headers = headers.concat(['X-Access-Token', 'authorization']); - cookies = cookies.concat(['access_token', 'authorization']); - - for(length = params.length; i < length; i++) { - id = req.param(params[i]); - - if(typeof id === 'string') { - return id; - } } - for(i = 0, length = headers.length; i < length; i++) { - id = req.header(headers[i]); + /** + * Validate the token. + * + * @callback {Function} callback + * @param {Error} err + * @param {Boolean} isValid + */ - if(typeof id === 'string') { - // Add support for oAuth 2.0 bearer token - // http://tools.ietf.org/html/rfc6750 - if (id.indexOf('Bearer ') === 0) { - id = id.substring(7); - // Decode from base64 - var buf = new Buffer(id, 'base64'); - id = buf.toString('utf8'); + AccessToken.prototype.validate = function(cb) { + try { + assert( + this.created && typeof this.created.getTime === 'function', + 'token.created must be a valid Date' + ); + assert(this.ttl !== 0, 'token.ttl must be not be 0'); + assert(this.ttl, 'token.ttl must exist'); + assert(this.ttl >= -1, 'token.ttl must be >= -1'); + + var now = Date.now(); + var created = this.created.getTime(); + var elapsedSeconds = (now - created) / 1000; + var secondsToLive = this.ttl; + var isValid = elapsedSeconds < secondsToLive; + + if (isValid) { + cb(null, isValid); + } else { + this.destroy(function(err) { + cb(err, isValid); + }); } - return id; + } catch (e) { + cb(e); } } - if(req.signedCookies) { - for(i = 0, length = cookies.length; i < length; i++) { - id = req.signedCookies[cookies[i]]; + function tokenIdForRequest(req, options) { + var params = options.params || []; + var headers = options.headers || []; + var cookies = options.cookies || []; + var i = 0; + var length; + var id; - if(typeof id === 'string') { + params = params.concat(['access_token']); + headers = headers.concat(['X-Access-Token', 'authorization']); + cookies = cookies.concat(['access_token', 'authorization']); + + for (length = params.length; i < length; i++) { + id = req.param(params[i]); + + if (typeof id === 'string') { return id; } } + + for (i = 0, length = headers.length; i < length; i++) { + id = req.header(headers[i]); + + if (typeof id === 'string') { + // Add support for oAuth 2.0 bearer token + // http://tools.ietf.org/html/rfc6750 + if (id.indexOf('Bearer ') === 0) { + id = id.substring(7); + // Decode from base64 + var buf = new Buffer(id, 'base64'); + id = buf.toString('utf8'); + } + return id; + } + } + + if (req.signedCookies) { + for (i = 0, length = cookies.length; i < length; i++) { + id = req.signedCookies[cookies[i]]; + + if (typeof id === 'string') { + return id; + } + } + } + return null; } - return null; -} +}; diff --git a/common/models/access-token.json b/common/models/access-token.json new file mode 100644 index 00000000..a5f360c4 --- /dev/null +++ b/common/models/access-token.json @@ -0,0 +1,38 @@ +{ + "name": "AccessToken", + "properties": { + "id": { + "type": "string", + "id": true + }, + "ttl": { + "type": "number", + "ttl": true, + "default": 1209600, + "description": "time to live in seconds (2 weeks by default)" + }, + "created": { + "type": "Date" + } + }, + "relations": { + "user": { + "type": "belongsTo", + "model": "User", + "foreignKey": "userId" + } + }, + "acls": [ + { + "principalType": "ROLE", + "principalId": "$everyone", + "permission": "DENY" + }, + { + "principalType": "ROLE", + "principalId": "$everyone", + "property": "create", + "permission": "ALLOW" + } + ] +} diff --git a/lib/access-context.js b/lib/access-context.js index ce8457f1..89edebaf 100644 --- a/lib/access-context.js +++ b/lib/access-context.js @@ -1,5 +1,4 @@ var loopback = require('./loopback'); -var AccessToken = require('../common/models/access-token'); var debug = require('debug')('loopback:security:access-context'); /** @@ -49,7 +48,9 @@ function AccessContext(context) { } this.accessType = context.accessType || AccessContext.ALL; - this.accessToken = context.accessToken || AccessToken.ANONYMOUS; + assert(loopback.AccessToken, + 'AccessToken model must be defined before AccessContext model'); + this.accessToken = context.accessToken || loopback.AccessToken.ANONYMOUS; var principalType = context.principalType || Principal.USER; var principalId = context.principalId || undefined; diff --git a/lib/builtin-models.js b/lib/builtin-models.js index ef847a07..23a27de3 100644 --- a/lib/builtin-models.js +++ b/lib/builtin-models.js @@ -9,7 +9,10 @@ module.exports = function(loopback) { require('../common/models/application.json'), require('../common/models/application.js')); - loopback.AccessToken = require('../common/models/access-token'); + loopback.AccessToken = createModel( + require('../common/models/access-token.json'), + require('../common/models/access-token.js')); + loopback.Role = require('../common/models/role').Role; loopback.RoleMapping = require('../common/models/role').RoleMapping; loopback.ACL = require('../common/models/acl').ACL; From ef890d5f2639d7642720651f5f7daa9ef94d6813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 13 Oct 2014 10:46:55 +0200 Subject: [PATCH 09/14] models: move Scope def into its own files --- common/models/acl.js | 43 ---------------------------------------- common/models/scope.js | 39 ++++++++++++++++++++++++++++++++++++ common/models/scope.json | 14 +++++++++++++ docs.json | 1 + lib/builtin-models.js | 5 ++++- 5 files changed, 58 insertions(+), 44 deletions(-) create mode 100644 common/models/scope.js create mode 100644 common/models/scope.json diff --git a/common/models/acl.js b/common/models/acl.js index 64f38219..0b210ad3 100644 --- a/common/models/acl.js +++ b/common/models/acl.js @@ -468,47 +468,4 @@ ACL.checkAccessForToken = function (token, model, modelId, method, callback) { }); }; -/*! - * 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 - * @class - */ -var Scope = loopback.createModel('Scope', ScopeSchema); - - -/** - * Check if the given scope is allowed to access the model/property - * @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 - * @callback {Function} callback - * @param {String|Error} err The error object - * @param {AccessRequest} result The access permission - */ -Scope.checkPermission = function (scope, model, property, accessType, callback) { - this.findOne({where: {name: scope}}, function (err, scope) { - if (err) { - callback && callback(err); - } else { - var aclModel = loopback.getModelByType(ACL); - aclModel.checkPermission(ACL.SCOPE, scope.id, model, property, accessType, callback); - } - }); -}; - module.exports.ACL = ACL; -module.exports.Scope = Scope; diff --git a/common/models/scope.js b/common/models/scope.js new file mode 100644 index 00000000..4a96e4a3 --- /dev/null +++ b/common/models/scope.js @@ -0,0 +1,39 @@ +var assert = require('assert'); + +/** + * 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 + * + * @class Scope + */ + +module.exports = function(Scope) { + /** + * Check if the given scope is allowed to access the model/property + * @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 + * @callback {Function} callback + * @param {String|Error} err The error object + * @param {AccessRequest} result The access permission + */ + Scope.checkPermission = function (scope, model, property, accessType, callback) { + var ACL = loopback.ACL; + assert(ACL, + 'ACL model must be defined before Scope.checkPermission is called'); + + this.findOne({where: {name: scope}}, function (err, scope) { + if (err) { + callback && callback(err); + } else { + var aclModel = loopback.getModelByType(ACL); + aclModel.checkPermission(ACL.SCOPE, scope.id, model, property, accessType, callback); + } + }); + }; +}; diff --git a/common/models/scope.json b/common/models/scope.json new file mode 100644 index 00000000..7786c946 --- /dev/null +++ b/common/models/scope.json @@ -0,0 +1,14 @@ +{ + "name": "Scope", + "description": [ + "Schema for Scope which represents the permissions that are granted", + "to client applications by the resource owner" + ], + "properties": { + "name": { + "type": "string", + "required": true + }, + "description": "string" + } +} diff --git a/docs.json b/docs.json index 71f47537..b554576a 100644 --- a/docs.json +++ b/docs.json @@ -16,6 +16,7 @@ { "title": "Built-in models", "depth": 2 }, "common/models/access-token.js", "common/models/acl.js", + "common/models/scope.js", "common/models/application.js", "common/models/email.js", "common/models/role.js", diff --git a/lib/builtin-models.js b/lib/builtin-models.js index 23a27de3..d8df2fb2 100644 --- a/lib/builtin-models.js +++ b/lib/builtin-models.js @@ -16,7 +16,10 @@ module.exports = function(loopback) { loopback.Role = require('../common/models/role').Role; loopback.RoleMapping = require('../common/models/role').RoleMapping; loopback.ACL = require('../common/models/acl').ACL; - loopback.Scope = require('../common/models/acl').Scope; + + loopback.Scope = createModel( + require('../common/models/scope.json'), + require('../common/models/scope.js')); loopback.User = createModel( require('../common/models/user.json'), From 7c01d59d809ce3a988c06d40ca27e84e6936658b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 13 Oct 2014 10:55:08 +0200 Subject: [PATCH 10/14] models: move ACL LDL def into a json file --- common/models/access-token.js | 4 +- common/models/acl.js | 683 +++++++++++++++++----------------- common/models/acl.json | 17 + lib/access-context.js | 2 +- lib/builtin-models.js | 5 +- 5 files changed, 360 insertions(+), 351 deletions(-) create mode 100644 common/models/acl.json diff --git a/common/models/access-token.js b/common/models/access-token.js index bfb9cb6a..03185009 100644 --- a/common/models/access-token.js +++ b/common/models/access-token.js @@ -5,9 +5,7 @@ var loopback = require('../../lib/loopback') , assert = require('assert') , uid = require('uid2') - , DEFAULT_TOKEN_LEN = 64 - , Role = require('./role').Role - , ACL = require('./acl').ACL; + , DEFAULT_TOKEN_LEN = 64; /** * Token based authentication and access control. diff --git a/common/models/acl.js b/common/models/acl.js index 0b210ad3..d665adba 100644 --- a/common/models/acl.js +++ b/common/models/acl.js @@ -45,6 +45,8 @@ var role = require('./role'); var Role = role.Role; /** + * A Model for access control meta data. + * * System grants permissions to principals (users/applications, can be grouped * into roles). * @@ -54,18 +56,6 @@ var Role = role.Role; * 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 - accessType: String, - permission: String, - principalType: String, - principalId: String -}; - -/** - * A Model for access control meta data. * * @header ACL * @property {String} model Name of the model. @@ -78,394 +68,395 @@ var ACLSchema = { * - DENY: Explicitly denies access to the resource. * @property {String} principalType Type of the principal; one of: Application, Use, Role. * @property {String} principalId ID of the principal - such as appId, userId or roleId - * @class - * @inherits Model + * + * @class ACL + * @inherits PersistedModel */ -var ACL = loopback.PersistedModel.extend('ACL', ACLSchema); +module.exports = function(ACL) { -ACL.ALL = AccessContext.ALL; + ACL.ALL = AccessContext.ALL; -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.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 = AccessContext.READ; // Read operation -ACL.WRITE = AccessContext.WRITE; // Write operation -ACL.EXECUTE = AccessContext.EXECUTE; // Execute operation + ACL.READ = AccessContext.READ; // Read operation + ACL.WRITE = AccessContext.WRITE; // Write operation + ACL.EXECUTE = AccessContext.EXECUTE; // Execute operation -ACL.USER = Principal.USER; -ACL.APP = ACL.APPLICATION = Principal.APPLICATION; -ACL.ROLE = Principal.ROLE; -ACL.SCOPE = Principal.SCOPE; + 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 {ACL} rule The ACL entry - * @param {AccessRequest} req The request - * @returns {Number} - */ -ACL.getMatchingScore = function getMatchingScore(rule, req) { - var props = ['model', 'property', 'accessType']; - var score = 0; + /** + * Calculate the matching score for the given rule and request + * @param {ACL} rule The ACL entry + * @param {AccessRequest} req The request + * @returns {Number} + */ + ACL.getMatchingScore = function getMatchingScore(rule, req) { + var props = ['model', 'property', 'accessType']; + var score = 0; - for (var i = 0; i < props.length; i++) { - // Shift the score by 4 for each of the properties as the weight - score = score * 4; - var val1 = rule[props[i]] || ACL.ALL; - var val2 = req[props[i]] || ACL.ALL; - var isMatchingMethodName = props[i] === 'property' && req.methodNames.indexOf(val1) !== -1; + for (var i = 0; i < props.length; i++) { + // Shift the score by 4 for each of the properties as the weight + score = score * 4; + var val1 = rule[props[i]] || ACL.ALL; + var val2 = req[props[i]] || ACL.ALL; + var isMatchingMethodName = props[i] === 'property' && req.methodNames.indexOf(val1) !== -1; - if (val1 === val2 || isMatchingMethodName) { - // Exact match - score += 3; - } else if (val1 === ACL.ALL) { - // Wildcard match - score += 2; - } else if (val2 === ACL.ALL) { - // Doesn't match at all - score += 1; - } else { - return -1; + if (val1 === val2 || isMatchingMethodName) { + // Exact match + score += 3; + } else if (val1 === ACL.ALL) { + // Wildcard match + score += 2; + } else if (val2 === ACL.ALL) { + // Doesn't match at all + score += 1; + } else { + return -1; + } } - } - // Weigh against the principal type into 4 levels - // - user level (explicitly allow/deny a given user) - // - app level (explicitly allow/deny a given app) - // - role level (role based authorization) - // - other - // user > app > role > ... - 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 - // everyone < authenticated/unauthenticated < related < owner < ... - score = score * 8; - if(rule.principalType === ACL.ROLE) { - switch(rule.principalId) { - case Role.OWNER: + // Weigh against the principal type into 4 levels + // - user level (explicitly allow/deny a given user) + // - app level (explicitly allow/deny a given app) + // - role level (role based authorization) + // - other + // user > app > role > ... + score = score * 4; + switch (rule.principalType) { + case ACL.USER: score += 4; break; - case Role.RELATED: + case ACL.APP: score += 3; break; - case Role.AUTHENTICATED: - case Role.UNAUTHENTICATED: + case ACL.ROLE: score += 2; break; - case Role.EVERYONE: - score += 1; - break; default: - score += 5; + score += 1; } - } - score = score * 4; - score += AccessContext.permissionOrder[rule.permission || ACL.ALLOW] - 1; - return score; -}; -/** - * Get matching score for the given `AccessRequest`. - * @param {AccessRequest} req The request - * @returns {Number} score - */ - -ACL.prototype.score = function(req) { - return this.constructor.getMatchingScore(this, req); -} - -/*! - * Resolve permission from the ACLs - * @param {Object[]) acls The list of ACLs - * @param {Object} req The request - * @returns {AccessRequest} result The effective ACL - */ -ACL.resolvePermission = function resolvePermission(acls, req) { - if(!(req instanceof AccessRequest)) { - req = new AccessRequest(req); - } - // Sort by the matching score in descending order - acls = acls.sort(function (rule1, rule2) { - 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 = ACL.getMatchingScore(acls[i], req); - if (score < 0) { - // the highest scored ACL did not match - break; + // Weigh against the roles + // everyone < authenticated/unauthenticated < related < owner < ... + 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; + } } - if (!req.isWildcard()) { - // We should stop from the first match for non-wildcard - permission = acls[i].permission; - break; - } else { - if(req.exactlyMatches(acls[i])) { - permission = acls[i].permission; + score = score * 4; + score += AccessContext.permissionOrder[rule.permission || ACL.ALLOW] - 1; + return score; + }; + + /** + * Get matching score for the given `AccessRequest`. + * @param {AccessRequest} req The request + * @returns {Number} score + */ + + ACL.prototype.score = function(req) { + return this.constructor.getMatchingScore(this, req); + } + + /*! + * Resolve permission from the ACLs + * @param {Object[]) acls The list of ACLs + * @param {Object} req The request + * @returns {AccessRequest} result The effective ACL + */ + ACL.resolvePermission = function resolvePermission(acls, req) { + if (!(req instanceof AccessRequest)) { + req = new AccessRequest(req); + } + // Sort by the matching score in descending order + acls = acls.sort(function(rule1, rule2) { + 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 = ACL.getMatchingScore(acls[i], req); + if (score < 0) { + // the highest scored ACL did not match break; } - // For wildcard match, find the strongest permission - if(AccessContext.permissionOrder[acls[i].permission] - > AccessContext.permissionOrder[permission]) { + if (!req.isWildcard()) { + // We should stop from the first match for non-wildcard permission = acls[i].permission; + break; + } else { + if (req.exactlyMatches(acls[i])) { + permission = acls[i].permission; + break; + } + // For wildcard match, find the strongest permission + if (AccessContext.permissionOrder[acls[i].permission] + > AccessContext.permissionOrder[permission]) { + permission = acls[i].permission; + } } } - } - if(debug.enabled) { - debug('The following ACLs were searched: '); - acls.forEach(function(acl) { - acl.debug(); - debug('with score:', acl.score(req)); - }); - } + if (debug.enabled) { + debug('The following ACLs were searched: '); + acls.forEach(function(acl) { + acl.debug(); + debug('with score:', acl.score(req)); + }); + } - var res = new AccessRequest(req.model, req.property, req.accessType, - permission || ACL.DEFAULT); - return res; -}; + var res = new AccessRequest(req.model, req.property, req.accessType, + permission || ACL.DEFAULT); + return res; + }; -/*! - * Get the static ACLs from the model definition - * @param {String} model The model name - * @param {String} property The property/method/relation name - * - * @return {Object[]} An array of ACLs - */ -ACL.getStaticACLs = function getStaticACLs(model, property) { - var modelClass = loopback.findModel(model); - var staticACLs = []; - if (modelClass && modelClass.settings.acls) { - modelClass.settings.acls.forEach(function (acl) { - if (!acl.property || acl.property === ACL.ALL - || property === acl.property) { + /*! + * Get the static ACLs from the model definition + * @param {String} model The model name + * @param {String} property The property/method/relation name + * + * @return {Object[]} An array of ACLs + */ + ACL.getStaticACLs = function getStaticACLs(model, property) { + var modelClass = loopback.findModel(model); + var staticACLs = []; + if (modelClass && modelClass.settings.acls) { + modelClass.settings.acls.forEach(function(acl) { + if (!acl.property || acl.property === ACL.ALL + || property === acl.property) { + 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 || ACL.ALL, + permission: acl.permission + })); + } + }); + } + 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(new ACL({ - model: model, - property: acl.property || ACL.ALL, + model: modelClass.modelName, + property: property, principalType: acl.principalType, - principalId: acl.principalId, // TODO: Should it be a name? - accessType: acl.accessType || ACL.ALL, + principalId: acl.principalId, + accessType: acl.accessType, permission: acl.permission })); - } - }); - } - 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(new ACL({ - model: modelClass.modelName, - property: property, - principalType: acl.principalType, - principalId: acl.principalId, - accessType: acl.accessType, - permission: acl.permission - })); - }); - } - return staticACLs; -}; + }); + } + return staticACLs; + }; -/** - * 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. - * @callback {Function} callback Callback function. - * @param {String|Error} err The error object - * @param {AccessRequest} result The access permission - */ -ACL.checkPermission = function checkPermission(principalType, principalId, - model, property, accessType, - callback) { - if(principalId !== null && principalId !== undefined && (typeof principalId !== 'string') ) { - principalId = principalId.toString(); - } - 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]}; + /** + * 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. + * @callback {Function} callback Callback function. + * @param {String|Error} err The error object + * @param {AccessRequest} result The access permission + */ + ACL.checkPermission = function checkPermission(principalType, principalId, + model, property, accessType, + callback) { + if (principalId !== null && principalId !== undefined && (typeof principalId !== 'string')) { + principalId = principalId.toString(); + } + 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 = new AccessRequest(model, property, accessType); + var req = new AccessRequest(model, property, accessType); - var acls = this.getStaticACLs(model, property); + var acls = this.getStaticACLs(model, property); - var resolved = this.resolvePermission(acls, req); + var resolved = this.resolvePermission(acls, req); - if(resolved && resolved.permission === ACL.DENY) { - debug('Permission denied by statically resolved permission'); - debug(' Resolved Permission: %j', resolved); - process.nextTick(function() { - callback && callback(null, resolved); - }); - return; + if (resolved && resolved.permission === ACL.DENY) { + debug('Permission denied by statically resolved permission'); + debug(' Resolved Permission: %j', resolved); + process.nextTick(function() { + callback && callback(null, resolved); + }); + return; + } + + var self = this; + this.find({where: {principalType: principalType, principalId: principalId, + model: model, property: propertyQuery, accessType: accessTypeQuery}}, + function(err, dynACLs) { + if (err) { + callback && callback(err); + return; + } + acls = acls.concat(dynACLs); + resolved = self.resolvePermission(acls, req); + if (resolved && resolved.permission === ACL.DEFAULT) { + var modelClass = loopback.findModel(model); + resolved.permission = (modelClass && modelClass.settings.defaultPermission) || ACL.ALLOW; + } + callback && callback(null, resolved); + }); + }; + + ACL.prototype.debug = function() { + if (debug.enabled) { + debug('---ACL---'); + debug('model %s', this.model); + debug('property %s', this.property); + debug('principalType %s', this.principalType); + debug('principalId %s', this.principalId); + debug('accessType %s', this.accessType); + debug('permission %s', this.permission); + } } - var self = this; - this.find({where: {principalType: principalType, principalId: principalId, - model: model, property: propertyQuery, accessType: accessTypeQuery}}, - function (err, dynACLs) { + /** + * Check if the request has the permission to access. + * @options {Object} context See below. + * @property {Object[]} principals An array of principals. + * @property {String|Model} model The model name or model class. + * @property {*} id The model instance ID. + * @property {String} property The property/method/relation name. + * @property {String} accessType The access type: READE, WRITE, or EXECUTE. + * @param {Function} callback Callback function + */ + + ACL.checkAccessForContext = function(context, callback) { + if (!(context instanceof AccessContext)) { + context = new AccessContext(context); + } + + var model = context.model; + var property = context.property; + var accessType = context.accessType; + var modelName = context.modelName; + + var methodNames = context.methodNames; + var propertyQuery = (property === ACL.ALL) ? undefined : {inq: methodNames.concat([ACL.ALL])}; + var accessTypeQuery = (accessType === ACL.ALL) ? undefined : {inq: [accessType, ACL.ALL]}; + + var req = new AccessRequest(modelName, property, accessType, ACL.DEFAULT, methodNames); + + var effectiveACLs = []; + var staticACLs = this.getStaticACLs(model.modelName, property); + + var self = this; + var roleModel = loopback.getModelByType(Role); + this.find({where: {model: model.modelName, property: propertyQuery, + accessType: accessTypeQuery}}, function(err, acls) { if (err) { callback && callback(err); return; } - acls = acls.concat(dynACLs); - resolved = self.resolvePermission(acls, req); - if(resolved && resolved.permission === ACL.DEFAULT) { - var modelClass = loopback.findModel(model); - resolved.permission = (modelClass && modelClass.settings.defaultPermission) || ACL.ALLOW; - } - callback && callback(null, resolved); - }); -}; + var inRoleTasks = []; -ACL.prototype.debug = function() { - if(debug.enabled) { - debug('---ACL---'); - debug('model %s', this.model); - debug('property %s', this.property); - debug('principalType %s', this.principalType); - debug('principalId %s', this.principalId); - debug('accessType %s', this.accessType); - debug('permission %s', this.permission); - } -} + acls = acls.concat(staticACLs); -/** - * Check if the request has the permission to access. - * @options {Object} context See below. - * @property {Object[]} principals An array of principals. - * @property {String|Model} model The model name or model class. - * @property {*} id The model instance ID. - * @property {String} property The property/method/relation name. - * @property {String} accessType The access type: READE, WRITE, or EXECUTE. - * @param {Function} callback Callback function - */ + acls.forEach(function(acl) { + // 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); + return; + } + } -ACL.checkAccessForContext = function (context, callback) { - if(!(context instanceof AccessContext)) { - context = new AccessContext(context); - } + // Check role matches + if (acl.principalType === ACL.ROLE) { + inRoleTasks.push(function(done) { + roleModel.isInRole(acl.principalId, context, + function(err, inRole) { + if (!err && inRole) { + effectiveACLs.push(acl); + } + done(err, acl); + }); + }); + } + }); - var model = context.model; - var property = context.property; - var accessType = context.accessType; - var modelName = context.modelName; - - var methodNames = context.methodNames; - var propertyQuery = (property === ACL.ALL) ? undefined : {inq: methodNames.concat([ACL.ALL])}; - var accessTypeQuery = (accessType === ACL.ALL) ? undefined : {inq: [accessType, ACL.ALL]}; - - var req = new AccessRequest(modelName, property, accessType, ACL.DEFAULT, methodNames); - - var effectiveACLs = []; - var staticACLs = this.getStaticACLs(model.modelName, property); - - var self = this; - var roleModel = loopback.getModelByType(Role); - this.find({where: {model: model.modelName, property: propertyQuery, - accessType: accessTypeQuery}}, function (err, acls) { - if (err) { - callback && callback(err); - return; - } - var inRoleTasks = []; - - acls = acls.concat(staticACLs); - - acls.forEach(function (acl) { - // 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); + async.parallel(inRoleTasks, function(err, results) { + if (err) { + callback && callback(err, null); return; } - } + var resolved = self.resolvePermission(effectiveACLs, req); + if (resolved && resolved.permission === ACL.DEFAULT) { + resolved.permission = (model && model.settings.defaultPermission) || ACL.ALLOW; + } + debug('---Resolved---'); + resolved.debug(); + callback && callback(null, resolved); + }); + }); + }; - // Check role matches - if (acl.principalType === ACL.ROLE) { - inRoleTasks.push(function (done) { - roleModel.isInRole(acl.principalId, context, - function (err, inRole) { - if (!err && inRole) { - effectiveACLs.push(acl); - } - done(err, acl); - }); - }); - } + /** + * 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 + * @callback {Function} callback Callback function + * @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 context = new AccessContext({ + accessToken: token, + model: model, + property: method, + method: method, + modelId: modelId }); - async.parallel(inRoleTasks, function (err, results) { - if(err) { - callback && callback(err, null); + this.checkAccessForContext(context, function(err, access) { + if (err) { + callback && callback(err); return; } - var resolved = self.resolvePermission(effectiveACLs, req); - if(resolved && resolved.permission === ACL.DEFAULT) { - resolved.permission = (model && model.settings.defaultPermission) || ACL.ALLOW; - } - debug('---Resolved---'); - resolved.debug(); - callback && callback(null, resolved); + callback && callback(null, access.permission !== ACL.DENY); }); - }); -}; + }; -/** - * 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 - * @callback {Function} callback Callback function - * @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 context = new AccessContext({ - accessToken: token, - model: model, - property: method, - method: method, - modelId: modelId - }); - - this.checkAccessForContext(context, function (err, access) { - if (err) { - callback && callback(err); - return; - } - callback && callback(null, access.permission !== ACL.DENY); - }); -}; - -module.exports.ACL = ACL; +} diff --git a/common/models/acl.json b/common/models/acl.json new file mode 100644 index 00000000..ef531136 --- /dev/null +++ b/common/models/acl.json @@ -0,0 +1,17 @@ +{ + "name": "ACL", + "properties": { + "model": { + "type": "string", + "description": "The name of the model" + }, + "property": { + "type": "string", + "description": "The name of the property, method, scope, or relation" + }, + "accessType": "string", + "permission": "string", + "principalType": "string", + "principalId": "string" + } +} diff --git a/lib/access-context.js b/lib/access-context.js index 89edebaf..4101ea0e 100644 --- a/lib/access-context.js +++ b/lib/access-context.js @@ -281,7 +281,7 @@ AccessRequest.prototype.exactlyMatches = function(acl) { */ AccessRequest.prototype.isAllowed = function() { - return this.permission !== require('../common/models/acl').ACL.DENY; + return this.permission !== loopback.ACL.DENY; } AccessRequest.prototype.debug = function() { diff --git a/lib/builtin-models.js b/lib/builtin-models.js index d8df2fb2..e2cf4054 100644 --- a/lib/builtin-models.js +++ b/lib/builtin-models.js @@ -15,7 +15,10 @@ module.exports = function(loopback) { loopback.Role = require('../common/models/role').Role; loopback.RoleMapping = require('../common/models/role').RoleMapping; - loopback.ACL = require('../common/models/acl').ACL; + + loopback.ACL = createModel( + require('../common/models/acl.json'), + require('../common/models/acl.js')); loopback.Scope = createModel( require('../common/models/scope.json'), From e9c86163aaf2a6524e605fbc03c37cce4bf72103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 13 Oct 2014 11:20:04 +0200 Subject: [PATCH 11/14] models: move RoleMapping def into its own files --- common/models/role-mapping.js | 70 +++++++++++++++++++++++++ common/models/role-mapping.json | 23 +++++++++ common/models/role.js | 91 ++------------------------------- docs.json | 1 + lib/builtin-models.js | 5 +- 5 files changed, 101 insertions(+), 89 deletions(-) create mode 100644 common/models/role-mapping.js create mode 100644 common/models/role-mapping.json diff --git a/common/models/role-mapping.js b/common/models/role-mapping.js new file mode 100644 index 00000000..e2f0982d --- /dev/null +++ b/common/models/role-mapping.js @@ -0,0 +1,70 @@ +/** + * The `RoleMapping` model extends from the built in `loopback.Model` type. + * + * @property {String} id Generated ID. + * @property {String} name Name of the role. + * @property {String} Description Text description. + * + * @class RoleMapping + * @inherits {PersistedModel} + */ + +module.exports = function(RoleMapping) { +// Principal types + RoleMapping.USER = 'USER'; + RoleMapping.APP = RoleMapping.APPLICATION = 'APP'; + RoleMapping.ROLE = 'ROLE'; + + /** + * Get the application principal + * @callback {Function} callback + * @param {Error} err + * @param {Application} application + */ + RoleMapping.prototype.application = function (callback) { + if (this.principalType === RoleMapping.APPLICATION) { + var applicationModel = this.constructor.Application + || loopback.getModelByType(loopback.Application); + applicationModel.findById(this.principalId, callback); + } else { + process.nextTick(function () { + callback && callback(null, null); + }); + } + }; + + /** + * Get the user principal + * @callback {Function} callback + * @param {Error} err + * @param {User} user + */ + RoleMapping.prototype.user = function (callback) { + if (this.principalType === RoleMapping.USER) { + var userModel = this.constructor.User + || loopback.getModelByType(loopback.User); + userModel.findById(this.principalId, callback); + } else { + process.nextTick(function () { + callback && callback(null, null); + }); + } + }; + + /** + * Get the child role principal + * @callback {Function} callback + * @param {Error} err + * @param {User} childUser + */ + RoleMapping.prototype.childRole = function (callback) { + if (this.principalType === RoleMapping.ROLE) { + var roleModel = this.constructor.Role || loopback.getModelByType(Role); + roleModel.findById(this.principalId, callback); + } else { + process.nextTick(function () { + callback && callback(null, null); + }); + } + }; +}; diff --git a/common/models/role-mapping.json b/common/models/role-mapping.json new file mode 100644 index 00000000..592f2906 --- /dev/null +++ b/common/models/role-mapping.json @@ -0,0 +1,23 @@ +{ + "name": "RoleMapping", + "description": "Map principals to roles", + "properties": { + "id": { + "type": "string", + "id": true, + "generated": true + }, + "principalType": { + "type": "string", + "description": "The principal type, such as user, application, or role" + }, + "principalId": "string" + }, + "relations": { + "role": { + "type": "belongsTo", + "model": "Role", + "foreignKey": "roleId" + } + } +} diff --git a/common/models/role.js b/common/models/role.js index 1a588040..48fce120 100644 --- a/common/models/role.js +++ b/common/models/role.js @@ -16,94 +16,6 @@ var RoleSchema = { modified: {type: Date, default: Date} }; -/*! - * Map principals to roles - */ -var RoleMappingSchema = { - id: {type: String, id: true, generated: true}, // Id - // roleId: String, // The role id, to be injected by the belongsTo relation - principalType: String, // The principal type, such as user, application, or role - principalId: String // The principal id -}; - -/** - * The `RoleMapping` model extends from the built in `loopback.Model` type. - * - * @class - * @property {String} id Generated ID. - * @property {String} name Name of the role. - * @property {String} Description Text description. - * @inherits {Model} - */ - -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 - * @callback {Function} callback - * @param {Error} err - * @param {Application} application - */ -RoleMapping.prototype.application = function (callback) { - if (this.principalType === RoleMapping.APPLICATION) { - var applicationModel = this.constructor.Application - || loopback.getModelByType(loopback.Application); - applicationModel.findById(this.principalId, callback); - } else { - process.nextTick(function () { - callback && callback(null, null); - }); - } -}; - -/** - * Get the user principal - * @callback {Function} callback - * @param {Error} err - * @param {User} user - */ -RoleMapping.prototype.user = function (callback) { - if (this.principalType === RoleMapping.USER) { - var userModel = this.constructor.User - || loopback.getModelByType(loopback.User); - userModel.findById(this.principalId, callback); - } else { - process.nextTick(function () { - callback && callback(null, null); - }); - } -}; - -/** - * Get the child role principal - * @callback {Function} callback - * @param {Error} err - * @param {User} childUser - */ -RoleMapping.prototype.childRole = function (callback) { - if (this.principalType === RoleMapping.ROLE) { - var roleModel = this.constructor.Role || loopback.getModelByType(Role); - roleModel.findById(this.principalId, callback); - } else { - process.nextTick(function () { - callback && callback(null, null); - }); - } -}; - /** * The Role Model * @class @@ -118,6 +30,9 @@ var Role = loopback.createModel('Role', RoleSchema, { } }); +var RoleMapping = loopback.RoleMapping; +assert(RoleMapping, 'RoleMapping model must be defined before Role model'); + // Set up the connection to users/applications/roles once the model Role.once('dataSourceAttached', function () { var roleMappingModel = this.RoleMapping || loopback.getModelByType(RoleMapping); diff --git a/docs.json b/docs.json index b554576a..5971d085 100644 --- a/docs.json +++ b/docs.json @@ -19,6 +19,7 @@ "common/models/scope.js", "common/models/application.js", "common/models/email.js", + "common/models/role-mapping.js", "common/models/role.js", "common/models/user.js", "common/models/change.js" diff --git a/lib/builtin-models.js b/lib/builtin-models.js index e2cf4054..5f5e531a 100644 --- a/lib/builtin-models.js +++ b/lib/builtin-models.js @@ -13,8 +13,11 @@ module.exports = function(loopback) { require('../common/models/access-token.json'), require('../common/models/access-token.js')); + loopback.RoleMapping = createModel( + require('../common/models/role-mapping.json'), + require('../common/models/role-mapping.js')); + loopback.Role = require('../common/models/role').Role; - loopback.RoleMapping = require('../common/models/role').RoleMapping; loopback.ACL = createModel( require('../common/models/acl.json'), From 461ae92c1c81d9fac30601148e595fe820bac4db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 13 Oct 2014 11:31:27 +0200 Subject: [PATCH 12/14] models: move Role LDL def into a json file --- common/models/acl.js | 4 +- common/models/role.js | 704 ++++++++++++++++++++-------------------- common/models/role.json | 26 ++ lib/builtin-models.js | 4 +- 4 files changed, 376 insertions(+), 362 deletions(-) create mode 100644 common/models/role.json diff --git a/common/models/acl.js b/common/models/acl.js index d665adba..1bb81407 100644 --- a/common/models/acl.js +++ b/common/models/acl.js @@ -41,8 +41,8 @@ var AccessContext = ctx.AccessContext; var Principal = ctx.Principal; var AccessRequest = ctx.AccessRequest; -var role = require('./role'); -var Role = role.Role; +var Role = loopback.Role; +assert(Role, 'Role model must be defined before ACL model'); /** * A Model for access control meta data. diff --git a/common/models/role.js b/common/models/role.js index 48fce120..f47093d3 100644 --- a/common/models/role.js +++ b/common/models/role.js @@ -5,399 +5,385 @@ var async = require('async'); var AccessContext = require('../../lib/access-context').AccessContext; -// Role model -var RoleSchema = { - id: {type: String, id: true, generated: 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} -}; - -/** - * The Role Model - * @class - */ -var Role = loopback.createModel('Role', RoleSchema, { - relations: { - principals: { - type: 'hasMany', - model: 'RoleMapping', - foreignKey: 'roleId' - } - } -}); - var RoleMapping = loopback.RoleMapping; assert(RoleMapping, 'RoleMapping model must be defined before Role model'); -// Set up the connection to users/applications/roles once the model -Role.once('dataSourceAttached', function () { - var roleMappingModel = this.RoleMapping || loopback.getModelByType(RoleMapping); - Role.prototype.users = function (callback) { - roleMappingModel.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) { - roleMappingModel.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) { - roleMappingModel.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.UNAUTHENTICATED = "$unauthenticated"; // 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 - * - * function(role, context, callback) + * The Role Model + * @class Role */ -Role.registerResolver = function(role, resolver) { - if(!Role.resolvers) { - Role.resolvers = {}; - } - Role.resolvers[role] = resolver; -}; +module.exports = function(Role) { -Role.registerResolver(Role.OWNER, function(role, context, callback) { - if(!context || !context.model || !context.modelId) { - process.nextTick(function() { - callback && callback(null, false); - }); - return; - } - var modelClass = context.model; - var modelId = context.modelId; - var userId = context.getUserId(); - Role.isOwner(modelClass, modelId, userId, callback); -}); + // Workaround for https://github.com/strongloop/loopback/issues/292 + Role.definition.rawProperties.created.default = + Role.definition.properties.created.default = function() { + return new Date(); + }; -function isUserClass(modelClass) { - return modelClass === loopback.User || - modelClass.prototype instanceof loopback.User; -} + // Workaround for https://github.com/strongloop/loopback/issues/292 + Role.definition.rawProperties.modified.default = + Role.definition.properties.modified.default = function() { + return new Date(); + }; -/*! - * 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 - * @param {Function} modelClass The model class - * @param {*} modelId The model id - * @param {*) userId The user id - * @param {Function} callback - */ -Role.isOwner = function isOwner(modelClass, modelId, userId, callback) { - assert(modelClass, 'Model class is required'); - debug('isOwner(): %s %s userId: %s', modelClass && modelClass.modelName, modelId, userId); - // No userId is present - if(!userId) { - process.nextTick(function() { - callback(null, false); - }); - return; - } - - // Is the modelClass User or a subclass of User? - if(isUserClass(modelClass)) { - process.nextTick(function() { - callback(null, matches(modelId, userId)); - }); - return; - } - - modelClass.findById(modelId, function(err, inst) { - if(err || !inst) { - debug('Model not found for id %j', modelId); - callback && callback(err, false); - return; - } - debug('Model found: %j', inst); - var ownerId = inst.userId || inst.owner; - if(ownerId) { - callback && callback(null, matches(ownerId, userId)); - return; - } else { - // Try to follow belongsTo - for(var r in modelClass.relations) { - var rel = modelClass.relations[r]; - if(rel.type === 'belongsTo' && isUserClass(rel.modelTo)) { - debug('Checking relation %s to %s: %j', r, rel.modelTo.modelName, rel); - inst[r](function(err, user) { - if(!err && user) { - debug('User found: %j', user.id); - callback && callback(null, matches(user.id, userId)); - } else { - callback && callback(err, false); - } - }); + // Set up the connection to users/applications/roles once the model + Role.once('dataSourceAttached', function() { + var roleMappingModel = this.RoleMapping || loopback.getModelByType(RoleMapping); + Role.prototype.users = function(callback) { + roleMappingModel.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) { + roleMappingModel.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) { + roleMappingModel.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.UNAUTHENTICATED = "$unauthenticated"; // 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 + * + * 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.modelId) { + process.nextTick(function() { + callback && callback(null, false); + }); + return; + } + var modelClass = context.model; + var modelId = context.modelId; + var userId = context.getUserId(); + Role.isOwner(modelClass, modelId, userId, callback); + }); + + function isUserClass(modelClass) { + return modelClass === 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 + * @param {Function} modelClass The model class + * @param {*} modelId The model id + * @param {*) userId The user id + * @param {Function} callback + */ + Role.isOwner = function isOwner(modelClass, modelId, userId, callback) { + assert(modelClass, 'Model class is required'); + debug('isOwner(): %s %s userId: %s', modelClass && modelClass.modelName, modelId, userId); + // No userId is present + if (!userId) { + process.nextTick(function() { + callback(null, false); + }); + return; + } + + // Is the modelClass User or a subclass of User? + if (isUserClass(modelClass)) { + process.nextTick(function() { + callback(null, matches(modelId, userId)); + }); + return; + } + + modelClass.findById(modelId, function(err, inst) { + if (err || !inst) { + debug('Model not found for id %j', modelId); + callback && callback(err, false); + return; } - debug('No matching belongsTo relation found for model %j and user: %j', modelId, userId); - callback && callback(null, false); - } - }); -}; + debug('Model found: %j', inst); + var ownerId = inst.userId || inst.owner; + if (ownerId) { + callback && callback(null, matches(ownerId, userId)); + return; + } else { + // Try to follow belongsTo + for (var r in modelClass.relations) { + var rel = modelClass.relations[r]; + if (rel.type === 'belongsTo' && isUserClass(rel.modelTo)) { + debug('Checking relation %s to %s: %j', r, rel.modelTo.modelName, rel); + inst[r](function(err, user) { + if (!err && user) { + debug('User found: %j', user.id); + callback && callback(null, matches(user.id, userId)); + } else { + callback && callback(err, false); + } + }); + return; + } + } + debug('No matching belongsTo relation found for model %j and user: %j', modelId, userId); + callback && callback(null, false); + } + }); + }; -Role.registerResolver(Role.AUTHENTICATED, function(role, context, callback) { - if(!context) { + Role.registerResolver(Role.AUTHENTICATED, function(role, context, callback) { + if (!context) { + process.nextTick(function() { + callback && callback(null, false); + }); + return; + } + Role.isAuthenticated(context, callback); + }); + + /** + * Check if the user id is authenticated + * @param {Object} context The security context + * @callback {Function} callback + * @param {Error} err + * @param {Boolean} isAuthenticated + */ + Role.isAuthenticated = function isAuthenticated(context, callback) { process.nextTick(function() { - callback && callback(null, false); + callback && callback(null, context.isAuthenticated()); }); - return; - } - Role.isAuthenticated(context, callback); -}); + }; -/** - * Check if the user id is authenticated - * @param {Object} context The security context - * @callback {Function} callback - * @param {Error} err - * @param {Boolean} isAuthenticated - */ -Role.isAuthenticated = function isAuthenticated(context, callback) { - process.nextTick(function() { - callback && callback(null, context.isAuthenticated()); - }); -}; - -Role.registerResolver(Role.UNAUTHENTICATED, function(role, context, callback) { - process.nextTick(function() { - callback && callback(null, !context || !context.isAuthenticated()); - }); -}); - -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 {String} role The role name - * @param {Object} context The context object - * @callback {Function} callback - * @param {Error} err - * @param {Boolean} isInRole - */ -Role.isInRole = function (role, context, callback) { - if (!(context instanceof AccessContext)) { - context = new AccessContext(context); - } - - debug('isInRole(): %s', role); - context.debug(); - - var resolver = Role.resolvers[role]; - if (resolver) { - debug('Custom resolver found for role %s', role); - resolver(role, context, callback); - return; - } - - if (context.principals.length === 0) { - debug('isInRole() returns: false'); - process.nextTick(function () { - callback && callback(null, false); + Role.registerResolver(Role.UNAUTHENTICATED, function(role, context, callback) { + process.nextTick(function() { + callback && callback(null, !context || !context.isAuthenticated()); }); - return; - } - - 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); + Role.registerResolver(Role.EVERYONE, function(role, context, callback) { + process.nextTick(function() { + callback && callback(null, true); // Always true }); - return; - } + }); - var roleMappingModel = this.RoleMapping || loopback.getModelByType(RoleMapping); - this.findOne({where: {name: role}}, function (err, result) { - if (err) { - callback && callback(err); + /** + * Check if a given principal is in the role + * + * @param {String} role The role name + * @param {Object} context The context object + * @callback {Function} callback + * @param {Error} err + * @param {Boolean} isInRole + */ + Role.isInRole = function(role, context, callback) { + if (!(context instanceof AccessContext)) { + context = new AccessContext(context); + } + + debug('isInRole(): %s', role); + context.debug(); + + var resolver = Role.resolvers[role]; + if (resolver) { + debug('Custom resolver found for role %s', role); + resolver(role, context, callback); return; } - if (!result) { - callback && callback(null, false); + + if (context.principals.length === 0) { + debug('isInRole() returns: false'); + process.nextTick(function() { + callback && callback(null, false); + }); return; } - debug('Role found: %j', result); - // Iterate through the list of principals - async.some(context.principals, function (p, done) { + var inRole = context.principals.some(function(p) { + var principalType = p.type || undefined; var principalId = p.id || undefined; - var roleId = result.id.toString(); - - if(principalId !== null && principalId !== undefined && (typeof principalId !== 'string') ) { - principalId = principalId.toString(); + + // 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; + } + + var roleMappingModel = this.RoleMapping || loopback.getModelByType(RoleMapping); + this.findOne({where: {name: role}}, function(err, result) { + if (err) { + callback && callback(err); + return; + } + if (!result) { + callback && callback(null, false); + return; + } + debug('Role found: %j', result); + + // Iterate through the list of principals + async.some(context.principals, function(p, done) { + var principalType = p.type || undefined; + var principalId = p.id || undefined; + var roleId = result.id.toString(); + + if (principalId !== null && principalId !== undefined && (typeof principalId !== 'string')) { + principalId = principalId.toString(); + } + + if (principalType && principalId) { + roleMappingModel.findOne({where: {roleId: roleId, + 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); + }); + }); + + }; + + /** + * List roles for a given principal + * @param {Object} context The security context + * @param {Function} callback + * + * @callback {Function} callback + * @param err + * @param {String[]} An array of role ids + */ + Role.getRoles = function(context, callback) { + if (!(context instanceof AccessContext)) { + context = new AccessContext(context); + } + var roles = []; + + var addRole = function(role) { + if (role && roles.indexOf(role) === -1) { + roles.push(role); + } + }; + + var self = this; + // Check against the smart roles + var inRoleTasks = []; + Object.keys(Role.resolvers).forEach(function(role) { + inRoleTasks.push(function(done) { + self.isInRole(role, context, function(err, inRole) { + if (debug.enabled) { + debug('In role %j: %j', role, inRole); + } + if (!err && inRole) { + addRole(role); + done(); + } else { + done(err, null); + } + }); + }); + }); + + var roleMappingModel = this.RoleMapping || loopback.getModelByType(RoleMapping); + context.principals.forEach(function(p) { + // Check against the role mappings + var principalType = p.type || undefined; + var principalId = p.id || undefined; + + // Add the role itself + if (principalType === RoleMapping.ROLE && principalId) { + addRole(principalId); } if (principalType && principalId) { - roleMappingModel.findOne({where: {roleId: roleId, - principalType: principalType, principalId: principalId}}, - function (err, result) { - debug('Role mapping found: %j', result); - done(!err && result); // The only arg is the result + // Please find() treat undefined matches all values + inRoleTasks.push(function(done) { + roleMappingModel.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(); }); - } else { - process.nextTick(function () { - done(false); }); } - }, function (inRole) { - debug('isInRole() returns: %j', inRole); - callback && callback(null, inRole); }); - }); -}; - -/** - * List roles for a given principal - * @param {Object} context The security context - * @param {Function} callback - * - * @callback {Function} callback - * @param err - * @param {String[]} An array of role ids - */ -Role.getRoles = function (context, callback) { - if(!(context instanceof AccessContext)) { - context = new AccessContext(context); - } - var roles = []; - - var addRole = function (role) { - if (role && roles.indexOf(role) === -1) { - roles.push(role); - } + async.parallel(inRoleTasks, function(err, results) { + debug('getRoles() returns: %j %j', err, roles); + callback && callback(err, roles); + }); }; - - var self = this; - // Check against the smart roles - var inRoleTasks = []; - Object.keys(Role.resolvers).forEach(function (role) { - inRoleTasks.push(function (done) { - self.isInRole(role, context, function (err, inRole) { - if(debug.enabled) { - debug('In role %j: %j', role, inRole); - } - if (!err && inRole) { - addRole(role); - done(); - } else { - done(err, null); - } - }); - }); - }); - - var roleMappingModel = this.RoleMapping || loopback.getModelByType(RoleMapping); - context.principals.forEach(function (p) { - // Check against the role mappings - var principalType = p.type || undefined; - var principalId = p.id || undefined; - - // 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) { - roleMappingModel.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(); - }); - }); - } - }); - - async.parallel(inRoleTasks, function (err, results) { - debug('getRoles() returns: %j %j', err, roles); - callback && callback(err, roles); - }); }; - -module.exports = { - Role: Role, - RoleMapping: RoleMapping -}; - - - diff --git a/common/models/role.json b/common/models/role.json new file mode 100644 index 00000000..ad519df9 --- /dev/null +++ b/common/models/role.json @@ -0,0 +1,26 @@ +{ + "name": "Role", + "properties": { + + "id": { + "type": "string", + "id": true, + "generated": true + }, + "name": { + "type": "string", + "required": true + }, + "description": "string", + + "created": "date", + "modified": "date" + }, + "relations": { + "principals": { + "type": "hasMany", + "model": "RoleMapping", + "foreignKey": "roleId" + } + } +} diff --git a/lib/builtin-models.js b/lib/builtin-models.js index 5f5e531a..88677a9e 100644 --- a/lib/builtin-models.js +++ b/lib/builtin-models.js @@ -17,7 +17,9 @@ module.exports = function(loopback) { require('../common/models/role-mapping.json'), require('../common/models/role-mapping.js')); - loopback.Role = require('../common/models/role').Role; + loopback.Role = createModel( + require('../common/models/role.json'), + require('../common/models/role.js')); loopback.ACL = createModel( require('../common/models/acl.json'), From 6cbc231fba442233b2afa91c628baa387b47c8c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 13 Oct 2014 11:45:06 +0200 Subject: [PATCH 13/14] models: move Checkpoint LDL def into a json file --- common/models/checkpoint.js | 100 +++++++++++++++------------------- common/models/checkpoint.json | 14 +++++ lib/builtin-models.js | 5 +- 3 files changed, 61 insertions(+), 58 deletions(-) create mode 100644 common/models/checkpoint.json diff --git a/common/models/checkpoint.js b/common/models/checkpoint.js index 9ac1f5b5..ed57de53 100644 --- a/common/models/checkpoint.js +++ b/common/models/checkpoint.js @@ -2,27 +2,7 @@ * Module Dependencies. */ -var PersistedModel = require('../../lib/loopback').PersistedModel - , loopback = require('../../lib/loopback') - , assert = require('assert'); - -/** - * Properties - */ - -var properties = { - seq: {type: Number}, - time: {type: Date, default: Date}, - sourceId: {type: String} -}; - -/** - * Options - */ - -var options = { - -}; +var assert = require('assert'); /** * Checkpoint list entry. @@ -30,48 +10,54 @@ var options = { * @property id {Number} the sequencial identifier of a checkpoint * @property time {Number} the time when the checkpoint was created * @property sourceId {String} the source identifier - * - * @class + * + * @class Checkpoint * @inherits {PersistedModel} */ -var Checkpoint = module.exports = PersistedModel.extend('Checkpoint', properties, options); +module.exports = function(Checkpoint) { -/** - * Get the current checkpoint id - * @callback {Function} callback - * @param {Error} err - * @param {Number} checkpointId The current checkpoint id - */ + // Workaround for https://github.com/strongloop/loopback/issues/292 + Checkpoint.definition.rawProperties.time.default = + Checkpoint.definition.properties.time.default = function() { + return new Date(); + }; -Checkpoint.current = function(cb) { - var Checkpoint = this; - this.find({ - limit: 1, - order: 'seq DESC' - }, function(err, checkpoints) { - if(err) return cb(err); - var checkpoint = checkpoints[0]; - if(checkpoint) { - cb(null, checkpoint.seq); - } else { - Checkpoint.create({seq: 0}, function(err, checkpoint) { - if(err) return cb(err); + /** + * Get the current checkpoint id + * @callback {Function} callback + * @param {Error} err + * @param {Number} checkpointId The current checkpoint id + */ + + Checkpoint.current = function(cb) { + var Checkpoint = this; + this.find({ + limit: 1, + order: 'seq DESC' + }, function(err, checkpoints) { + if (err) return cb(err); + var checkpoint = checkpoints[0]; + if (checkpoint) { cb(null, checkpoint.seq); - }); - } - }); -} - -Checkpoint.beforeSave = function(next, model) { - if(!model.getId() && model.seq === undefined) { - model.constructor.current(function(err, seq) { - if(err) return next(err); - model.seq = seq + 1; - next(); + } else { + Checkpoint.create({seq: 0}, function(err, checkpoint) { + if (err) return cb(err); + cb(null, checkpoint.seq); + }); + } }); - } else { - next(); } -} + Checkpoint.beforeSave = function(next, model) { + if (!model.getId() && model.seq === undefined) { + model.constructor.current(function(err, seq) { + if (err) return next(err); + model.seq = seq + 1; + next(); + }); + } else { + next(); + } + } +}; diff --git a/common/models/checkpoint.json b/common/models/checkpoint.json new file mode 100644 index 00000000..d557ffb5 --- /dev/null +++ b/common/models/checkpoint.json @@ -0,0 +1,14 @@ +{ + "name": "Checkpoint", + "properties": { + "seq": { + "type": "number" + }, + "time": { + "type": "date" + }, + "sourceId": { + "type": "string" + } + } +} diff --git a/lib/builtin-models.js b/lib/builtin-models.js index 88677a9e..a6175e9f 100644 --- a/lib/builtin-models.js +++ b/lib/builtin-models.js @@ -34,7 +34,10 @@ module.exports = function(loopback) { require('../common/models/user.js')); loopback.Change = require('../common/models/change'); - loopback.Checkpoint = require('../common/models/checkpoint'); + + loopback.Checkpoint = createModel( + require('../common/models/checkpoint.json'), + require('../common/models/checkpoint.js')); /*! * Automatically attach these models to dataSources From 0906a6f5b3ae978c50a89716f5e2014dbabed270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 13 Oct 2014 11:58:14 +0200 Subject: [PATCH 14/14] models: move Change LDL def into a json file --- common/models/change.js | 1104 ++++++++++++++++++------------------- common/models/change.json | 25 + lib/builtin-models.js | 4 +- lib/persisted-model.js | 5 +- 4 files changed, 575 insertions(+), 563 deletions(-) create mode 100644 common/models/change.json diff --git a/common/models/change.js b/common/models/change.js index bedfc1cc..6ab714a8 100644 --- a/common/models/change.js +++ b/common/models/change.js @@ -10,26 +10,6 @@ var PersistedModel = require('../../lib/loopback').PersistedModel , assert = require('assert') , debug = require('debug')('loopback:change'); -/*! - * Properties - */ - -var properties = { - id: {type: String, id: true}, - rev: {type: String}, - prev: {type: String}, - checkpoint: {type: Number}, - modelName: {type: String}, - modelId: {type: String} -}; - -/*! - * Options - */ - -var options = { - trackChanges: false -}; /** * Change list entry. @@ -40,607 +20,609 @@ var options = { * @property {Number} checkpoint The current checkpoint at time of the change * @property {String} modelName Model name * @property {String} modelId Model ID - * - * @class - * @inherits {Model} + * + * @class Change + * @inherits {PersistedModel} */ -var Change = module.exports = PersistedModel.extend('Change', properties, options); +module.exports = function(Change) { -/*! - * Constants - */ + /*! + * Constants + */ -Change.UPDATE = 'update'; -Change.CREATE = 'create'; -Change.DELETE = 'delete'; -Change.UNKNOWN = 'unknown'; + Change.UPDATE = 'update'; + Change.CREATE = 'create'; + Change.DELETE = 'delete'; + Change.UNKNOWN = 'unknown'; -/*! - * Conflict Class - */ + /*! + * Conflict Class + */ -Change.Conflict = Conflict; + Change.Conflict = Conflict; -/*! - * Setup the extended model. - */ + /*! + * Setup the extended model. + */ -Change.setup = function() { - PersistedModel.setup.call(this); - var Change = this; + Change.setup = function() { + PersistedModel.setup.call(this); + var Change = this; - Change.getter.id = function() { - var hasModel = this.modelName && this.modelId; - if(!hasModel) return null; + Change.getter.id = function() { + var hasModel = this.modelName && this.modelId; + if (!hasModel) return null; - return Change.idForModel(this.modelName, this.modelId); + return Change.idForModel(this.modelName, this.modelId); + } } -} -Change.setup(); + Change.setup(); -/** - * Track the recent change of the given modelIds. - * - * @param {String} modelName - * @param {Array} modelIds - * @callback {Function} callback - * @param {Error} err - * @param {Array} changes Changes that were tracked - */ + /** + * Track the recent change of the given modelIds. + * + * @param {String} modelName + * @param {Array} modelIds + * @callback {Function} callback + * @param {Error} err + * @param {Array} changes Changes that were tracked + */ -Change.rectifyModelChanges = function(modelName, modelIds, callback) { - var tasks = []; - var Change = this; + Change.rectifyModelChanges = function(modelName, modelIds, callback) { + var tasks = []; + var Change = this; - modelIds.forEach(function(id) { - tasks.push(function(cb) { - Change.findOrCreateChange(modelName, id, function(err, change) { - if(err) return Change.handleError(err, cb); - change.rectify(cb); + modelIds.forEach(function(id) { + tasks.push(function(cb) { + Change.findOrCreateChange(modelName, id, function(err, change) { + if (err) return Change.handleError(err, cb); + change.rectify(cb); + }); }); }); - }); - async.parallel(tasks, callback); -} - -/** - * Get an identifier for a given model. - * - * @param {String} modelName - * @param {String} modelId - * @return {String} - */ - -Change.idForModel = function(modelName, modelId) { - return this.hash([modelName, modelId].join('-')); -} - -/** - * Find or create a change for the given model. - * - * @param {String} modelName - * @param {String} modelId - * @callback {Function} callback - * @param {Error} err - * @param {Change} change - * @end - */ - -Change.findOrCreateChange = function(modelName, modelId, callback) { - assert(loopback.findModel(modelName), modelName + ' does not exist'); - var id = this.idForModel(modelName, modelId); - var Change = this; - - this.findById(id, function(err, change) { - if(err) return callback(err); - if(change) { - callback(null, change); - } else { - var ch = new Change({ - id: id, - modelName: modelName, - modelId: modelId - }); - ch.debug('creating change'); - ch.save(callback); - } - }); -} - -/** - * Update (or create) the change with the current revision. - * - * @callback {Function} callback - * @param {Error} err - * @param {Change} change - */ - -Change.prototype.rectify = function(cb) { - var change = this; - var tasks = [ - updateRevision, - updateCheckpoint - ]; - var currentRev = this.rev; - - change.debug('rectify change'); - - cb = cb || function(err) { - if(err) throw new Error(err); + async.parallel(tasks, callback); } - async.parallel(tasks, function(err) { - if(err) return cb(err); - if(change.prev === Change.UNKNOWN) { - // this occurs when a record of a change doesn't exist - // and its current revision is null (not found) - change.remove(cb); - } else { - change.save(cb); - } - }); + /** + * Get an identifier for a given model. + * + * @param {String} modelName + * @param {String} modelId + * @return {String} + */ - function updateRevision(cb) { - // get the current revision - change.currentRevision(function(err, rev) { - if(err) return Change.handleError(err, cb); - if(rev) { - // avoid setting rev and prev to the same value - if(currentRev !== rev) { - change.rev = rev; - change.prev = currentRev; - } else { - change.debug('rev and prev are equal (not updating rev)'); - } + Change.idForModel = function(modelName, modelId) { + return this.hash([modelName, modelId].join('-')); + } + + /** + * Find or create a change for the given model. + * + * @param {String} modelName + * @param {String} modelId + * @callback {Function} callback + * @param {Error} err + * @param {Change} change + * @end + */ + + Change.findOrCreateChange = function(modelName, modelId, callback) { + assert(loopback.findModel(modelName), modelName + ' does not exist'); + var id = this.idForModel(modelName, modelId); + var Change = this; + + this.findById(id, function(err, change) { + if (err) return callback(err); + if (change) { + callback(null, change); } else { - change.rev = null; - if(currentRev) { - change.prev = currentRev; - } else if(!change.prev) { - change.debug('ERROR - could not determing prev'); - change.prev = Change.UNKNOWN; - } + var ch = new Change({ + id: id, + modelName: modelName, + modelId: modelId + }); + ch.debug('creating change'); + ch.save(callback); } - change.debug('updated revision (was ' + currentRev + ')'); - cb(); }); } - function updateCheckpoint(cb) { - change.constructor.getCheckpointModel().current(function(err, checkpoint) { - if(err) return Change.handleError(err); - change.checkpoint = checkpoint; - cb(); - }); - } -} + /** + * Update (or create) the change with the current revision. + * + * @callback {Function} callback + * @param {Error} err + * @param {Change} change + */ -/** - * Get a change's current revision based on current data. - * @callback {Function} callback - * @param {Error} err - * @param {String} rev The current revision - */ + Change.prototype.rectify = function(cb) { + var change = this; + var tasks = [ + updateRevision, + updateCheckpoint + ]; + var currentRev = this.rev; -Change.prototype.currentRevision = function(cb) { - var model = this.getModelCtor(); - var id = this.getModelId(); - model.findById(id, function(err, inst) { - if(err) return Change.handleError(err, cb); - if(inst) { - cb(null, Change.revisionForInst(inst)); - } else { - cb(null, null); + change.debug('rectify change'); + + cb = cb || function(err) { + if (err) throw new Error(err); } - }); -} -/** - * Create a hash of the given `string` with the `options.hashAlgorithm`. - * **Default: `sha1`** - * - * @param {String} str The string to be hashed - * @return {String} The hashed string - */ + async.parallel(tasks, function(err) { + if (err) return cb(err); + if (change.prev === Change.UNKNOWN) { + // this occurs when a record of a change doesn't exist + // and its current revision is null (not found) + change.remove(cb); + } else { + change.save(cb); + } + }); -Change.hash = function(str) { - return crypto - .createHash(Change.settings.hashAlgorithm || 'sha1') - .update(str) - .digest('hex'); -} + function updateRevision(cb) { + // get the current revision + change.currentRevision(function(err, rev) { + if (err) return Change.handleError(err, cb); + if (rev) { + // avoid setting rev and prev to the same value + if (currentRev !== rev) { + change.rev = rev; + change.prev = currentRev; + } else { + change.debug('rev and prev are equal (not updating rev)'); + } + } else { + change.rev = null; + if (currentRev) { + change.prev = currentRev; + } else if (!change.prev) { + change.debug('ERROR - could not determing prev'); + change.prev = Change.UNKNOWN; + } + } + change.debug('updated revision (was ' + currentRev + ')'); + cb(); + }); + } -/** - * Get the revision string for the given object - * @param {Object} inst The data to get the revision string for - * @return {String} The revision string - */ - -Change.revisionForInst = function(inst) { - return this.hash(CJSON.stringify(inst)); -} - -/** - * Get a change's type. Returns one of: - * - * - `Change.UPDATE` - * - `Change.CREATE` - * - `Change.DELETE` - * - `Change.UNKNOWN` - * - * @return {String} the type of change - */ - -Change.prototype.type = function() { - if(this.rev && this.prev) { - return Change.UPDATE; + function updateCheckpoint(cb) { + change.constructor.getCheckpointModel().current(function(err, checkpoint) { + if (err) return Change.handleError(err); + change.checkpoint = checkpoint; + cb(); + }); + } } - if(this.rev && !this.prev) { - return Change.CREATE; + + /** + * Get a change's current revision based on current data. + * @callback {Function} callback + * @param {Error} err + * @param {String} rev The current revision + */ + + Change.prototype.currentRevision = function(cb) { + var model = this.getModelCtor(); + var id = this.getModelId(); + model.findById(id, function(err, inst) { + if (err) return Change.handleError(err, cb); + if (inst) { + cb(null, Change.revisionForInst(inst)); + } else { + cb(null, null); + } + }); } - if(!this.rev && this.prev) { - return Change.DELETE; + + /** + * Create a hash of the given `string` with the `options.hashAlgorithm`. + * **Default: `sha1`** + * + * @param {String} str The string to be hashed + * @return {String} The hashed string + */ + + Change.hash = function(str) { + return crypto + .createHash(Change.settings.hashAlgorithm || 'sha1') + .update(str) + .digest('hex'); } - return Change.UNKNOWN; -} -/** - * Compare two changes. - * @param {Change} change - * @return {Boolean} - */ + /** + * Get the revision string for the given object + * @param {Object} inst The data to get the revision string for + * @return {String} The revision string + */ -Change.prototype.equals = function(change) { - if(!change) return false; - var thisRev = this.rev || null; - var thatRev = change.rev || null; - return thisRev === thatRev; -} + Change.revisionForInst = function(inst) { + return this.hash(CJSON.stringify(inst)); + } -/** - * Does this change conflict with the given change. - * @param {Change} change - * @return {Boolean} - */ + /** + * Get a change's type. Returns one of: + * + * - `Change.UPDATE` + * - `Change.CREATE` + * - `Change.DELETE` + * - `Change.UNKNOWN` + * + * @return {String} the type of change + */ -Change.prototype.conflictsWith = function(change) { - if(!change) return false; - if(this.equals(change)) return false; - if(Change.bothDeleted(this, change)) return false; - if(this.isBasedOn(change)) return false; - return true; -} + Change.prototype.type = function() { + if (this.rev && this.prev) { + return Change.UPDATE; + } + if (this.rev && !this.prev) { + return Change.CREATE; + } + if (!this.rev && this.prev) { + return Change.DELETE; + } + return Change.UNKNOWN; + } -/** - * Are both changes deletes? - * @param {Change} a - * @param {Change} b - * @return {Boolean} - */ + /** + * Compare two changes. + * @param {Change} change + * @return {Boolean} + */ -Change.bothDeleted = function(a, b) { - return a.type() === Change.DELETE + Change.prototype.equals = function(change) { + if (!change) return false; + var thisRev = this.rev || null; + var thatRev = change.rev || null; + return thisRev === thatRev; + } + + /** + * Does this change conflict with the given change. + * @param {Change} change + * @return {Boolean} + */ + + Change.prototype.conflictsWith = function(change) { + if (!change) return false; + if (this.equals(change)) return false; + if (Change.bothDeleted(this, change)) return false; + if (this.isBasedOn(change)) return false; + return true; + } + + /** + * Are both changes deletes? + * @param {Change} a + * @param {Change} b + * @return {Boolean} + */ + + Change.bothDeleted = function(a, b) { + return a.type() === Change.DELETE && b.type() === Change.DELETE; -} + } -/** - * Determine if the change is based on the given change. - * @param {Change} change - * @return {Boolean} - */ + /** + * Determine if the change is based on the given change. + * @param {Change} change + * @return {Boolean} + */ -Change.prototype.isBasedOn = function(change) { - return this.prev === change.rev; -} + Change.prototype.isBasedOn = function(change) { + return this.prev === change.rev; + } -/** - * Determine the differences for a given model since a given checkpoint. - * - * The callback will contain an error or `result`. - * - * **result** - * - * ```js - * { + /** + * Determine the differences for a given model since a given checkpoint. + * + * The callback will contain an error or `result`. + * + * **result** + * + * ```js + * { * deltas: Array, * conflicts: Array * } - * ``` - * - * **deltas** - * - * An array of changes that differ from `remoteChanges`. - * - * **conflicts** - * - * An array of changes that conflict with `remoteChanges`. - * - * @param {String} modelName - * @param {Number} since Compare changes after this checkpoint - * @param {Change[]} remoteChanges A set of changes to compare - * @callback {Function} callback - * @param {Error} err - * @param {Object} result See above. - */ + * ``` + * + * **deltas** + * + * An array of changes that differ from `remoteChanges`. + * + * **conflicts** + * + * An array of changes that conflict with `remoteChanges`. + * + * @param {String} modelName + * @param {Number} since Compare changes after this checkpoint + * @param {Change[]} remoteChanges A set of changes to compare + * @callback {Function} callback + * @param {Error} err + * @param {Object} result See above. + */ -Change.diff = function(modelName, since, remoteChanges, callback) { - var remoteChangeIndex = {}; - var modelIds = []; - remoteChanges.forEach(function(ch) { - modelIds.push(ch.modelId); - remoteChangeIndex[ch.modelId] = new Change(ch); - }); + Change.diff = function(modelName, since, remoteChanges, callback) { + var remoteChangeIndex = {}; + var modelIds = []; + remoteChanges.forEach(function(ch) { + modelIds.push(ch.modelId); + remoteChangeIndex[ch.modelId] = new Change(ch); + }); - // normalize `since` - since = Number(since) || 0; - this.find({ - where: { - modelName: modelName, - modelId: {inq: modelIds}, - checkpoint: {gte: since} - } - }, function(err, localChanges) { - if(err) return callback(err); - var deltas = []; - var conflicts = []; - var localModelIds = []; + // normalize `since` + since = Number(since) || 0; + this.find({ + where: { + modelName: modelName, + modelId: {inq: modelIds}, + checkpoint: {gte: since} + } + }, function(err, localChanges) { + if (err) return callback(err); + var deltas = []; + var conflicts = []; + var localModelIds = []; - localChanges.forEach(function(localChange) { - localChange = new Change(localChange); - localModelIds.push(localChange.modelId); - var remoteChange = remoteChangeIndex[localChange.modelId]; - if(remoteChange && !localChange.equals(remoteChange)) { - if(remoteChange.conflictsWith(localChange)) { - remoteChange.debug('remote conflict'); - localChange.debug('local conflict'); - conflicts.push(localChange); - } else { - remoteChange.debug('remote delta'); - deltas.push(remoteChange); + localChanges.forEach(function(localChange) { + localChange = new Change(localChange); + localModelIds.push(localChange.modelId); + var remoteChange = remoteChangeIndex[localChange.modelId]; + if (remoteChange && !localChange.equals(remoteChange)) { + if (remoteChange.conflictsWith(localChange)) { + remoteChange.debug('remote conflict'); + localChange.debug('local conflict'); + conflicts.push(localChange); + } else { + remoteChange.debug('remote delta'); + deltas.push(remoteChange); + } } - } - }); + }); - modelIds.forEach(function(id) { - if(localModelIds.indexOf(id) === -1) { - deltas.push(remoteChangeIndex[id]); - } - }); + modelIds.forEach(function(id) { + if (localModelIds.indexOf(id) === -1) { + deltas.push(remoteChangeIndex[id]); + } + }); - callback(null, { - deltas: deltas, - conflicts: conflicts - }); - }); -} - -/** - * Correct all change list entries. - * @param {Function} callback - */ - -Change.rectifyAll = function(cb) { - debug('rectify all'); - var Change = this; - // this should be optimized - this.find(function(err, changes) { - if(err) return cb(err); - changes.forEach(function(change) { - change = new Change(change); - change.rectify(); - }); - }); -} - -/** - * Get the checkpoint model. - * @return {Checkpoint} - */ - -Change.getCheckpointModel = function() { - var checkpointModel = this.Checkpoint; - if(checkpointModel) return checkpointModel; - this.checkpoint = checkpointModel = loopback.Checkpoint.extend('checkpoint'); - assert(this.dataSource, 'Cannot getCheckpointModel(): ' + this.modelName - + ' is not attached to a dataSource'); - checkpointModel.attachTo(this.dataSource); - return checkpointModel; -} - -Change.handleError = function(err) { - if(!this.settings.ignoreErrors) { - throw err; - } -} - -Change.prototype.debug = function() { - if(debug.enabled) { - var args = Array.prototype.slice.call(arguments); - debug.apply(this, args); - debug('\tid', this.id); - debug('\trev', this.rev); - debug('\tprev', this.prev); - debug('\tmodelName', this.modelName); - debug('\tmodelId', this.modelId); - debug('\ttype', this.type()); - } -} - -/** - * Get the `Model` class for `change.modelName`. - * @return {Model} - */ - -Change.prototype.getModelCtor = function() { - return this.constructor.settings.trackModel; -} - -Change.prototype.getModelId = function() { - // TODO(ritch) get rid of the need to create an instance - var Model = this.getModelCtor(); - var id = this.modelId; - var m = new Model(); - m.setId(id); - return m.getId(); -} - -Change.prototype.getModel = function(callback) { - var Model = this.constructor.settings.trackModel; - var id = this.getModelId(); - Model.findById(id, callback); -} - -/** - * When two changes conflict a conflict is created. - * - * **Note: call `conflict.fetch()` to get the `target` and `source` models. - * - * @param {*} modelId - * @param {PersistedModel} SourceModel - * @param {PersistedModel} TargetModel - * @property {ModelClass} source The source model instance - * @property {ModelClass} target The target model instance - */ - -function Conflict(modelId, SourceModel, TargetModel) { - this.SourceModel = SourceModel; - this.TargetModel = TargetModel; - this.SourceChange = SourceModel.getChangeModel(); - this.TargetChange = TargetModel.getChangeModel(); - this.modelId = modelId; -} - -/** - * Fetch the conflicting models. - * - * @callback {Function} callback - * @param {Error} err - * @param {PersistedModel} source - * @param {PersistedModel} target - */ - -Conflict.prototype.models = function(cb) { - var conflict = this; - var SourceModel = this.SourceModel; - var TargetModel = this.TargetModel; - var source; - var target; - - async.parallel([ - getSourceModel, - getTargetModel - ], done); - - function getSourceModel(cb) { - SourceModel.findById(conflict.modelId, function(err, model) { - if(err) return cb(err); - source = model; - cb(); + callback(null, { + deltas: deltas, + conflicts: conflicts + }); }); } - function getTargetModel(cb) { - TargetModel.findById(conflict.modelId, function(err, model) { - if(err) return cb(err); - target = model; - cb(); + /** + * Correct all change list entries. + * @param {Function} callback + */ + + Change.rectifyAll = function(cb) { + debug('rectify all'); + var Change = this; + // this should be optimized + this.find(function(err, changes) { + if (err) return cb(err); + changes.forEach(function(change) { + change = new Change(change); + change.rectify(); + }); }); } - function done(err) { - if(err) return cb(err); - cb(null, source, target); - } -} + /** + * Get the checkpoint model. + * @return {Checkpoint} + */ -/** - * Get the conflicting changes. - * - * @callback {Function} callback - * @param {Error} err - * @param {Change} sourceChange - * @param {Change} targetChange - */ - -Conflict.prototype.changes = function(cb) { - var conflict = this; - var sourceChange; - var targetChange; - - async.parallel([ - getSourceChange, - getTargetChange - ], done); - - function getSourceChange(cb) { - conflict.SourceChange.findOne({where: { - modelId: conflict.modelId - }}, function(err, change) { - if(err) return cb(err); - sourceChange = change; - cb(); - }); + Change.getCheckpointModel = function() { + var checkpointModel = this.Checkpoint; + if (checkpointModel) return checkpointModel; + this.checkpoint = checkpointModel = loopback.Checkpoint.extend('checkpoint'); + assert(this.dataSource, 'Cannot getCheckpointModel(): ' + this.modelName + + ' is not attached to a dataSource'); + checkpointModel.attachTo(this.dataSource); + return checkpointModel; } - function getTargetChange(cb) { - conflict.TargetChange.findOne({where: { - modelId: conflict.modelId - }}, function(err, change) { - if(err) return cb(err); - targetChange = change; - cb(); - }); - } - - function done(err) { - if(err) return cb(err); - cb(null, sourceChange, targetChange); - } -} - -/** - * Resolve the conflict. - * - * @callback {Function} callback - * @param {Error} err - */ - -Conflict.prototype.resolve = function(cb) { - var conflict = this; - conflict.changes(function(err, sourceChange, targetChange) { - if(err) return cb(err); - sourceChange.prev = targetChange.rev; - sourceChange.save(cb); - }); -} - -/** - * Determine the conflict type. - * - * Possible results are - * - * - `Change.UPDATE`: Source and target models were updated. - * - `Change.DELETE`: Source and or target model was deleted. - * - `Change.UNKNOWN`: the conflict type is uknown or due to an error. - * - * @callback {Function} callback - * @param {Error} err - * @param {String} type The conflict type. - */ - -Conflict.prototype.type = function(cb) { - var conflict = this; - this.changes(function(err, sourceChange, targetChange) { - if(err) return cb(err); - var sourceChangeType = sourceChange.type(); - var targetChangeType = targetChange.type(); - if(sourceChangeType === Change.UPDATE && targetChangeType === Change.UPDATE) { - return cb(null, Change.UPDATE); + Change.handleError = function(err) { + if (!this.settings.ignoreErrors) { + throw err; } - if(sourceChangeType === Change.DELETE || targetChangeType === Change.DELETE) { - return cb(null, Change.DELETE); + } + + Change.prototype.debug = function() { + if (debug.enabled) { + var args = Array.prototype.slice.call(arguments); + debug.apply(this, args); + debug('\tid', this.id); + debug('\trev', this.rev); + debug('\tprev', this.prev); + debug('\tmodelName', this.modelName); + debug('\tmodelId', this.modelId); + debug('\ttype', this.type()); } - return cb(null, Change.UNKNOWN); - }); -} + } + + /** + * Get the `Model` class for `change.modelName`. + * @return {Model} + */ + + Change.prototype.getModelCtor = function() { + return this.constructor.settings.trackModel; + } + + Change.prototype.getModelId = function() { + // TODO(ritch) get rid of the need to create an instance + var Model = this.getModelCtor(); + var id = this.modelId; + var m = new Model(); + m.setId(id); + return m.getId(); + } + + Change.prototype.getModel = function(callback) { + var Model = this.constructor.settings.trackModel; + var id = this.getModelId(); + Model.findById(id, callback); + } + + /** + * When two changes conflict a conflict is created. + * + * **Note: call `conflict.fetch()` to get the `target` and `source` models. + * + * @param {*} modelId + * @param {PersistedModel} SourceModel + * @param {PersistedModel} TargetModel + * @property {ModelClass} source The source model instance + * @property {ModelClass} target The target model instance + * @class Change.Conflict + */ + + function Conflict(modelId, SourceModel, TargetModel) { + this.SourceModel = SourceModel; + this.TargetModel = TargetModel; + this.SourceChange = SourceModel.getChangeModel(); + this.TargetChange = TargetModel.getChangeModel(); + this.modelId = modelId; + } + + /** + * Fetch the conflicting models. + * + * @callback {Function} callback + * @param {Error} err + * @param {PersistedModel} source + * @param {PersistedModel} target + */ + + Conflict.prototype.models = function(cb) { + var conflict = this; + var SourceModel = this.SourceModel; + var TargetModel = this.TargetModel; + var source; + var target; + + async.parallel([ + getSourceModel, + getTargetModel + ], done); + + function getSourceModel(cb) { + SourceModel.findById(conflict.modelId, function(err, model) { + if (err) return cb(err); + source = model; + cb(); + }); + } + + function getTargetModel(cb) { + TargetModel.findById(conflict.modelId, function(err, model) { + if (err) return cb(err); + target = model; + cb(); + }); + } + + function done(err) { + if (err) return cb(err); + cb(null, source, target); + } + } + + /** + * Get the conflicting changes. + * + * @callback {Function} callback + * @param {Error} err + * @param {Change} sourceChange + * @param {Change} targetChange + */ + + Conflict.prototype.changes = function(cb) { + var conflict = this; + var sourceChange; + var targetChange; + + async.parallel([ + getSourceChange, + getTargetChange + ], done); + + function getSourceChange(cb) { + conflict.SourceChange.findOne({where: { + modelId: conflict.modelId + }}, function(err, change) { + if (err) return cb(err); + sourceChange = change; + cb(); + }); + } + + function getTargetChange(cb) { + conflict.TargetChange.findOne({where: { + modelId: conflict.modelId + }}, function(err, change) { + if (err) return cb(err); + targetChange = change; + cb(); + }); + } + + function done(err) { + if (err) return cb(err); + cb(null, sourceChange, targetChange); + } + } + + /** + * Resolve the conflict. + * + * @callback {Function} callback + * @param {Error} err + */ + + Conflict.prototype.resolve = function(cb) { + var conflict = this; + conflict.changes(function(err, sourceChange, targetChange) { + if (err) return cb(err); + sourceChange.prev = targetChange.rev; + sourceChange.save(cb); + }); + } + + /** + * Determine the conflict type. + * + * Possible results are + * + * - `Change.UPDATE`: Source and target models were updated. + * - `Change.DELETE`: Source and or target model was deleted. + * - `Change.UNKNOWN`: the conflict type is uknown or due to an error. + * + * @callback {Function} callback + * @param {Error} err + * @param {String} type The conflict type. + */ + + Conflict.prototype.type = function(cb) { + var conflict = this; + this.changes(function(err, sourceChange, targetChange) { + if (err) return cb(err); + var sourceChangeType = sourceChange.type(); + var targetChangeType = targetChange.type(); + if (sourceChangeType === Change.UPDATE && targetChangeType === Change.UPDATE) { + return cb(null, Change.UPDATE); + } + if (sourceChangeType === Change.DELETE || targetChangeType === Change.DELETE) { + return cb(null, Change.DELETE); + } + return cb(null, Change.UNKNOWN); + }); + } +}; diff --git a/common/models/change.json b/common/models/change.json new file mode 100644 index 00000000..b968703a --- /dev/null +++ b/common/models/change.json @@ -0,0 +1,25 @@ +{ + "name": "Change", + "trackChanges": false, + "properties": { + "id": { + "type": "string", + "id": true + }, + "rev": { + "type": "string" + }, + "prev": { + "type": "string" + }, + "checkpoint": { + "type": "number" + }, + "modelName": { + "type": "string" + }, + "modelId": { + "type": "string" + } + } +} diff --git a/lib/builtin-models.js b/lib/builtin-models.js index a6175e9f..2a346d3e 100644 --- a/lib/builtin-models.js +++ b/lib/builtin-models.js @@ -33,7 +33,9 @@ module.exports = function(loopback) { require('../common/models/user.json'), require('../common/models/user.js')); - loopback.Change = require('../common/models/change'); + loopback.Change = createModel( + require('../common/models/change.json'), + require('../common/models/change.js')); loopback.Checkpoint = createModel( require('../common/models/checkpoint.json'), diff --git a/lib/persisted-model.js b/lib/persisted-model.js index 8b98e71c..0df9fcfe 100644 --- a/lib/persisted-model.js +++ b/lib/persisted-model.js @@ -960,7 +960,10 @@ PersistedModel.enableChangeTracking = function() { } PersistedModel._defineChangeModel = function() { - var BaseChangeModel = require('./../common/models/change'); + var BaseChangeModel = loopback.Change; + assert(BaseChangeModel, + 'Change model must be defined before enabling change replication'); + return this.Change = BaseChangeModel.extend(this.modelName + '-change', {}, {