diff --git a/bin/parse-server b/bin/parse-server index 94ffe964..298906d9 100755 --- a/bin/parse-server +++ b/bin/parse-server @@ -1 +1,3 @@ +#!/usr/bin/env node + require("../lib/cli/parse-server"); diff --git a/package.json b/package.json index 60bf17f8..4c1de2eb 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "build": "./node_modules/.bin/babel src/ -d lib/", "pretest": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=3.0.8} ./node_modules/.bin/mongodb-runner start", "test": "cross-env NODE_ENV=test TESTING=1 ./node_modules/.bin/babel-node $COVERAGE_OPTION ./node_modules/jasmine/bin/jasmine.js", + "test:win": "npm run pretest && cross-env NODE_ENV=test TESTING=1 ./node_modules/.bin/babel-node ./node_modules/babel-istanbul/lib/cli.js cover -x **/spec/** ./node_modules/jasmine/bin/jasmine.js && npm run posttest", "posttest": "./node_modules/.bin/mongodb-runner stop", "coverage": "cross-env COVERAGE_OPTION='./node_modules/babel-istanbul/lib/cli.js cover -x **/spec/**' npm test", "start": "node ./bin/parse-server", diff --git a/spec/DatabaseAdapter.spec.js b/spec/DatabaseAdapter.spec.js new file mode 100644 index 00000000..0f43a16b --- /dev/null +++ b/spec/DatabaseAdapter.spec.js @@ -0,0 +1,23 @@ +'use strict'; + +let DatabaseAdapter = require('../src/DatabaseAdapter'); + +describe('DatabaseAdapter', () => { + it('options and URI are available to adapter', done => { + DatabaseAdapter.setAppDatabaseURI('optionsTest', 'mongodb://localhost:27017/optionsTest'); + DatabaseAdapter.setAppDatabaseOptions('optionsTest', {foo: "bar"}); + let optionsTestDatabaseConnection = DatabaseAdapter.getDatabaseConnection('optionsTest'); + + expect(optionsTestDatabaseConnection instanceof Object).toBe(true); + expect(optionsTestDatabaseConnection.adapter._options instanceof Object).toBe(true); + expect(optionsTestDatabaseConnection.adapter._options.foo).toBe("bar"); + + DatabaseAdapter.setAppDatabaseURI('noOptionsTest', 'mongodb://localhost:27017/noOptionsTest'); + let noOptionsTestDatabaseConnection = DatabaseAdapter.getDatabaseConnection('noOptionsTest'); + + expect(noOptionsTestDatabaseConnection instanceof Object).toBe(true); + expect(noOptionsTestDatabaseConnection.adapter._options instanceof Object).toBe(false); + + done(); + }); +}); diff --git a/spec/FileLoggerAdapter.spec.js b/spec/FileLoggerAdapter.spec.js index 4466e087..82c98f63 100644 --- a/spec/FileLoggerAdapter.spec.js +++ b/spec/FileLoggerAdapter.spec.js @@ -35,8 +35,13 @@ describe('info logs', () => { size: 1, level: 'info' }, (results) => { - expect(results[0].message).toEqual('testing info logs'); - done(); + if(results.length == 0) { + fail('The adapter should return non-empty results'); + done(); + } else { + expect(results[0].message).toEqual('testing info logs'); + done(); + } }); }); }); @@ -56,8 +61,14 @@ describe('error logs', () => { size: 1, level: 'error' }, (results) => { - expect(results[0].message).toEqual('testing error logs'); - done(); + if(results.length == 0) { + fail('The adapter should return non-empty results'); + done(); + } + else { + expect(results[0].message).toEqual('testing error logs'); + done(); + } }); }); }); diff --git a/spec/OAuth.spec.js b/spec/OAuth.spec.js index 47e4349d..48f22ace 100644 --- a/spec/OAuth.spec.js +++ b/spec/OAuth.spec.js @@ -1,4 +1,4 @@ -var OAuth = require("../src/oauth/OAuth1Client"); +var OAuth = require("../src/authDataManager/OAuth1Client"); var request = require('request'); describe('OAuth', function() { @@ -138,7 +138,7 @@ describe('OAuth', function() { ["facebook", "github", "instagram", "google", "linkedin", "meetup", "twitter"].map(function(providerName){ it("Should validate structure of "+providerName, (done) => { - var provider = require("../src/oauth/"+providerName); + var provider = require("../src/authDataManager/"+providerName); jequal(typeof provider.validateAuthData, "function"); jequal(typeof provider.validateAppId, "function"); jequal(provider.validateAuthData({}, {}).constructor, Promise.prototype.constructor); diff --git a/spec/ParseAPI.spec.js b/spec/ParseAPI.spec.js index bd29bcc7..92e30732 100644 --- a/spec/ParseAPI.spec.js +++ b/spec/ParseAPI.spec.js @@ -643,6 +643,7 @@ describe('miscellaneous', function() { it('test afterSave get original object on update', function(done) { var triggerTime = 0; // Register a mock beforeSave hook + Parse.Cloud.afterSave('GameScore', function(req, res) { var object = req.object; expect(object instanceof Parse.Object).toBeTruthy(); @@ -693,6 +694,56 @@ describe('miscellaneous', function() { }); }); + it('test afterSave get full original object even req auth can not query it', (done) => { + var triggerTime = 0; + // Register a mock beforeSave hook + Parse.Cloud.afterSave('GameScore', function(req, res) { + var object = req.object; + var originalObject = req.original; + if (triggerTime == 0) { + // Create + } else if (triggerTime == 1) { + // Update + expect(object.get('foo')).toEqual('baz'); + // Make sure we get the full originalObject + expect(originalObject instanceof Parse.Object).toBeTruthy(); + expect(originalObject.get('fooAgain')).toEqual('barAgain'); + expect(originalObject.id).not.toBeUndefined(); + expect(originalObject.createdAt).not.toBeUndefined(); + expect(originalObject.updatedAt).not.toBeUndefined(); + expect(originalObject.get('foo')).toEqual('bar'); + } else { + res.error(); + } + triggerTime++; + res.success(); + }); + + var obj = new Parse.Object('GameScore'); + obj.set('foo', 'bar'); + obj.set('fooAgain', 'barAgain'); + var acl = new Parse.ACL(); + // Make sure our update request can not query the object + acl.setPublicReadAccess(false); + acl.setPublicWriteAccess(true); + obj.setACL(acl); + obj.save().then(function() { + // We only update foo + obj.set('foo', 'baz'); + return obj.save(); + }).then(function() { + // Make sure the checking has been triggered + expect(triggerTime).toBe(2); + // Clear mock afterSave + Parse.Cloud._removeHook("Triggers", "afterSave", "GameScore"); + done(); + }, function(error) { + console.error(error); + fail(error); + done(); + }); + }); + it('afterSave flattens custom operations', done => { var triggerTime = 0; // Register a mock beforeSave hook diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index a74644ae..1b2e04bc 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -9,6 +9,7 @@ var request = require('request'); var passwordCrypto = require('../src/password'); +var Config = require('../src/Config'); function verifyACL(user) { const ACL = user.getACL(); @@ -904,6 +905,50 @@ describe('Parse.User testing', () => { } }; }; + + var getMockMyOauthProvider = function() { + return { + authData: { + id: "12345", + access_token: "12345", + expiration_date: new Date().toJSON(), + }, + shouldError: false, + loggedOut: false, + synchronizedUserId: null, + synchronizedAuthToken: null, + synchronizedExpiration: null, + + authenticate: function(options) { + if (this.shouldError) { + options.error(this, "An error occurred"); + } else if (this.shouldCancel) { + options.error(this, null); + } else { + options.success(this, this.authData); + } + }, + restoreAuthentication: function(authData) { + if (!authData) { + this.synchronizedUserId = null; + this.synchronizedAuthToken = null; + this.synchronizedExpiration = null; + return true; + } + this.synchronizedUserId = authData.id; + this.synchronizedAuthToken = authData.access_token; + this.synchronizedExpiration = authData.expiration_date; + return true; + }, + getAuthType: function() { + return "myoauth"; + }, + deauthenticate: function() { + this.loggedOut = true; + this.restoreAuthentication(null); + } + }; + }; var ExtendedUser = Parse.User.extend({ extended: function() { @@ -1284,6 +1329,151 @@ describe('Parse.User testing', () => { } }); }); + + it("link multiple providers", (done) => { + var provider = getMockFacebookProvider(); + var mockProvider = getMockMyOauthProvider(); + Parse.User._registerAuthenticationProvider(provider); + Parse.User._logInWith("facebook", { + success: function(model) { + ok(model instanceof Parse.User, "Model should be a Parse.User"); + strictEqual(Parse.User.current(), model); + ok(model.extended(), "Should have used the subclass."); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); + ok(model._isLinked("facebook"), "User should be linked to facebook"); + Parse.User._registerAuthenticationProvider(mockProvider); + let objectId = model.id; + model._linkWith("myoauth", { + success: function(model) { + expect(model.id).toEqual(objectId); + ok(model._isLinked("facebook"), "User should be linked to facebook"); + ok(model._isLinked("myoauth"), "User should be linked to myoauth"); + done(); + }, + error: function(error) { + console.error(error); + fail('SHould not fail'); + done(); + } + }) + }, + error: function(model, error) { + ok(false, "linking should have worked"); + done(); + } + }); + }); + + it("link multiple providers and update token", (done) => { + var provider = getMockFacebookProvider(); + var mockProvider = getMockMyOauthProvider(); + Parse.User._registerAuthenticationProvider(provider); + Parse.User._logInWith("facebook", { + success: function(model) { + ok(model instanceof Parse.User, "Model should be a Parse.User"); + strictEqual(Parse.User.current(), model); + ok(model.extended(), "Should have used the subclass."); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); + ok(model._isLinked("facebook"), "User should be linked to facebook"); + Parse.User._registerAuthenticationProvider(mockProvider); + let objectId = model.id; + model._linkWith("myoauth", { + success: function(model) { + expect(model.id).toEqual(objectId); + ok(model._isLinked("facebook"), "User should be linked to facebook"); + ok(model._isLinked("myoauth"), "User should be linked to myoauth"); + model._linkWith("facebook", { + success: () => { + ok(model._isLinked("facebook"), "User should be linked to facebook"); + ok(model._isLinked("myoauth"), "User should be linked to myoauth"); + done(); + }, + error: () => { + fail('should link again'); + done(); + } + }) + }, + error: function(error) { + console.error(error); + fail('SHould not fail'); + done(); + } + }) + }, + error: function(model, error) { + ok(false, "linking should have worked"); + done(); + } + }); + }); + + it('should fail linking with existing', (done) => { + var provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + Parse.User._logInWith("facebook", { + success: function(model) { + Parse.User.logOut().then(() => { + let user = new Parse.User(); + user.setUsername('user'); + user.setPassword('password'); + return user.signUp().then(() => { + // try to link here + user._linkWith('facebook', { + success: () => { + fail('should not succeed'); + done(); + }, + error: (err) => { + done(); + } + }); + }); + }); + } + }); + }); + + it('should have authData in beforeSave and afterSave', (done) => { + + Parse.Cloud.beforeSave('_User', (request, response) => { + let authData = request.object.get('authData'); + expect(authData).not.toBeUndefined(); + if (authData) { + expect(authData.facebook.id).toEqual('8675309'); + expect(authData.facebook.access_token).toEqual('jenny'); + } else { + fail('authData should be set'); + } + response.success(); + }); + + Parse.Cloud.afterSave('_User', (request, response) => { + let authData = request.object.get('authData'); + expect(authData).not.toBeUndefined(); + if (authData) { + expect(authData.facebook.id).toEqual('8675309'); + expect(authData.facebook.access_token).toEqual('jenny'); + } else { + fail('authData should be set'); + } + response.success(); + }); + + var provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + Parse.User._logInWith("facebook", { + success: function(model) { + Parse.Cloud._removeHook('Triggers', 'beforeSave', Parse.User.className); + Parse.Cloud._removeHook('Triggers', 'afterSave', Parse.User.className); + done(); + } + }); + }); it('set password then change password', (done) => { Parse.User.signUp('bob', 'barker').then((bob) => { @@ -1780,5 +1970,41 @@ describe('Parse.User testing', () => { } }); }); + + // Sometimes the authData still has null on that keys + // https://github.com/ParsePlatform/parse-server/issues/935 + it('should cleanup null authData keys', (done) => { + let database = new Config(Parse.applicationId).database; + database.create('_User', { + username: 'user', + password: '$2a$10$8/wZJyEuiEaobBBqzTG.jeY.XSFJd0rzaN//ososvEI4yLqI.4aie', + _auth_data_facebook: null + }, {}).then(() => { + return new Promise((resolve, reject) => { + request.get({ + url: 'http://localhost:8378/1/login?username=user&password=test', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + json: true + }, (err, res, body) => { + if (err) { + reject(err); + } else { + resolve(body); + } + }) + }) + }).then((user) => { + let authData = user.authData; + expect(user.username).toEqual('user'); + expect(authData).toBeUndefined(); + done(); + }).catch((err) => { + fail('this should not fail'); + done(); + }) + }); }); diff --git a/spec/RestCreate.spec.js b/spec/RestCreate.spec.js index f9b94b37..d07e18ed 100644 --- a/spec/RestCreate.spec.js +++ b/spec/RestCreate.spec.js @@ -148,7 +148,8 @@ describe('rest create', () => { }); it('handles no anonymous users config', (done) => { - var NoAnnonConfig = Object.assign({}, config, {enableAnonymousUsers: false}); + var NoAnnonConfig = Object.assign({}, config); + NoAnnonConfig.authDataManager.setEnableAnonymousUsers(false); var data1 = { authData: { anonymous: { @@ -162,6 +163,7 @@ describe('rest create', () => { }, (err) => { expect(err.code).toEqual(Parse.Error.UNSUPPORTED_SERVICE); expect(err.message).toEqual('This authentication method is unsupported.'); + NoAnnonConfig.authDataManager.setEnableAnonymousUsers(true); done(); }) }); diff --git a/spec/helper.js b/spec/helper.js index e2daa6ed..407135b2 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -5,8 +5,9 @@ jasmine.DEFAULT_TIMEOUT_INTERVAL = 2000; var cache = require('../src/cache').default; var DatabaseAdapter = require('../src/DatabaseAdapter'); var express = require('express'); -var facebook = require('../src/oauth/facebook'); +var facebook = require('../src/authDataManager/facebook'); var ParseServer = require('../src/index').ParseServer; +var path = require('path'); var databaseURI = process.env.DATABASE_URI; var cloudMain = process.env.CLOUD_CODE_MAIN || '../spec/cloud/main.js'; @@ -26,7 +27,7 @@ var defaultConfiguration = { collectionPrefix: 'test_', fileKey: 'test', push: { - 'ios': { + 'ios': { cert: 'prodCert.pem', key: 'prodKey.pem', production: true, @@ -36,7 +37,7 @@ var defaultConfiguration = { oauth: { // Override the facebook provider facebook: mockFacebook(), myoauth: { - module: "../spec/myoauth" // relative path as it's run from src + module: path.resolve(__dirname, "myoauth") // relative path as it's run from src } } }; @@ -81,7 +82,7 @@ afterEach(function(done) { Parse.User.logOut().then(() => { return clearData(); }).then(() => { - DatabaseAdapter.clearDatabaseURIs(); + DatabaseAdapter.clearDatabaseSettings(); done(); }, (error) => { console.log('error in clearData', error); diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 9a410eed..e9195615 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -23,6 +23,27 @@ var hasAllPODobject = () => { return obj; }; +let defaultClassLevelPermissions = { + find: { + '*': true + }, + create: { + '*': true + }, + get: { + '*': true + }, + update: { + '*': true + }, + addField: { + '*': true + }, + delete: { + '*': true + } +} + var plainOldDataSchema = { className: 'HasAllPOD', fields: { @@ -40,7 +61,8 @@ var plainOldDataSchema = { aArray: {type: 'Array'}, aGeoPoint: {type: 'GeoPoint'}, aFile: {type: 'File'} - } + }, + classLevelPermissions: defaultClassLevelPermissions }; var pointersAndRelationsSchema = { @@ -61,6 +83,7 @@ var pointersAndRelationsSchema = { targetClass: 'HasAllPOD', }, }, + classLevelPermissions: defaultClassLevelPermissions } var noAuthHeaders = { @@ -296,7 +319,8 @@ describe('schemas', () => { objectId: {type: 'String'}, foo: {type: 'Number'}, ptr: {type: 'Pointer', targetClass: 'SomeClass'}, - } + }, + classLevelPermissions: defaultClassLevelPermissions }); done(); }); @@ -318,7 +342,8 @@ describe('schemas', () => { createdAt: {type: 'Date'}, updatedAt: {type: 'Date'}, objectId: {type: 'String'}, - } + }, + classLevelPermissions: defaultClassLevelPermissions }); done(); }); @@ -490,7 +515,8 @@ describe('schemas', () => { "objectId": {"type": "String"}, "updatedAt": {"type": "Date"}, "geo2": {"type": "GeoPoint"}, - } + }, + classLevelPermissions: defaultClassLevelPermissions })).toEqual(undefined); done(); }); @@ -539,6 +565,7 @@ describe('schemas', () => { "updatedAt": {"type": "Date"}, "newField": {"type": "String"}, }, + classLevelPermissions: defaultClassLevelPermissions })).toEqual(undefined); request.get({ url: 'http://localhost:8378/1/schemas/NewClass', @@ -553,7 +580,8 @@ describe('schemas', () => { updatedAt: {type: 'Date'}, objectId: {type: 'String'}, newField: {type: 'String'}, - } + }, + classLevelPermissions: defaultClassLevelPermissions }); done(); }); @@ -590,7 +618,8 @@ describe('schemas', () => { emailVerified: {type: 'Boolean'}, newField: {type: 'String'}, ACL: {type: 'ACL'} - } + }, + classLevelPermissions: defaultClassLevelPermissions }); request.get({ url: 'http://localhost:8378/1/schemas/_User', @@ -610,7 +639,8 @@ describe('schemas', () => { emailVerified: {type: 'Boolean'}, newField: {type: 'String'}, ACL: {type: 'ACL'} - } + }, + classLevelPermissions: defaultClassLevelPermissions }); done(); }); @@ -656,7 +686,8 @@ describe('schemas', () => { aNewString: {type: 'String'}, aNewPointer: {type: 'Pointer', targetClass: 'HasAllPOD'}, aNewRelation: {type: 'Relation', targetClass: 'HasAllPOD'}, - } + }, + classLevelPermissions: defaultClassLevelPermissions }); var obj2 = new Parse.Object('HasAllPOD'); obj2.set('aNewPointer', obj1); @@ -872,4 +903,597 @@ describe('schemas', () => { }); }); }); + + it('should set/get schema permissions', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/AClass', + headers: masterKeyHeaders, + json: true, + body: { + classLevelPermissions: { + find: { + '*': true + }, + create: { + 'role:admin': true + } + } + } + }, (error, response, body) => { + expect(error).toEqual(null); + request.get({ + url: 'http://localhost:8378/1/schemas/AClass', + headers: masterKeyHeaders, + json: true, + }, (error, response, body) => { + expect(response.statusCode).toEqual(200); + expect(response.body.classLevelPermissions).toEqual({ + find: { + '*': true + }, + create: { + 'role:admin': true + }, + get: { + '*': true + }, + update: { + '*': true + }, + addField: { + '*': true + }, + delete: { + '*': true + } + }); + done(); + }); + }); + }); + + it('should fail setting schema permissions with invalid key', done => { + + let object = new Parse.Object('AClass'); + object.save().then(() => { + request.put({ + url: 'http://localhost:8378/1/schemas/AClass', + headers: masterKeyHeaders, + json: true, + body: { + classLevelPermissions: { + find: { + '*': true + }, + create: { + 'role:admin': true + }, + dummy: { + 'some': true + } + } + } + }, (error, response, body) => { + expect(error).toEqual(null); + expect(body.code).toEqual(107); + expect(body.error).toEqual('dummy is not a valid operation for class level permissions'); + done(); + }); + }); + }); + + it('should not be able to add a field', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/AClass', + headers: masterKeyHeaders, + json: true, + body: { + classLevelPermissions: { + find: { + '*': true + }, + addField: { + 'role:admin': true + } + } + } + }, (error, response, body) => { + expect(error).toEqual(null); + let object = new Parse.Object('AClass'); + object.set('hello', 'world'); + return object.save().then(() => { + fail('should not be able to add a field'); + done(); + }, (err) => { + expect(err.message).toEqual('Permission denied for this action.'); + done(); + }) + }) + }); + + it('should not be able to add a field', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/AClass', + headers: masterKeyHeaders, + json: true, + body: { + classLevelPermissions: { + find: { + '*': true + }, + addField: { + '*': true + } + } + } + }, (error, response, body) => { + expect(error).toEqual(null); + let object = new Parse.Object('AClass'); + object.set('hello', 'world'); + return object.save().then(() => { + done(); + }, (err) => { + fail('should be able to add a field'); + done(); + }) + }) + }); + + it('should throw with invalid userId (>10 chars)', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/AClass', + headers: masterKeyHeaders, + json: true, + body: { + classLevelPermissions: { + find: { + '1234567890A': true + }, + } + } + }, (error, response, body) => { + expect(body.error).toEqual("'1234567890A' is not a valid key for class level permissions"); + done(); + }) + }); + + it('should throw with invalid userId (<10 chars)', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/AClass', + headers: masterKeyHeaders, + json: true, + body: { + classLevelPermissions: { + find: { + 'a12345678': true + }, + } + } + }, (error, response, body) => { + expect(body.error).toEqual("'a12345678' is not a valid key for class level permissions"); + done(); + }) + }); + + it('should throw with invalid userId (invalid char)', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/AClass', + headers: masterKeyHeaders, + json: true, + body: { + classLevelPermissions: { + find: { + '12345_6789': true + }, + } + } + }, (error, response, body) => { + expect(body.error).toEqual("'12345_6789' is not a valid key for class level permissions"); + done(); + }) + }); + + it('should throw with invalid * (spaces)', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/AClass', + headers: masterKeyHeaders, + json: true, + body: { + classLevelPermissions: { + find: { + ' *': true + }, + } + } + }, (error, response, body) => { + expect(body.error).toEqual("' *' is not a valid key for class level permissions"); + done(); + }) + }); + + it('should throw with invalid * (spaces)', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/AClass', + headers: masterKeyHeaders, + json: true, + body: { + classLevelPermissions: { + find: { + '* ': true + }, + } + } + }, (error, response, body) => { + expect(body.error).toEqual("'* ' is not a valid key for class level permissions"); + done(); + }) + }); + + it('should throw with invalid value', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/AClass', + headers: masterKeyHeaders, + json: true, + body: { + classLevelPermissions: { + find: { + '*': 1 + }, + } + } + }, (error, response, body) => { + expect(body.error).toEqual("'1' is not a valid value for class level permissions find:*:1"); + done(); + }) + }); + + it('should throw with invalid value', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/AClass', + headers: masterKeyHeaders, + json: true, + body: { + classLevelPermissions: { + find: { + '*': "" + }, + } + } + }, (error, response, body) => { + expect(body.error).toEqual("'' is not a valid value for class level permissions find:*:"); + done(); + }) + }); + + function setPermissionsOnClass(className, permissions, doPut) { + let op = request.post; + if (doPut) + { + op = request.put; + } + return new Promise((resolve, reject) => { + op({ + url: 'http://localhost:8378/1/schemas/'+className, + headers: masterKeyHeaders, + json: true, + body: { + classLevelPermissions: permissions + } + }, (error, response, body) => { + if (error) { + return reject(error); + } + if (body.error) { + return reject(body); + } + return resolve(body); + }) + }); + } + + it('validate CLP 1', done => { + let user = new Parse.User(); + user.setUsername('user'); + user.setPassword('user'); + + let admin = new Parse.User(); + admin.setUsername('admin'); + admin.setPassword('admin'); + + let role = new Parse.Role('admin', new Parse.ACL()); + + setPermissionsOnClass('AClass', { + 'find': { + 'role:admin': true + } + }).then(() => { + return Parse.Object.saveAll([user, admin, role], {useMasterKey: true}); + }).then(()=> { + role.relation('users').add(admin); + return role.save(null, {useMasterKey: true}); + }).then(() => { + return Parse.User.logIn('user', 'user').then(() => { + let obj = new Parse.Object('AClass'); + return obj.save(); + }) + }).then(() => { + let query = new Parse.Query('AClass'); + return query.find().then((err) => { + fail('Use should hot be able to find!') + }, (err) => { + expect(err.message).toEqual('Permission denied for this action.'); + return Promise.resolve(); + }) + }).then(() => { + return Parse.User.logIn('admin', 'admin'); + }).then( () => { + let query = new Parse.Query('AClass'); + return query.find(); + }).then((results) => { + expect(results.length).toBe(1); + done(); + }, () => { + fail("should not fail!"); + done(); + }).catch( (err) => { + done(); + }) + }); + + it('validate CLP 2', done => { + let user = new Parse.User(); + user.setUsername('user'); + user.setPassword('user'); + + let admin = new Parse.User(); + admin.setUsername('admin'); + admin.setPassword('admin'); + + let role = new Parse.Role('admin', new Parse.ACL()); + + setPermissionsOnClass('AClass', { + 'find': { + 'role:admin': true + } + }).then(() => { + return Parse.Object.saveAll([user, admin, role], {useMasterKey: true}); + }).then(()=> { + role.relation('users').add(admin); + return role.save(null, {useMasterKey: true}); + }).then(() => { + return Parse.User.logIn('user', 'user').then(() => { + let obj = new Parse.Object('AClass'); + return obj.save(); + }) + }).then(() => { + let query = new Parse.Query('AClass'); + return query.find().then((err) => { + fail('User should not be able to find!') + }, (err) => { + expect(err.message).toEqual('Permission denied for this action.'); + return Promise.resolve(); + }) + }).then(() => { + // let everyone see it now + return setPermissionsOnClass('AClass', { + 'find': { + 'role:admin': true, + '*': true + } + }, true); + }).then(() => { + let query = new Parse.Query('AClass'); + return query.find().then((result) => { + expect(result.length).toBe(1); + }, (err) => { + fail('User should be able to find!') + done(); + }); + }).then(() => { + return Parse.User.logIn('admin', 'admin'); + }).then( () => { + let query = new Parse.Query('AClass'); + return query.find(); + }).then((results) => { + expect(results.length).toBe(1); + done(); + }, (err) => { + fail("should not fail!"); + done(); + }).catch( (err) => { + done(); + }) + }); + + it('validate CLP 3', done => { + let user = new Parse.User(); + user.setUsername('user'); + user.setPassword('user'); + + let admin = new Parse.User(); + admin.setUsername('admin'); + admin.setPassword('admin'); + + let role = new Parse.Role('admin', new Parse.ACL()); + + setPermissionsOnClass('AClass', { + 'find': { + 'role:admin': true + } + }).then(() => { + return Parse.Object.saveAll([user, admin, role], {useMasterKey: true}); + }).then(()=> { + role.relation('users').add(admin); + return role.save(null, {useMasterKey: true}); + }).then(() => { + return Parse.User.logIn('user', 'user').then(() => { + let obj = new Parse.Object('AClass'); + return obj.save(); + }) + }).then(() => { + let query = new Parse.Query('AClass'); + return query.find().then((err) => { + fail('User should not be able to find!') + }, (err) => { + expect(err.message).toEqual('Permission denied for this action.'); + return Promise.resolve(); + }) + }).then(() => { + // delete all CLP + return setPermissionsOnClass('AClass', null, true); + }).then(() => { + let query = new Parse.Query('AClass'); + return query.find().then((result) => { + expect(result.length).toBe(1); + }, (err) => { + fail('User should be able to find!') + done(); + }); + }).then(() => { + return Parse.User.logIn('admin', 'admin'); + }).then( () => { + let query = new Parse.Query('AClass'); + return query.find(); + }).then((results) => { + expect(results.length).toBe(1); + done(); + }, (err) => { + fail("should not fail!"); + done(); + }); + }); + + it('validate CLP 4', done => { + let user = new Parse.User(); + user.setUsername('user'); + user.setPassword('user'); + + let admin = new Parse.User(); + admin.setUsername('admin'); + admin.setPassword('admin'); + + let role = new Parse.Role('admin', new Parse.ACL()); + + setPermissionsOnClass('AClass', { + 'find': { + 'role:admin': true + } + }).then(() => { + return Parse.Object.saveAll([user, admin, role], {useMasterKey: true}); + }).then(()=> { + role.relation('users').add(admin); + return role.save(null, {useMasterKey: true}); + }).then(() => { + return Parse.User.logIn('user', 'user').then(() => { + let obj = new Parse.Object('AClass'); + return obj.save(); + }) + }).then(() => { + let query = new Parse.Query('AClass'); + return query.find().then((err) => { + fail('User should not be able to find!') + }, (err) => { + expect(err.message).toEqual('Permission denied for this action.'); + return Promise.resolve(); + }) + }).then(() => { + // borked CLP should not affec security + return setPermissionsOnClass('AClass', { + 'found': { + 'role:admin': true + } + }, true).then(() => { + fail("Should not be able to save a borked CLP"); + }, () => { + return Promise.resolve(); + }) + }).then(() => { + let query = new Parse.Query('AClass'); + return query.find().then((result) => { + fail('User should not be able to find!') + }, (err) => { + expect(err.message).toEqual('Permission denied for this action.'); + return Promise.resolve(); + }); + }).then(() => { + return Parse.User.logIn('admin', 'admin'); + }).then( () => { + let query = new Parse.Query('AClass'); + return query.find(); + }).then((results) => { + expect(results.length).toBe(1); + done(); + }, (err) => { + fail("should not fail!"); + done(); + }).catch( (err) => { + done(); + }) + }); + + it('validate CLP 5', done => { + let user = new Parse.User(); + user.setUsername('user'); + user.setPassword('user'); + + let user2 = new Parse.User(); + user2.setUsername('user2'); + user2.setPassword('user2'); + let admin = new Parse.User(); + admin.setUsername('admin'); + admin.setPassword('admin'); + + let role = new Parse.Role('admin', new Parse.ACL()); + + Promise.resolve().then(() => { + return Parse.Object.saveAll([user, user2, admin, role], {useMasterKey: true}); + }).then(()=> { + role.relation('users').add(admin); + return role.save(null, {useMasterKey: true}).then(() => { + let perm = { + find: {} + }; + // let the user find + perm['find'][user.id] = true; + return setPermissionsOnClass('AClass', perm); + }) + }).then(() => { + return Parse.User.logIn('user', 'user').then(() => { + let obj = new Parse.Object('AClass'); + return obj.save(); + }) + }).then(() => { + let query = new Parse.Query('AClass'); + return query.find().then((res) => { + expect(res.length).toEqual(1); + }, (err) => { + fail('User should be able to find!') + return Promise.resolve(); + }) + }).then(() => { + return Parse.User.logIn('admin', 'admin'); + }).then( () => { + let query = new Parse.Query('AClass'); + return query.find(); + }).then((results) => { + fail("should not be able to read!"); + return Promise.resolve(); + }, (err) => { + expect(err.message).toEqual('Permission denied for this action.'); + return Promise.resolve(); + }).then(() => { + return Parse.User.logIn('user2', 'user2'); + }).then( () => { + let query = new Parse.Query('AClass'); + return query.find(); + }).then((results) => { + fail("should not be able to read!"); + return Promise.resolve(); + }, (err) => { + expect(err.message).toEqual('Permission denied for this action.'); + return Promise.resolve(); + }).then(() => { + done(); + }); + }); }); diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index e3d59493..d3d2bc7e 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -10,12 +10,14 @@ const MongoSchemaCollectionName = '_SCHEMA'; export class MongoStorageAdapter { // Private _uri: string; + _options: Object; // Public connectionPromise; database; - constructor(uri: string) { + constructor(uri: string, options: Object) { this._uri = uri; + this._options = options; } connect() { @@ -23,7 +25,7 @@ export class MongoStorageAdapter { return this.connectionPromise; } - this.connectionPromise = MongoClient.connect(this._uri).then(database => { + this.connectionPromise = MongoClient.connect(this._uri, this._options).then(database => { this.database = database; }); return this.connectionPromise; diff --git a/src/Config.js b/src/Config.js index 8042d6db..e80c3b28 100644 --- a/src/Config.js +++ b/src/Config.js @@ -20,7 +20,6 @@ export class Config { this.restAPIKey = cacheInfo.restAPIKey; this.fileKey = cacheInfo.fileKey; this.facebookAppIds = cacheInfo.facebookAppIds; - this.enableAnonymousUsers = cacheInfo.enableAnonymousUsers; this.allowClientClassCreation = cacheInfo.allowClientClassCreation; this.database = DatabaseAdapter.getDatabaseConnection(applicationId, cacheInfo.collectionPrefix); @@ -34,7 +33,7 @@ export class Config { this.pushController = cacheInfo.pushController; this.loggerController = cacheInfo.loggerController; this.userController = cacheInfo.userController; - this.oauth = cacheInfo.oauth; + this.authDataManager = cacheInfo.authDataManager; this.customPages = cacheInfo.customPages || {}; this.mount = mount; } diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index d5752088..3e85eca0 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -101,8 +101,12 @@ DatabaseController.prototype.redirectClassNameForKey = function(className, key) // Returns a promise that resolves to the new schema. // This does not update this.schema, because in a situation like a // batch request, that could confuse other users of the schema. -DatabaseController.prototype.validateObject = function(className, object, query) { - return this.loadSchema().then((schema) => { +DatabaseController.prototype.validateObject = function(className, object, query, options) { + let schema; + return this.loadSchema().then(s => { + schema = s; + return this.canAddField(schema, className, object, options.acl || []); + }).then(() => { return schema.validateObject(className, object, query); }); }; @@ -332,6 +336,22 @@ DatabaseController.prototype.create = function(className, object, options) { }); }; +DatabaseController.prototype.canAddField = function(schema, className, object, aclGroup) { + let classSchema = schema.data[className]; + if (!classSchema) { + return Promise.resolve(); + } + let fields = Object.keys(object); + let schemaFields = Object.keys(classSchema); + let newKeys = fields.filter((field) => { + return schemaFields.indexOf(field) < 0; + }) + if (newKeys.length > 0) { + return schema.validatePermission(className, aclGroup, 'addField'); + } + return Promise.resolve(); +} + // Runs a mongo query on the database. // This should only be used for testing - use 'find' for normal code // to avoid Mongo-format dependencies. diff --git a/src/DatabaseAdapter.js b/src/DatabaseAdapter.js index 6663f36b..51403ba3 100644 --- a/src/DatabaseAdapter.js +++ b/src/DatabaseAdapter.js @@ -24,6 +24,7 @@ let adapter = MongoStorageAdapter; let dbConnections = {}; let databaseURI = DefaultDatabaseURI; let appDatabaseURIs = {}; +let appDatabaseOptions = {}; function setAdapter(databaseAdapter) { adapter = databaseAdapter; @@ -37,10 +38,15 @@ function setAppDatabaseURI(appId, uri) { appDatabaseURIs[appId] = uri; } +function setAppDatabaseOptions(appId: string, options: Object) { + appDatabaseOptions[appId] = options; +} + //Used by tests -function clearDatabaseURIs() { +function clearDatabaseSettings() { appDatabaseURIs = {}; dbConnections = {}; + appDatabaseOptions = {}; } function getDatabaseConnection(appId: string, collectionPrefix: string) { @@ -50,7 +56,7 @@ function getDatabaseConnection(appId: string, collectionPrefix: string) { var dbURI = (appDatabaseURIs[appId] ? appDatabaseURIs[appId] : databaseURI); - let storageAdapter = new adapter(dbURI); + let storageAdapter = new adapter(dbURI, appDatabaseOptions[appId]); dbConnections[appId] = new DatabaseController(storageAdapter, { collectionPrefix: collectionPrefix }); @@ -62,7 +68,8 @@ module.exports = { getDatabaseConnection: getDatabaseConnection, setAdapter: setAdapter, setDatabaseURI: setDatabaseURI, + setAppDatabaseOptions: setAppDatabaseOptions, setAppDatabaseURI: setAppDatabaseURI, - clearDatabaseURIs: clearDatabaseURIs, + clearDatabaseSettings: clearDatabaseSettings, defaultDatabaseURI: databaseURI }; diff --git a/src/RestWrite.js b/src/RestWrite.js index 9e07c93a..0aaa5d5a 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -9,7 +9,6 @@ var Auth = require('./Auth'); var Config = require('./Config'); var cryptoUtils = require('./cryptoUtils'); var passwordCrypto = require('./password'); -var oauth = require("./oauth"); var Parse = require('parse/node'); var triggers = require('./triggers'); @@ -33,7 +32,7 @@ function RestWrite(config, auth, className, query, data, originalData) { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'objectId ' + 'is an invalid field name.'); } - + // When the operation is complete, this.response may have several // fields. // response: the actual data to be returned @@ -128,7 +127,7 @@ RestWrite.prototype.validateClientClassCreation = function() { // Validates this operation against the schema. RestWrite.prototype.validateSchema = function() { - return this.config.database.validateObject(this.className, this.data, this.query); + return this.config.database.validateObject(this.className, this.data, this.query, this.runOptions); }; // Runs any beforeSave triggers against this operation. @@ -211,170 +210,96 @@ RestWrite.prototype.validateAuthData = function() { } var authData = this.data.authData; - var anonData = this.data.authData.anonymous; - - if (this.config.enableAnonymousUsers === true && (anonData === null || - (anonData && anonData.id))) { - return this.handleAnonymousAuthData(); - } - - // Not anon, try other providers var providers = Object.keys(authData); - if (!anonData && providers.length == 1) { - var provider = providers[0]; - var providerAuthData = authData[provider]; - var hasToken = (providerAuthData && providerAuthData.id); - if (providerAuthData === null || hasToken) { - return this.handleOAuthAuthData(provider); + if (providers.length > 0) { + let canHandleAuthData = providers.reduce((canHandle, provider) => { + var providerAuthData = authData[provider]; + var hasToken = (providerAuthData && providerAuthData.id); + return canHandle && (hasToken || providerAuthData == null); + }, true); + if (canHandleAuthData) { + return this.handleAuthData(authData); } } throw new Parse.Error(Parse.Error.UNSUPPORTED_SERVICE, 'This authentication method is unsupported.'); }; -RestWrite.prototype.handleAnonymousAuthData = function() { - var anonData = this.data.authData.anonymous; - if (anonData === null && this.query) { - // We are unlinking the user from the anonymous provider - this.data._auth_data_anonymous = null; - return; - } - - // Check if this user already exists - return this.config.database.find( - this.className, - {'authData.anonymous.id': anonData.id}, {}) - .then((results) => { - if (results.length > 0) { - if (!this.query) { - // We're signing up, but this user already exists. Short-circuit - delete results[0].password; - this.response = { - response: results[0], - location: this.location() - }; - return; - } - - // If this is a PUT for the same user, allow the linking - if (results[0].objectId === this.query.objectId) { - // Delete the rest format key before saving - delete this.data.authData; - return; - } - - // We're trying to create a duplicate account. Forbid it - throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, - 'this auth is already used'); - } - - // This anonymous user does not already exist, so transform it - // to a saveable format - this.data._auth_data_anonymous = anonData; - - // Delete the rest format key before saving - delete this.data.authData; - }) - -}; - -RestWrite.prototype.handleOAuthAuthData = function(provider) { - var authData = this.data.authData[provider]; - - if (authData === null && this.query) { - // We are unlinking from the provider. - this.data["_auth_data_" + provider ] = null; - return; - } - - var appIds; - var oauthOptions = this.config.oauth[provider]; - if (oauthOptions) { - appIds = oauthOptions.appIds; - } else if (provider == "facebook") { - appIds = this.config.facebookAppIds; - } - - var validateAuthData; - var validateAppId; - - - if (oauth[provider]) { - validateAuthData = oauth[provider].validateAuthData; - validateAppId = oauth[provider].validateAppId; - } - - // Try the configuration methods - if (oauthOptions) { - if (oauthOptions.module) { - validateAuthData = require(oauthOptions.module).validateAuthData; - validateAppId = require(oauthOptions.module).validateAppId; - }; - - if (oauthOptions.validateAuthData) { - validateAuthData = oauthOptions.validateAuthData; - } - if (oauthOptions.validateAppId) { - validateAppId = oauthOptions.validateAppId; - } - } - // try the custom provider first, fallback on the oauth implementation - - if (!validateAuthData || !validateAppId) { - return false; - }; - - return validateAuthData(authData, oauthOptions) - .then(() => { - if (appIds && typeof validateAppId === "function") { - return validateAppId(appIds, authData, oauthOptions); - } - - // No validation required by the developer +RestWrite.prototype.handleAuthDataValidation = function(authData) { + let validations = Object.keys(authData).map((provider) => { + if (authData[provider] === null) { return Promise.resolve(); + } + let validateAuthData = this.config.authDataManager.getValidatorForProvider(provider); + if (!validateAuthData) { + throw new Parse.Error(Parse.Error.UNSUPPORTED_SERVICE, + 'This authentication method is unsupported.'); + }; + return validateAuthData(authData[provider]); + }); + return Promise.all(validations); +} - }).then(() => { - // Check if this user already exists - // TODO: does this handle re-linking correctly? - var query = {}; - query['authData.' + provider + '.id'] = authData.id; - return this.config.database.find( +RestWrite.prototype.findUsersWithAuthData = function(authData) { + let providers = Object.keys(authData); + let query = providers.reduce((memo, provider) => { + if (!authData[provider]) { + return memo; + } + let queryKey = `authData.${provider}.id`; + let query = {}; + query[queryKey] = authData[provider].id; + memo.push(query); + return memo; + }, []).filter((q) => { + return typeof q !== undefined; + }); + + let findPromise = Promise.resolve([]); + if (query.length > 0) { + findPromise = this.config.database.find( this.className, - query, {}); - }).then((results) => { - this.storage['authProvider'] = provider; - if (results.length > 0) { - if (!this.query) { - // We're signing up, but this user already exists. Short-circuit - delete results[0].password; - this.response = { - response: results[0], - location: this.location() - }; - this.data.objectId = results[0].objectId; - return; - } + {'$or': query}, {}) + } + + return findPromise; +} - // If this is a PUT for the same user, allow the linking - if (results[0].objectId === this.query.objectId) { - // Delete the rest format key before saving - delete this.data.authData; - return; - } - // We're trying to create a duplicate oauth auth. Forbid it - throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, +RestWrite.prototype.handleAuthData = function(authData) { + let results; + return this.handleAuthDataValidation(authData).then(() => { + return this.findUsersWithAuthData(authData); + }).then((r) => { + results = r; + if (results.length > 1) { + // More than 1 user with the passed id's + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); - } else { - this.data.username = cryptoUtils.newToken(); + } + + this.storage['authProvider'] = Object.keys(authData).join(','); + + if (results.length == 0) { + this.data.username = cryptoUtils.newToken(); + } else if (!this.query) { + // Login with auth data + // Short circuit + delete results[0].password; + this.response = { + response: results[0], + location: this.location() + }; + this.data.objectId = results[0].objectId; + } else if (this.query && this.query.objectId) { + // Trying to update auth data but users + // are different + if (results[0].objectId !== this.query.objectId) { + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, + 'this auth is already used'); } - - // This FB auth does not already exist, so transform it to a - // saveable format - this.data["_auth_data_" + provider ] = authData; - - // Delete the rest format key before saving - delete this.data.authData; - }); + } + return Promise.resolve(); + }); } // The non-third-party parts of User transformation diff --git a/src/Routers/SchemasRouter.js b/src/Routers/SchemasRouter.js index a0a90ef2..49e4bbb2 100644 --- a/src/Routers/SchemasRouter.js +++ b/src/Routers/SchemasRouter.js @@ -46,7 +46,7 @@ function createSchema(req) { } return req.config.database.loadSchema() - .then(schema => schema.addClassIfNotExists(className, req.body.fields)) + .then(schema => schema.addClassIfNotExists(className, req.body.fields, req.body.classLevelPermissions)) .then(result => ({ response: Schema.mongoSchemaToSchemaAPIResponse(result) })); } @@ -60,52 +60,20 @@ function modifySchema(req) { return req.config.database.loadSchema() .then(schema => { - if (!schema.data[className]) { - throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${req.params.className} does not exist.`); - } - - let existingFields = Object.assign(schema.data[className], { _id: className }); - Object.keys(submittedFields).forEach(name => { - let field = submittedFields[name]; - if (existingFields[name] && field.__op !== 'Delete') { - throw new Parse.Error(255, `Field ${name} exists, cannot update.`); - } - if (!existingFields[name] && field.__op === 'Delete') { - throw new Parse.Error(255, `Field ${name} does not exist, cannot delete.`); - } - }); - - let newSchema = Schema.buildMergedSchemaObject(existingFields, submittedFields); - let mongoObject = Schema.mongoSchemaFromFieldsAndClassName(newSchema, className); - if (!mongoObject.result) { - throw new Parse.Error(mongoObject.code, mongoObject.error); - } - - // Finally we have checked to make sure the request is valid and we can start deleting fields. - // Do all deletions first, then add fields to avoid duplicate geopoint error. - let deletePromises = []; - let insertedFields = []; - Object.keys(submittedFields).forEach(fieldName => { - if (submittedFields[fieldName].__op === 'Delete') { - const promise = schema.deleteField(fieldName, className, req.config.database); - deletePromises.push(promise); - } else { - insertedFields.push(fieldName); - } - }); - return Promise.all(deletePromises) // Delete Everything - .then(() => schema.reloadData()) // Reload our Schema, so we have all the new values - .then(() => { - let promises = insertedFields.map(fieldName => { - const mongoType = mongoObject.result[fieldName]; - return schema.validateField(className, fieldName, mongoType); - }); - return Promise.all(promises); - }) - .then(() => ({ response: Schema.mongoSchemaToSchemaAPIResponse(mongoObject.result) })); + return schema.updateClass(className, submittedFields, req.body.classLevelPermissions, req.config.database); + }).then((result) => { + return Promise.resolve({response: result}); }); } +function getSchemaPermissions(req) { + var className = req.params.className; + return req.config.database.loadSchema() + .then(schema => { + return Promise.resolve({response: schema.perms[className]}); + }); +} + // A helper function that removes all join tables for a schema. Returns a promise. var removeJoinTables = (database, mongoSchema) => { return Promise.all(Object.keys(mongoSchema) diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 21dc80ba..ac1d1007 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -102,7 +102,20 @@ export class UsersRouter extends ClassesRouter { let token = 'r:' + cryptoUtils.newToken(); user.sessionToken = token; delete user.password; - + + // Sometimes the authData still has null on that keys + // https://github.com/ParsePlatform/parse-server/issues/935 + if (user.authData) { + Object.keys(user.authData).forEach((provider) => { + if (user.authData[provider] === null) { + delete user.authData[provider]; + } + }); + if (Object.keys(user.authData).length == 0) { + delete user.authData; + } + } + req.config.filesController.expandFilesInObject(req.config, user); let expiresAt = new Date(); diff --git a/src/Schema.js b/src/Schema.js index 2a048a54..ffb7b088 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -76,6 +76,50 @@ var requiredColumns = { _Role: ["name", "ACL"] } +// 10 alpha numberic chars + uppercase +const userIdRegex = /^[a-zA-Z0-9]{10}$/; +// Anything that start with role +const roleRegex = /^role:.*/; +// * permission +const publicRegex = /^\*$/ + +const permissionKeyRegex = [userIdRegex, roleRegex, publicRegex]; + +function verifyPermissionKey(key) { + let result = permissionKeyRegex.reduce((isGood, regEx) => { + isGood = isGood || key.match(regEx) != null; + return isGood; + }, false); + if (!result) { + throw new Parse.Error(Parse.Error.INVALID_JSON, `'${key}' is not a valid key for class level permissions`); + } +} + +let CLPValidKeys = ['find', 'get', 'create', 'update', 'delete', 'addField']; +let DefaultClassLevelPermissions = CLPValidKeys.reduce((perms, key) => { + perms[key] = { + '*': true + }; + return perms; + }, {}); + +function validateCLP(perms) { + if (!perms) { + return; + } + Object.keys(perms).forEach((operation) => { + if (CLPValidKeys.indexOf(operation) == -1) { + throw new Parse.Error(Parse.Error.INVALID_JSON, `${operation} is not a valid operation for class level permissions`); + } + Object.keys(perms[operation]).forEach((key) => { + verifyPermissionKey(key); + let perm = perms[operation][key]; + if (perm !== true) { + throw new Parse.Error(Parse.Error.INVALID_JSON, `'${perm}' is not a valid value for class level permissions ${operation}:${key}:${perm}`); + } + }); + }); +} // Valid classes must: // Be one of _User, _Installation, _Role, _Session OR // Be a join table OR @@ -221,12 +265,12 @@ class Schema { // on success, and rejects with an error on fail. Ensure you // have authorization (master key, or client class creation // enabled) before calling this function. - addClassIfNotExists(className, fields) { + addClassIfNotExists(className, fields, classLevelPermissions) { if (this.data[className]) { throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`); } - let mongoObject = mongoSchemaFromFieldsAndClassName(fields, className); + let mongoObject = mongoSchemaFromFieldsAndClassNameAndCLP(fields, className, classLevelPermissions); if (!mongoObject.result) { return Promise.reject(mongoObject); } @@ -240,6 +284,54 @@ class Schema { return Promise.reject(error); }); } + + updateClass(className, submittedFields, classLevelPermissions, database) { + if (!this.data[className]) { + throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} does not exist.`); + } + let existingFields = Object.assign(this.data[className], {_id: className}); + Object.keys(submittedFields).forEach(name => { + let field = submittedFields[name]; + if (existingFields[name] && field.__op !== 'Delete') { + throw new Parse.Error(255, `Field ${name} exists, cannot update.`); + } + if (!existingFields[name] && field.__op === 'Delete') { + throw new Parse.Error(255, `Field ${name} does not exist, cannot delete.`); + } + }); + + let newSchema = buildMergedSchemaObject(existingFields, submittedFields); + let mongoObject = mongoSchemaFromFieldsAndClassNameAndCLP(newSchema, className, classLevelPermissions); + if (!mongoObject.result) { + throw new Parse.Error(mongoObject.code, mongoObject.error); + } + + // Finally we have checked to make sure the request is valid and we can start deleting fields. + // Do all deletions first, then a single save to _SCHEMA collection to handle all additions. + let deletePromises = []; + let insertedFields = []; + Object.keys(submittedFields).forEach(fieldName => { + if (submittedFields[fieldName].__op === 'Delete') { + const promise = this.deleteField(fieldName, className, database); + deletePromises.push(promise); + } else { + insertedFields.push(fieldName); + } + }); + return Promise.all(deletePromises) // Delete Everything + .then(() => this.reloadData()) // Reload our Schema, so we have all the new values + .then(() => { + let promises = insertedFields.map(fieldName => { + const mongoType = mongoObject.result[fieldName]; + return this.validateField(className, fieldName, mongoType); + }); + return Promise.all(promises); + }) + .then(() => { + return this.setPermissions(className, classLevelPermissions) + }) + .then(() => { return mongoSchemaToSchemaAPIResponse(mongoObject.result) }); + } // Returns whether the schema knows the type of all these keys. @@ -288,6 +380,10 @@ class Schema { // Sets the Class-level permissions for a given className, which must exist. setPermissions(className, perms) { + if (typeof perms === 'undefined') { + return Promise.resolve(); + } + validateCLP(perms); var update = { _metadata: { class_permissions: perms @@ -548,7 +644,7 @@ function load(collection) { // Returns { code, error } if invalid, or { result }, an object // suitable for inserting into _SCHEMA collection, otherwise -function mongoSchemaFromFieldsAndClassName(fields, className) { +function mongoSchemaFromFieldsAndClassNameAndCLP(fields, className, classLevelPermissions) { if (!classNameIsValid(className)) { return { code: Parse.Error.INVALID_CLASS_NAME, @@ -601,6 +697,16 @@ function mongoSchemaFromFieldsAndClassName(fields, className) { error: 'currently, only one GeoPoint field may exist in an object. Adding ' + geoPoints[1] + ' when ' + geoPoints[0] + ' already exists.', }; } + + validateCLP(classLevelPermissions); + if (typeof classLevelPermissions !== 'undefined') { + mongoObject._metadata = mongoObject._metadata || {}; + if (!classLevelPermissions) { + delete mongoObject._metadata.class_permissions; + } else { + mongoObject._metadata.class_permissions = classLevelPermissions; + } + } return { result: mongoObject }; } @@ -776,17 +882,23 @@ function mongoSchemaAPIResponseFields(schema) { } function mongoSchemaToSchemaAPIResponse(schema) { - return { + let result = { className: schema._id, fields: mongoSchemaAPIResponseFields(schema), }; + + let classLevelPermissions = DefaultClassLevelPermissions; + if (schema._metadata && schema._metadata.class_permissions) { + classLevelPermissions = Object.assign(classLevelPermissions, schema._metadata.class_permissions); + } + result.classLevelPermissions = classLevelPermissions; + return result; } module.exports = { load: load, classNameIsValid: classNameIsValid, invalidClassNameMessage: invalidClassNameMessage, - mongoSchemaFromFieldsAndClassName: mongoSchemaFromFieldsAndClassName, schemaAPITypeToMongoFieldType: schemaAPITypeToMongoFieldType, buildMergedSchemaObject: buildMergedSchemaObject, mongoFieldTypeToSchemaAPIType: mongoFieldTypeToSchemaAPIType, diff --git a/src/oauth/OAuth1Client.js b/src/authDataManager/OAuth1Client.js similarity index 100% rename from src/oauth/OAuth1Client.js rename to src/authDataManager/OAuth1Client.js diff --git a/src/oauth/facebook.js b/src/authDataManager/facebook.js similarity index 100% rename from src/oauth/facebook.js rename to src/authDataManager/facebook.js diff --git a/src/oauth/github.js b/src/authDataManager/github.js similarity index 100% rename from src/oauth/github.js rename to src/authDataManager/github.js diff --git a/src/oauth/google.js b/src/authDataManager/google.js similarity index 100% rename from src/oauth/google.js rename to src/authDataManager/google.js diff --git a/src/authDataManager/index.js b/src/authDataManager/index.js new file mode 100644 index 00000000..77ee7473 --- /dev/null +++ b/src/authDataManager/index.js @@ -0,0 +1,94 @@ +let facebook = require('./facebook'); +let instagram = require("./instagram"); +let linkedin = require("./linkedin"); +let meetup = require("./meetup"); +let google = require("./google"); +let github = require("./github"); +let twitter = require("./twitter"); + +let anonymous = { + validateAuthData: () => { + return Promise.resolve(); + }, + validateAppId: () => { + return Promise.resolve(); + } +} + +let providers = { + facebook, + instagram, + linkedin, + meetup, + google, + github, + twitter, + anonymous +} + +module.exports = function(oauthOptions = {}, enableAnonymousUsers = true) { + let _enableAnonymousUsers = enableAnonymousUsers; + let setEnableAnonymousUsers = function(enable) { + _enableAnonymousUsers = enable; + } + // To handle the test cases on configuration + let getValidatorForProvider = function(provider) { + + if (provider === 'anonymous' && !_enableAnonymousUsers) { + return; + } + + let defaultProvider = providers[provider]; + let optionalProvider = oauthOptions[provider]; + + if (!defaultProvider && !optionalProvider) { + return; + } + + let appIds; + if (optionalProvider) { + appIds = optionalProvider.appIds; + } + + var validateAuthData; + var validateAppId; + + if (defaultProvider) { + validateAuthData = defaultProvider.validateAuthData; + validateAppId = defaultProvider.validateAppId; + } + + // Try the configuration methods + if (optionalProvider) { + if (optionalProvider.module) { + validateAuthData = require(optionalProvider.module).validateAuthData; + validateAppId = require(optionalProvider.module).validateAppId; + }; + + if (optionalProvider.validateAuthData) { + validateAuthData = optionalProvider.validateAuthData; + } + if (optionalProvider.validateAppId) { + validateAppId = optionalProvider.validateAppId; + } + } + + if (!validateAuthData || !validateAppId) { + return; + } + + return function(authData) { + return validateAuthData(authData, optionalProvider).then(() => { + if (appIds) { + return validateAppId(appIds, authData, optionalProvider); + } + return Promise.resolve(); + }) + } + } + + return Object.freeze({ + getValidatorForProvider, + setEnableAnonymousUsers, + }) +} diff --git a/src/oauth/instagram.js b/src/authDataManager/instagram.js similarity index 100% rename from src/oauth/instagram.js rename to src/authDataManager/instagram.js diff --git a/src/oauth/linkedin.js b/src/authDataManager/linkedin.js similarity index 100% rename from src/oauth/linkedin.js rename to src/authDataManager/linkedin.js diff --git a/src/oauth/meetup.js b/src/authDataManager/meetup.js similarity index 100% rename from src/oauth/meetup.js rename to src/authDataManager/meetup.js diff --git a/src/oauth/twitter.js b/src/authDataManager/twitter.js similarity index 100% rename from src/oauth/twitter.js rename to src/authDataManager/twitter.js diff --git a/src/cli/parse-server.js b/src/cli/parse-server.js index c5945b6c..b4cbbb12 100755 --- a/src/cli/parse-server.js +++ b/src/cli/parse-server.js @@ -65,7 +65,7 @@ const app = express(); const api = new ParseServer(options); app.use(options.mountPath, api); -app.listen(options.port, function() { +var server = app.listen(options.port, function() { for (let key in options) { let value = options[key]; @@ -77,3 +77,12 @@ app.listen(options.port, function() { console.log(''); console.log('parse-server running on '+options.serverURL); }); + +var handleShutdown = function() { + console.log('Termination signal received. Shutting down.'); + server.close(function () { + process.exit(0); + }); +}; +process.on('SIGTERM', handleShutdown); +process.on('SIGINT', handleShutdown); diff --git a/src/index.js b/src/index.js index 131f1f69..87ab0331 100644 --- a/src/index.js +++ b/src/index.js @@ -8,7 +8,8 @@ var batch = require('./batch'), express = require('express'), middlewares = require('./middlewares'), multer = require('multer'), - Parse = require('parse/node').Parse; + Parse = require('parse/node').Parse, + authDataManager = require('./authDataManager'); //import passwordReset from './passwordReset'; import cache from './cache'; @@ -84,6 +85,7 @@ function ParseServer({ push, loggerAdapter, databaseURI = DatabaseAdapter.defaultDatabaseURI, + databaseOptions, cloud, collectionPrefix = '', clientKey, @@ -120,6 +122,10 @@ function ParseServer({ DatabaseAdapter.setAppDatabaseURI(appId, databaseURI); } + if (databaseOptions) { + DatabaseAdapter.setAppDatabaseOptions(appId, databaseOptions); + } + if (cloud) { addParseCloud(); if (typeof cloud === 'function') { @@ -163,9 +169,8 @@ function ParseServer({ hooksController: hooksController, userController: userController, verifyUserEmails: verifyUserEmails, - enableAnonymousUsers: enableAnonymousUsers, allowClientClassCreation: allowClientClassCreation, - oauth: oauth, + authDataManager: authDataManager(oauth, enableAnonymousUsers), appName: appName, publicServerURL: publicServerURL, customPages: customPages, diff --git a/src/oauth/index.js b/src/oauth/index.js deleted file mode 100644 index f39aea07..00000000 --- a/src/oauth/index.js +++ /dev/null @@ -1,17 +0,0 @@ -var facebook = require('./facebook'); -var instagram = require("./instagram"); -var linkedin = require("./linkedin"); -var meetup = require("./meetup"); -var google = require("./google"); -var github = require("./github"); -var twitter = require("./twitter"); - -module.exports = { - facebook: facebook, - github: github, - google: google, - instagram: instagram, - linkedin: linkedin, - meetup: meetup, - twitter: twitter -} \ No newline at end of file diff --git a/src/rest.js b/src/rest.js index d624f068..96269bc7 100644 --- a/src/rest.js +++ b/src/rest.js @@ -9,6 +9,7 @@ var Parse = require('parse/node').Parse; import cache from './cache'; +import Auth from './Auth'; var RestQuery = require('./RestQuery'); var RestWrite = require('./RestWrite'); @@ -42,7 +43,7 @@ function del(config, auth, className, objectId) { if (triggers.getTrigger(className, triggers.Types.beforeDelete, config.applicationId) || triggers.getTrigger(className, triggers.Types.afterDelete, config.applicationId) || className == '_Session') { - return find(config, auth, className, {objectId: objectId}) + return find(config, Auth.master(config), className, {objectId: objectId}) .then((response) => { if (response && response.results && response.results.length) { response.results[0].className = className; @@ -97,7 +98,7 @@ function update(config, auth, className, objectId, restObject) { return Promise.resolve().then(() => { if (triggers.getTrigger(className, triggers.Types.beforeSave, config.applicationId) || triggers.getTrigger(className, triggers.Types.afterSave, config.applicationId)) { - return find(config, auth, className, {objectId: objectId}); + return find(config, Auth.master(config), className, {objectId: objectId}); } return Promise.resolve({}); }).then((response) => { diff --git a/src/transform.js b/src/transform.js index 738f2453..aae5cc2a 100644 --- a/src/transform.js +++ b/src/transform.js @@ -87,7 +87,7 @@ export function transformKeyValue(schema, className, restKey, restValue, options return transformWhere(schema, className, s); }); return {key: '$and', value: mongoSubqueries}; - default: + default: // Other auth data var authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)\.id$/); if (authDataMatch) { @@ -203,6 +203,9 @@ function transformWhere(schema, className, restWhere) { // restCreate is the "create" clause in REST API form. // Returns the mongo form of the object. function transformCreate(schema, className, restCreate) { + if (className == '_User') { + restCreate = transformAuthData(restCreate); + } var mongoCreate = transformACL(restCreate); for (var restKey in restCreate) { var out = transformKeyValue(schema, className, restKey, restCreate[restKey]); @@ -218,6 +221,10 @@ function transformUpdate(schema, className, restUpdate) { if (!restUpdate) { throw 'got empty restUpdate'; } + if (className == '_User') { + restUpdate = transformAuthData(restUpdate); + } + var mongoUpdate = {}; var acl = transformACL(restUpdate); if (acl._rperm || acl._wperm) { @@ -250,6 +257,16 @@ function transformUpdate(schema, className, restUpdate) { return mongoUpdate; } +function transformAuthData(restObject) { + if (restObject.authData) { + Object.keys(restObject.authData).forEach((provider) => { + restObject[`_auth_data_${provider}`] = restObject.authData[provider]; + }); + delete restObject.authData; + } + return restObject; +} + // Transforms a REST API formatted ACL object to our two-field mongo format. // This mutates the restObject passed in to remove the ACL key. function transformACL(restObject) {