From 4c4369719fe9f0788c5c0528b555d4746320ea06 Mon Sep 17 00:00:00 2001 From: dblythy Date: Sat, 26 Dec 2020 13:48:33 +1100 Subject: [PATCH 01/10] new: requireUserRole for Parse Cloud Validator --- spec/CloudCode.Validator.spec.js | 54 ++++++++++++++++++++++++++++++++ src/cloud-code/Parse.Cloud.js | 2 ++ src/triggers.js | 15 ++++++++- 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/spec/CloudCode.Validator.spec.js b/spec/CloudCode.Validator.spec.js index d15bc2479d..0b39674c97 100644 --- a/spec/CloudCode.Validator.spec.js +++ b/spec/CloudCode.Validator.spec.js @@ -860,6 +860,60 @@ describe('cloud validator', () => { }); }); + it('basic validator requireUserRole', async function (done) { + Parse.Cloud.define( + 'cloudFunction', + () => { + return true; + }, + { + requireUser: true, + requireUserRole: ['Admin'], + } + ); + const user = await Parse.User.signUp('testuser', 'p@ssword'); + try { + await Parse.Cloud.run('cloudFunction'); + fail('cloud validator should have failed.'); + } catch (e) { + expect(e.message).toBe('Validation failed. User does not match the required roles.'); + } + const roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + const role = new Parse.Role('Admin', roleACL); + role.getUsers().add(user); + await role.save({ useMasterKey: true }); + await Parse.Cloud.run('cloudFunction'); + done(); + }); + + it('basic string requireUserRole', async function (done) { + Parse.Cloud.define( + 'cloudFunction', + () => { + return true; + }, + { + requireUser: true, + requireUserRole: 'Admin', + } + ); + const user = await Parse.User.signUp('testuser', 'p@ssword'); + try { + await Parse.Cloud.run('cloudFunction'); + fail('cloud validator should have failed.'); + } catch (e) { + expect(e.message).toBe('Validation failed. User does not match the required roles.'); + } + const roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + const role = new Parse.Role('Admin', roleACL); + role.getUsers().add(user); + await role.save({ useMasterKey: true }); + await Parse.Cloud.run('cloudFunction'); + done(); + }); + it('basic beforeSave requireMaster', function (done) { Parse.Cloud.beforeSave('BeforeSaveFail', () => {}, { requireMaster: true, diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index c04c48205e..d9a9cbab20 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -719,6 +719,8 @@ module.exports = ParseCloud; * @property {Array|function|Any} requireUserKeys.field.options array of options that the field can be, function to validate field, or single value. Throw an error if value is invalid. * @property {String} requireUserKeys.field.error custom error message if field is invalid. * + * @property {Array|String} requireUserRole If set, string or array of roles allowed on request.user to make the request. + * * @property {Object|Array} fields if an array of strings, validator will look for keys in request.params, and throw if not provided. If Object, fields to validate. If the trigger is a cloud function, `request.params` will be validated, otherwise `request.object`. * @property {String} fields.field name of field to validate. * @property {String} fields.field.type expected type of data for field. diff --git a/src/triggers.js b/src/triggers.js index 47331675b0..74b140a398 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -617,7 +617,7 @@ export function maybeRunValidator(request, functionName) { }); }); } -function builtInTriggerValidator(options, request) { +async function builtInTriggerValidator(options, request) { if (request.master && !options.validateMasterKey) { return; } @@ -722,6 +722,19 @@ function builtInTriggerValidator(options, request) { } } } + let userRoles = options.requireUserRole || []; + if (typeof userRoles === 'string') { + userRoles = [userRoles]; + } + if (userRoles.length != 0 && reqUser) { + const roleQuery = new Parse.Query(Parse.Role); + roleQuery.containedIn('name', userRoles); + roleQuery.equalTo('users', reqUser); + const role = await roleQuery.first({ useMasterKey: true }); + if (!role) { + throw `Validation failed. User does not match the required roles.`; + } + } const userKeys = options.requireUserKeys || []; if (Array.isArray(userKeys)) { for (const key of userKeys) { From 8dc59b4190ab4e1adbfad59432adcb6c0b8491a8 Mon Sep 17 00:00:00 2001 From: dblythy Date: Tue, 5 Jan 2021 01:04:58 +1100 Subject: [PATCH 02/10] change to requireUserRoles --- spec/CloudCode.Validator.spec.js | 13 ++++++------- src/cloud-code/Parse.Cloud.js | 2 +- src/triggers.js | 9 +++------ 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/spec/CloudCode.Validator.spec.js b/spec/CloudCode.Validator.spec.js index 0b39674c97..d10cd06627 100644 --- a/spec/CloudCode.Validator.spec.js +++ b/spec/CloudCode.Validator.spec.js @@ -860,7 +860,7 @@ describe('cloud validator', () => { }); }); - it('basic validator requireUserRole', async function (done) { + it('basic validator requireUserRoles', async function (done) { Parse.Cloud.define( 'cloudFunction', () => { @@ -868,7 +868,7 @@ describe('cloud validator', () => { }, { requireUser: true, - requireUserRole: ['Admin'], + requireUserRoles: ['Admin'], } ); const user = await Parse.User.signUp('testuser', 'p@ssword'); @@ -887,24 +887,23 @@ describe('cloud validator', () => { done(); }); - it('basic string requireUserRole', async function (done) { + it('basic requireUserRoles but no user', async function (done) { Parse.Cloud.define( 'cloudFunction', () => { return true; }, { - requireUser: true, - requireUserRole: 'Admin', + requireUserRoles: ['Admin'], } ); - const user = await Parse.User.signUp('testuser', 'p@ssword'); try { await Parse.Cloud.run('cloudFunction'); fail('cloud validator should have failed.'); } catch (e) { - expect(e.message).toBe('Validation failed. User does not match the required roles.'); + expect(e.message).toBe('Validation failed. Please login to continue.'); } + const user = await Parse.User.signUp('testuser', 'p@ssword'); const roleACL = new Parse.ACL(); roleACL.setPublicReadAccess(true); const role = new Parse.Role('Admin', roleACL); diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index d9a9cbab20..3c75fcfcd2 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -719,7 +719,7 @@ module.exports = ParseCloud; * @property {Array|function|Any} requireUserKeys.field.options array of options that the field can be, function to validate field, or single value. Throw an error if value is invalid. * @property {String} requireUserKeys.field.error custom error message if field is invalid. * - * @property {Array|String} requireUserRole If set, string or array of roles allowed on request.user to make the request. + * @property {Array}requireUserRoles If set, array of roles names allowed on request.user to make the request. * * @property {Object|Array} fields if an array of strings, validator will look for keys in request.params, and throw if not provided. If Object, fields to validate. If the trigger is a cloud function, `request.params` will be validated, otherwise `request.object`. * @property {String} fields.field name of field to validate. diff --git a/src/triggers.js b/src/triggers.js index 74b140a398..05079494a7 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -630,7 +630,7 @@ async function builtInTriggerValidator(options, request) { ) { reqUser = request.object; } - if (options.requireUser && !reqUser) { + if ((options.requireUser || options.requireUserRoles) && !reqUser) { throw 'Validation failed. Please login to continue.'; } if (options.requireMaster && !request.master) { @@ -722,11 +722,8 @@ async function builtInTriggerValidator(options, request) { } } } - let userRoles = options.requireUserRole || []; - if (typeof userRoles === 'string') { - userRoles = [userRoles]; - } - if (userRoles.length != 0 && reqUser) { + const userRoles = options.requireUserRoles; + if (userRoles) { const roleQuery = new Parse.Query(Parse.Role); roleQuery.containedIn('name', userRoles); roleQuery.equalTo('users', reqUser); From e4d276ea94374cc6a01a47f4306507f81e3e59ec Mon Sep 17 00:00:00 2001 From: dblythy Date: Tue, 5 Jan 2021 01:06:17 +1100 Subject: [PATCH 03/10] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71df536115..9831a8c5e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ __BREAKING CHANGES:__ ___ - IMPROVE: Optimize queries on classes with pointer permissions. [#7061](https://github.com/parse-community/parse-server/pull/7061). Thanks to [Pedro Diaz](https://github.com/pdiaz) - FIX: request.context for afterFind triggers. [#7078](https://github.com/parse-community/parse-server/pull/7078). Thanks to [dblythy](https://github.com/dblythy) +- NEW: requireUserRoles for Parse Cloud validator. [#7097](https://github.com/parse-community/parse-server/pull/7097). Thanks to [dblythy](https://github.com/dblythy) ### 4.5.0 [Full Changelog](https://github.com/parse-community/parse-server/compare/4.4.0...4.5.0) From cba81ddde263a4c974485c561411f90f20bc08cc Mon Sep 17 00:00:00 2001 From: dblythy Date: Tue, 5 Jan 2021 05:01:45 +1100 Subject: [PATCH 04/10] revoke triggers --- src/LiveQuery/ParseLiveQueryServer.js | 293 +++++++++++++++----------- src/Routers/FunctionsRouter.js | 2 +- src/triggers.js | 110 +++------- 3 files changed, 195 insertions(+), 210 deletions(-) diff --git a/src/LiveQuery/ParseLiveQueryServer.js b/src/LiveQuery/ParseLiveQueryServer.js index 0f00635cab..9611bf35f1 100644 --- a/src/LiveQuery/ParseLiveQueryServer.js +++ b/src/LiveQuery/ParseLiveQueryServer.js @@ -10,12 +10,7 @@ import { ParsePubSub } from './ParsePubSub'; import SchemaController from '../Controllers/SchemaController'; import _ from 'lodash'; import { v4 as uuidv4 } from 'uuid'; -import { - runLiveQueryEventHandlers, - maybeRunConnectTrigger, - maybeRunSubscribeTrigger, - maybeRunAfterEventTrigger, -} from '../triggers'; +import { runLiveQueryEventHandlers, getTrigger, runTrigger } from '../triggers'; import { getAuthForSessionToken, Auth } from '../Auth'; import { getCacheController } from '../Controllers'; import LRU from 'lru-cache'; @@ -121,7 +116,7 @@ class ParseLiveQueryServer { // Message is the JSON object from publisher after inflated. Message.currentParseObject is the ParseObject after changes. // Message.originalParseObject is the original ParseObject. - _onAfterDelete(message: any): void { + async _onAfterDelete(message: any): void { logger.verbose(Parse.applicationId + 'afterDelete is triggered'); let deletedParseObject = message.currentParseObject.toJSON(); @@ -135,6 +130,7 @@ class ParseLiveQueryServer { logger.debug('Can not find subscriptions under this class ' + className); return; } + for (const subscription of classSubscriptions.values()) { const isSubscriptionMatched = this._matchesSubscription(deletedParseObject, subscription); if (!isSubscriptionMatched) { @@ -150,50 +146,58 @@ class ParseLiveQueryServer { // Check CLP const op = this._getCLPOperation(subscription.query); let res = {}; - this._matchesCLP(classLevelPermissions, message.currentParseObject, client, requestId, op) - .then(() => { - // Check ACL - return this._matchesACL(acl, client, requestId); - }) - .then(isMatched => { - if (!isMatched) { - return null; - } - res = { - event: 'delete', - sessionToken: client.sessionToken, - object: deletedParseObject, - clients: this.clients.size, - subscriptions: this.subscriptions.size, - useMasterKey: client.hasMasterKey, - installationId: client.installationId, - sendEvent: true, - }; - return maybeRunAfterEventTrigger('afterEvent', className, res); - }) - .then(() => { - if (!res.sendEvent) { - return; - } - if (res.object && typeof res.object.toJSON === 'function') { - deletedParseObject = res.object.toJSON(); - deletedParseObject.className = className; + try { + await this._matchesCLP( + classLevelPermissions, + message.currentParseObject, + client, + requestId, + op + ); + const isMatched = await this._matchesACL(acl, client, requestId); + if (!isMatched) { + return null; + } + res = { + event: 'delete', + sessionToken: client.sessionToken, + object: deletedParseObject, + clients: this.clients.size, + subscriptions: this.subscriptions.size, + useMasterKey: client.hasMasterKey, + installationId: client.installationId, + sendEvent: true, + }; + const trigger = getTrigger(className, 'afterEvent', Parse.applicationId); + if (trigger) { + const auth = await this.getAuthForSessionToken(res.sessionToken); + res.user = auth.user; + if (res.object) { + res.object = Parse.Object.fromJSON(res.object); } - client.pushDelete(requestId, deletedParseObject); - }) - .catch(error => { - Client.pushError( - client.parseWebSocket, - error.code || 141, - error.message || error, - false, - requestId - ); - logger.error( - `Failed running afterLiveQueryEvent on class ${className} for event ${res.event} with session ${res.sessionToken} with:\n Error: ` + - JSON.stringify(error) - ); - }); + await runTrigger(trigger, `afterEvent.${className}`, res, auth); + } + if (!res.sendEvent) { + return; + } + if (res.object && typeof res.object.toJSON === 'function') { + deletedParseObject = res.object.toJSON(); + deletedParseObject.className = className; + } + client.pushDelete(requestId, deletedParseObject); + } catch (error) { + Client.pushError( + client.parseWebSocket, + error.code || 141, + error.message || error, + false, + requestId + ); + logger.error( + `Failed running afterLiveQueryEvent on class ${className} for event ${res.event} with session ${res.sessionToken} with:\n Error: ` + + JSON.stringify(error) + ); + } } } } @@ -201,7 +205,7 @@ class ParseLiveQueryServer { // Message is the JSON object from publisher after inflated. Message.currentParseObject is the ParseObject after changes. // Message.originalParseObject is the original ParseObject. - _onAfterSave(message: any): void { + async _onAfterSave(message: any): void { logger.verbose(Parse.applicationId + 'afterSave is triggered'); let originalParseObject = null; @@ -256,85 +260,98 @@ class ParseLiveQueryServer { const currentACL = message.currentParseObject.getACL(); currentACLCheckingPromise = this._matchesACL(currentACL, client, requestId); } - const op = this._getCLPOperation(subscription.query); - this._matchesCLP(classLevelPermissions, message.currentParseObject, client, requestId, op) - .then(() => { - return Promise.all([originalACLCheckingPromise, currentACLCheckingPromise]); - }) - .then(([isOriginalMatched, isCurrentMatched]) => { - logger.verbose( - 'Original %j | Current %j | Match: %s, %s, %s, %s | Query: %s', - originalParseObject, - currentParseObject, - isOriginalSubscriptionMatched, - isCurrentSubscriptionMatched, - isOriginalMatched, - isCurrentMatched, - subscription.hash - ); - // Decide event type - let type; - if (isOriginalMatched && isCurrentMatched) { - type = 'update'; - } else if (isOriginalMatched && !isCurrentMatched) { - type = 'leave'; - } else if (!isOriginalMatched && isCurrentMatched) { - if (originalParseObject) { - type = 'enter'; - } else { - type = 'create'; - } + try { + const op = this._getCLPOperation(subscription.query); + await this._matchesCLP( + classLevelPermissions, + message.currentParseObject, + client, + requestId, + op + ); + const [isOriginalMatched, isCurrentMatched] = await Promise.all([ + originalACLCheckingPromise, + currentACLCheckingPromise, + ]); + logger.verbose( + 'Original %j | Current %j | Match: %s, %s, %s, %s | Query: %s', + originalParseObject, + currentParseObject, + isOriginalSubscriptionMatched, + isCurrentSubscriptionMatched, + isOriginalMatched, + isCurrentMatched, + subscription.hash + ); + // Decide event type + let type; + if (isOriginalMatched && isCurrentMatched) { + type = 'update'; + } else if (isOriginalMatched && !isCurrentMatched) { + type = 'leave'; + } else if (!isOriginalMatched && isCurrentMatched) { + if (originalParseObject) { + type = 'enter'; } else { - return null; + type = 'create'; } - message.event = type; - res = { - event: type, - sessionToken: client.sessionToken, - object: currentParseObject, - original: originalParseObject, - clients: this.clients.size, - subscriptions: this.subscriptions.size, - useMasterKey: client.hasMasterKey, - installationId: client.installationId, - sendEvent: true, - }; - return maybeRunAfterEventTrigger('afterEvent', className, res); - }) - .then( - () => { - if (!res.sendEvent) { - return; - } - if (res.object && typeof res.object.toJSON === 'function') { - currentParseObject = res.object.toJSON(); - currentParseObject.className = res.object.className || className; - } - - if (res.original && typeof res.original.toJSON === 'function') { - originalParseObject = res.original.toJSON(); - originalParseObject.className = res.original.className || className; - } - const functionName = - 'push' + message.event.charAt(0).toUpperCase() + message.event.slice(1); - if (client[functionName]) { - client[functionName](requestId, currentParseObject, originalParseObject); - } - }, - error => { - Client.pushError( - client.parseWebSocket, - error.code || 141, - error.message || error, - false, - requestId - ); - logger.error( - `Failed running afterLiveQueryEvent on class ${className} for event ${res.event} with session ${res.sessionToken} with:\n Error: ` + - JSON.stringify(error) - ); + } else { + return null; + } + message.event = type; + res = { + event: type, + sessionToken: client.sessionToken, + object: currentParseObject, + original: originalParseObject, + clients: this.clients.size, + subscriptions: this.subscriptions.size, + useMasterKey: client.hasMasterKey, + installationId: client.installationId, + sendEvent: true, + }; + const trigger = getTrigger(className, 'afterEvent', Parse.applicationId); + if (trigger) { + if (res.object) { + res.object = Parse.Object.fromJSON(res.object); + } + if (res.original) { + res.original = Parse.Object.fromJSON(res.original); } + const auth = await this.getAuthForSessionToken(res.sessionToken); + res.user = auth.user; + await runTrigger(trigger, `afterEvent.${className}`, res, auth); + } + if (!res.sendEvent) { + return; + } + if (res.object && typeof res.object.toJSON === 'function') { + currentParseObject = res.object.toJSON(); + currentParseObject.className = res.object.className || className; + } + + if (res.original && typeof res.original.toJSON === 'function') { + originalParseObject = res.original.toJSON(); + originalParseObject.className = res.original.className || className; + } + const functionName = + 'push' + message.event.charAt(0).toUpperCase() + message.event.slice(1); + if (client[functionName]) { + client[functionName](requestId, currentParseObject, originalParseObject); + } + } catch (error) { + Client.pushError( + client.parseWebSocket, + error.code || 141, + error.message || error, + false, + requestId + ); + logger.error( + `Failed running afterLiveQueryEvent on class ${className} for event ${res.event} with session ${res.sessionToken} with:\n Error: ` + + JSON.stringify(error) ); + } } } } @@ -614,7 +631,12 @@ class ParseLiveQueryServer { useMasterKey: client.hasMasterKey, installationId: request.installationId, }; - await maybeRunConnectTrigger('beforeConnect', req); + const trigger = getTrigger('@Connect', 'beforeConnect', Parse.applicationId); + if (trigger) { + const auth = await this.getAuthForSessionToken(req.sessionToken); + req.user = auth.user; + await runTrigger(trigger, `beforeConnect.@Connect`, req, auth); + } parseWebsocket.clientId = clientId; this.clients.set(parseWebsocket.clientId, client); logger.info(`Create new client: ${parseWebsocket.clientId}`); @@ -668,7 +690,22 @@ class ParseLiveQueryServer { const client = this.clients.get(parseWebsocket.clientId); const className = request.query.className; try { - await maybeRunSubscribeTrigger('beforeSubscribe', className, request); + const trigger = getTrigger(className, 'beforeSubscribe', Parse.applicationId); + if (trigger) { + const auth = await this.getAuthForSessionToken(request.sessionToken); + request.user = auth.user; + + const parseQuery = new Parse.Query(className); + parseQuery.withJSON(request.query); + request.query = parseQuery; + await runTrigger(trigger, `beforeSubscribe.${className}`, request, auth); + + const query = request.query.toJSON(); + if (query.keys) { + query.fields = query.keys.split(','); + } + request.query = query; + } // Get subscription from subscriptions, create one if necessary const subscriptionHash = queryHash(request.query); diff --git a/src/Routers/FunctionsRouter.js b/src/Routers/FunctionsRouter.js index 1b891bf26c..d239908103 100644 --- a/src/Routers/FunctionsRouter.js +++ b/src/Routers/FunctionsRouter.js @@ -173,7 +173,7 @@ export class FunctionsRouter extends PromiseRouter { ); return Promise.resolve() .then(() => { - return triggers.maybeRunValidator(request, functionName); + return triggers.maybeRunValidator(request, functionName, req.auth); }) .then(() => { return theFunction(request); diff --git a/src/triggers.js b/src/triggers.js index 05079494a7..4aa69f405a 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -168,6 +168,17 @@ export function getTrigger(className, triggerType, applicationId) { return get(Category.Triggers, `${triggerType}.${className}`, applicationId); } +export async function runTrigger(trigger, name, request, auth) { + if (!trigger) { + return; + } + await maybeRunValidator(request, name, auth); + if (request.skipWithMasterKey) { + return; + } + return await trigger(request); +} + export function getFileTrigger(type, applicationId) { return getTrigger(FileClassName, type, applicationId); } @@ -423,7 +434,7 @@ export function maybeRunAfterFindTrigger( }); return Promise.resolve() .then(() => { - return maybeRunValidator(request, `${triggerType}.${className}`); + return maybeRunValidator(request, `${triggerType}.${className}`, auth); }) .then(() => { if (request.skipWithMasterKey) { @@ -488,7 +499,7 @@ export function maybeRunQueryTrigger( ); return Promise.resolve() .then(() => { - return maybeRunValidator(requestObject, `${triggerType}.${className}`); + return maybeRunValidator(requestObject, `${triggerType}.${className}`, auth); }) .then(() => { if (requestObject.skipWithMasterKey) { @@ -590,7 +601,7 @@ export function resolveError(message, defaultOpts) { } return error; } -export function maybeRunValidator(request, functionName) { +export function maybeRunValidator(request, functionName, auth) { const theValidator = getValidator(functionName, Parse.applicationId); if (!theValidator) { return; @@ -602,7 +613,7 @@ export function maybeRunValidator(request, functionName) { return Promise.resolve() .then(() => { return typeof theValidator === 'object' - ? builtInTriggerValidator(theValidator, request) + ? builtInTriggerValidator(theValidator, request, auth) : theValidator(request); }) .then(() => { @@ -617,7 +628,7 @@ export function maybeRunValidator(request, functionName) { }); }); } -async function builtInTriggerValidator(options, request) { +async function builtInTriggerValidator(options, request, auth) { if (request.master && !options.validateMasterKey) { return; } @@ -724,13 +735,17 @@ async function builtInTriggerValidator(options, request) { } const userRoles = options.requireUserRoles; if (userRoles) { - const roleQuery = new Parse.Query(Parse.Role); - roleQuery.containedIn('name', userRoles); - roleQuery.equalTo('users', reqUser); - const role = await roleQuery.first({ useMasterKey: true }); - if (!role) { - throw `Validation failed. User does not match the required roles.`; - } + const roles = await auth.getUserRoles(); + const validateRoles = () => { + const roleNames = roles.map(role => role.replace('role:', '')); + for (const role of userRoles) { + if (roleNames.includes(role)) { + return; + } + throw `Validation failed. User does not match the required roles.`; + } + }; + validateRoles(); } const userKeys = options.requireUserKeys || []; if (Array.isArray(userKeys)) { @@ -819,7 +834,7 @@ export function maybeRunTrigger( // to the RestWrite.execute() call. return Promise.resolve() .then(() => { - return maybeRunValidator(request, `${triggerType}.${parseObject.className}`); + return maybeRunValidator(request, `${triggerType}.${parseObject.className}`, auth); }) .then(() => { if (request.skipWithMasterKey) { @@ -900,7 +915,7 @@ export async function maybeRunFileTrigger(triggerType, fileObject, config, auth) if (typeof fileTrigger === 'function') { try { const request = getRequestFileObject(triggerType, auth, fileObject, config); - await maybeRunValidator(request, `${triggerType}.${FileClassName}`); + await maybeRunValidator(request, `${triggerType}.${FileClassName}`, auth); if (request.skipWithMasterKey) { return fileObject; } @@ -926,70 +941,3 @@ export async function maybeRunFileTrigger(triggerType, fileObject, config, auth) } return fileObject; } - -export async function maybeRunConnectTrigger(triggerType, request) { - const trigger = getTrigger(ConnectClassName, triggerType, Parse.applicationId); - if (!trigger) { - return; - } - request.user = await userForSessionToken(request.sessionToken); - await maybeRunValidator(request, `${triggerType}.${ConnectClassName}`); - if (request.skipWithMasterKey) { - return; - } - return trigger(request); -} - -export async function maybeRunSubscribeTrigger(triggerType, className, request) { - const trigger = getTrigger(className, triggerType, Parse.applicationId); - if (!trigger) { - return; - } - const parseQuery = new Parse.Query(className); - parseQuery.withJSON(request.query); - request.query = parseQuery; - request.user = await userForSessionToken(request.sessionToken); - await maybeRunValidator(request, `${triggerType}.${className}`); - if (request.skipWithMasterKey) { - return; - } - await trigger(request); - const query = request.query.toJSON(); - if (query.keys) { - query.fields = query.keys.split(','); - } - request.query = query; -} - -export async function maybeRunAfterEventTrigger(triggerType, className, request) { - const trigger = getTrigger(className, triggerType, Parse.applicationId); - if (!trigger) { - return; - } - if (request.object) { - request.object = Parse.Object.fromJSON(request.object); - } - if (request.original) { - request.original = Parse.Object.fromJSON(request.original); - } - request.user = await userForSessionToken(request.sessionToken); - await maybeRunValidator(request, `${triggerType}.${className}`); - if (request.skipWithMasterKey) { - return; - } - return trigger(request); -} - -async function userForSessionToken(sessionToken) { - if (!sessionToken) { - return; - } - const q = new Parse.Query('_Session'); - q.equalTo('sessionToken', sessionToken); - q.include('user'); - const session = await q.first({ useMasterKey: true }); - if (!session) { - return; - } - return session.get('user'); -} From 5dc7b6f1e6a7550864b31b959f7753dc984b4399 Mon Sep 17 00:00:00 2001 From: dblythy Date: Tue, 5 Jan 2021 17:10:33 +1100 Subject: [PATCH 05/10] Update triggers.js --- src/triggers.js | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/triggers.js b/src/triggers.js index 4aa69f405a..7d77b382d9 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -736,16 +736,10 @@ async function builtInTriggerValidator(options, request, auth) { const userRoles = options.requireUserRoles; if (userRoles) { const roles = await auth.getUserRoles(); - const validateRoles = () => { - const roleNames = roles.map(role => role.replace('role:', '')); - for (const role of userRoles) { - if (roleNames.includes(role)) { - return; - } - throw `Validation failed. User does not match the required roles.`; - } - }; - validateRoles(); + const hasRole = userRoles.some(requiredRole => roles.includes(`role:${requiredRole}`)); + if (!hasRole) { + throw `Validation failed. User does not match the required roles.`; + } } const userKeys = options.requireUserKeys || []; if (Array.isArray(userKeys)) { From b4a3005f1f1408a626cf62b181613ffdf7693cbc Mon Sep 17 00:00:00 2001 From: dblythy Date: Sun, 31 Jan 2021 18:10:18 +1100 Subject: [PATCH 06/10] Update ParseLiveQueryServer.js --- src/LiveQuery/ParseLiveQueryServer.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/LiveQuery/ParseLiveQueryServer.js b/src/LiveQuery/ParseLiveQueryServer.js index 9611bf35f1..6b45bf1fb7 100644 --- a/src/LiveQuery/ParseLiveQueryServer.js +++ b/src/LiveQuery/ParseLiveQueryServer.js @@ -141,7 +141,7 @@ class ParseLiveQueryServer { if (typeof client === 'undefined') { continue; } - for (const requestId of requestIds) { + requestsIds.forEach(async requestId => { const acl = message.currentParseObject.getACL(); // Check CLP const op = this._getCLPOperation(subscription.query); @@ -237,7 +237,7 @@ class ParseLiveQueryServer { if (typeof client === 'undefined') { continue; } - for (const requestId of requestIds) { + requestsIds.forEach(async requestId => { // Set orignal ParseObject ACL checking promise, if the object does not match // subscription, we do not need to check ACL let originalACLCheckingPromise; From e8112c5b2a56a376e1c0d35e456cf60446140351 Mon Sep 17 00:00:00 2001 From: dblythy Date: Sun, 31 Jan 2021 22:51:11 +1100 Subject: [PATCH 07/10] Update ParseLiveQueryServer.js --- src/LiveQuery/ParseLiveQueryServer.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/LiveQuery/ParseLiveQueryServer.js b/src/LiveQuery/ParseLiveQueryServer.js index 6b45bf1fb7..8715e94683 100644 --- a/src/LiveQuery/ParseLiveQueryServer.js +++ b/src/LiveQuery/ParseLiveQueryServer.js @@ -141,7 +141,7 @@ class ParseLiveQueryServer { if (typeof client === 'undefined') { continue; } - requestsIds.forEach(async requestId => { + requestIds.forEach(async requestId => { const acl = message.currentParseObject.getACL(); // Check CLP const op = this._getCLPOperation(subscription.query); @@ -198,7 +198,7 @@ class ParseLiveQueryServer { JSON.stringify(error) ); } - } + }); } } } @@ -237,7 +237,7 @@ class ParseLiveQueryServer { if (typeof client === 'undefined') { continue; } - requestsIds.forEach(async requestId => { + requestIds.forEach(async requestId => { // Set orignal ParseObject ACL checking promise, if the object does not match // subscription, we do not need to check ACL let originalACLCheckingPromise; @@ -352,7 +352,7 @@ class ParseLiveQueryServer { JSON.stringify(error) ); } - } + }); } } } From 00f2fd9a2847fc2313938aa6fed3de3568610e6c Mon Sep 17 00:00:00 2001 From: dblythy Date: Mon, 1 Feb 2021 00:47:07 +1100 Subject: [PATCH 08/10] create requireUserRoles --- spec/CloudCode.Validator.spec.js | 34 ++++++++++++++++++++++++++++++-- src/triggers.js | 17 +++++++++++++--- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/spec/CloudCode.Validator.spec.js b/spec/CloudCode.Validator.spec.js index d10cd06627..7c6d80b213 100644 --- a/spec/CloudCode.Validator.spec.js +++ b/spec/CloudCode.Validator.spec.js @@ -860,7 +860,7 @@ describe('cloud validator', () => { }); }); - it('basic validator requireUserRoles', async function (done) { + it('basic validator requireUserRole', async function (done) { Parse.Cloud.define( 'cloudFunction', () => { @@ -868,7 +868,7 @@ describe('cloud validator', () => { }, { requireUser: true, - requireUserRoles: ['Admin'], + requireUserRole: ['Admin'], } ); const user = await Parse.User.signUp('testuser', 'p@ssword'); @@ -887,6 +887,36 @@ describe('cloud validator', () => { done(); }); + it('basic validator requireUserRoles', async function (done) { + Parse.Cloud.define( + 'cloudFunction', + () => { + return true; + }, + { + requireUser: true, + requireUserRoles: ['Admin', 'Admin2'], + } + ); + const user = await Parse.User.signUp('testuser', 'p@ssword'); + try { + await Parse.Cloud.run('cloudFunction'); + fail('cloud validator should have failed.'); + } catch (e) { + expect(e.message).toBe('Validation failed. User does not match all the required roles.'); + } + const roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + const role = new Parse.Role('Admin', roleACL); + role.getUsers().add(user); + + const role2 = new Parse.Role('Admin2', roleACL); + role2.getUsers().add(user); + await Promise.all([role.save({ useMasterKey: true }), role2.save({ useMasterKey: true })]); + await Parse.Cloud.run('cloudFunction'); + done(); + }); + it('basic requireUserRoles but no user', async function (done) { Parse.Cloud.define( 'cloudFunction', diff --git a/src/triggers.js b/src/triggers.js index 7d77b382d9..bc5d4aef11 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -641,7 +641,7 @@ async function builtInTriggerValidator(options, request, auth) { ) { reqUser = request.object; } - if ((options.requireUser || options.requireUserRoles) && !reqUser) { + if ((options.requireUser || options.requireUserRole || options.requireUserRoles) && !reqUser) { throw 'Validation failed. Please login to continue.'; } if (options.requireMaster && !request.master) { @@ -733,14 +733,25 @@ async function builtInTriggerValidator(options, request, auth) { } } } - const userRoles = options.requireUserRoles; + const userRoles = options.requireUserRole; + const requireAllRoles = options.requireUserRoles; + let roles; + if (userRoles || requireAllRoles) { + roles = await auth.getUserRoles(); + } if (userRoles) { - const roles = await auth.getUserRoles(); const hasRole = userRoles.some(requiredRole => roles.includes(`role:${requiredRole}`)); if (!hasRole) { throw `Validation failed. User does not match the required roles.`; } } + if (requireAllRoles) { + for (const requiredRole of requireAllRoles) { + if (!roles.includes(`role:${requiredRole}`)) { + throw `Validation failed. User does not match all the required roles.`; + } + } + } const userKeys = options.requireUserKeys || []; if (Array.isArray(userKeys)) { for (const key of userKeys) { From 20e1cf4b0251d2bad0a7682f4055eed014fe7a50 Mon Sep 17 00:00:00 2001 From: dblythy Date: Sat, 13 Feb 2021 04:02:06 +1100 Subject: [PATCH 09/10] rename to requireAny and requireAll --- CHANGELOG.md | 2 +- spec/CloudCode.Validator.spec.js | 12 ++++++------ src/cloud-code/Parse.Cloud.js | 3 ++- src/triggers.js | 9 ++++++--- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccfbe4d308..314d74bc8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ ___ - IMPROVE: Parse Server will from now on be continuously tested against all relevant MongoDB versions (minor versions). Added MongoDB compatibility table to Parse Server docs. [7161](https://github.com/parse-community/parse-server/pull/7161). Thanks to [Manuel Trezza](https://github.com/mtrezza). - IMPROVE: Optimize queries on classes with pointer permissions. [#7061](https://github.com/parse-community/parse-server/pull/7061). Thanks to [Pedro Diaz](https://github.com/pdiaz) - FIX: request.context for afterFind triggers. [#7078](https://github.com/parse-community/parse-server/pull/7078). Thanks to [dblythy](https://github.com/dblythy) -- NEW: `requireUserRole` and `requireUserRoles` for Parse Cloud validator. [#7097](https://github.com/parse-community/parse-server/pull/7097). Thanks to [dblythy](https://github.com/dblythy) +- NEW: `requireAnyUserRoles` and `requireAllUserRoles` for Parse Cloud validator. [#7097](https://github.com/parse-community/parse-server/pull/7097). Thanks to [dblythy](https://github.com/dblythy) - NEW: Added convenience method Parse.Cloud.sendEmail(...) to send email via email adapter in Cloud Code. [#7089](https://github.com/parse-community/parse-server/pull/7089). Thanks to [dblythy](https://github.com/dblythy) - FIX: Winston Logger interpolating stdout to console [#7114](https://github.com/parse-community/parse-server/pull/7114). Thanks to [dplewis](https://github.com/dplewis) diff --git a/spec/CloudCode.Validator.spec.js b/spec/CloudCode.Validator.spec.js index b70ef34e01..063cc82f34 100644 --- a/spec/CloudCode.Validator.spec.js +++ b/spec/CloudCode.Validator.spec.js @@ -878,7 +878,7 @@ describe('cloud validator', () => { }); }); - it('basic validator requireUserRole', async function (done) { + it('basic validator requireAnyUserRoles', async function (done) { Parse.Cloud.define( 'cloudFunction', () => { @@ -886,7 +886,7 @@ describe('cloud validator', () => { }, { requireUser: true, - requireUserRole: ['Admin'], + requireAnyUserRoles: ['Admin'], } ); const user = await Parse.User.signUp('testuser', 'p@ssword'); @@ -905,7 +905,7 @@ describe('cloud validator', () => { done(); }); - it('basic validator requireUserRoles', async function (done) { + it('basic validator requireAllUserRoles', async function (done) { Parse.Cloud.define( 'cloudFunction', () => { @@ -913,7 +913,7 @@ describe('cloud validator', () => { }, { requireUser: true, - requireUserRoles: ['Admin', 'Admin2'], + requireAllUserRoles: ['Admin', 'Admin2'], } ); const user = await Parse.User.signUp('testuser', 'p@ssword'); @@ -935,14 +935,14 @@ describe('cloud validator', () => { done(); }); - it('basic requireUserRoles but no user', async function (done) { + it('basic requireAllUserRoles but no user', async function (done) { Parse.Cloud.define( 'cloudFunction', () => { return true; }, { - requireUserRoles: ['Admin'], + requireAllUserRoles: ['Admin'], } ); try { diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index 09938b0906..9df9a9786c 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -751,7 +751,8 @@ module.exports = ParseCloud; * @property {Array|function|Any} requireUserKeys.field.options array of options that the field can be, function to validate field, or single value. Throw an error if value is invalid. * @property {String} requireUserKeys.field.error custom error message if field is invalid. * - * @property {Array}requireUserRoles If set, array of roles names allowed on request.user to make the request. + * @property {Array}requireAnyUserRoles If set, request.user has to be part of at least one roles name to make the request. + * @property {Array}requireAllUserRoles If set, request.user has to be part all roles name to make the request. * * @property {Object|Array} fields if an array of strings, validator will look for keys in request.params, and throw if not provided. If Object, fields to validate. If the trigger is a cloud function, `request.params` will be validated, otherwise `request.object`. * @property {String} fields.field name of field to validate. diff --git a/src/triggers.js b/src/triggers.js index 96209c6372..0faeed9223 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -641,7 +641,10 @@ async function builtInTriggerValidator(options, request, auth) { ) { reqUser = request.object; } - if ((options.requireUser || options.requireUserRole || options.requireUserRoles) && !reqUser) { + if ( + (options.requireUser || options.requireAnyUserRoles || options.requireAllUserRoles) && + !reqUser + ) { throw 'Validation failed. Please login to continue.'; } if (options.requireMaster && !request.master) { @@ -732,8 +735,8 @@ async function builtInTriggerValidator(options, request, auth) { } } } - const userRoles = options.requireUserRole; - const requireAllRoles = options.requireUserRoles; + const userRoles = options.requireAnyUserRoles; + const requireAllRoles = options.requireAllUserRoles; let roles; if (userRoles || requireAllRoles) { roles = await auth.getUserRoles(); From 2fb782703b690cee07bd0f70b9006f7b243dfb7c Mon Sep 17 00:00:00 2001 From: dblythy Date: Sat, 13 Feb 2021 04:25:33 +1100 Subject: [PATCH 10/10] allow for a function --- spec/CloudCode.Validator.spec.js | 61 ++++++++++++++++++++++++++++++++ src/cloud-code/Parse.Cloud.js | 4 +-- src/triggers.js | 21 ++++++++--- 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/spec/CloudCode.Validator.spec.js b/spec/CloudCode.Validator.spec.js index 063cc82f34..36a7fc96b1 100644 --- a/spec/CloudCode.Validator.spec.js +++ b/spec/CloudCode.Validator.spec.js @@ -935,6 +935,67 @@ describe('cloud validator', () => { done(); }); + it('allow requireAnyUserRoles to be a function', async function (done) { + Parse.Cloud.define( + 'cloudFunction', + () => { + return true; + }, + { + requireUser: true, + requireAnyUserRoles: () => { + return ['Admin Func']; + }, + } + ); + const user = await Parse.User.signUp('testuser', 'p@ssword'); + try { + await Parse.Cloud.run('cloudFunction'); + fail('cloud validator should have failed.'); + } catch (e) { + expect(e.message).toBe('Validation failed. User does not match the required roles.'); + } + const roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + const role = new Parse.Role('Admin Func', roleACL); + role.getUsers().add(user); + await role.save({ useMasterKey: true }); + await Parse.Cloud.run('cloudFunction'); + done(); + }); + + it('allow requireAllUserRoles to be a function', async function (done) { + Parse.Cloud.define( + 'cloudFunction', + () => { + return true; + }, + { + requireUser: true, + requireAllUserRoles: () => { + return ['AdminA', 'AdminB']; + }, + } + ); + const user = await Parse.User.signUp('testuser', 'p@ssword'); + try { + await Parse.Cloud.run('cloudFunction'); + fail('cloud validator should have failed.'); + } catch (e) { + expect(e.message).toBe('Validation failed. User does not match all the required roles.'); + } + const roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + const role = new Parse.Role('AdminA', roleACL); + role.getUsers().add(user); + + const role2 = new Parse.Role('AdminB', roleACL); + role2.getUsers().add(user); + await Promise.all([role.save({ useMasterKey: true }), role2.save({ useMasterKey: true })]); + await Parse.Cloud.run('cloudFunction'); + done(); + }); + it('basic requireAllUserRoles but no user', async function (done) { Parse.Cloud.define( 'cloudFunction', diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index 9df9a9786c..5ed6aa728f 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -751,8 +751,8 @@ module.exports = ParseCloud; * @property {Array|function|Any} requireUserKeys.field.options array of options that the field can be, function to validate field, or single value. Throw an error if value is invalid. * @property {String} requireUserKeys.field.error custom error message if field is invalid. * - * @property {Array}requireAnyUserRoles If set, request.user has to be part of at least one roles name to make the request. - * @property {Array}requireAllUserRoles If set, request.user has to be part all roles name to make the request. + * @property {Array|function}requireAnyUserRoles If set, request.user has to be part of at least one roles name to make the request. If set to a function, function must return role names. + * @property {Array|function}requireAllUserRoles If set, request.user has to be part all roles name to make the request. If set to a function, function must return role names. * * @property {Object|Array} fields if an array of strings, validator will look for keys in request.params, and throw if not provided. If Object, fields to validate. If the trigger is a cloud function, `request.params` will be validated, otherwise `request.object`. * @property {String} fields.field name of field to validate. diff --git a/src/triggers.js b/src/triggers.js index 0faeed9223..f0f65a5aed 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -735,11 +735,24 @@ async function builtInTriggerValidator(options, request, auth) { } } } - const userRoles = options.requireAnyUserRoles; - const requireAllRoles = options.requireAllUserRoles; - let roles; + let userRoles = options.requireAnyUserRoles; + let requireAllRoles = options.requireAllUserRoles; + const promises = [Promise.resolve(), Promise.resolve(), Promise.resolve()]; if (userRoles || requireAllRoles) { - roles = await auth.getUserRoles(); + promises[0] = auth.getUserRoles(); + } + if (typeof userRoles === 'function') { + promises[1] = userRoles(); + } + if (typeof requireAllRoles === 'function') { + promises[2] = requireAllRoles(); + } + const [roles, resolvedUserRoles, resolvedRequireAll] = await Promise.all(promises); + if (resolvedUserRoles && Array.isArray(resolvedUserRoles)) { + userRoles = resolvedUserRoles; + } + if (resolvedRequireAll && Array.isArray(resolvedRequireAll)) { + requireAllRoles = resolvedRequireAll; } if (userRoles) { const hasRole = userRoles.some(requiredRole => roles.includes(`role:${requiredRole}`));