feat: Allow returning objects in Parse.Cloud.beforeFind without invoking database query (#9770)

This commit is contained in:
EmpiDev
2025-10-14 18:13:28 +02:00
committed by GitHub
parent 0b606ae9c6
commit 0b4740714c
4 changed files with 524 additions and 41 deletions

View File

@@ -50,6 +50,7 @@ async function RestQuery({
if (![RestQuery.Method.find, RestQuery.Method.get].includes(method)) {
throw new Parse.Error(Parse.Error.INVALID_QUERY, 'bad query type');
}
const isGet = method === RestQuery.Method.get;
enforceRoleSecurity(method, className, auth);
const result = runBeforeFind
? await triggers.maybeRunQueryTrigger(
@@ -60,7 +61,7 @@ async function RestQuery({
config,
auth,
context,
method === RestQuery.Method.get
isGet
)
: Promise.resolve({ restWhere, restOptions });
@@ -72,7 +73,8 @@ async function RestQuery({
result.restOptions || restOptions,
clientSDK,
runAfterFind,
context
context,
isGet
);
}
@@ -101,7 +103,8 @@ function _UnsafeRestQuery(
restOptions = {},
clientSDK,
runAfterFind = true,
context
context,
isGet
) {
this.config = config;
this.auth = auth;
@@ -113,6 +116,7 @@ function _UnsafeRestQuery(
this.response = null;
this.findOptions = {};
this.context = context || {};
this.isGet = isGet;
if (!this.auth.isMaster) {
if (this.className == '_Session') {
if (!this.auth.user) {
@@ -914,7 +918,8 @@ _UnsafeRestQuery.prototype.runAfterFindTrigger = function () {
this.response.results,
this.config,
parseQuery,
this.context
this.context,
this.isGet
)
.then(results => {
// Ensure we properly set the className back

View File

@@ -23,11 +23,91 @@ function checkTriggers(className, config, types) {
function checkLiveQuery(className, config) {
return config.liveQueryController && config.liveQueryController.hasLiveQuery(className);
}
async function runFindTriggers(
config,
auth,
className,
restWhere,
restOptions,
clientSDK,
context,
options = {}
) {
const { isGet } = options;
// Returns a promise for an object with optional keys 'results' and 'count'.
const find = async (config, auth, className, restWhere, restOptions, clientSDK, context) => {
// Run beforeFind trigger - may modify query or return objects directly
const result = await triggers.maybeRunQueryTrigger(
triggers.Types.beforeFind,
className,
restWhere,
restOptions,
config,
auth,
context,
isGet
);
restWhere = result.restWhere || restWhere;
restOptions = result.restOptions || restOptions;
// Short-circuit path: beforeFind returned objects directly
// Security risk: These objects may have been fetched with master privileges
if (result?.objects) {
const objectsFromBeforeFind = result.objects;
let objectsForAfterFind = objectsFromBeforeFind;
// Security check: Re-filter objects if not master to ensure ACL/CLP compliance
if (!auth?.isMaster && !auth?.isMaintenance) {
const ids = (Array.isArray(objectsFromBeforeFind) ? objectsFromBeforeFind : [objectsFromBeforeFind])
.map(o => (o && (o.id || o.objectId)) || null)
.filter(Boolean);
// Objects without IDs are(normally) unsaved objects
// For unsaved objects, the ACL security does not apply, so no need to redo the query.
// For saved objects, we need to re-query to ensure proper ACL/CLP enforcement
if (ids.length > 0) {
const refilterWhere = isGet ? { objectId: ids[0] } : { objectId: { $in: ids } };
// Re-query with proper security: no triggers to avoid infinite loops
const refilterQuery = await RestQuery({
method: isGet ? RestQuery.Method.get : RestQuery.Method.find,
config,
auth,
className,
restWhere: refilterWhere,
restOptions,
clientSDK,
context,
runBeforeFind: false,
runAfterFind: false,
});
const refiltered = await refilterQuery.execute();
objectsForAfterFind = (refiltered && refiltered.results) || [];
}
}
// Run afterFind trigger on security-filtered objects
const afterFindProcessedObjects = await triggers.maybeRunAfterFindTrigger(
triggers.Types.afterFind,
auth,
className,
objectsForAfterFind,
config,
new Parse.Query(className).withJSON({ where: restWhere, ...restOptions }),
context,
isGet
);
return {
results: afterFindProcessedObjects,
};
}
// Normal path: execute database query with modified conditions
const query = await RestQuery({
method: RestQuery.Method.find,
method: isGet ? RestQuery.Method.get : RestQuery.Method.find,
config,
auth,
className,
@@ -35,24 +115,40 @@ const find = async (config, auth, className, restWhere, restOptions, clientSDK,
restOptions,
clientSDK,
context,
runBeforeFind: false,
});
return query.execute();
}
// Returns a promise for an object with optional keys 'results' and 'count'.
const find = async (config, auth, className, restWhere, restOptions, clientSDK, context) => {
enforceRoleSecurity('find', className, auth);
return runFindTriggers(
config,
auth,
className,
restWhere,
restOptions,
clientSDK,
context,
{ isGet: false }
);
};
// get is just like find but only queries an objectId.
const get = async (config, auth, className, objectId, restOptions, clientSDK, context) => {
var restWhere = { objectId };
const query = await RestQuery({
method: RestQuery.Method.get,
enforceRoleSecurity('get', className, auth);
return runFindTriggers(
config,
auth,
className,
restWhere,
{ objectId },
restOptions,
clientSDK,
context,
});
return query.execute();
{ isGet: true }
);
};
// Returns a promise that doesn't resolve to any useful value.

View File

@@ -182,8 +182,11 @@ export function toJSONwithObjects(object, className) {
}
toJSON[key] = val._toFullJSON();
}
// Preserve original object's className if no override className is provided
if (className) {
toJSON.className = className;
} else if (object.className && !toJSON.className) {
toJSON.className = object.className;
}
return toJSON;
}
@@ -257,7 +260,8 @@ export function getRequestObject(
parseObject,
originalParseObject,
config,
context
context,
isGet
) {
const request = {
triggerName: triggerType,
@@ -268,6 +272,10 @@ export function getRequestObject(
ip: config.ip,
};
if (isGet !== undefined) {
request.isGet = !!isGet;
}
if (originalParseObject) {
request.original = originalParseObject;
}
@@ -437,69 +445,93 @@ function logTriggerErrorBeforeHook(triggerType, className, input, auth, error, l
export function maybeRunAfterFindTrigger(
triggerType,
auth,
className,
objects,
classNameQuery,
objectsInput,
config,
query,
context
context,
isGet
) {
return new Promise((resolve, reject) => {
const trigger = getTrigger(className, triggerType, config.applicationId);
const trigger = getTrigger(classNameQuery, triggerType, config.applicationId);
if (!trigger) {
return resolve();
if (objectsInput && objectsInput.length > 0 && objectsInput[0] instanceof Parse.Object) {
return resolve(objectsInput.map(obj => toJSONwithObjects(obj)));
}
return resolve(objectsInput || []);
}
const request = getRequestObject(triggerType, auth, null, null, config, context);
if (query) {
const request = getRequestObject(triggerType, auth, null, null, config, context, isGet);
// Convert query parameter to Parse.Query instance
if (query instanceof Parse.Query) {
request.query = query;
} else if (typeof query === 'object' && query !== null) {
const parseQueryInstance = new Parse.Query(classNameQuery);
if (query.where) {
parseQueryInstance.withJSON(query);
}
request.query = parseQueryInstance;
} else {
request.query = new Parse.Query(classNameQuery);
}
const { success, error } = getResponseObject(
request,
object => {
resolve(object);
processedObjectsJSON => {
resolve(processedObjectsJSON);
},
error => {
reject(error);
errorData => {
reject(errorData);
}
);
logTriggerSuccessBeforeHook(
triggerType,
className,
'AfterFind',
JSON.stringify(objects),
classNameQuery,
'AfterFind Input (Pre-Transform)',
JSON.stringify(
objectsInput.map(o => (o instanceof Parse.Object ? o.id + ':' + o.className : o))
),
auth,
config.logLevels.triggerBeforeSuccess
);
request.objects = objects.map(object => {
//setting the class name to transform into parse object
object.className = className;
return Parse.Object.fromJSON(object);
// Convert plain objects to Parse.Object instances for trigger
request.objects = objectsInput.map(currentObject => {
if (currentObject instanceof Parse.Object) {
return currentObject;
}
// Preserve the original className if it exists, otherwise use the query className
const originalClassName = currentObject.className || classNameQuery;
const tempObjectWithClassName = { ...currentObject, className: originalClassName };
return Parse.Object.fromJSON(tempObjectWithClassName);
});
return Promise.resolve()
.then(() => {
return maybeRunValidator(request, `${triggerType}.${className}`, auth);
return maybeRunValidator(request, `${triggerType}.${classNameQuery}`, auth);
})
.then(() => {
if (request.skipWithMasterKey) {
return request.objects;
}
const response = trigger(request);
if (response && typeof response.then === 'function') {
return response.then(results => {
const responseFromTrigger = trigger(request);
if (responseFromTrigger && typeof responseFromTrigger.then === 'function') {
return responseFromTrigger.then(results => {
return results;
});
}
return response;
return responseFromTrigger;
})
.then(success, error);
}).then(results => {
}).then(resultsAsJSON => {
logTriggerAfterHook(
triggerType,
className,
JSON.stringify(results),
classNameQuery,
JSON.stringify(resultsAsJSON),
auth,
config.logLevels.triggerAfter
);
return results;
return resultsAsJSON;
});
}
@@ -607,9 +639,19 @@ export function maybeRunQueryTrigger(
restOptions = restOptions || {};
restOptions.subqueryReadPreference = requestObject.subqueryReadPreference;
}
let objects = undefined;
if (result instanceof Parse.Object) {
objects = [result];
} else if (
Array.isArray(result) &&
(!result.length || result.every(obj => obj instanceof Parse.Object))
) {
objects = result;
}
return {
restWhere,
restOptions,
objects,
};
},
err => {