From 6f0cd70efb53c6b4e9a83b79bed9d828e0f4d04d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 3 Apr 2015 16:41:32 +0200 Subject: [PATCH] WIP - access control step1 --- lib/persisted-model.js | 129 +++++----- test/model.test.js | 2 +- test/replication.rest.test.js | 438 ++++++++++++++++++++++++++++++++++ test/util/model-tests.js | 4 + 4 files changed, 504 insertions(+), 69 deletions(-) create mode 100644 test/replication.rest.test.js diff --git a/lib/persisted-model.js b/lib/persisted-model.js index 34b4158e9..72d79551c 100644 --- a/lib/persisted-model.js +++ b/lib/persisted-model.js @@ -644,73 +644,64 @@ module.exports = function(registry) { http: {verb: 'put', path: '/'} }); - if (options.trackChanges) { - setRemoting(PersistedModel, 'diff', { - description: 'Get a set of deltas and conflicts since the given checkpoint', - accessType: 'READ', - accepts: [ - {arg: 'since', type: 'number', description: 'Find deltas since this checkpoint'}, - {arg: 'remoteChanges', type: 'array', description: 'an array of change objects', - http: {source: 'body'}} - ], - returns: {arg: 'result', type: 'object', root: true}, - http: {verb: 'post', path: '/diff'} - }); - - setRemoting(PersistedModel, 'changes', { - description: 'Get the changes to a model since a given checkpoint.' + - 'Provide a filter object to reduce the number of results returned.', - accessType: 'READ', - accepts: [ - {arg: 'since', type: 'number', description: 'Only return changes since this checkpoint'}, - {arg: 'filter', type: 'object', description: 'Only include changes that match this filter'} - ], - returns: {arg: 'changes', type: 'array', root: true}, - http: {verb: 'get', path: '/changes'} - }); - - setRemoting(PersistedModel, 'checkpoint', { - description: 'Create a checkpoint.', - accessType: 'WRITE', - returns: {arg: 'checkpoint', type: 'object', root: true}, - http: {verb: 'post', path: '/checkpoint'} - }); + setRemoting(PersistedModel, 'diff', { + description: 'Get a set of deltas and conflicts since the given checkpoint', + accessType: 'READ', + accepts: [ + {arg: 'since', type: 'number', description: 'Find deltas since this checkpoint'}, + {arg: 'remoteChanges', type: 'array', description: 'an array of change objects', + http: {source: 'body'}} + ], + returns: {arg: 'result', type: 'object', root: true}, + http: {verb: 'post', path: '/diff'} + }); - setRemoting(PersistedModel, 'currentCheckpoint', { - description: 'Get the current checkpoint.', - accessType: 'READ', - returns: {arg: 'checkpoint', type: 'object', root: true}, - http: {verb: 'get', path: '/checkpoint'} - }); + setRemoting(PersistedModel, 'changes', { + description: 'Get the changes to a model since a given checkpoint.' + + 'Provide a filter object to reduce the number of results returned.', + accessType: 'READ', + accepts: [ + {arg: 'since', type: 'number', description: 'Only return changes since this checkpoint'}, + {arg: 'filter', type: 'object', description: 'Only include changes that match this filter'} + ], + returns: {arg: 'changes', type: 'array', root: true}, + http: {verb: 'get', path: '/changes'} + }); - setRemoting(PersistedModel, 'createUpdates', { - description: 'Create an update list from a delta list', - accessType: 'WRITE', - accepts: {arg: 'deltas', type: 'array', http: {source: 'body'}}, - returns: {arg: 'updates', type: 'array', root: true}, - http: {verb: 'post', path: '/create-updates'} - }); + setRemoting(PersistedModel, 'checkpoint', { + description: 'Create a checkpoint.', + // 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 have only READ permissions. + accessType: 'READ', + returns: {arg: 'checkpoint', type: 'object', root: true}, + http: {verb: 'post', path: '/checkpoint'} + }); - setRemoting(PersistedModel, 'bulkUpdate', { - description: 'Run multiple updates at once. Note: this is not atomic.', - accessType: 'WRITE', - accepts: {arg: 'updates', type: 'array'}, - http: {verb: 'post', path: '/bulk-update'} - }); + setRemoting(PersistedModel, 'currentCheckpoint', { + description: 'Get the current checkpoint.', + accessType: 'READ', + returns: {arg: 'checkpoint', type: 'object', root: true}, + http: {verb: 'get', path: '/checkpoint'} + }); - setRemoting(PersistedModel, 'rectifyAllChanges', { - description: 'Rectify all Model changes.', - accessType: 'WRITE', - http: {verb: 'post', path: '/rectify-all'} - }); + setRemoting(PersistedModel, 'createUpdates', { + description: 'Create an update list from a delta list', + // 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'} + }); - setRemoting(PersistedModel, 'rectifyChange', { - description: 'Tell loopback that a change to the model with the given id has occurred.', - accessType: 'WRITE', - accepts: {arg: 'id', type: 'any', http: {source: 'path'}}, - http: {verb: 'post', path: '/:id/rectify-change'} - }); - } + setRemoting(PersistedModel, 'bulkUpdate', { + description: 'Run multiple updates at once. Note: this is not atomic.', + accessType: 'WRITE', + accepts: {arg: 'updates', type: 'array'}, + http: {verb: 'post', path: '/bulk-update'} + }); }; /** @@ -893,13 +884,15 @@ module.exports = function(registry) { }; function tryReplicate(sourceModel, targetModel, since, options, callback) { - var Change = sourceModel.getChangeModel(); - var TargetChange = targetModel.getChangeModel(); - var changeTrackingEnabled = Change && TargetChange; + // TODO(bajtos) Move Change.Conflict to a standalone model + // that can be used without the Change model. That will allow + // us to replicate changes between two remote models, with no + // client Change model needed. + var Change = sourceModel.Change || targetModel.Change; assert( - changeTrackingEnabled, - 'You must enable change tracking before replicating' + !!Change, + 'You must enable change tracking in at least one model before replicating' ); var diff; @@ -1281,7 +1274,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; }; diff --git a/test/model.test.js b/test/model.test.js index db05e13e8..c3f2df1d8 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -509,7 +509,7 @@ describe.onServer('Remote Methods', function() { methodNames = methodNames.concat(method.sharedMethod.aliases || []); }); - expect(methodNames).to.have.members([ + expect(methodNames).to.include.members([ // NOTE(bajtos) These three methods are disabled by default // Because all tests share the same global registry model // and one of the tests was enabling remoting of "destroyAll", diff --git a/test/replication.rest.test.js b/test/replication.rest.test.js new file mode 100644 index 000000000..5a771a393 --- /dev/null +++ b/test/replication.rest.test.js @@ -0,0 +1,438 @@ +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 serverApp, serverUrl, ServerUser, ServerCar, serverCars; + var aliceId, peterId, aliceToken, peterToken, request; + var clientApp, LocalUser, LocalCar, RemoteUser, RemoteCar, clientCars; + + before(setupServer); + before(setupClient); + beforeEach(seedServerData); + beforeEach(seedClientData); + + describe('scenario under test', 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 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(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 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: 'User', + 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' }); + + // Disable change tracking on all remote model, + // server will call rectify/rectifyAll after each change + // because it's tracking the changes too. + + var remoteOpts = extend(USER_OPTS, { trackChanges: false }); + RemoteUser = loopback.createModel('RemoteUser', USER_PROPS, remoteOpts); + clientApp.model(RemoteUser, { dataSource: 'remote' }); + + remoteOpts = extend(CAR_OPTS, { trackChanges: false }); + RemoteCar = loopback.createModel('RemoteCar', CAR_PROPS, remoteOpts); + clientApp.model(RemoteCar, { dataSource: 'remote' }); + } + + function seedServerData(done) { + async.series([ + function(next) { + serverApp.dataSources.db.automigrate(next); + }, + function(next) { + ServerUser.deleteAll(next); + }, + function(next) { + ServerUser.create([ALICE, PETER], 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; + 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/util/model-tests.js b/test/util/model-tests.js index 487e9820e..cd89f307b 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() {