diff --git a/common/models/acl.js b/common/models/acl.js index 81070972..0bc48c7d 100644 --- a/common/models/acl.js +++ b/common/models/acl.js @@ -88,6 +88,7 @@ module.exports = function(ACL) { ACL.DENY = AccessContext.DENY; // Deny ACL.READ = AccessContext.READ; // Read operation + ACL.REPLICATE = AccessContext.REPLICATE; // Replicate (pull) changes ACL.WRITE = AccessContext.WRITE; // Write operation ACL.EXECUTE = AccessContext.EXECUTE; // Execute operation @@ -109,21 +110,31 @@ module.exports = function(ACL) { 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; + var ruleValue = rule[props[i]] || ACL.ALL; + var requestedValue = req[props[i]] || ACL.ALL; + var isMatchingMethodName = props[i] === 'property' && req.methodNames.indexOf(ruleValue) !== -1; - // accessType: EXECUTE should match READ or WRITE - var isMatchingAccessType = props[i] === 'accessType' && - val1 === ACL.EXECUTE; + var isMatchingAccessType = ruleValue === requestedValue; + if (props[i] === 'accessType' && !isMatchingAccessType) { + switch (ruleValue) { + case ACL.EXECUTE: + // EXECUTE should match READ, REPLICATE and WRITE + isMatchingAccessType = true; + break; + case ACL.WRITE: + // WRITE should match REPLICATE too + isMatchingAccessType = requestedValue === ACL.REPLICATE; + break; + } + } - if (val1 === val2 || isMatchingMethodName || isMatchingAccessType) { + if (isMatchingMethodName || isMatchingAccessType) { // Exact match score += 3; - } else if (val1 === ACL.ALL) { + } else if (ruleValue === ACL.ALL) { // Wildcard match score += 2; - } else if (val2 === ACL.ALL) { + } else if (requestedValue === ACL.ALL) { score += 1; } else { // Doesn't match at all @@ -370,7 +381,8 @@ module.exports = function(ACL) { * @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. + * @property {String} accessType The access type: + * READ, REPLICATE, WRITE, or EXECUTE. * @param {Function} callback Callback function */ @@ -388,7 +400,12 @@ module.exports = function(ACL) { 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 accessTypeQuery = (accessType === ACL.ALL) ? + undefined : + (accessType === ACL.REPLICATE) ? + {inq: [ACL.REPLICATE, ACL.WRITE, ACL.ALL]} : + {inq: [accessType, ACL.ALL]}; var req = new AccessRequest(modelName, property, accessType, ACL.DEFAULT, methodNames); @@ -438,6 +455,7 @@ module.exports = function(ACL) { if (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; diff --git a/lib/access-context.js b/lib/access-context.js index b838bcc1..75ec5016 100644 --- a/lib/access-context.js +++ b/lib/access-context.js @@ -76,6 +76,7 @@ AccessContext.ALL = '*'; // Define constants for access types AccessContext.READ = 'READ'; // Read operation +AccessContext.REPLICATE = 'REPLICATE'; // Replicate (pull) changes AccessContext.WRITE = 'WRITE'; // Write operation AccessContext.EXECUTE = 'EXECUTE'; // Execute operation diff --git a/lib/model.js b/lib/model.js index c9e1da25..5a06b3f4 100644 --- a/lib/model.js +++ b/lib/model.js @@ -333,10 +333,11 @@ module.exports = function(registry) { // Check the explicit setting of accessType if (method.accessType) { assert(method.accessType === ACL.READ || + method.accessType === ACL.REPLICATE || method.accessType === ACL.WRITE || method.accessType === ACL.EXECUTE, 'invalid accessType ' + method.accessType + - '. It must be "READ", "WRITE", or "EXECUTE"'); + '. It must be "READ", "REPLICATE", "WRITE", or "EXECUTE"'); return method.accessType; } diff --git a/lib/persisted-model.js b/lib/persisted-model.js index 07847787..53f8cc62 100644 --- a/lib/persisted-model.js +++ b/lib/persisted-model.js @@ -45,6 +45,8 @@ module.exports = function(registry) { PersistedModel.once('dataSourceAttached', function() { PersistedModel.enableChangeTracking(); }); + } else if (this.settings.enableRemoteReplication) { + PersistedModel._defineChangeModel(); } PersistedModel.setupRemoting(); @@ -643,7 +645,7 @@ module.exports = function(registry) { http: {verb: 'put', path: '/'} }); - if (options.trackChanges) { + if (options.trackChanges || options.enableRemoteReplication) { setRemoting(PersistedModel, 'diff', { description: 'Get a set of deltas and conflicts since the given checkpoint', accessType: 'READ', @@ -670,7 +672,11 @@ module.exports = function(registry) { setRemoting(PersistedModel, 'checkpoint', { description: 'Create a checkpoint.', - accessType: 'WRITE', + // The replication algorithm needs to create a source checkpoint, + // even though it is otherwise not making any source changes. + // We need to allow this method for users that don't have full + // WRITE permissions. + accessType: 'REPLICATE', returns: {arg: 'checkpoint', type: 'object', root: true}, http: {verb: 'post', path: '/checkpoint'} }); @@ -684,7 +690,10 @@ module.exports = function(registry) { setRemoting(PersistedModel, 'createUpdates', { description: 'Create an update list from a delta list', - accessType: 'WRITE', + // This operation is read-only, it does not change any local data. + // It is called by the replication algorithm to compile a list + // of changes to apply on the target. + accessType: 'READ', accepts: {arg: 'deltas', type: 'array', http: {source: 'body'}}, returns: {arg: 'updates', type: 'array', root: true}, http: {verb: 'post', path: '/create-updates'} @@ -696,7 +705,11 @@ module.exports = function(registry) { accepts: {arg: 'updates', type: 'array'}, http: {verb: 'post', path: '/bulk-update'} }); + } + if (options.trackChanges) { + // Deprecated (legacy) exports kept for backwards compatibility + // TODO(bajtos) Hide these two exports in LoopBack 3.0 setRemoting(PersistedModel, 'rectifyAllChanges', { description: 'Rectify all Model changes.', accessType: 'WRITE', @@ -1280,7 +1293,7 @@ module.exports = function(registry) { var changeModel = this.Change; var isSetup = changeModel && changeModel.dataSource; - assert(isSetup, 'Cannot get a setup Change model'); + assert(isSetup, 'Cannot get a setup Change model for ' + this.modelName); return changeModel; }; @@ -1327,9 +1340,6 @@ module.exports = function(registry) { 'which requries a string id with GUID/UUID default value.'); } - Change.attachTo(this.dataSource); - Change.getCheckpointModel().attachTo(this.dataSource); - Model.observe('after save', rectifyOnSave); Model.observe('after delete', rectifyOnDelete); @@ -1411,7 +1421,18 @@ module.exports = function(registry) { } ); + if (this.dataSource) { + attachRelatedModels(this); + } else { + this.once('dataSourceAttached', attachRelatedModels); + } + return this.Change; + + function attachRelatedModels(self) { + self.Change.attachTo(self.dataSource); + self.Change.getCheckpointModel().attachTo(self.dataSource); + } }; PersistedModel.rectifyAllChanges = function(callback) { diff --git a/test/model.test.js b/test/model.test.js index 29e0fef8..7b037adb 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -501,9 +501,11 @@ describe.onServer('Remote Methods', function() { describe('Model._getACLModel()', function() { it('should return the subclass of ACL', function() { var Model = require('../').Model; + var originalValue = Model._ACL(); var acl = ACL.extend('acl'); Model._ACL(null); // Reset the ACL class for the base model var model = Model._ACL(); + Model._ACL(originalValue); // Reset the value back assert.equal(model, acl); }); }); diff --git a/test/replication.rest.test.js b/test/replication.rest.test.js new file mode 100644 index 00000000..a1ae6765 --- /dev/null +++ b/test/replication.rest.test.js @@ -0,0 +1,484 @@ +var async = require('async'); +var debug = require('debug')('test'); +var extend = require('util')._extend; +var loopback = require('../'); +var expect = require('chai').expect; +var supertest = require('supertest'); + +describe('Replication over REST', function() { + var ALICE = { id: 'a', username: 'alice', email: 'a@t.io', password: 'p' }; + var PETER = { id: 'p', username: 'peter', email: 'p@t.io', password: 'p' }; + var EMERY = { id: 'e', username: 'emery', email: 'e@t.io', password: 'p' }; + + var serverApp, serverUrl, ServerUser, ServerCar, serverCars; + var aliceId, peterId, aliceToken, peterToken, emeryToken, request; + var clientApp, LocalUser, LocalCar, RemoteUser, RemoteCar, clientCars; + + before(setupServer); + before(setupClient); + beforeEach(seedServerData); + beforeEach(seedClientData); + + describe('the replication scenario scaffolded for the tests', function() { + describe('Car model', function() { + it('rejects anonymous READ', function(done) { + listCars().expect(401, done); + }); + + it('rejects anonymous WRITE', function(done) { + createCar().expect(401, done); + }); + + it('allows EMERY to READ', function(done) { + listCars() + .set('Authorization', emeryToken) + .expect(200, done); + }); + + it('denies EMERY to WRITE', function(done) { + createCar() + .set('Authorization', emeryToken) + .expect(401, done); + }); + + it('allows ALICE to READ', function(done) { + listCars() + .set('Authorization', aliceToken) + .expect(200, done); + }); + + it('denies ALICE to WRITE', function(done) { + createCar() + .set('Authorization', aliceToken) + .expect(401, done); + }); + + it('allows PETER to READ', function(done) { + listCars() + .set('Authorization', peterToken) + .expect(200, done); + }); + + it('allows PETER to WRITE', function(done) { + createCar() + .set('Authorization', peterToken) + .expect(200, done); + }); + + function listCars() { + return request.get('/Cars'); + } + + function createCar() { + return request.post('/Cars').send({ model: 'a-model' }); + } + }); + }); + + describe('sync with model-level permissions', function() { + describe('as anonymous user', function() { + it('rejects pull from server', function(done) { + RemoteCar.replicate(LocalCar, expectHttpError(401, done)); + }); + + it('rejects push to the server', function(done) { + LocalCar.replicate(RemoteCar, expectHttpError(401, done)); + }); + }); + + describe('as user with READ-only permissions', function() { + beforeEach(function() { + setAccessToken(emeryToken); + }); + + it('rejects pull from server', function(done) { + RemoteCar.replicate(LocalCar, expectHttpError(401, done)); + }); + + it('rejects push to the server', function(done) { + LocalCar.replicate(RemoteCar, expectHttpError(401, done)); + }); + }); + + describe('as user with REPLICATE-only permissions', function() { + beforeEach(function() { + setAccessToken(aliceToken); + }); + + it('allows pull from server', function(done) { + RemoteCar.replicate(LocalCar, function(err, conflicts, cps) { + if (err) return done(err); + if (conflicts.length) return done(conflictError(conflicts)); + + LocalCar.find(function(err, list) { + if (err) return done(err); + expect(list.map(carToString)).to.include.members(serverCars); + done(); + }); + }); + }); + + it('rejects push to the server', function(done) { + LocalCar.replicate(RemoteCar, expectHttpError(401, done)); + }); + }); + + describe('as user with READ and WRITE permissions', function() { + beforeEach(function() { + setAccessToken(peterToken); + }); + + it('allows pull from server', function(done) { + RemoteCar.replicate(LocalCar, function(err, conflicts, cps) { + if (err) return done(err); + if (conflicts.length) return done(conflictError(conflicts)); + + LocalCar.find(function(err, list) { + if (err) return done(err); + expect(list.map(carToString)).to.include.members(serverCars); + done(); + }); + }); + }); + + it('allows push to the server', function(done) { + LocalCar.replicate(RemoteCar, function(err, conflicts, cps) { + if (err) return done(err); + if (conflicts.length) return done(conflictError(conflicts)); + + ServerCar.find(function(err, list) { + if (err) return done(err); + expect(list.map(carToString)).to.include.members(clientCars); + done(); + }); + }); + }); + }); + + // TODO conflict resolution + // TODO verify permissions of individual methods + }); + + describe.skip('sync with instance-level permissions', function() { + it('pulls only authorized records', function(done) { + setAccessToken(aliceToken); + RemoteUser.replicate(LocalUser, function(err, conflicts, cps) { + if (err) return done(err); + if (conflicts.length) return done(conflictError(conflicts)); + LocalUser.find(function(err, users) { + var userNames = users.map(function(u) { return u.username; }); + expect(userNames).to.eql([ALICE.username]); + done(); + }); + }); + }); + + it('allows push of authorized records', function(done) { + async.series([ + setupModifiedLocalCopyOfAlice, + + function replicateAsCurrentUser(next) { + setAccessToken(aliceToken); + LocalUser.replicate(RemoteUser, function(err, conflicts) { + if (err) return next(err); + if (conflicts.length) return next(conflictError(conflicts)); + next(); + }); + }, + + function verify(next) { + RemoteUser.findById(aliceId, function(err, found) { + if (err) return next(err); + expect(found.toObject()) + .to.have.property('fullname', 'Alice Smith'); + next(); + }); + } + ], done); + }); + + it('rejects push of unauthorized records', function(done) { + async.series([ + setupModifiedLocalCopyOfAlice, + + function replicateAsDifferentUser(next) { + setAccessToken(peterToken); + LocalUser.replicate(RemoteUser, function(err, conflicts) { + if (!err) + return next(new Error('Replicate should have failed.')); + expect(err).to.have.property('statusCode', 401); // or 403? + next(); + }); + }, + + function verify(next) { + ServerUser.findById(aliceId, function(err, found) { + if (err) return next(err); + expect(found.toObject()) + .to.not.have.property('fullname'); + next(); + }); + } + ], done); + }); + + // TODO verify conflict resolution + + function setupModifiedLocalCopyOfAlice(done) { + // Replicate directly, bypassing REST+AUTH layers + replicateServerToLocal(function(err) { + if (err) return done(err); + + LocalUser.updateAll( + { id: aliceId }, + { fullname: 'Alice Smith' }, + done); + }); + } + }); + + var USER_PROPS = { + id: { type: 'string', id: true } + }; + + var USER_OPTS = { + base: 'User', + plural: 'Users', // use the same REST path in all models + trackChanges: true, + strict: true, + persistUndefinedAsNull: true + }; + + var CAR_PROPS = { + id: { type: 'string', id: true, defaultFn: 'guid' }, + model: { type: 'string', required: true }, + maker: { type: 'string' } + }; + + var CAR_OPTS = { + base: 'PersistedModel', + plural: 'Cars', // use the same REST path in all models + trackChanges: true, + strict: true, + persistUndefinedAsNull: true, + acls: [ + // disable anonymous access + { + principalType: 'ROLE', + principalId: '$everyone', + permission: 'DENY' + }, + // allow all authenticated users to read data + { + principalType: 'ROLE', + principalId: '$authenticated', + permission: 'ALLOW', + accessType: 'READ' + }, + // allow Alice to pull changes + { + principalType: 'USER', + principalId: ALICE.id, + permission: 'ALLOW', + accessType: 'REPLICATE' + }, + // allow Peter to write data + { + principalType: 'USER', + principalId: PETER.id, + permission: 'ALLOW', + accessType: 'WRITE' + } + ] + }; + + function setupServer(done) { + serverApp = loopback(); + serverApp.enableAuth(); + + serverApp.dataSource('db', { connector: 'memory' }); + + // Setup a custom access-token model that is not shared + // with the client app + var ServerToken = loopback.createModel('ServerToken', {}, { + base: 'AccessToken', + relations: { + user: { + type: 'belongsTo', + model: 'ServerUser', + foreignKey: 'userId' + } + } + }); + serverApp.model(ServerToken, { dataSource: 'db', public: false }); + serverApp.model(loopback.ACL, { dataSource: 'db', public: false }); + serverApp.model(loopback.Role, { dataSource: 'db', public: false }); + serverApp.model(loopback.RoleMapping, { dataSource: 'db', public: false }); + + ServerUser = loopback.createModel('ServerUser', USER_PROPS, USER_OPTS); + serverApp.model(ServerUser, { + dataSource: 'db', + public: true, + relations: { accessTokens: { model: 'ServerToken' } } + }); + + ServerCar = loopback.createModel('ServerCar', CAR_PROPS, CAR_OPTS); + serverApp.model(ServerCar, { dataSource: 'db', public: true }); + + serverApp.use(function(req, res, next) { + debug(req.method + ' ' + req.path); + next(); + }); + serverApp.use(loopback.token({ model: ServerToken })); + serverApp.use(loopback.rest()); + + serverApp.set('legacyExplorer', false); + serverApp.set('port', 0); + serverApp.set('host', '127.0.0.1'); + serverApp.listen(function() { + serverUrl = serverApp.get('url').replace(/\/+$/, ''); + request = supertest(serverUrl); + done(); + }); + } + + function setupClient() { + clientApp = loopback(); + clientApp.dataSource('db', { connector: 'memory' }); + clientApp.dataSource('remote', { + connector: 'remote', + url: serverUrl + }); + + // NOTE(bajtos) At the moment, all models share the same Checkpoint + // model. This causes the in-process replication to work differently + // than client-server replication. + // As a workaround, we manually setup unique Checkpoint for ClientModel. + var ClientCheckpoint = loopback.Checkpoint.extend('ClientCheckpoint'); + ClientCheckpoint.attachTo(clientApp.dataSources.db); + + LocalUser = loopback.createModel('LocalUser', USER_PROPS, USER_OPTS); + if (LocalUser.Change) LocalUser.Change.Checkpoint = ClientCheckpoint; + clientApp.model(LocalUser, { dataSource: 'db' }); + + LocalCar = loopback.createModel('LocalCar', CAR_PROPS, CAR_OPTS); + LocalCar.Change.Checkpoint = ClientCheckpoint; + clientApp.model(LocalCar, { dataSource: 'db' }); + + var remoteOpts = createRemoteModelOpts(USER_OPTS); + RemoteUser = loopback.createModel('RemoteUser', USER_PROPS, remoteOpts); + clientApp.model(RemoteUser, { dataSource: 'remote' }); + + remoteOpts = createRemoteModelOpts(CAR_OPTS); + RemoteCar = loopback.createModel('RemoteCar', CAR_PROPS, remoteOpts); + clientApp.model(RemoteCar, { dataSource: 'remote' }); + } + + function createRemoteModelOpts(modelOpts) { + return extend(modelOpts, { + // Disable change tracking, server will call rectify/rectifyAll + // after each change, because it's tracking the changes too. + trackChanges: false, + // Enable remote replication in order to get remoting API metadata + // used by the remoting connector + enableRemoteReplication: true + }); + } + + function seedServerData(done) { + async.series([ + function(next) { + serverApp.dataSources.db.automigrate(next); + }, + function(next) { + ServerUser.deleteAll(next); + }, + function(next) { + ServerUser.create([ALICE, PETER, EMERY], function(err, created) { + if (err) return next(err); + aliceId = created[0].id; + peterId = created[1].id; + next(); + }); + }, + function(next) { + ServerUser.login(ALICE, function(err, token) { + if (err) return next(err); + aliceToken = token.id; + + ServerUser.login(PETER, function(err, token) { + if (err) return next(err); + peterToken = token.id; + + ServerUser.login(EMERY, function(err, token) { + emeryToken = token.id; + + next(); + }); + }); + }); + }, + function(next) { + ServerCar.create( + [ + { maker: 'Ford', model: 'Mustang' }, + { maker: 'Audi', model: 'R8' } + ], + function(err, cars) { + if (err) return next(err); + serverCars = cars.map(carToString); + next(); + }); + } + ], done); + } + + function seedClientData(done) { + LocalUser.deleteAll(function(err) { + if (err) return done(err); + LocalCar.deleteAll(function(err) { + if (err) return done(err); + LocalCar.create( + [{ maker: 'Local', model: 'Custom' }], + function(err, cars) { + if (err) return done(err); + clientCars = cars.map(carToString); + done(); + }); + }); + }); + } + + function setAccessToken(token) { + clientApp.dataSources.remote.connector.remotes.auth = { + bearer: new Buffer(token).toString('base64'), + sendImmediately: true + }; + } + + function expectHttpError(code, done) { + return function(err) { + if (!err) return done(new Error('The method should have failed.')); + expect(err).to.have.property('statusCode', code); + done(); + }; + } + + function replicateServerToLocal(next) { + ServerUser.replicate(LocalUser, function(err, conflicts) { + if (err) return next(err); + if (conflicts.length) return next(conflictError(conflicts)); + next(); + }); + } + + function conflictError(conflicts) { + var err = new Error('Unexpected conflicts\n' + + conflicts.map(JSON.stringify).join('\n')); + err.name = 'ConflictError'; + } + + function carToString(c) { + return c.maker ? c.maker + ' ' + c.model : c.model; + } +}); diff --git a/test/replication.test.js b/test/replication.test.js index cc091d03..2d0eb21c 100644 --- a/test/replication.test.js +++ b/test/replication.test.js @@ -1,3 +1,4 @@ +var assert = require('assert'); var async = require('async'); var loopback = require('../'); var Change = loopback.Change; diff --git a/test/rest.middleware.test.js b/test/rest.middleware.test.js index e608c5b2..8168a491 100644 --- a/test/rest.middleware.test.js +++ b/test/rest.middleware.test.js @@ -3,6 +3,7 @@ describe('loopback.rest', function() { beforeEach(function() { var ds = app.dataSource('db', { connector: loopback.Memory }); MyModel = ds.createModel('MyModel', {name: String}); + loopback.autoAttach(); }); it('works out-of-the-box', function(done) { diff --git a/test/support.js b/test/support.js index 1ef0eefc..0f925a38 100644 --- a/test/support.js +++ b/test/support.js @@ -30,9 +30,6 @@ beforeEach(function() { {type: 'STUB'} ] }); - - // auto attach data sources to models - loopback.autoAttach(); }); assertValidDataSource = function(dataSource) { diff --git a/test/user.test.js b/test/user.test.js index 56aae9e9..682b2f46 100644 --- a/test/user.test.js +++ b/test/user.test.js @@ -23,6 +23,7 @@ describe('User', function() { // Update the AccessToken relation to use the subclass of User AccessToken.belongsTo(User); + User.hasMany(AccessToken); // allow many User.afterRemote's to be called User.setMaxListeners(0); @@ -1071,6 +1072,7 @@ describe('User', function() { assert.equal(info.accessToken.ttl / 60, 15); assert(calledBack); info.accessToken.user(function(err, user) { + if (err) return done(err); assert.equal(user.email, email); done(); }); diff --git a/test/util/model-tests.js b/test/util/model-tests.js index 487e9820..cd89f307 100644 --- a/test/util/model-tests.js +++ b/test/util/model-tests.js @@ -50,6 +50,10 @@ module.exports = function defineModelTestsWithDataSource(options) { }); User.attachTo(dataSource); + User.handleChangeError = function(err) { + console.warn('WARNING: unhandled change-tracking error'); + console.warn(err); + }; }); describe('Model.validatesPresenceOf(properties...)', function() {