diff --git a/spec/ParseObject.spec.js b/spec/ParseObject.spec.js index 978a304e60..78df95e598 100644 --- a/spec/ParseObject.spec.js +++ b/spec/ParseObject.spec.js @@ -313,7 +313,15 @@ describe('Parse.Object testing', () => { it('invalid __type', function(done) { const item = new Parse.Object('Item'); - const types = ['Pointer', 'File', 'Date', 'GeoPoint', 'Bytes', 'Polygon']; + const types = [ + 'Pointer', + 'File', + 'Date', + 'GeoPoint', + 'Bytes', + 'Polygon', + 'Relation', + ]; const tests = types.map(type => { const test = new Parse.Object('Item'); test.set('foo', { diff --git a/spec/PointerPermissions.spec.js b/spec/PointerPermissions.spec.js index d0d9c80178..bd7e34b308 100644 --- a/spec/PointerPermissions.spec.js +++ b/spec/PointerPermissions.spec.js @@ -2014,4 +2014,1089 @@ describe('Pointer Permissions', () => { } }); }); + + describe('Granular ', () => { + const className = 'AnObject'; + + const actionGet = id => new Parse.Query(className).get(id); + const actionFind = () => new Parse.Query(className).find(); + const actionCount = () => new Parse.Query(className).count(); + const actionCreate = () => new Parse.Object(className).save(); + const actionUpdate = obj => obj.save({ revision: 2 }); + const actionDelete = obj => obj.destroy(); + const actionAddFieldOnCreate = () => + new Parse.Object(className, { ['extra' + Date.now()]: 'field' }).save(); + const actionAddFieldOnUpdate = obj => + obj.save({ ['another' + Date.now()]: 'field' }); + + const OBJECT_NOT_FOUND = new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.' + ); + const PERMISSION_DENIED = jasmine.stringMatching('Permission denied'); + + async function createUser(username, password = 'password') { + const user = new Parse.User({ + username: username + Date.now(), + password, + }); + + await user.save(); + + return user; + } + + async function logIn(userObject) { + await Parse.User.logIn(userObject.getUsername(), 'password'); + } + + async function updateCLP(clp) { + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + + await schemaController.updateClass(className, {}, clp); + } + + describe('on single-pointer fields', () => { + /** owns: **obj1** */ + let user1; + + /** owns: **obj2** */ + let user2; + + /** owned by: **user1** */ + let obj1; + + /** owned by: **user2** */ + let obj2; + + async function initialize() { + await Config.get(Parse.applicationId).database.schemaCache.clear(); + + [user1, user2] = await Promise.all([ + createUser('user1'), + createUser('user2'), + ]); + + obj1 = new Parse.Object(className, { + owner: user1, + revision: 0, + }); + + obj2 = new Parse.Object(className, { + owner: user2, + revision: 0, + }); + + await Parse.Object.saveAll([obj1, obj2], { + useMasterKey: true, + }); + } + + beforeEach(async () => { + await initialize(); + }); + + describe('get action', () => { + it('should be allowed', async done => { + await updateCLP({ + get: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + const result = await actionGet(obj1.id); + expect(result).toBeDefined(); + done(); + }); + + it('should fail for user not listed', async done => { + await updateCLP({ + get: { + pointerFields: ['owner'], + }, + }); + + await logIn(user2); + + await expectAsync(actionGet(obj1.id)).toBeRejectedWith( + OBJECT_NOT_FOUND + ); + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + get: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionFind(), + actionCount(), + actionCreate(), + actionUpdate(obj1), + actionAddFieldOnCreate(), + actionDelete(obj1), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('find action', () => { + it('should be allowed', async done => { + await updateCLP({ + find: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await expectAsync(actionFind()).toBeResolved(); + done(); + }); + + it('should be limited to objects where user is listed in field', async done => { + await updateCLP({ + find: { + pointerFields: ['owner'], + }, + }); + + await logIn(user2); + + const results = await actionFind(); + expect(results.length).toBe(1); + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + find: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionGet(obj1.id), + actionCount(), + actionCreate(), + actionUpdate(obj1), + actionAddFieldOnCreate(), + actionDelete(obj1), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('count action', () => { + it('should be allowed', async done => { + await updateCLP({ + count: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + const count = await actionCount(); + expect(count).toBe(1); + done(); + }); + + it('should be limited to objects where user is listed in field', async done => { + await updateCLP({ + count: { + pointerFields: ['owner'], + }, + }); + + const user3 = await createUser('user3'); + await logIn(user3); + + const p = await actionCount(); + expect(p).toBe(0); + + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + count: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionGet(obj1.id), + actionFind(), + actionCreate(), + actionUpdate(obj1), + actionAddFieldOnCreate(), + actionDelete(obj1), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('update action', () => { + it('should be allowed', async done => { + await updateCLP({ + update: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + await expectAsync(actionUpdate(obj1)).toBeResolved(); + done(); + }); + + it('should fail for user not listed', async done => { + await updateCLP({ + update: { + pointerFields: ['owner'], + }, + }); + + await logIn(user2); + + await expectAsync(actionUpdate(obj1)).toBeRejectedWith( + OBJECT_NOT_FOUND + ); + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + update: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionGet(obj1.id), + actionFind(), + actionCount(), + actionCreate(), + actionAddFieldOnCreate(), + actionDelete(obj1), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('delete action', () => { + it('should be allowed', async done => { + await updateCLP({ + delete: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await expectAsync(actionDelete(obj1)).toBeResolved(); + done(); + }); + + it('should fail for user not listed', async done => { + await updateCLP({ + delete: { + pointerFields: ['owner'], + }, + }); + + await logIn(user2); + + await expectAsync(actionDelete(obj1)).toBeRejectedWith( + OBJECT_NOT_FOUND + ); + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + delete: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionGet(obj1.id), + actionFind(), + actionCount(), + actionCreate(), + actionUpdate(obj1), + actionAddFieldOnCreate(), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('create action', () => { + // For Pointer permissions create is different from other operations + // since there's no object holding the pointer before created + it('should be denied (writelock) when no other permissions on class', async done => { + await updateCLP({ + create: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + await expectAsync(actionCreate()).toBeRejectedWith(PERMISSION_DENIED); + done(); + }); + }); + + describe('addField action', () => { + xit('should have no effect when creating object (and allowed by explicit userid permission)', async done => { + await updateCLP({ + create: { + '*': true, + }, + addField: { + [user1.id]: true, + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await expectAsync(actionAddFieldOnCreate()).toBeResolved(); + done(); + }); + + xit('should be denied when creating object (and no explicit permission)', async done => { + await updateCLP({ + create: { + '*': true, + }, + addField: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + const newObject = new Parse.Object(className, { + owner: user1, + extra: 'field', + }); + await expectAsync(newObject.save()).toBeRejectedWith( + PERMISSION_DENIED + ); + done(); + }); + + it('should be allowed when updating object', async done => { + await updateCLP({ + update: { + '*': true, + }, + addField: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await expectAsync(actionAddFieldOnUpdate(obj1)).toBeResolved(); + + done(); + }); + + it('should be denied when updating object for user without addField permission', async done => { + await updateCLP({ + update: { + '*': true, + }, + addField: { + pointerFields: ['owner'], + }, + }); + + await logIn(user2); + + await expectAsync(actionAddFieldOnUpdate(obj1)).toBeRejectedWith( + OBJECT_NOT_FOUND + ); + + done(); + }); + }); + }); + + describe('on array of pointers', () => { + /** + * owns: **obj1** + * + * moderates: **obj1** */ + let user1; + + /** + * owns: **obj2** + * + * moderates: **obj1, obj2** */ + let user2; + + /** + * owns: **obj3** + * + * moderates: **obj1, obj2, obj3 ** */ + let user3; + + /** + * owned by: **user1** + * + * moderated by: **user1, user2, user3** */ + let obj1; + + /** + * owned by: **user2** + * + * moderated by: **user2, user3** */ + let obj2; + + /** + * owned by: **user3** + * + * moderated by: **user3** */ + let obj3; + + /** + * owned by: **noboody** + * + * moderated by: **nobody** */ + let objNobody; + + async function initialize() { + await Config.get(Parse.applicationId).database.schemaCache.clear(); + + [user1, user2, user3] = await Promise.all([ + createUser('user1'), + createUser('user2'), + createUser('user3'), + ]); + + obj1 = new Parse.Object(className); + obj2 = new Parse.Object(className); + obj3 = new Parse.Object(className); + objNobody = new Parse.Object(className); + + obj1.set({ + owners: [user1], + moderators: [user3, user2, user1], + revision: 0, + }); + + obj2.set({ + owners: [user2], + moderators: [user3, user2], + revision: 0, + }); + + obj3.set({ + owners: [user3], + moderators: [user3], + revision: 0, + }); + + objNobody.set({ + owners: [], + moderators: [], + revision: 0, + }); + + await Parse.Object.saveAll([obj1, obj2, obj3, objNobody], { + useMasterKey: true, + }); + } + + beforeEach(async () => { + await initialize(); + }); + + describe('get action', () => { + it('should be allowed (1 user in array)', async done => { + await updateCLP({ + get: { + pointerFields: ['owners'], + }, + }); + + await logIn(user1); + + const result = await actionGet(obj1.id); + expect(result).toBeDefined(); + done(); + }); + + it('should be allowed (multiple users in array)', async done => { + await updateCLP({ + get: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user2); + + const result = await actionGet(obj1.id); + expect(result).toBeDefined(); + done(); + }); + + it('should fail for user not listed', async done => { + await updateCLP({ + get: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + + await expectAsync(actionGet(obj3.id)).toBeRejectedWith( + OBJECT_NOT_FOUND + ); + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + get: { + pointerFields: ['owners'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionFind(), + actionCount(), + actionCreate(), + actionUpdate(obj2), + actionAddFieldOnCreate(), + actionAddFieldOnUpdate(obj2), + actionDelete(obj2), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('find action', () => { + it('should be allowed (1 user in array)', async done => { + await updateCLP({ + find: { + pointerFields: ['owners'], + }, + }); + + await logIn(user1); + + const results = await actionFind(); + expect(results.length).toBe(1); + done(); + }); + + it('should be allowed (multiple users in array)', async done => { + await updateCLP({ + find: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user2); + + const results = await actionFind(); + expect(results.length).toBe(2); + done(); + }); + + it('should be limited to objects where user is listed in field', async done => { + await updateCLP({ + find: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + + const results = await actionFind(); + expect(results.length).toBe(1); + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + find: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionGet(obj1.id), + actionCount(), + actionCreate(), + actionUpdate(obj1), + actionAddFieldOnCreate(), + actionAddFieldOnUpdate(obj1), + actionDelete(obj1), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('count action', () => { + beforeEach(async () => { + await updateCLP({ + count: { + pointerFields: ['moderators'], + }, + }); + }); + + it('should be allowed', async done => { + await logIn(user1); + + const count = await actionCount(); + expect(count).toBe(1); + done(); + }); + + it('should be limited to objects where user is listed in field', async done => { + await logIn(user2); + + const count = await actionCount(); + expect(count).toBe(2); + + done(); + }); + + it('should not allow other actions', async done => { + await logIn(user1); + + await Promise.all( + [ + actionGet(obj1.id), + actionFind(), + actionCreate(), + actionUpdate(obj1), + actionAddFieldOnCreate(), + actionAddFieldOnUpdate(obj1), + actionDelete(obj1), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('update action', () => { + it('should be allowed (1 user in array)', async done => { + await updateCLP({ + update: { + pointerFields: ['owners'], + }, + }); + + await logIn(user1); + + await expectAsync(actionUpdate(obj1)).toBeResolved(); + done(); + }); + + it('should be allowed (multiple users in array)', async done => { + await updateCLP({ + update: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user2); + + await expectAsync(actionUpdate(obj1)).toBeResolved(); + done(); + }); + + it('should fail for user not listed', async done => { + await updateCLP({ + update: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user2); + + await expectAsync(actionUpdate(obj3)).toBeRejectedWith( + OBJECT_NOT_FOUND + ); + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + update: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionGet(obj1.id), + actionFind(), + actionCount(), + actionCreate(), + actionAddFieldOnCreate(), + actionAddFieldOnUpdate(obj1), + actionDelete(obj1), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('delete action', () => { + it('should be allowed (1 user in array)', async done => { + await updateCLP({ + delete: { + pointerFields: ['owners'], + }, + }); + + await logIn(user1); + + await expectAsync(actionDelete(obj1)).toBeResolved(); + done(); + }); + + it('should be allowed (multiple users in array)', async done => { + await updateCLP({ + delete: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user3); + + await expectAsync(actionDelete(obj2)).toBeResolved(); + done(); + }); + + it('should fail for user not listed', async done => { + await updateCLP({ + delete: { + pointerFields: ['owners'], + }, + }); + + await logIn(user1); + + await expectAsync(actionDelete(obj3)).toBeRejectedWith( + OBJECT_NOT_FOUND + ); + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + delete: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionGet(obj1.id), + actionFind(), + actionCount(), + actionCreate(), + actionUpdate(obj1), + actionAddFieldOnCreate(), + actionAddFieldOnUpdate(obj1), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('create action', () => { + /* For Pointer permissions 'create' is different from other operations + since there's no object holding the pointer before created */ + it('should be denied (writelock) when no other permissions on class', async done => { + await updateCLP({ + create: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + await expectAsync(actionCreate()).toBeRejectedWith(PERMISSION_DENIED); + done(); + }); + }); + + describe('addField action', () => { + it('should have no effect on create (allowed by explicit userid)', async done => { + await updateCLP({ + create: { + '*': true, + }, + addField: { + [user1.id]: true, + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + + await expectAsync(actionAddFieldOnCreate()).toBeResolved(); + done(); + }); + + it('should be denied when creating object (and no explicit permission)', async done => { + await updateCLP({ + create: { + '*': true, + }, + addField: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + + const newObject = new Parse.Object(className, { + moderators: user1, + extra: 'field', + }); + await expectAsync(newObject.save()).toBeRejectedWith( + PERMISSION_DENIED + ); + done(); + }); + + it('should be allowed when updating object', async done => { + await updateCLP({ + update: { + '*': true, + }, + addField: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user2); + + await expectAsync(actionAddFieldOnUpdate(obj1)).toBeResolved(); + + done(); + }); + + it('should be restricted when updating object without addField permission', async done => { + await updateCLP({ + update: { + '*': true, + }, + addField: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + + await expectAsync(actionAddFieldOnUpdate(obj2)).toBeRejectedWith( + OBJECT_NOT_FOUND + ); + + done(); + }); + }); + }); + + describe('combined with grouped', () => { + /** + * owns: **obj1** + * + * moderates: **obj2** */ + let user1; + + /** + * owns: **obj2** + * + * moderates: **obj1, obj2** */ + let user2; + + /** + * owned by: **user1** + * + * moderated by: **user2** */ + let obj1; + + /** + * owned by: **user2** + * + * moderated by: **user1, user2** */ + let obj2; + + async function initialize() { + await Config.get(Parse.applicationId).database.schemaCache.clear(); + + [user1, user2] = await Promise.all([ + createUser('user1'), + createUser('user2'), + ]); + + // User1 owns object1 + // User2 owns object2 + obj1 = new Parse.Object(className, { + owner: user1, + moderators: [user2], + revision: 0, + }); + + obj2 = new Parse.Object(className, { + owner: user2, + moderators: [user1, user2], + revision: 0, + }); + + await Parse.Object.saveAll([obj1, obj2], { + useMasterKey: true, + }); + } + + beforeEach(async () => { + await initialize(); + }); + + it('should not limit the scope of grouped read permissions', async done => { + await updateCLP({ + get: { + pointerFields: ['owner'], + }, + readUserFields: ['moderators'], + }); + + await logIn(user2); + + await expectAsync(actionGet(obj1.id)).toBeResolved(); + + const found = await actionFind(); + expect(found.length).toBe(2); + + const counted = await actionCount(); + expect(counted).toBe(2); + + done(); + }); + + it('should not limit the scope of grouped write permissions', async done => { + await updateCLP({ + update: { + pointerFields: ['owner'], + }, + writeUserFields: ['moderators'], + }); + + await logIn(user2); + + await expectAsync(actionUpdate(obj1)).toBeResolved(); + await expectAsync(actionAddFieldOnUpdate(obj1)).toBeResolved(); + await expectAsync(actionDelete(obj1)).toBeResolved(); + // [create] and [addField on create] can't be enabled with pointer by design + + done(); + }); + + it('should not inherit scope of grouped read permissions from another field', async done => { + await updateCLP({ + get: { + pointerFields: ['owner'], + }, + readUserFields: ['moderators'], + }); + + await logIn(user1); + + const found = await actionFind(); + expect(found.length).toBe(1); + + const counted = await actionCount(); + expect(counted).toBe(1); + + done(); + }); + + it('should not inherit scope of grouped write permissions from another field', async done => { + await updateCLP({ + update: { + pointerFields: ['moderators'], + }, + writeUserFields: ['owner'], + }); + + await logIn(user1); + + await expectAsync(actionDelete(obj2)).toBeRejectedWith( + OBJECT_NOT_FOUND + ); + + done(); + }); + }); + }); }); diff --git a/spec/ProtectedFields.spec.js b/spec/ProtectedFields.spec.js index 786222dc9e..78e44f3bc1 100644 --- a/spec/ProtectedFields.spec.js +++ b/spec/ProtectedFields.spec.js @@ -758,4 +758,37 @@ describe('ProtectedFields', function() { }); }); }); + + describe('schema setup', () => { + const className = 'AObject'; + async function updateCLP(clp) { + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + + await schemaController.updateClass(className, {}, clp); + } + + it('should fail setting non-existing protected field', async () => { + const object = new Parse.Object(className, { + revision: 0, + }); + await object.save(); + + const field = 'non-existing'; + const entity = '*'; + + await expectAsync( + updateCLP({ + protectedFields: { + [entity]: [field], + }, + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_JSON, + `Field '${field}' in protectedFields:${entity} does not exist` + ) + ); + }); + }); }); diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index 5325e48a74..7cdc88a632 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -1665,7 +1665,7 @@ describe('Class Level Permissions for requiredAuth', () => { ); }); - it('required auth test get not authenitcated', done => { + it('required auth test get not authenticated', done => { config.database .loadSchema() .then(schema => { @@ -1704,7 +1704,7 @@ describe('Class Level Permissions for requiredAuth', () => { ); }); - it('required auth test find not authenitcated', done => { + it('required auth test find not authenticated', done => { config.database .loadSchema() .then(schema => { diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 6097f5aee4..c547bf858a 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -2752,6 +2752,115 @@ describe('schemas', () => { ); }); + it('should reject creating class schema with field with invalid key', async done => { + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + + const fieldName = '1invalid'; + + const schemaCreation = () => + schemaController.addClassIfNotExists('AnObject', { + [fieldName]: { __type: 'String' }, + }); + + await expectAsync(schemaCreation()).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + `invalid field name: ${fieldName}` + ) + ); + done(); + }); + + it('should reject creating invalid field name', async done => { + const object = new Parse.Object('AnObject'); + + await expectAsync( + object.save({ + '!12field': 'field', + }) + ).toBeRejectedWith(new Parse.Error(Parse.Error.INVALID_KEY_NAME)); + done(); + }); + + it('should be rejected if CLP operation is not an object', async done => { + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + + const operationKey = 'get'; + const operation = true; + + const schemaSetup = async () => + await schemaController.addClassIfNotExists( + 'AnObject', + {}, + { + [operationKey]: operation, + } + ); + + await expectAsync(schemaSetup()).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_JSON, + `'${operation}' is not a valid value for class level permissions ${operationKey} - must be an object` + ) + ); + + done(); + }); + + it('should be rejected if CLP protectedFields is not an object', async done => { + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + + const operationKey = 'get'; + const operation = 'wrongtype'; + + const schemaSetup = async () => + await schemaController.addClassIfNotExists( + 'AnObject', + {}, + { + [operationKey]: operation, + } + ); + + await expectAsync(schemaSetup()).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_JSON, + `'${operation}' is not a valid value for class level permissions ${operationKey} - must be an object` + ) + ); + + done(); + }); + + it('should be rejected if CLP read/writeUserFields is not an array', async done => { + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + + const operationKey = 'readUserFields'; + const operation = true; + + const schemaSetup = async () => + await schemaController.addClassIfNotExists( + 'AnObject', + {}, + { + [operationKey]: operation, + } + ); + + await expectAsync(schemaSetup()).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_JSON, + `'${operation}' is not a valid value for class level permissions ${operationKey} - must be an array` + ) + ); + + done(); + }); + describe('index management', () => { beforeEach(() => require('../lib/TestUtils').destroyAllDataPermanently()); it('cannot create index if field does not exist', done => { diff --git a/src/Adapters/Storage/StorageAdapter.js b/src/Adapters/Storage/StorageAdapter.js index ce134493bf..6d2aba1060 100644 --- a/src/Adapters/Storage/StorageAdapter.js +++ b/src/Adapters/Storage/StorageAdapter.js @@ -16,6 +16,8 @@ export type QueryOptions = { readPreference?: ?string, hint?: ?mixed, explain?: Boolean, + action?: string, + addsField?: boolean, }; export type UpdateQueryOptions = { diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 81e32aff62..ee0f7d4954 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -553,9 +553,10 @@ class DatabaseController { className: string, object: any, query: any, - { acl }: QueryOptions + runOptions: QueryOptions ): Promise { let schema; + const acl = runOptions.acl; const isMaster = acl === undefined; var aclGroup: string[] = acl || []; return this.loadSchema() @@ -564,7 +565,13 @@ class DatabaseController { if (isMaster) { return Promise.resolve(); } - return this.canAddField(schema, className, object, aclGroup); + return this.canAddField( + schema, + className, + object, + aclGroup, + runOptions + ); }) .then(() => { return schema.validateObject(className, object, query); @@ -575,7 +582,7 @@ class DatabaseController { className: string, query: any, update: any, - { acl, many, upsert }: FullQueryOptions = {}, + { acl, many, upsert, addsField }: FullQueryOptions = {}, skipSanitization: boolean = false, validateOnly: boolean = false, validSchemaController: SchemaController.SchemaController @@ -608,6 +615,21 @@ class DatabaseController { query, aclGroup ); + + if (addsField) { + query = { + $and: [ + query, + this.addPointerPermissions( + schemaController, + className, + 'addField', + query, + aclGroup + ), + ], + }; + } } if (!query) { return Promise.resolve(); @@ -994,7 +1016,8 @@ class DatabaseController { schema: SchemaController.SchemaController, className: string, object: any, - aclGroup: string[] + aclGroup: string[], + runOptions: QueryOptions ): Promise { const classSchema = schema.schemaData[className]; if (!classSchema) { @@ -1014,7 +1037,11 @@ class DatabaseController { return schemaFields.indexOf(field) < 0; }); if (newKeys.length > 0) { - return schema.validatePermission(className, aclGroup, 'addField'); + // adds a marker that new field is being adding during update + runOptions.addsField = true; + + const action = runOptions.action; + return schema.validatePermission(className, aclGroup, 'addField', action); } return Promise.resolve(); } @@ -1525,28 +1552,50 @@ class DatabaseController { }); } + // Constraints query using CLP's pointer permissions (PP) if any. + // 1. Etract the user id from caller's ACLgroup; + // 2. Exctract a list of field names that are PP for target collection and operation; + // 3. Constraint the original query so that each PP field must + // point to caller's id (or contain it in case of PP field being an array) addPointerPermissions( schema: SchemaController.SchemaController, className: string, operation: string, query: any, aclGroup: any[] = [] - ) { + ): any { // Check if class has public permission for operation // If the BaseCLP pass, let go through if (schema.testPermissionsForClassName(className, aclGroup, operation)) { return query; } const perms = schema.getClassLevelPermissions(className); - const field = - ['get', 'find'].indexOf(operation) > -1 - ? 'readUserFields' - : 'writeUserFields'; + const userACL = aclGroup.filter(acl => { return acl.indexOf('role:') != 0 && acl != '*'; }); + + const groupKey = + ['get', 'find', 'count'].indexOf(operation) > -1 + ? 'readUserFields' + : 'writeUserFields'; + + const permFields = []; + + if (perms[operation] && perms[operation].pointerFields) { + permFields.push(...perms[operation].pointerFields); + } + + if (perms[groupKey]) { + for (const field of perms[groupKey]) { + if (!permFields.includes(field)) { + permFields.push(field); + } + } + } // the ACL should have exactly 1 user - if (perms && perms[field] && perms[field].length > 0) { + if (permFields.length > 0) { + // the ACL should have exactly 1 user // No user set return undefined // If the length is > 1, that means we didn't de-dupe users correctly if (userACL.length != 1) { @@ -1559,7 +1608,6 @@ class DatabaseController { objectId: userId, }; - const permFields = perms[field]; const ors = permFields.flatMap(key => { // constraint for single pointer setup const q = { @@ -1588,7 +1636,7 @@ class DatabaseController { query: any = {}, aclGroup: any[] = [], auth: any = {} - ) { + ): null | string[] { const perms = schema.getClassLevelPermissions(className); if (!perms) return null; diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index 73ce91b867..8c799d0533 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -182,11 +182,14 @@ const publicRegex = /^\*$/; const requireAuthenticationRegex = /^requiresAuthentication$/; +const pointerFieldsRegex = /^pointerFields$/; + const permissionKeyRegex = Object.freeze([ roleRegex, pointerPermissionRegex, publicRegex, requireAuthenticationRegex, + pointerFieldsRegex, ]); function validatePermissionKey(key, userIdRegExp) { @@ -238,26 +241,19 @@ function validateCLP( } const operation = perms[operationKey]; - if (!operation) { - // proceed with next operationKey - continue; - } + // proceed with next operationKey + + // throws when root fields are of wrong type + validateCLPjson(operation, operationKey); - // validate grouped pointer permissions if ( operationKey === 'readUserFields' || operationKey === 'writeUserFields' ) { + // validate grouped pointer permissions // must be an array with field names - if (!Array.isArray(operation)) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - `'${operation}' is not a valid value for class level permissions ${operationKey}` - ); - } else { - for (const fieldName of operation) { - validatePointerPermission(fieldName, fields, operationKey); - } + for (const fieldName of operation) { + validatePointerPermission(fieldName, fields, operationKey); } // readUserFields and writerUserFields do not have nesdted fields // proceed with next operationKey @@ -299,11 +295,29 @@ function validateCLP( // "*" - Public, // "requiresAuthentication" - authenticated users, // "objectId" - _User id, - // "role:objectId", + // "role:rolename", + // "pointerFields" - array of field names containing pointers to users for (const entity in operation) { // throws on unexpected key validatePermissionKey(entity, userIdRegExp); + if (entity === 'pointerFields') { + const pointerFields = operation[entity]; + + if (Array.isArray(pointerFields)) { + for (const pointerField of pointerFields) { + validatePointerPermission(pointerField, fields, operation); + } + } else { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `'${pointerFields}' is not a valid value for protectedFields[${entity}] - expected an array.` + ); + } + // proceed with next entity key + continue; + } + const permit = operation[entity]; if (permit !== true) { @@ -316,13 +330,34 @@ function validateCLP( } } +function validateCLPjson(operation: any, operationKey: string) { + if (operationKey === 'readUserFields' || operationKey === 'writeUserFields') { + if (!Array.isArray(operation)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `'${operation}' is not a valid value for class level permissions ${operationKey} - must be an array` + ); + } + } else { + if (typeof operation === 'object' && operation !== null) { + // ok to proceed + return; + } else { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `'${operation}' is not a valid value for class level permissions ${operationKey} - must be an object` + ); + } + } +} + function validatePointerPermission( fieldName: string, fields: Object, operation: string ) { // Uses collection schema to ensure the field is of type: - // - Pointer<_User> (pointers/relations) + // - Pointer<_User> (pointers) // - Array // // It's not possible to enforce type on Array's items in schema @@ -1340,7 +1375,8 @@ export default class SchemaController { classPermissions: ?any, className: string, aclGroup: string[], - operation: string + operation: string, + action?: string ) { if ( SchemaController.testPermissions(classPermissions, aclGroup, operation) @@ -1394,6 +1430,16 @@ export default class SchemaController { ) { return Promise.resolve(); } + + const pointerFields = classPermissions[operation].pointerFields; + if (Array.isArray(pointerFields) && pointerFields.length > 0) { + // any op except 'addField as part of create' is ok. + if (operation !== 'addField' || action === 'update') { + // We can allow adding field on update flow only. + return Promise.resolve(); + } + } + throw new Parse.Error( Parse.Error.OPERATION_FORBIDDEN, `Permission denied for action ${operation} on class ${className}.` @@ -1401,12 +1447,18 @@ export default class SchemaController { } // Validates an operation passes class-level-permissions set in the schema - validatePermission(className: string, aclGroup: string[], operation: string) { + validatePermission( + className: string, + aclGroup: string[], + operation: string, + action?: string + ) { return SchemaController.validatePermission( this.getClassLevelPermissions(className), className, aclGroup, - operation + operation, + action ); } diff --git a/src/RestWrite.js b/src/RestWrite.js index 584303fc7f..c87a49ab7e 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -31,7 +31,8 @@ function RestWrite( query, data, originalData, - clientSDK + clientSDK, + action ) { if (auth.isReadOnly) { throw new Parse.Error( @@ -47,6 +48,10 @@ function RestWrite( this.runOptions = {}; this.context = {}; + if (action) { + this.runOptions.action = action; + } + if (!query) { if (this.config.allowCustomObjectId) { if ( diff --git a/src/rest.js b/src/rest.js index 19527db227..201cd89bbc 100644 --- a/src/rest.js +++ b/src/rest.js @@ -251,7 +251,8 @@ function update(config, auth, className, restWhere, restObject, clientSDK) { restWhere, restObject, originalRestObject, - clientSDK + clientSDK, + 'update' ).execute(); }) .catch(error => {