diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 9a410eed4b..e919561565 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/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index d575208873..3e85eca0e6 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/RestWrite.js b/src/RestWrite.js index 9e07c93a10..b68074d421 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -128,7 +128,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. diff --git a/src/Routers/SchemasRouter.js b/src/Routers/SchemasRouter.js index a0a90ef295..49e4bbb29e 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/Schema.js b/src/Schema.js index 2a048a548d..ffb7b088b1 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,