From 292bdb713a1557510b01986b1dd9d2d69efcf4ed Mon Sep 17 00:00:00 2001 From: Old Grandpa Date: Wed, 19 Feb 2020 12:34:08 +0300 Subject: [PATCH] Allow protectedFields for Authenticated users and Public. Fix userField with keys/excludedKeys (#6415) * fix error message and test it * protected fields fixes * clean * remove duplicate test, add some comments * no need for 'requiresAuthentication' --- spec/ParseGraphQLServer.spec.js | 80 +++ spec/ParseQuery.spec.js | 32 + spec/PointerPermissions.spec.js | 52 +- spec/ProtectedFields.spec.js | 961 +++++++++++++++++++++++++- spec/dev.js | 98 +++ spec/schemas.spec.js | 29 + src/Controllers/DatabaseController.js | 122 +++- src/Controllers/SchemaController.js | 57 +- src/Routers/ClassesRouter.js | 4 + 9 files changed, 1390 insertions(+), 45 deletions(-) create mode 100644 spec/dev.js diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index ca08f227..cc44aaa8 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -5,6 +5,8 @@ const fetch = require('node-fetch'); const FormData = require('form-data'); const ws = require('ws'); require('./helper'); +const { updateCLP } = require('./dev'); + const pluralize = require('pluralize'); const { getMainDefinition } = require('apollo-utilities'); const { ApolloLink, split } = require('apollo-link'); @@ -4632,6 +4634,84 @@ describe('ParseGraphQLServer', () => { ).toBeDefined(); }); + it('should respect protectedFields', async done => { + await prepareData(); + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const className = 'GraphQLClass'; + + await updateCLP( + { + get: { '*': true }, + find: { '*': true }, + + protectedFields: { + '*': ['someField', 'someOtherField'], + authenticated: ['someField'], + 'userField:pointerToUser': [], + [user2.id]: [], + }, + }, + className + ); + + const getObject = async (className, id, user) => { + const headers = user + ? { ['X-Parse-Session-Token']: user.getSessionToken() } + : undefined; + + const specificQueryResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + get: graphQLClass(id: $id) { + pointerToUser { + username + id + } + someField + someOtherField + } + } + `, + variables: { + id: id, + }, + context: { + headers: headers, + }, + }); + + return specificQueryResult.data.get; + }; + + const id = object3.id; + + /* not authenticated */ + const objectPublic = await getObject(className, id, undefined); + + expect(objectPublic.someField).toBeNull(); + expect(objectPublic.someOtherField).toBeNull(); + + /* authenticated */ + const objectAuth = await getObject(className, id, user1); + + expect(objectAuth.someField).toBeNull(); + expect(objectAuth.someOtherField).toBe('B'); + + /* pointer field */ + const objectPointed = await getObject(className, id, user5); + + expect(objectPointed.someField).toBe('someValue3'); + expect(objectPointed.someOtherField).toBe('B'); + + /* for user id */ + const objectForUser = await getObject(className, id, user2); + + expect(objectForUser.someField).toBe('someValue3'); + expect(objectForUser.someOtherField).toBe('B'); + + done(); + }); describe_only_db('mongo')('read preferences', () => { it('should read from primary by default', async () => { try { diff --git a/spec/ParseQuery.spec.js b/spec/ParseQuery.spec.js index 260a48f2..ee472740 100644 --- a/spec/ParseQuery.spec.js +++ b/spec/ParseQuery.spec.js @@ -4868,4 +4868,36 @@ describe('Parse.Query testing', () => { const results = await query.find(); equal(results[0].get('array').length, 105); }); + + it('exclude keys (sdk query)', async done => { + const obj = new TestObject({ foo: 'baz', hello: 'world' }); + await obj.save(); + + const query = new Parse.Query('TestObject'); + query.exclude('foo'); + + const object = await query.get(obj.id); + expect(object.get('foo')).toBeUndefined(); + expect(object.get('hello')).toBe('world'); + done(); + }); + + xit('todo: exclude keys with select key (sdk query get)', async done => { + // there is some problem with js sdk caching + + const obj = new TestObject({ foo: 'baz', hello: 'world' }); + await obj.save(); + + const query = new Parse.Query('TestObject'); + + query.withJSON({ + keys: 'hello', + excludeKeys: 'hello', + }); + + const object = await query.get(obj.id); + expect(object.get('foo')).toBeUndefined(); + expect(object.get('hello')).toBeUndefined(); + done(); + }); }); diff --git a/spec/PointerPermissions.spec.js b/spec/PointerPermissions.spec.js index bd7e34b3..a3a1b9f4 100644 --- a/spec/PointerPermissions.spec.js +++ b/spec/PointerPermissions.spec.js @@ -2047,7 +2047,7 @@ describe('Pointer Permissions', () => { } async function logIn(userObject) { - await Parse.User.logIn(userObject.getUsername(), 'password'); + return await Parse.User.logIn(userObject.getUsername(), 'password'); } async function updateCLP(clp) { @@ -3098,5 +3098,55 @@ describe('Pointer Permissions', () => { done(); }); }); + + describe('using pointer-fields and queries with keys projection', () => { + let user1; + /** + * owner: user1 + * + * testers: [user1] + */ + let obj; + + /** + * Clear cache, create user and object, login user + */ + async function initialize() { + await Config.get(Parse.applicationId).database.schemaCache.clear(); + + user1 = await createUser('user1'); + user1 = await logIn(user1); + + obj = new Parse.Object(className); + + obj.set('owner', user1); + obj.set('field', 'field'); + obj.set('test', 'test'); + + await Parse.Object.saveAll([obj], { useMasterKey: true }); + + await obj.fetch(); + } + + beforeEach(async () => { + await initialize(); + }); + + it('should be enforced regardless of pointer-field being included in keys (select)', async done => { + await updateCLP({ + get: { '*': true }, + find: { pointerFields: ['owner'] }, + update: { pointerFields: ['owner'] }, + }); + + const query = new Parse.Query('AnObject'); + query.select('field', 'test'); + + const [object] = await query.find({ objectId: obj.id }); + expect(object.get('field')).toBe('field'); + expect(object.get('test')).toBe('test'); + done(); + }); + }); }); }); diff --git a/spec/ProtectedFields.spec.js b/spec/ProtectedFields.spec.js index 78e44f3b..3794996c 100644 --- a/spec/ProtectedFields.spec.js +++ b/spec/ProtectedFields.spec.js @@ -1,5 +1,13 @@ const Config = require('../lib/Config'); const Parse = require('parse/node'); +const request = require('../lib/request'); +const { + className, + createRole, + createUser, + logIn, + updateCLP, +} = require('./dev'); describe('ProtectedFields', function() { it('should handle and empty protectedFields', async function() { @@ -310,7 +318,7 @@ describe('ProtectedFields', function() { done(); }); - it('should create merge protected fields when using multiple pointer-permission fields', async done => { + it('should intersect protected fields when using multiple pointer-permission fields', async done => { const config = Config.get(Parse.applicationId); const obj = new Parse.Object('AnObject'); @@ -327,8 +335,8 @@ describe('ProtectedFields', function() { get: { '*': true }, find: { '*': true }, protectedFields: { - '*': [], - 'userField:owners': ['owners'], + '*': ['owners', 'owner', 'test'], + 'userField:owners': ['owners', 'owner'], 'userField:owner': ['owner'], }, } @@ -337,7 +345,7 @@ describe('ProtectedFields', function() { // 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('owners').length).toBe(1); expect(objectAgain.get('owner')).toBe(undefined); expect(objectAgain.get('test')).toBe('test'); done(); @@ -605,7 +613,7 @@ describe('ProtectedFields', function() { done(); }); - it('should create merge protected fields when using multiple pointer-permission fields', async done => { + it('should intersect 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'); @@ -614,7 +622,6 @@ describe('ProtectedFields', function() { 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]); @@ -626,8 +633,8 @@ describe('ProtectedFields', function() { get: { '*': true }, find: { '*': true }, protectedFields: { - '*': [], - 'userField:owners': ['owners'], + '*': ['owners', 'owner', 'test'], + 'userField:owners': ['owners', 'owner'], 'userField:owner': ['owner'], }, } @@ -642,7 +649,7 @@ describe('ProtectedFields', function() { 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('owners').length).toBe(1); expect(results[0].get('owner')).toBe(undefined); expect(results[0].get('test')).toBe('test'); expect(results[1].get('owners')).toBe(undefined); @@ -760,20 +767,24 @@ 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(); + let object; - await schemaController.updateClass(className, {}, clp); + async function initialize() { + await Config.get(Parse.applicationId).database.schemaCache.clear(); + + object = new Parse.Object(className); + + object.set('revision', 0); + object.set('test', 'test'); + + await object.save({ useMasterKey: true }); } - it('should fail setting non-existing protected field', async () => { - const object = new Parse.Object(className, { - revision: 0, - }); - await object.save(); + beforeEach(async () => { + await initialize(); + }); + it('should fail setting non-existing protected field', async done => { const field = 'non-existing'; const entity = '*'; @@ -789,6 +800,918 @@ describe('ProtectedFields', function() { `Field '${field}' in protectedFields:${entity} does not exist` ) ); + done(); + }); + + it('should allow setting authenticated', async () => { + await expectAsync( + updateCLP({ + protectedFields: { + authenticated: ['test'], + }, + }) + ).toBeResolved(); + }); + }); + + describe('targeting public access', () => { + let obj1; + + async function initialize() { + await Config.get(Parse.applicationId).database.schemaCache.clear(); + + obj1 = new Parse.Object(className); + + obj1.set('foo', 'foo'); + obj1.set('bar', 'bar'); + obj1.set('qux', 'qux'); + + await obj1.save(null, { + useMasterKey: true, + }); + } + + beforeEach(async () => { + await initialize(); + }); + + it('should hide field', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['foo'], + }, + }); + + // unauthenticated + const object = await obj1.fetch(); + + expect(object.get('foo')).toBe(undefined); + expect(object.get('bar')).toBeDefined(); + expect(object.get('qux')).toBeDefined(); + + done(); + }); + + it('should hide mutiple fields', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['foo', 'bar'], + }, + }); + + // unauthenticated + const object = await obj1.fetch(); + + expect(object.get('foo')).toBe(undefined); + expect(object.get('bar')).toBe(undefined); + expect(object.get('qux')).toBeDefined(); + + done(); + }); + + it('should not hide any fields when set as empty array', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': [], + }, + }); + + // unauthenticated + const object = await obj1.fetch(); + + expect(object.get('foo')).toBeDefined(); + expect(object.get('bar')).toBeDefined(); + expect(object.get('qux')).toBeDefined(); + expect(object.id).toBeDefined(); + expect(object.createdAt).toBeDefined(); + expect(object.updatedAt).toBeDefined(); + expect(object.getACL()).toBeDefined(); + + done(); + }); + }); + + describe('targeting authenticated', () => { + /** + * is **owner** of: _obj1_ + * + * is **tester** of: [ _obj1, obj2_ ] + */ + let user1; + + /** + * is **owner** of: _obj2_ + * + * is **tester** of: [ _obj1_ ] + */ + let user2; + + /** + * **owner**: _user1_ + * + * **testers**: [ _user1,user2_ ] + */ + let obj1; + + /** + * **owner**: _user2_ + * + * **testers**: [ _user1_ ] + */ + let obj2; + + async function initialize() { + await Config.get(Parse.applicationId).database.schemaCache.clear(); + + await Parse.User.logOut(); + + [user1, user2] = await Promise.all([ + createUser('user1'), + createUser('user2'), + ]); + + obj1 = new Parse.Object(className); + obj2 = new Parse.Object(className); + + obj1.set('owner', user1); + obj1.set('testers', [user1, user2]); + obj1.set('test', 'test'); + + obj2.set('owner', user2); + obj2.set('testers', [user1]); + obj2.set('test', 'test'); + + await Parse.Object.saveAll([obj1, obj2], { + useMasterKey: true, + }); + } + + beforeEach(async () => { + await initialize(); + }); + + it('should not hide any fields when set as empty array', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + authenticated: [], + }, + }); + + // authenticated + await logIn(user1); + + const object = await obj1.fetch(); + + expect(object.get('owner')).toBeDefined(); + expect(object.get('testers')).toBeDefined(); + expect(object.get('test')).toBeDefined(); + expect(object.id).toBeDefined(); + expect(object.createdAt).toBeDefined(); + expect(object.updatedAt).toBeDefined(); + expect(object.getACL()).toBeDefined(); + + done(); + }); + + it('should hide fields for authenticated users only (* not set)', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + authenticated: ['test'], + }, + }); + + // not authenticated + const objectNonAuth = await obj1.fetch(); + + expect(objectNonAuth.get('test')).toBeDefined(); + + // authenticated + await logIn(user1); + const object = await obj1.fetch(); + + expect(object.get('test')).toBe(undefined); + + done(); + }); + + it('should intersect public and auth for authenticated user', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['owner', 'testers'], + authenticated: ['testers'], + }, + }); + + // authenticated + await logIn(user1); + const objectAuth = await obj1.fetch(); + + // ( {A,B} intersect {B} ) == {B} + + expect(objectAuth.get('testers')).not.toBeDefined( + 'Should not be visible - protected for * and authenticated' + ); + expect(objectAuth.get('test')).toBeDefined( + 'Should be visible - not protected for everyone (* and authenticated)' + ); + expect(objectAuth.get('owner')).toBeDefined( + 'Should be visible - not protected for authenticated' + ); + + done(); + }); + + it('should have higher prio than public for logged in users (intersect)', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['test'], + authenticated: [], + }, + }); + // authenticated, permitted + await logIn(user1); + + const object = await obj1.fetch(); + expect(object.get('test')).toBe('test'); + + done(); + }); + + it('should have no effect on unauthenticated users (public not set)', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + authenticated: ['test'], + }, + }); + + // unauthenticated, protected + const objectNonAuth = await obj1.fetch(); + expect(objectNonAuth.get('test')).toBe('test'); + + done(); + }); + + it('should protect multiple fields for authenticated users', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + authenticated: ['test', 'owner'], + }, + }); + + // authenticated + await logIn(user1); + const object = await obj1.fetch(); + + expect(object.get('test')).toBe(undefined); + expect(object.get('owner')).toBe(undefined); + + done(); + }); + + it('should not be affected by rules not applicable to user (smoke)', async done => { + const role = await createRole({ users: user1 }); + const roleName = role.get('name'); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + authenticated: ['owner', 'testers'], + [`role:${roleName}`]: ['test'], + 'userField:owner': [], + [user1.id]: [], + }, + }); + + // authenticated, non-owner, no role + await logIn(user2); + const objectNotOwned = await obj1.fetch(); + + expect(objectNotOwned.get('owner')).toBe(undefined); + expect(objectNotOwned.get('testers')).toBe(undefined); + expect(objectNotOwned.get('test')).toBeDefined(); + + done(); + }); + }); + + describe('targeting roles', () => { + let user1, user2; + + /** + * owner: user1 + * + * testers: [user1,user2] + */ + let obj1; + + /** + * owner: user2 + * + * testers: [user1] + */ + 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); + obj2 = new Parse.Object(className); + + obj1.set('owner', user1); + obj1.set('testers', [user1, user2]); + obj1.set('test', 'test'); + + obj2.set('owner', user2); + obj2.set('testers', [user1]); + obj2.set('test', 'test'); + + await Parse.Object.saveAll([obj1, obj2], { + useMasterKey: true, + }); + } + + beforeEach(async () => { + await initialize(); + }); + + it('should hide field when user belongs to a role', async done => { + const role = await createRole({ users: user1 }); + const roleName = role.get('name'); + + await updateCLP({ + protectedFields: { + [`role:${roleName}`]: ['test'], + }, + get: { '*': true }, + find: { '*': true }, + }); + + // user has role + await logIn(user1); + + const object = await obj1.fetch(); + expect(object.get('test')).toBe(undefined); // field protected + expect(object.get('owner')).toBeDefined(); + expect(object.get('testers')).toBeDefined(); + + done(); + }); + + it('should not hide any fields when set as empty array', async done => { + const role = await createRole({ users: user1 }); + const roleName = role.get('name'); + + await updateCLP({ + protectedFields: { + [`role:${roleName}`]: [], + }, + get: { '*': true }, + find: { '*': true }, + }); + + // user has role + await logIn(user1); + + const object = await obj1.fetch(); + + expect(object.get('owner')).toBeDefined(); + expect(object.get('testers')).toBeDefined(); + expect(object.get('test')).toBeDefined(); + expect(object.id).toBeDefined(); + expect(object.createdAt).toBeDefined(); + expect(object.updatedAt).toBeDefined(); + expect(object.getACL()).toBeDefined(); + + done(); + }); + + it('should hide multiple fields when user belongs to a role', async done => { + const role = await createRole({ users: user1 }); + const roleName = role.get('name'); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + [`role:${roleName}`]: ['test', 'owner'], + }, + }); + + // user has role + await logIn(user1); + + const object = await obj1.fetch(); + + expect(object.get('test')).toBe( + undefined, + 'Field should not be visible - protected by role' + ); + expect(object.get('owner')).toBe( + undefined, + 'Field should not be visible - protected by role' + ); + expect(object.get('testers')).toBeDefined(); + + done(); + }); + + it('should not protect when user does not belong to a role', async done => { + const role = await createRole({ users: user1 }); + const roleName = role.get('name'); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + [`role:${roleName}`]: ['test', 'owner'], + }, + }); + + // user doesn't have role + await logIn(user2); + const object = await obj1.fetch(); + + expect(object.get('test')).toBeDefined(); + expect(object.get('owner')).toBeDefined(); + expect(object.get('testers')).toBeDefined(); + + done(); + }); + + it('should intersect protected fields when user belongs to multiple roles', async done => { + const role1 = await createRole({ users: user1 }); + const role2 = await createRole({ users: user1 }); + + const role1name = role1.get('name'); + const role2name = role2.get('name'); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + [`role:${role1name}`]: ['owner'], + [`role:${role2name}`]: ['test', 'owner'], + }, + }); + + // user has both roles + await logIn(user1); + const object = await obj1.fetch(); + + // "owner" is a result of intersection + expect(object.get('owner')).toBe( + undefined, + 'Must not be visible - protected for all roles the user belongs to' + ); + expect(object.get('test')).toBeDefined( + 'Has to be visible - is not protected for users with role1' + ); + done(); + }); + + it('should intersect protected fields when user belongs to multiple roles hierarchy', async done => { + const admin = await createRole({ + users: user1, + roleName: 'admin', + }); + + const moder = await createRole({ + users: [user1, user2], + roleName: 'moder', + }); + + const tester = await createRole({ + roleName: 'tester', + }); + + // admin supersets moder role + moder.relation('roles').add(admin); + await moder.save({ useMasterKey: true }); + + tester.relation('roles').add(moder); + await tester.save({ useMasterKey: true }); + + const roleAdmin = `role:${admin.get('name')}`; + const roleModer = `role:${moder.get('name')}`; + const roleTester = `role:${tester.get('name')}`; + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + [roleAdmin]: [], + [roleModer]: ['owner'], + [roleTester]: ['test', 'owner'], + }, + }); + + // user1 has admin & moder & tester roles, (moder includes tester). + await logIn(user1); + const object = await obj1.fetch(); + + // being admin makes all fields visible + expect(object.get('test')).toBeDefined( + 'Should be visible - admin role explicitly removes protection for all fields ( [] )' + ); + expect(object.get('owner')).toBeDefined( + 'Should be visible - admin role explicitly removes protection for all fields ( [] )' + ); + + // user2 has moder & tester role, moder includes tester. + await logIn(user2); + const objectAgain = await obj1.fetch(); + + // being moder allows "test" field + expect(objectAgain.get('owner')).toBe( + undefined, + '"owner" should not be visible - protected for each role user belongs to' + ); + expect(objectAgain.get('test')).toBeDefined( + 'Should be visible - moder role does not protect "test" field' + ); + + done(); + }); + + it('should be able to clear protected fields for role (protected for authenticated)', async done => { + const role = await createRole({ users: user1 }); + const roleName = role.get('name'); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + authenticated: ['test'], + [`role:${roleName}`]: [], + }, + }); + + // user has role, test field visible + await logIn(user1); + const object = await obj1.fetch(); + expect(object.get('test')).toBe('test'); + + done(); + }); + + it('should determine protectedFields as intersection of field sets for public and role', async done => { + const role = await createRole({ users: user1 }); + const roleName = role.get('name'); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['test', 'owner'], + [`role:${roleName}`]: ['owner', 'testers'], + }, + }); + + // user has role + await logIn(user1); + + const object = await obj1.fetch(); + expect(object.get('test')).toBeDefined( + 'Should be visible - "test" is not protected for role user belongs to' + ); + expect(object.get('testers')).toBeDefined( + 'Should be visible - "testers" is allowed for everyone (*)' + ); + expect(object.get('owner')).toBe( + undefined, + 'Should not be visible - "test" is not allowed for both public(*) and role' + ); + done(); + }); + + it('should be determined as an intersection of protecedFields for authenticated and role', async done => { + const role = await createRole({ users: user1 }); + const roleName = role.get('name'); + + // this is an example of misunderstood configuration. + // If you allow (== do not restrict) some field for broader audience + // (having a role implies user inheres to 'authenticated' group) + // it's not possible to narrow by protecting field for a role. + // You'd have to protect it for 'authenticated' as well. + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + authenticated: ['test'], + [`role:${roleName}`]: ['owner'], + }, + }); + + // user has role + await logIn(user1); + const object = await obj1.fetch(); + + // + expect(object.get('test')).toBeDefined( + "Being both auhenticated and having a role leads to clearing protection on 'test' (by role rules)" + ); + expect(object.get('owner')).toBeDefined( + 'All authenticated users allowed to see "owner"' + ); + expect(object.get('testers')).toBeDefined(); + + done(); + }); + + it('should not hide fields when user does not belong to a role protectedFields set for', async done => { + const role = await createRole({ users: user2 }); + const roleName = role.get('name'); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + [`role:${roleName}`]: ['test'], + }, + }); + + // relate user1 to some role, no protectedFields for it + await createRole({ users: user1 }); + + await logIn(user1); + + const object = await obj1.fetch(); + expect(object.get('test')).toBeDefined( + 'Field should be visible - user belongs to a role that has no protectedFields set' + ); + + done(); + }); + }); + + describe('using pointer-fields and queries with keys projection', () => { + /* + * Pointer variant ("userField:column") relies on User ids + * returned after query executed (hides fields before sending it to client) + * If such column is excluded/not included (not returned from db because of 'project') + * there will be no user ids to check against + * and protectedFields won't be applied correctly. + */ + + let user1; + /** + * owner: user1 + * + * testers: [user1] + */ + let obj; + + let headers; + + /** + * Clear cache, create user and object, login user and setup rest headers with token + */ + async function initialize() { + await Config.get(Parse.applicationId).database.schemaCache.clear(); + + user1 = await createUser('user1'); + user1 = await logIn(user1); + + // await user1.fetch(); + obj = new Parse.Object(className); + + obj.set('owner', user1); + obj.set('field', 'field'); + obj.set('test', 'test'); + + await Parse.Object.saveAll([obj], { useMasterKey: true }); + + headers = { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Rest-API-Key': 'rest', + 'Content-Type': 'application/json', + 'X-Parse-Session-Token': user1.getSessionToken(), + }; + } + + beforeEach(async () => { + await initialize(); + }); + + it('should be enforced regardless of pointer-field being included in keys (select)', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['field', 'test'], + 'userField:owner': [], + }, + }); + + const query = new Parse.Query('AnObject'); + query.select('field', 'test'); + + const object = await query.get(obj.id); + expect(object.get('field')).toBe('field'); + expect(object.get('test')).toBe('test'); + done(); + }); + + it('should protect fields for query where pointer field is not included via keys (REST GET)', async done => { + const obj = new Parse.Object(className); + + obj.set('owner', user1); + obj.set('field', 'field'); + obj.set('test', 'test'); + + await Parse.Object.saveAll([obj], { useMasterKey: true }); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['field', 'test'], + 'userField:owner': ['test'], + }, + }); + + const { data: object } = await request({ + url: `${Parse.serverURL}/classes/${className}/${obj.id}`, + qs: { + keys: 'field,test', + }, + headers: headers, + }); + + expect(object.field).toBe( + 'field', + 'Should BE in response - not protected by "userField:owner"' + ); + expect(object.test).toBe( + undefined, + 'Should NOT be in response - protected by "userField:owner"' + ); + expect(object.owner).toBe( + undefined, + 'Should not be in response - not included in "keys"' + ); + done(); + }); + + it('should protect fields for query where pointer field is not included via keys (REST FIND)', async done => { + const obj = new Parse.Object(className); + + obj.set('owner', user1); + obj.set('field', 'field'); + obj.set('test', 'test'); + + await Parse.Object.saveAll([obj], { useMasterKey: true }); + + await obj.fetch(); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['field', 'test'], + 'userField:owner': ['test'], + }, + }); + + const { data } = await request({ + url: `${Parse.serverURL}/classes/${className}`, + qs: { + keys: 'field,test', + where: JSON.stringify({ objectId: obj.id }), + }, + headers, + }); + + const object = data.results[0]; + + expect(object.field).toBe( + 'field', + 'Should be in response - not protected by "userField:owner"' + ); + expect(object.test).toBe( + undefined, + 'Should not be in response - protected by "userField:owner"' + ); + expect(object.owner).toBe( + undefined, + 'Should not be in response - not included in "keys"' + ); + done(); + }); + + it('should protect fields for query where pointer field is in excludeKeys (REST GET)', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['field', 'test'], + 'userField:owner': ['test'], + }, + }); + + const { data: object } = await request({ + qs: { + excludeKeys: 'owner', + }, + headers, + url: `${Parse.serverURL}/classes/${className}/${obj.id}`, + }); + + expect(object.field).toBe( + 'field', + 'Should be in response - not protected by "userField:owner"' + ); + expect(object['test']).toBe( + undefined, + 'Should not be in response - protected by "userField:owner"' + ); + expect(object['owner']).toBe( + undefined, + 'Should not be in response - not included in "keys"' + ); + done(); + }); + + it('should protect fields for query where pointer field is in excludedKeys (REST FIND)', async done => { + await updateCLP({ + protectedFields: { + '*': ['field', 'test'], + 'userField:owner': ['test'], + }, + get: { '*': true }, + find: { '*': true }, + }); + + const { data } = await request({ + qs: { + excludeKeys: 'owner', + where: JSON.stringify({ objectId: obj.id }), + }, + headers, + url: `${Parse.serverURL}/classes/${className}`, + }); + + const object = data.results[0]; + + expect(object.field).toBe( + 'field', + 'Should be in response - not protected by "userField:owner"' + ); + expect(object.test).toBe( + undefined, + 'Should not be in response - protected by "userField:owner"' + ); + expect(object.owner).toBe( + undefined, + 'Should not be in response - not included in "keys"' + ); + done(); + }); + + xit('todo: should be enforced regardless of pointer-field being excluded', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['field', 'test'], + 'userField:owner': [], + }, + }); + + const query = new Parse.Query('AnObject'); + + /* TODO: this has some caching problems on JS-SDK (2.11.) side */ + // query.exclude('owner') + + const object = await query.get(obj.id); + expect(object.get('field')).toBe('field'); + expect(object.get('test')).toBe('test'); + expect(object.get('owner')).toBe(undefined); + done(); }); }); }); diff --git a/spec/dev.js b/spec/dev.js new file mode 100644 index 00000000..31425dec --- /dev/null +++ b/spec/dev.js @@ -0,0 +1,98 @@ +const Config = require('../lib/Config'); +const Parse = require('parse/node'); + +const className = 'AnObject'; +const defaultRoleName = 'tester'; + +let schemaCache; + +module.exports = { + /* AnObject */ + className, + schemaCache, + + /** + * Creates and returns new user. + * + * This method helps to avoid 'User already exists' when re-running/debugging a single test. + * @param {string} username - username base, will be postfixed with current time in millis; + * @param {string} [password='password'] - optional, defaults to "password" if not set; + */ + createUser: async (username, password = 'password') => { + const user = new Parse.User({ + username: username + Date.now(), + password, + }); + await user.save(); + return user; + }, + + /** + * Logs the user in. + * + * If password not provided, default 'password' is used. + * @param {string} username - username base, will be postfixed with current time in millis; + * @param {string} [password='password'] - optional, defaults to "password" if not set; + */ + logIn: async (userObject, password) => { + return await Parse.User.logIn( + userObject.getUsername(), + password || 'password' + ); + }, + + /** + * Sets up Class-Level Permissions for 'AnObject' class. + * @param clp {ClassLevelPermissions} + */ + updateCLP: async (clp, targetClass = className) => { + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + + await schemaController.updateClass(targetClass, {}, clp); + }, + + /** + * Creates and returns role. Adds user(s) if provided. + * + * This method helps to avoid errors when re-running/debugging a single test. + * + * @param {Parse.User|Parse.User[]} [users] - user or array of users to be related with this role; + * @param {string?} [roleName] - uses this name for role if provided. Generates from datetime if not set; + * @param {string?} [exactName] - sets exact name (no generated part added); + * @param {Parse.Role[]} [roles] - uses this name for role if provided. Generates from datetime if not set; + * @param {boolean} [read] - value for role's acl public read. Defaults to true; + * @param {boolean} [write] - value for role's acl public write. Defaults to true; + */ + createRole: async ({ + users = null, + exactName = defaultRoleName + Date.now(), + roleName = null, + roles = null, + read = true, + write = true, + }) => { + const acl = new Parse.ACL(); + acl.setPublicReadAccess(read); + acl.setPublicWriteAccess(write); + + const role = new Parse.Object('_Role'); + role.setACL(acl); + + // generate name based on roleName or use exactName (if botth not provided name is generated) + const name = roleName ? roleName + Date.now() : exactName; + role.set('name', name); + + if (roles) { + role.relation('roles').add(roles); + } + + if (users) { + role.relation('users').add(users); + } + + await role.save({ useMasterKey: true }); + + return role; + }, +}; diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index c547bf85..661a19ee 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -2861,6 +2861,35 @@ describe('schemas', () => { done(); }); + it('should be rejected if CLP pointerFields is not an array', async done => { + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + + const operationKey = 'get'; + const entity = 'pointerFields'; + const value = {}; + + const schemaSetup = async () => + await schemaController.addClassIfNotExists( + 'AnObject', + {}, + { + [operationKey]: { + [entity]: value, + }, + } + ); + + await expectAsync(schemaSetup()).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_JSON, + `'${value}' is not a valid value for ${operationKey}[${entity}] - expected 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/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 4b69d05e..650781b5 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -217,7 +217,7 @@ const filterSensitiveData = ( return { key: key.substring(10), value: perms.protectedFields[key] }; }); - const newProtectedFields: Array = []; + const newProtectedFields: Array[] = []; let overrideProtectedFields = false; // check if the object grants the current user access based on the extracted fields @@ -238,12 +238,28 @@ const filterSensitiveData = ( if (pointerPermIncludesUser) { overrideProtectedFields = true; - newProtectedFields.push(...pointerPerm.value); + newProtectedFields.push(pointerPerm.value); } }); - // if atleast one pointer-permission affected the current user override the protectedFields - if (overrideProtectedFields) protectedFields = newProtectedFields; + // if at least one pointer-permission affected the current user + // intersect vs protectedFields from previous stage (@see addProtectedFields) + // Sets theory (intersections): A x (B x C) == (A x B) x C + if (overrideProtectedFields && protectedFields) { + newProtectedFields.push(protectedFields); + } + // intersect all sets of protectedFields + newProtectedFields.forEach(fields => { + if (fields) { + // if there're no protctedFields by other criteria ( id / role / auth) + // then we must intersect each set (per userField) + if (!protectedFields) { + protectedFields = fields; + } else { + protectedFields = protectedFields.filter(v => fields.includes(v)); + } + } + }); } } @@ -251,9 +267,16 @@ const filterSensitiveData = ( /* special treat for the user class: don't filter protectedFields if currently loggedin user is the retrieved user */ - if (!(isUserClass && userId && object.objectId === userId)) + if (!(isUserClass && userId && object.objectId === userId)) { protectedFields && protectedFields.forEach(k => delete object[k]); + // fields not requested by client (excluded), + //but were needed to apply protecttedFields + perms.protectedFields && + perms.protectedFields.temporaryKeys && + perms.protectedFields.temporaryKeys.forEach(k => delete object[k]); + } + if (!isUserClass) { return object; } @@ -1416,7 +1439,8 @@ class DatabaseController { className, query, aclGroup, - auth + auth, + queryOptions ); } if (!query) { @@ -1638,7 +1662,8 @@ class DatabaseController { className: string, query: any = {}, aclGroup: any[] = [], - auth: any = {} + auth: any = {}, + queryOptions: FullQueryOptions = {} ): null | string[] { const perms = schema.getClassLevelPermissions(className); if (!perms) return null; @@ -1648,14 +1673,85 @@ class DatabaseController { if (aclGroup.indexOf(query.objectId) > -1) return null; - // 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]); + // for queries where "keys" are set and do not include all 'userField':{field}, + // we have to transparently include it, and then remove before returning to client + // Because if such key not projected the permission won't be enforced properly + // PS this is called when 'excludeKeys' already reduced to 'keys' + const preserveKeys = queryOptions.keys; + + // these are keys that need to be included only + // to be able to apply protectedFields by pointer + // and then unset before returning to client (later in filterSensitiveFields) + const serverOnlyKeys = []; + + const authenticated = auth.user; + + // map to allow check without array search + const roles = (auth.userRoles || []).reduce((acc, r) => { + acc[r] = protectedFields[r]; + return acc; + }, {}); + + // array of sets of protected fields. separate item for each applicable criteria + const protectedKeysSets = []; + + for (const key in protectedFields) { + // skip userFields + if (key.startsWith('userField:')) { + if (preserveKeys) { + const fieldName = key.substring(10); + if (!preserveKeys.includes(fieldName)) { + // 1. put it there temporarily + queryOptions.keys && queryOptions.keys.push(fieldName); + // 2. preserve it delete later + serverOnlyKeys.push(fieldName); + } + } + continue; + } + + // add public tier + if (key === '*') { + protectedKeysSets.push(protectedFields[key]); + continue; + } + + if (authenticated) { + if (key === 'authenticated') { + // for logged in users + protectedKeysSets.push(protectedFields[key]); + continue; + } + + if (roles[key] && key.startsWith('role:')) { + // add applicable roles + protectedKeysSets.push(roles[key]); + } + } + } + + // check if there's a rule for current user's id + if (authenticated) { + const userId = auth.user.id; + if (perms.protectedFields[userId]) { + protectedKeysSets.push(perms.protectedFields[userId]); + } + } + + // preserve fields to be removed before sending response to client + if (serverOnlyKeys.length > 0) { + perms.protectedFields.temporaryKeys = serverOnlyKeys; + } + + let protectedKeys = protectedKeysSets.reduce((acc, next) => { + if (next) { + acc.push(...next); + } + return acc; }, []); - [...(auth.userRoles || [])].forEach(role => { - const fields = protectedFields[role]; + // intersect all sets of protectedFields + protectedKeysSets.forEach(fields => { if (fields) { protectedKeys = protectedKeys.filter(v => fields.includes(v)); } diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index 8c799d05..69489077 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -175,32 +175,62 @@ const volatileClasses = Object.freeze([ // Anything that start with role const roleRegex = /^role:.*/; -// Anything that starts with userField -const pointerPermissionRegex = /^userField:.*/; +// Anything that starts with userField (allowed for protected fields only) +const protectedFieldsPointerRegex = /^userField:.*/; // * permission const publicRegex = /^\*$/; -const requireAuthenticationRegex = /^requiresAuthentication$/; +const authenticatedRegex = /^authenticated$/; -const pointerFieldsRegex = /^pointerFields$/; +const requiresAuthenticationRegex = /^requiresAuthentication$/; -const permissionKeyRegex = Object.freeze([ - roleRegex, - pointerPermissionRegex, +const clpPointerRegex = /^pointerFields$/; + +// regex for validating entities in protectedFields object +const protectedFieldsRegex = Object.freeze([ + protectedFieldsPointerRegex, publicRegex, - requireAuthenticationRegex, - pointerFieldsRegex, + authenticatedRegex, + roleRegex, +]); + +// clp regex +const clpFieldsRegex = Object.freeze([ + clpPointerRegex, + publicRegex, + requiresAuthenticationRegex, + roleRegex, ]); function validatePermissionKey(key, userIdRegExp) { let matchesSome = false; - for (const regEx of permissionKeyRegex) { + for (const regEx of clpFieldsRegex) { if (key.match(regEx) !== null) { matchesSome = true; break; } } + // userId depends on startup options so it's dynamic + const valid = matchesSome || key.match(userIdRegExp) !== null; + if (!valid) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `'${key}' is not a valid key for class level permissions` + ); + } +} + +function validateProtectedFieldsKey(key, userIdRegExp) { + let matchesSome = false; + for (const regEx of protectedFieldsRegex) { + if (key.match(regEx) !== null) { + matchesSome = true; + break; + } + } + + // userId regex depends on launch options so it's dynamic const valid = matchesSome || key.match(userIdRegExp) !== null; if (!valid) { throw new Parse.Error( @@ -264,7 +294,7 @@ function validateCLP( if (operationKey === 'protectedFields') { for (const entity in operation) { // throws on unexpected key - validatePermissionKey(entity, userIdRegExp); + validateProtectedFieldsKey(entity, userIdRegExp); const protectedFields = operation[entity]; @@ -301,6 +331,8 @@ function validateCLP( // throws on unexpected key validatePermissionKey(entity, userIdRegExp); + // entity can be either: + // "pointerFields": string[] if (entity === 'pointerFields') { const pointerFields = operation[entity]; @@ -311,13 +343,14 @@ function validateCLP( } else { throw new Parse.Error( Parse.Error.INVALID_JSON, - `'${pointerFields}' is not a valid value for protectedFields[${entity}] - expected an array.` + `'${pointerFields}' is not a valid value for ${operationKey}[${entity}] - expected an array.` ); } // proceed with next entity key continue; } + // or [entity]: boolean const permit = operation[entity]; if (permit !== true) { diff --git a/src/Routers/ClassesRouter.js b/src/Routers/ClassesRouter.js index 0cbc8d21..d85101af 100644 --- a/src/Routers/ClassesRouter.js +++ b/src/Routers/ClassesRouter.js @@ -6,6 +6,7 @@ import Parse from 'parse/node'; const ALLOWED_GET_QUERY_KEYS = [ 'keys', 'include', + 'excludeKeys', 'readPreference', 'includeReadPreference', 'subqueryReadPreference', @@ -69,6 +70,9 @@ export class ClassesRouter extends PromiseRouter { if (body.include) { options.include = String(body.include); } + if (typeof body.excludeKeys == 'string') { + options.excludeKeys = body.excludeKeys; + } if (typeof body.readPreference === 'string') { options.readPreference = body.readPreference; }