AccessControl for change replication

1) Add integration tests running change replication over REST to verify
that access control at model level is correctly enforced.

2) Implement a new access type "REPLICATE" that allows principals
to create new checkpoints, even though they don't have full WRITE
access to the model. Together with the "READ" permission, these
two types allow principals to replicate (pull) changes from the server.

Note that anybody having "WRITE" access type is automatically
granted "REPLICATE" type too.

3) Add a new model option "enableRemoteReplication" that exposes
replication methods via strong remoting, but does not configure
change rectification. This option should be used the clients
when setting up Remote models attached to the server via the remoting
connector.
This commit is contained in:
Miroslav Bajtoš 2015-04-03 16:41:32 +02:00
parent 699bc7aa97
commit 9c5fe088e3
7 changed files with 549 additions and 19 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
}
});

View File

@ -1,3 +1,4 @@
var assert = require('assert');
var async = require('async');
var loopback = require('../');
var Change = loopback.Change;

View File

@ -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() {