diff --git a/spec/ProtectedFields.spec.js b/spec/ProtectedFields.spec.js index 92b89a6bae..786222dc9e 100644 --- a/spec/ProtectedFields.spec.js +++ b/spec/ProtectedFields.spec.js @@ -1,3 +1,6 @@ +const Config = require('../lib/Config'); +const Parse = require('parse/node'); + describe('ProtectedFields', function() { it('should handle and empty protectedFields', async function() { const protectedFields = {}; @@ -138,4 +141,621 @@ describe('ProtectedFields', function() { expect(fetchedUser.has('favoriteColor')).toBeTruthy(); }); }); + + describe('using the pointer-permission variant', () => { + let user1, user2; + beforeEach(async () => { + Config.get(Parse.applicationId).database.schemaCache.clear(); + user1 = await Parse.User.signUp('user1', 'password'); + user2 = await Parse.User.signUp('user2', 'password'); + await Parse.User.logOut(); + }); + + describe('and get/fetch', () => { + it('should allow access using single user pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + + obj.set('owner', user1); + obj.set('test', 'test'); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owner'], 'userField:owner': [] }, + } + ); + + await Parse.User.logIn('user1', 'password'); + const objectAgain = await obj.fetch(); + expect(objectAgain.get('owner').id).toBe(user1.id); + expect(objectAgain.get('test')).toBe('test'); + done(); + }); + + it('should deny access to other users using single user pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + + obj.set('owner', user1); + obj.set('test', 'test'); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owner'], 'userField:owner': [] }, + } + ); + + await Parse.User.logIn('user2', 'password'); + const objectAgain = await obj.fetch(); + expect(objectAgain.get('owner')).toBe(undefined); + expect(objectAgain.get('test')).toBe('test'); + done(); + }); + + it('should deny access to public using single user pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + + obj.set('owner', user1); + obj.set('test', 'test'); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owner'], 'userField:owner': [] }, + } + ); + + const objectAgain = await obj.fetch(); + expect(objectAgain.get('owner')).toBe(undefined); + expect(objectAgain.get('test')).toBe('test'); + done(); + }); + + it('should allow access using user array pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + + obj.set('owners', [user1, user2]); + obj.set('test', 'test'); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owners'], 'userField:owners': [] }, + } + ); + + await Parse.User.logIn('user1', 'password'); + let objectAgain = await obj.fetch(); + expect(objectAgain.get('owners')[0].id).toBe(user1.id); + expect(objectAgain.get('test')).toBe('test'); + await Parse.User.logIn('user2', 'password'); + objectAgain = await obj.fetch(); + expect(objectAgain.get('owners')[1].id).toBe(user2.id); + expect(objectAgain.get('test')).toBe('test'); + done(); + }); + + it('should deny access to other users using user array pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + + obj.set('owners', [user1]); + obj.set('test', 'test'); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owners'], 'userField:owners': [] }, + } + ); + + await Parse.User.logIn('user2', 'password'); + const objectAgain = await obj.fetch(); + expect(objectAgain.get('owners')).toBe(undefined); + expect(objectAgain.get('test')).toBe('test'); + done(); + }); + + it('should deny access to public using user array pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + + obj.set('owners', [user1, user2]); + obj.set('test', 'test'); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owners'], 'userField:owners': [] }, + } + ); + + const objectAgain = await obj.fetch(); + expect(objectAgain.get('owners')).toBe(undefined); + expect(objectAgain.get('test')).toBe('test'); + done(); + }); + + it('should create merge protected fields when using multiple pointer-permission fields', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + + obj.set('owners', [user1]); + obj.set('owner', user1); + obj.set('test', 'test'); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': [], + 'userField:owners': ['owners'], + 'userField:owner': ['owner'], + }, + } + ); + + // Check if protectFields from pointer-permissions got combined + await Parse.User.logIn('user1', 'password'); + const objectAgain = await obj.fetch(); + expect(objectAgain.get('owners')).toBe(undefined); + expect(objectAgain.get('owner')).toBe(undefined); + expect(objectAgain.get('test')).toBe('test'); + done(); + }); + + it('should ignore pointer-permission fields not present in object', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + + obj.set('owners', [user1]); + obj.set('owner', user1); + obj.set('test', 'test'); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': [], + 'userField:idontexist': ['owner'], + 'userField:idontexist2': ['owners'], + }, + } + ); + + await Parse.User.logIn('user1', 'password'); + const objectAgain = await obj.fetch(); + expect(objectAgain.get('owners')).not.toBe(undefined); + expect(objectAgain.get('owner')).not.toBe(undefined); + expect(objectAgain.get('test')).toBe('test'); + done(); + }); + }); + + describe('and find', () => { + it('should allow access using single user pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + obj.set('owner', user1); + obj.set('test', 'test'); + obj2.set('owner', user1); + obj2.set('test', 'test2'); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owner'], 'userField:owner': [] }, + } + ); + + await Parse.User.logIn('user1', 'password'); + + const q = new Parse.Query('AnObject'); + const results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owner').id).toBe(user1.id); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owner').id).toBe(user1.id); + expect(results[1].get('test')).toBe('test2'); + done(); + }); + + it('should deny access to other users using single user pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + obj.set('owner', user1); + obj.set('test', 'test'); + obj2.set('owner', user1); + obj2.set('test', 'test2'); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owner'], 'userField:owner': [] }, + } + ); + + await Parse.User.logIn('user2', 'password'); + const q = new Parse.Query('AnObject'); + const results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owner')).toBe(undefined); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owner')).toBe(undefined); + expect(results[1].get('test')).toBe('test2'); + done(); + }); + + it('should deny access to public using single user pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + obj.set('owner', user1); + obj.set('test', 'test'); + obj2.set('owner', user1); + obj2.set('test', 'test2'); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owner'], 'userField:owner': [] }, + } + ); + + const q = new Parse.Query('AnObject'); + const results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owner')).toBe(undefined); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owner')).toBe(undefined); + expect(results[1].get('test')).toBe('test2'); + done(); + }); + + it('should allow access using user array pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + obj.set('owners', [user1, user2]); + obj.set('test', 'test'); + obj2.set('owners', [user1, user2]); + obj2.set('test', 'test2'); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owners'], 'userField:owners': [] }, + } + ); + + const q = new Parse.Query('AnObject'); + let results; + + await Parse.User.logIn('user1', 'password'); + results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owners')[0].id).toBe(user1.id); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owners')[0].id).toBe(user1.id); + expect(results[1].get('test')).toBe('test2'); + + await Parse.User.logIn('user2', 'password'); + results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owners')[1].id).toBe(user2.id); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owners')[1].id).toBe(user2.id); + expect(results[1].get('test')).toBe('test2'); + done(); + }); + + it('should deny access to other users using user array pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + obj.set('owners', [user1]); + obj.set('test', 'test'); + obj2.set('owners', [user1]); + obj2.set('test', 'test2'); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owners'], 'userField:owners': [] }, + } + ); + + await Parse.User.logIn('user2', 'password'); + const q = new Parse.Query('AnObject'); + const results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owners')).toBe(undefined); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owners')).toBe(undefined); + expect(results[1].get('test')).toBe('test2'); + done(); + }); + + it('should deny access to public using user array pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + obj.set('owners', [user1, user2]); + obj.set('test', 'test'); + obj2.set('owners', [user1, user2]); + obj2.set('test', 'test2'); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owners'], 'userField:owners': [] }, + } + ); + + const q = new Parse.Query('AnObject'); + const results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owners')).toBe(undefined); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owners')).toBe(undefined); + expect(results[1].get('test')).toBe('test2'); + done(); + }); + + it('should create merge protected fields when using multiple pointer-permission fields', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + obj.set('owners', [user1]); + obj.set('owner', user1); + obj.set('test', 'test'); + obj2.set('owners', [user1]); + obj2.set('owner', user1); + obj2.set('test', 'test2'); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': [], + 'userField:owners': ['owners'], + 'userField:owner': ['owner'], + }, + } + ); + + // Check if protectFields from pointer-permissions got combined + await Parse.User.logIn('user1', 'password'); + + const q = new Parse.Query('AnObject'); + const results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owners')).toBe(undefined); + expect(results[0].get('owner')).toBe(undefined); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owners')).toBe(undefined); + expect(results[1].get('owner')).toBe(undefined); + expect(results[1].get('test')).toBe('test2'); + done(); + }); + + it('should ignore pointer-permission fields not present in object', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + obj.set('owners', [user1]); + obj.set('owner', user1); + obj.set('test', 'test'); + obj2.set('owners', [user1]); + obj2.set('owner', user1); + obj2.set('test', 'test2'); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': [], + 'userField:idontexist': ['owner'], + 'userField:idontexist2': ['owners'], + }, + } + ); + + await Parse.User.logIn('user1', 'password'); + + const q = new Parse.Query('AnObject'); + const results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owners')).not.toBe(undefined); + expect(results[0].get('owner')).not.toBe(undefined); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owners')).not.toBe(undefined); + expect(results[1].get('owner')).not.toBe(undefined); + expect(results[1].get('test')).toBe('test2'); + done(); + }); + + it('should filter only fields from objects not owned by the user', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + const obj3 = new Parse.Object('AnObject'); + + obj.set('owner', user1); + obj.set('test', 'test'); + obj2.set('owner', user2); + obj2.set('test', 'test2'); + obj3.set('owner', user2); + obj3.set('test', 'test3'); + await Parse.Object.saveAll([obj, obj2, obj3]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['owner'], + 'userField:owner': [], + }, + } + ); + + const q = new Parse.Query('AnObject'); + let results; + + await Parse.User.logIn('user1', 'password'); + + results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(3); + + expect(results[0].get('owner')).not.toBe(undefined); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owner')).toBe(undefined); + expect(results[1].get('test')).toBe('test2'); + expect(results[2].get('owner')).toBe(undefined); + expect(results[2].get('test')).toBe('test3'); + + await Parse.User.logIn('user2', 'password'); + + results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(3); + + expect(results[0].get('owner')).toBe(undefined); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owner')).not.toBe(undefined); + expect(results[1].get('test')).toBe('test2'); + expect(results[2].get('owner')).not.toBe(undefined); + expect(results[2].get('test')).toBe('test3'); + done(); + }); + }); + }); }); diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index c426a8280e..0a643e64ee 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -192,15 +192,69 @@ const validateQuery = ( // Filters out any data that shouldn't be on this REST-formatted object. const filterSensitiveData = ( - isMaster, - aclGroup, - className, - protectedFields, - object + isMaster: boolean, + aclGroup: any[], + auth: any, + operation: any, + schema: SchemaController.SchemaController, + className: string, + protectedFields: null | Array, + object: any ) => { - protectedFields && protectedFields.forEach(k => delete object[k]); + let userId = null; + if (auth && auth.user) userId = auth.user.id; + + // replace protectedFields when using pointer-permissions + const perms = schema.getClassLevelPermissions(className); + if (perms) { + const isReadOperation = ['get', 'find'].indexOf(operation) > -1; + + if (isReadOperation && perms.protectedFields) { + // extract protectedFields added with the pointer-permission prefix + const protectedFieldsPointerPerm = Object.keys(perms.protectedFields) + .filter(key => key.startsWith('userField:')) + .map(key => { + return { key: key.substring(10), value: perms.protectedFields[key] }; + }); + + const newProtectedFields: Array = []; + let overrideProtectedFields = false; + + // check if the object grants the current user access based on the extracted fields + protectedFieldsPointerPerm.forEach(pointerPerm => { + let pointerPermIncludesUser = false; + const readUserFieldValue = object[pointerPerm.key]; + if (readUserFieldValue) { + if (Array.isArray(readUserFieldValue)) { + pointerPermIncludesUser = readUserFieldValue.some( + user => user.objectId && user.objectId === userId + ); + } else { + pointerPermIncludesUser = + readUserFieldValue.objectId && + readUserFieldValue.objectId === userId; + } + } + + if (pointerPermIncludesUser) { + overrideProtectedFields = true; + newProtectedFields.push(...pointerPerm.value); + } + }); - if (className !== '_User') { + // if atleast one pointer-permission affected the current user override the protectedFields + if (overrideProtectedFields) protectedFields = newProtectedFields; + } + } + + const isUserClass = className === '_User'; + + /* special treat for the user class: don't filter protectedFields if currently loggedin user is + the retrieved user */ + if (!(isUserClass && userId && object.objectId === userId)) + protectedFields && protectedFields.forEach(k => delete object[k]); + + if (!isUserClass) { return object; } @@ -1315,8 +1369,9 @@ class DatabaseController { query, aclGroup ); - // ProtectedFields is generated before executing the query so we - // can optimize the query using Mongo Projection at a later stage. + /* Don't use projections to optimize the protectedFields since the protectedFields + based on pointer-permissions are determined after querying. The filtering can + overwrite the protected fields. */ protectedFields = this.addProtectedFields( schemaController, className, @@ -1385,6 +1440,9 @@ class DatabaseController { return filterSensitiveData( isMaster, aclGroup, + auth, + op, + schemaController, className, protectedFields, object @@ -1518,18 +1576,13 @@ class DatabaseController { if (!protectedFields) return null; if (aclGroup.indexOf(query.objectId) > -1) return null; - if ( - Object.keys(query).length === 0 && - auth && - auth.user && - aclGroup.indexOf(auth.user.id) > -1 - ) - return null; - - let protectedKeys = Object.values(protectedFields).reduce( - (acc, val) => acc.concat(val), - [] - ); //.flat(); + + // remove userField keys since they are filtered after querying + let protectedKeys = Object.keys(protectedFields).reduce((acc, val) => { + if (val.startsWith('userField:')) return acc; + return acc.concat(protectedFields[val]); + }, []); + [...(auth.userRoles || [])].forEach(role => { const fields = protectedFields[role]; if (fields) { diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index da75c2f1f2..7c64b48bf2 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -176,6 +176,8 @@ const volatileClasses = Object.freeze([ const userIdRegex = /^[a-zA-Z0-9]{10}$/; // Anything that start with role const roleRegex = /^role:.*/; +// Anything that starts with userField +const pointerPermissionRegex = /^userField:.*/; // * permission const publicRegex = /^\*$/; @@ -184,6 +186,7 @@ const requireAuthenticationRegex = /^requiresAuthentication$/; const permissionKeyRegex = Object.freeze([ userIdRegex, roleRegex, + pointerPermissionRegex, publicRegex, requireAuthenticationRegex, ]); @@ -905,10 +908,15 @@ export default class SchemaController { let defaultValueType = getType(fieldType.defaultValue); if (typeof defaultValueType === 'string') { defaultValueType = { type: defaultValueType }; - } else if (typeof defaultValueType === 'object' && fieldType.type === 'Relation') { + } else if ( + typeof defaultValueType === 'object' && + fieldType.type === 'Relation' + ) { return { code: Parse.Error.INCORRECT_TYPE, - error: `The 'default value' option is not applicable for ${typeToString(fieldType)}` + error: `The 'default value' option is not applicable for ${typeToString( + fieldType + )}`, }; } if (!dbTypeMatchesObjectType(fieldType, defaultValueType)) { @@ -923,7 +931,9 @@ export default class SchemaController { if (typeof fieldType === 'object' && fieldType.type === 'Relation') { return { code: Parse.Error.INCORRECT_TYPE, - error: `The 'required' option is not applicable for ${typeToString(fieldType)}` + error: `The 'required' option is not applicable for ${typeToString( + fieldType + )}`, }; } }