diff --git a/src/collections/initLocal.ts b/src/collections/initLocal.ts index 8f5e64e7df..8b1eae234f 100644 --- a/src/collections/initLocal.ts +++ b/src/collections/initLocal.ts @@ -78,7 +78,7 @@ export default function initCollectionsLocal(ctx: Payload): void { ); versionSchema.plugin(paginate, { useEstimatedCount: true }) - .plugin(getBuildQueryPlugin({ collectionSlug: collection.slug, isVersionsModel: true })); + .plugin(getBuildQueryPlugin({ collectionSlug: collection.slug, versionsFields: versionCollectionFields })); if (collection.versions?.drafts) { versionSchema.plugin(mongooseAggregatePaginate); diff --git a/src/globals/buildModel.ts b/src/globals/buildModel.ts index 8c18321d59..b7d2fc6863 100644 --- a/src/globals/buildModel.ts +++ b/src/globals/buildModel.ts @@ -8,7 +8,7 @@ const buildModel = (config: SanitizedConfig): GlobalModel | null => { if (config.globals && config.globals.length > 0) { const globalsSchema = new mongoose.Schema({}, { discriminatorKey: 'globalType', timestamps: true, minimize: false }); - globalsSchema.plugin(getBuildQueryPlugin({ isGlobalModel: true })); + globalsSchema.plugin(getBuildQueryPlugin()); const Globals = mongoose.model('globals', globalsSchema) as unknown as GlobalModel; diff --git a/src/globals/initLocal.ts b/src/globals/initLocal.ts index 337408a9f2..0728c552b9 100644 --- a/src/globals/initLocal.ts +++ b/src/globals/initLocal.ts @@ -35,7 +35,7 @@ export default function initGlobalsLocal(ctx: Payload): void { ); versionSchema.plugin(paginate, { useEstimatedCount: true }) - .plugin(getBuildQueryPlugin({ globalSlug: global.slug, isVersionsModel: true })); + .plugin(getBuildQueryPlugin({ versionsFields: versionGlobalFields })); ctx.versions[global.slug] = mongoose.model(versionModelName, versionSchema) as CollectionModel; } diff --git a/src/globals/operations/findOne.ts b/src/globals/operations/findOne.ts index 72eaca4e8d..b3e7974a04 100644 --- a/src/globals/operations/findOne.ts +++ b/src/globals/operations/findOne.ts @@ -64,6 +64,7 @@ async function findOne>(args: Args): Promise = any>(args: Argumen req: { t, payload, - locale, }, disableErrors, currentDepth, @@ -68,6 +67,7 @@ async function findVersionByID = any>(args: Argumen where: queryToBuild, req, overrideAccess, + globalSlug: globalConfig.slug, }); // ///////////////////////////////////// diff --git a/src/globals/operations/findVersions.ts b/src/globals/operations/findVersions.ts index e245cd7ad2..5ddceaea57 100644 --- a/src/globals/operations/findVersions.ts +++ b/src/globals/operations/findVersions.ts @@ -88,6 +88,7 @@ async function findVersions>( where: queryToBuild, req, overrideAccess, + globalSlug: globalConfig.slug, }); // ///////////////////////////////////// diff --git a/src/globals/operations/update.ts b/src/globals/operations/update.ts index ee3b2d1ab2..349944052f 100644 --- a/src/globals/operations/update.ts +++ b/src/globals/operations/update.ts @@ -33,7 +33,6 @@ async function update( slug, req, req: { - locale, payload, payload: { globals: { @@ -80,6 +79,7 @@ async function update( where: queryToBuild, req, overrideAccess, + globalSlug: slug, }); // ///////////////////////////////////// diff --git a/src/mongoose/buildQuery.ts b/src/mongoose/buildQuery.ts index da1a2cf778..86d51d67b4 100644 --- a/src/mongoose/buildQuery.ts +++ b/src/mongoose/buildQuery.ts @@ -1,13 +1,17 @@ +/* eslint-disable no-continue */ /* eslint-disable no-await-in-loop */ /* eslint-disable no-restricted-syntax */ import deepmerge from 'deepmerge'; -import mongoose, { FilterQuery } from 'mongoose'; +import { FilterQuery } from 'mongoose'; import { combineMerge } from '../utilities/combineMerge'; -import { CollectionModel } from '../collections/config/types'; -import { getSchemaTypeOptions } from './getSchemaTypeOptions'; import { operatorMap } from './operatorMap'; -import { sanitizeQueryValue } from './sanitizeFormattedValue'; +import { sanitizeQueryValue } from './sanitizeQueryValue'; import { PayloadRequest, Where } from '../types'; +import { Field, FieldAffectingData, TabAsField, UIField, fieldAffectsData } from '../fields/config/types'; +import { CollectionPermission, FieldPermissions, GlobalPermission } from '../auth'; +import flattenFields from '../utilities/flattenTopLevelFields'; +import { getEntityPolicies } from '../utilities/getEntityPolicies'; +import { SanitizedConfig } from '../config/types'; const validOperators = ['like', 'contains', 'in', 'all', 'not_in', 'greater_than_equal', 'greater_than', 'less_than_equal', 'less_than', 'not_equals', 'equals', 'exists', 'near']; @@ -16,18 +20,15 @@ const subQueryOptions = { lean: true, }; -type ParseType = { - searchParams?: - { - [key: string]: any; - }; - sort?: boolean; -}; - type PathToQuery = { complete: boolean + collectionSlug?: string path: string - Model: CollectionModel + field: Field | TabAsField + fields?: (FieldAffectingData | UIField | TabAsField)[] + fieldPolicies?: { + [field: string]: FieldPermissions + } } type SearchParam = { @@ -35,15 +36,23 @@ type SearchParam = { value: unknown, } -class ParamParser { +type ParamParserArgs = { + req: PayloadRequest + collectionSlug?: string + globalSlug?: string + versionsFields?: Field[] + model: any + where: Where + overrideAccess?: boolean +} + +type QueryError = { path: string } + +export class ParamParser { collectionSlug?: string globalSlug?: string - isGlobalModel?: boolean - - isVersionsModel?: boolean - overrideAccess: boolean req: PayloadRequest @@ -52,49 +61,68 @@ class ParamParser { model: any; - query: { - searchParams: { - [key: string]: any; + fields: Field[] + + localizationConfig: SanitizedConfig['localization'] + + policies: { + collections?: { + [collectionSlug: string]: CollectionPermission; }; - sort: boolean; - }; + globals?: { + [globalSlug: string]: GlobalPermission; + }; + } + + errors: QueryError[] constructor({ req, collectionSlug, globalSlug, - isGlobalModel, - isVersionsModel, + versionsFields, model, where, overrideAccess, - }) { + }: ParamParserArgs) { this.req = req; this.collectionSlug = collectionSlug; this.globalSlug = globalSlug; - this.isGlobalModel = isGlobalModel; - this.isVersionsModel = isVersionsModel; this.parse = this.parse.bind(this); this.model = model; this.where = where; this.overrideAccess = overrideAccess; - this.query = { - searchParams: {}, - sort: false, + this.localizationConfig = req.payload.config.localization; + this.policies = { + collections: {}, + globals: {}, }; + this.errors = []; + + // Get entity fields + if (globalSlug) { + const globalConfig = req.payload.globals.config.find(({ slug }) => slug === globalSlug); + this.fields = versionsFields || globalConfig.fields; + } + + if (collectionSlug) { + const collectionConfig = req.payload.collections[collectionSlug].config; + this.fields = versionsFields || collectionConfig.fields; + } } // Entry point to the ParamParser class - async parse(): Promise { + async parse(): Promise> { if (typeof this.where === 'object') { - this.query.searchParams = await this.parsePathOrRelation(this.where); - return this.query; + const query = await this.parsePathOrRelation(this.where); + return query; } + return {}; } - async parsePathOrRelation(object) { + async parsePathOrRelation(object: Where): Promise> { let result = {} as FilterQuery; // We need to determine if the whereKey is an AND, OR, or a schema path for (const relationOrPath of Object.keys(object)) { @@ -114,7 +142,12 @@ class ParamParser { if (typeof pathOperators === 'object') { for (const operator of Object.keys(pathOperators)) { if (validOperators.includes(operator)) { - const searchParam = await this.buildSearchParam(this.model.schema, relationOrPath, pathOperators[operator], operator); + const searchParam = await this.buildSearchParam({ + fields: this.fields, + incomingPath: relationOrPath, + val: pathOperators[operator], + operator, + }); if (searchParam?.value && searchParam?.path) { result = { @@ -148,123 +181,85 @@ class ParamParser { return completedConditions; } - // Build up an array of auto-localized paths to search on - // Multiple paths may be possible if searching on properties of relationship fields - - getLocalizedPaths(Model: CollectionModel, incomingPath: string, operator): PathToQuery[] { - const { schema } = Model; - const pathSegments = incomingPath.split('.'); - - let paths: PathToQuery[] = [ - { - path: '', - complete: false, - Model, - }, - ]; - - pathSegments.every((segment, i) => { - const lastIncompletePath = paths.find(({ complete }) => !complete); - const { path } = lastIncompletePath; - - const currentPath = path ? `${path}.${segment}` : segment; - const currentSchemaType = schema.path(currentPath); - const currentSchemaPathType = schema.pathType(currentPath); - - if (currentSchemaPathType === 'nested') { - lastIncompletePath.path = currentPath; - return true; - } - - const upcomingSegment = pathSegments[i + 1]; - - if (currentSchemaType && currentSchemaPathType !== 'adhocOrUndefined') { - const currentSchemaTypeOptions = getSchemaTypeOptions(currentSchemaType); - - if (currentSchemaTypeOptions.localized) { - const upcomingLocalizedPath = `${currentPath}.${upcomingSegment}`; - const upcomingSchemaTypeWithLocale = schema.path(upcomingLocalizedPath); - - if (upcomingSchemaTypeWithLocale) { - lastIncompletePath.path = currentPath; - return true; - } - - const localePath = `${currentPath}.${this.req.locale}`; - const localizedSchemaType = schema.path(localePath); - - if (localizedSchemaType || operator === 'near') { - lastIncompletePath.path = localePath; - return true; - } - } - - lastIncompletePath.path = currentPath; - return true; - } - - const priorSchemaType = schema.path(path); - - if (priorSchemaType) { - const priorSchemaTypeOptions = getSchemaTypeOptions(priorSchemaType); - if (typeof priorSchemaTypeOptions.ref === 'string') { - const RefModel = mongoose.model(priorSchemaTypeOptions.ref) as any; - - lastIncompletePath.complete = true; - - const remainingPath = pathSegments.slice(i).join('.'); - - paths = [ - ...paths, - ...this.getLocalizedPaths(RefModel, remainingPath, operator), - ]; - - return false; - } - } - - if (operator === 'near' || currentSchemaPathType === 'adhocOrUndefined') { - lastIncompletePath.path = currentPath; - } - - return true; - }); - - return paths; - } - // Convert the Payload key / value / operator into a MongoDB query - async buildSearchParam(schema, incomingPath, val, operator): Promise { + async buildSearchParam({ + fields, + incomingPath, + val, + operator, + }: { + fields: Field[], + incomingPath: string, + val: unknown, + operator: string + }): Promise { // Replace GraphQL nested field double underscore formatting let sanitizedPath = incomingPath.replace(/__/gi, '.'); if (sanitizedPath === 'id') sanitizedPath = '_id'; - const collectionPaths = this.getLocalizedPaths(this.model, sanitizedPath, operator); - const [{ path }] = collectionPaths; + let paths: PathToQuery[] = []; + + let hasCustomID = false; + + if (sanitizedPath === '_id') { + const customIDfield = this.req.payload.collections[this.collectionSlug]?.config.fields.find((field) => fieldAffectsData(field) && field.name === 'id'); + + let idFieldType: 'text' | 'number' = 'text'; + + if (customIDfield) { + if (customIDfield?.type === 'text' || customIDfield?.type === 'number') { + idFieldType = customIDfield.type; + } + + hasCustomID = true; + } + + paths.push({ + path: '_id', + field: { + name: 'id', + type: idFieldType, + }, + complete: true, + collectionSlug: this.collectionSlug, + }); + } else { + paths = await this.getLocalizedPaths({ + collectionSlug: this.collectionSlug, + globalSlug: this.globalSlug, + fields, + incomingPath: sanitizedPath, + }); + } + + const [{ path, field }] = paths; if (path) { - const schemaType = schema.path(path); - const schemaOptions = getSchemaTypeOptions(schemaType); - const formattedValue = sanitizeQueryValue(schemaType, path, operator, val); - - if (!this.overrideAccess && (['salt', 'hash'].includes(path) || schemaType?.options?.hidden)) { - return undefined; - } + const formattedValue = sanitizeQueryValue({ + ctx: this, + field, + path, + operator, + val, + hasCustomID, + }); // If there are multiple collections to search through, // Recursively build up a list of query constraints - if (collectionPaths.length > 1) { + if (paths.length > 1) { // Remove top collection and reverse array // to work backwards from top - const collectionPathsToSearch = collectionPaths.slice(1).reverse(); + const pathsToQuery = paths.slice(1).reverse(); const initialRelationshipQuery = { value: {}, } as SearchParam; - const relationshipQuery = await collectionPathsToSearch.reduce(async (priorQuery, { Model: SubModel, path: subPath }, i) => { + const relationshipQuery = await pathsToQuery.reduce(async (priorQuery, { path: subPath, collectionSlug }, i) => { const priorQueryResult = await priorQuery; + const SubModel = this.req.payload.collections[collectionSlug].Model; + // On the "deepest" collection, // Search on the value passed through the query if (i === 0) { @@ -282,9 +277,9 @@ class ParamParser { const $in = result.map((doc) => doc._id.toString()); - if (collectionPathsToSearch.length === 1) return { path, value: { $in } }; + if (pathsToQuery.length === 1) return { path, value: { $in } }; - const nextSubPath = collectionPathsToSearch[i + 1].path; + const nextSubPath = pathsToQuery[i + 1].path; return { value: { [nextSubPath]: { $in } }, @@ -298,7 +293,7 @@ class ParamParser { // If it is the last recursion // then pass through the search param - if (i + 1 === collectionPathsToSearch.length) { + if (i + 1 === pathsToQuery.length) { return { path, value: { $in } }; } @@ -315,51 +310,6 @@ class ParamParser { if (operator && validOperators.includes(operator)) { const operatorKey = operatorMap[operator]; - let overrideQuery = false; - let query; - - // If there is a ref, this is a relationship or upload field - // IDs can be either string, number, or ObjectID - // So we need to build an `or` query for all these types - if (schemaOptions && (schemaOptions.ref || schemaOptions.refPath)) { - overrideQuery = true; - - query = { - $or: [ - { - [path]: { - [operatorKey]: formattedValue, - }, - }, - ], - }; - - if (typeof formattedValue === 'number' || (typeof formattedValue === 'string' && mongoose.Types.ObjectId.isValid(formattedValue))) { - query.$or.push({ - [path]: { - [operatorKey]: formattedValue.toString(), - }, - }); - } - - if (typeof formattedValue === 'string') { - if (!Number.isNaN(formattedValue)) { - query.$or.push({ - [path]: { - [operatorKey]: parseFloat(formattedValue), - }, - }); - } - } - } - - // If forced query - if (overrideQuery) { - return { - value: query, - }; - } - // Some operators like 'near' need to define a full query // so if there is no operator key, just return the value if (!operatorKey) { @@ -377,44 +327,247 @@ class ParamParser { } return undefined; } + + // Build up an array of auto-localized paths to search on + // Multiple paths may be possible if searching on properties of relationship fields + + async getLocalizedPaths({ + collectionSlug, + globalSlug, + fields, + incomingPath, + }: { + collectionSlug?: string + globalSlug?: string + fields: Field[] + incomingPath: string + }): Promise { + const pathSegments = incomingPath.split('.'); + + let paths: PathToQuery[] = [ + { + path: '', + complete: false, + field: undefined, + fields: flattenFields(fields, false), + fieldPolicies: undefined, + collectionSlug, + }, + ]; + + if (!this.overrideAccess) { + if (collectionSlug) { + const collection = { ...this.req.payload.collections[collectionSlug].config }; + collection.fields = fields; + + if (!this.policies.collections[collectionSlug]) { + const [policy, promises] = getEntityPolicies({ + req: this.req, + entity: collection, + operations: ['read'], + type: 'collection', + }); + + await Promise.all(promises); + this.policies.collections[collectionSlug] = policy; + } + + paths[0].fieldPolicies = this.policies.collections[collectionSlug].fields; + + if (['salt', 'hash'].includes(incomingPath) && collection.auth && !collection.auth?.disableLocalStrategy) { + this.errors.push({ path: incomingPath }); + return []; + } + } + + if (globalSlug) { + if (!this.policies.globals[globalSlug]) { + const global = { ...this.req.payload.globals.config.find(({ slug }) => slug === globalSlug) }; + global.fields = fields; + + const [policy, promises] = getEntityPolicies({ + req: this.req, + entity: global, + operations: ['read'], + type: 'global', + }); + + await Promise.all(promises); + this.policies.globals[globalSlug] = policy; + } + + paths[0].fieldPolicies = this.policies.globals[globalSlug].fields; + } + } + + // Use a 'some' so that we can bail out + // if a relationship query is found + // or if Rich Text / JSON + + let done = false; + + for (let i = 0; i < pathSegments.length; i += 1) { + if (done) continue; + + const segment = pathSegments[i]; + + const lastIncompletePath = paths.find(({ complete }) => !complete); + + if (lastIncompletePath) { + const { path } = lastIncompletePath; + let currentPath = path ? `${path}.${segment}` : segment; + + const matchedField = lastIncompletePath.fields.find((field) => fieldAffectsData(field) && field.name === segment); + lastIncompletePath.field = matchedField; + + if (currentPath === 'globalType' && this.globalSlug) { + lastIncompletePath.path = currentPath; + lastIncompletePath.complete = true; + lastIncompletePath.field = { + name: 'globalType', + type: 'text', + }; + + done = true; + continue; + } + + if (matchedField) { + if (!this.overrideAccess) { + const fieldAccess = lastIncompletePath.fieldPolicies[matchedField.name].read.permission; + + if (!fieldAccess || ('hidden' in matchedField && matchedField.hidden)) { + this.errors.push({ path: currentPath }); + done = true; + continue; + } + } + + const nextSegment = pathSegments[i + 1]; + const nextSegmentIsLocale = this.localizationConfig && this.localizationConfig.locales.includes(nextSegment); + + if (nextSegmentIsLocale) { + // Skip the next iteration, because it's a locale + i += 1; + currentPath = `${currentPath}.${nextSegment}`; + } else if ('localized' in matchedField && matchedField.localized) { + currentPath = `${currentPath}.${this.req.locale}`; + } + + switch (matchedField.type) { + case 'blocks': + case 'richText': + case 'json': { + const upcomingSegments = pathSegments.slice(i + 1).join('.'); + lastIncompletePath.complete = true; + lastIncompletePath.path = upcomingSegments ? `${currentPath}.${upcomingSegments}` : currentPath; + done = true; + continue; + } + + case 'relationship': + case 'upload': { + // If this is a polymorphic relation, + // We only support querying directly (no nested querying) + if (typeof matchedField.relationTo !== 'string') { + const lastSegmentIsValid = ['value', 'relationTo'].includes(pathSegments[pathSegments.length - 1]); + + if (lastSegmentIsValid) { + lastIncompletePath.complete = true; + lastIncompletePath.path = pathSegments.join('.'); + } else { + this.errors.push({ path: currentPath }); + done = true; + continue; + } + } else { + lastIncompletePath.complete = true; + lastIncompletePath.collectionSlug = matchedField.relationTo; + lastIncompletePath.path = currentPath; + + const nestedPathToQuery = pathSegments.slice(nextSegmentIsLocale ? i + 2 : i + 1).join('.'); + + if (nestedPathToQuery) { + const relatedCollection = this.req.payload.collections[matchedField.relationTo as string].config; + + const remainingPaths = await this.getLocalizedPaths({ + collectionSlug: relatedCollection.slug, + fields: relatedCollection.fields, + incomingPath: nestedPathToQuery, + }); + + paths = [ + ...paths, + ...remainingPaths, + ]; + } + + done = true; + continue; + } + + break; + } + + default: { + if ('fields' in lastIncompletePath.field) { + lastIncompletePath.fields = flattenFields(lastIncompletePath.field.fields, false); + } + + if (!this.overrideAccess && 'fields' in lastIncompletePath.fieldPolicies[lastIncompletePath.field.name]) { + lastIncompletePath.fieldPolicies = lastIncompletePath.fieldPolicies[lastIncompletePath.field.name].fields; + } + + if (i + 1 === pathSegments.length) lastIncompletePath.complete = true; + lastIncompletePath.path = currentPath; + continue; + } + } + } else { + this.errors.push({ path: currentPath }); + done = true; + continue; + } + } + } + + return paths; + } } type GetBuildQueryPluginArgs = { collectionSlug?: string - globalSlug?: string - isGlobalModel?: boolean - isVersionsModel?: boolean + versionsFields?: Field[] } export type BuildQueryArgs = { req: PayloadRequest where: Where overrideAccess: boolean + globalSlug?: string } // This plugin asynchronously builds a list of Mongoose query constraints // which can then be used in subsequent Mongoose queries. const getBuildQueryPlugin = ({ collectionSlug, - globalSlug, - isGlobalModel, - isVersionsModel, -}: GetBuildQueryPluginArgs) => { + versionsFields, +}: GetBuildQueryPluginArgs = {}) => { return function buildQueryPlugin(schema) { const modifiedSchema = schema; - async function buildQuery({ req, where, overrideAccess = false }: BuildQueryArgs) { + async function buildQuery({ req, where, overrideAccess = false, globalSlug }: BuildQueryArgs): Promise> { const paramParser = new ParamParser({ req, collectionSlug, globalSlug, - isGlobalModel, - isVersionsModel, + versionsFields, model: this, where, overrideAccess, }); - const params = await paramParser.parse(); - return params.searchParams; + const result = await paramParser.parse(); + // TODO: throw errors here + return result; } modifiedSchema.statics.buildQuery = buildQuery; }; diff --git a/src/mongoose/sanitizeFormattedValue.ts b/src/mongoose/sanitizeQueryValue.ts similarity index 74% rename from src/mongoose/sanitizeFormattedValue.ts rename to src/mongoose/sanitizeQueryValue.ts index d538a472e2..845ac4505f 100644 --- a/src/mongoose/sanitizeFormattedValue.ts +++ b/src/mongoose/sanitizeQueryValue.ts @@ -1,27 +1,40 @@ -import mongoose, { SchemaType } from 'mongoose'; +import mongoose from 'mongoose'; import { createArrayFromCommaDelineated } from './createArrayFromCommaDelineated'; -import { getSchemaTypeOptions } from './getSchemaTypeOptions'; import wordBoundariesRegex from '../utilities/wordBoundariesRegex'; +import { Field, TabAsField } from '../fields/config/types'; +import { ParamParser } from './buildQuery'; -export const sanitizeQueryValue = (schemaType: SchemaType, path: string, operator: string, val: any): unknown => { +type SanitizeQueryValueArgs = { + ctx: ParamParser, + field: Field | TabAsField, + path: string, + operator: string, + val: any + hasCustomID: boolean +} + +export const sanitizeQueryValue = ({ ctx, field, path, operator, val, hasCustomID }: SanitizeQueryValueArgs): unknown => { let formattedValue = val; - const schemaOptions = getSchemaTypeOptions(schemaType); // Disregard invalid _ids if (path === '_id' && typeof val === 'string' && val.split(',').length === 1) { - if (schemaType?.instance === 'ObjectID') { + if (!hasCustomID) { const isValid = mongoose.Types.ObjectId.isValid(val); + formattedValue = new mongoose.Types.ObjectId(val); + if (!isValid) { + ctx.errors.push({ path }); return undefined; } } - if (schemaType?.instance === 'Number') { + if (field.type === 'number') { const parsedNumber = parseFloat(val); if (Number.isNaN(parsedNumber)) { + ctx.errors.push({ path }); return undefined; } } @@ -29,17 +42,34 @@ export const sanitizeQueryValue = (schemaType: SchemaType, path: string, operato // Cast incoming values as proper searchable types - if (schemaType?.instance === 'Boolean' && typeof val === 'string') { + if (field.type === 'checkbox' && typeof val === 'string') { if (val.toLowerCase() === 'true') formattedValue = true; if (val.toLowerCase() === 'false') formattedValue = false; } - if (schemaType?.instance === 'Number' && typeof val === 'string') { + if (field.type === 'number' && typeof val === 'string') { formattedValue = Number(val); } - if ((schemaOptions?.ref || schemaOptions?.refPath) && val === 'null') { - formattedValue = null; + if (['relationship', 'upload'].includes(field.type) && val === 'null') { + if (val === 'null') { + formattedValue = null; + } + + if (operator === 'in' && Array.isArray(formattedValue)) { + formattedValue = formattedValue.reduce((formattedValues, inVal) => { + const newValues = [inVal]; + if (mongoose.Types.ObjectId.isValid(inVal)) newValues.push(new mongoose.Types.ObjectId(inVal)); + + const parsedNumber = parseFloat(inVal); + if (!Number.isNaN(parsedNumber)) newValues.push(parsedNumber); + + return [ + ...formattedValues, + ...newValues, + ]; + }, []); + } } // Set up specific formatting necessary by operators @@ -74,23 +104,6 @@ export const sanitizeQueryValue = (schemaType: SchemaType, path: string, operato formattedValue = createArrayFromCommaDelineated(formattedValue); } - if (schemaOptions && (schemaOptions.ref || schemaOptions.refPath) && operator === 'in') { - if (Array.isArray(formattedValue)) { - formattedValue = formattedValue.reduce((formattedValues, inVal) => { - const newValues = [inVal]; - if (mongoose.Types.ObjectId.isValid(inVal)) newValues.push(new mongoose.Types.ObjectId(inVal)); - - const parsedNumber = parseFloat(inVal); - if (!Number.isNaN(parsedNumber)) newValues.push(parsedNumber); - - return [ - ...formattedValues, - ...newValues, - ]; - }, []); - } - } - if (path !== '_id') { if (operator === 'contains') { formattedValue = { $regex: formattedValue, $options: 'i' }; diff --git a/src/utilities/getEntityPolicies.ts b/src/utilities/getEntityPolicies.ts index 52b465b460..9aeae2ef49 100644 --- a/src/utilities/getEntityPolicies.ts +++ b/src/utilities/getEntityPolicies.ts @@ -1,23 +1,20 @@ import { Access } from '../config/types'; -import { AllOperations, Where, Document } from '../types'; +import { AllOperations, Document, Where } from '../types'; import { FieldAccess, tabHasName } from '../fields/config/types'; -import type { CollectionConfig } from '../collections/config/types'; -import type { GlobalConfig } from '../globals/config/types'; +import type { SanitizedCollectionConfig } from '../collections/config/types'; +import { TypeWithID } from '../collections/config/types'; +import type { SanitizedGlobalConfig } from '../globals/config/types'; import type { PayloadRequest } from '../express/types'; import type { CollectionPermission, GlobalPermission } from '../auth/types'; -import { TypeWithID } from '../collections/config/types'; -type Args = ({ +type Args = { req: PayloadRequest operations: AllOperations[] id?: string -} & ({ - type: 'collection' - entity: CollectionConfig -} | { - type: 'global' - entity: GlobalConfig -})) + type: 'collection' | 'global' + entity: SanitizedCollectionConfig | SanitizedGlobalConfig +} + type ReturnType = T['type'] extends 'global' ? [GlobalPermission, Promise[]] : [CollectionPermission, Promise[]] type CreateAccessPromise = (args: { @@ -111,14 +108,15 @@ export function getEntityPolicies(args: T): ReturnType { } }; - const executeFieldPolicies = ({ - policiesObj = {}, + const executeFieldPolicies = async ({ + policiesObj, fields, operation, + entityAccessPromise, }) => { - const mutablePolicies = policiesObj; + const mutablePolicies = policiesObj.fields; - fields.forEach((field) => { + fields.forEach(async (field) => { if (field.name) { if (!mutablePolicies[field.name]) mutablePolicies[field.name] = {}; @@ -131,17 +129,19 @@ export function getEntityPolicies(args: T): ReturnType { accessLevel: 'field', })); } else { + if (entityAccessPromise) await entityAccessPromise; mutablePolicies[field.name][operation] = { - permission: isLoggedIn, + permission: policiesObj[operation]?.permission, }; } if (field.fields) { if (!mutablePolicies[field.name].fields) mutablePolicies[field.name].fields = {}; executeFieldPolicies({ - policiesObj: mutablePolicies[field.name].fields, + policiesObj: mutablePolicies[field.name], fields: field.fields, operation, + entityAccessPromise, }); } } else if (field.fields) { @@ -149,6 +149,7 @@ export function getEntityPolicies(args: T): ReturnType { policiesObj: mutablePolicies, fields: field.fields, operation, + entityAccessPromise, }); } else if (field.type === 'tabs') { field.tabs.forEach((tab) => { @@ -158,12 +159,14 @@ export function getEntityPolicies(args: T): ReturnType { policiesObj: mutablePolicies[tab.name].fields, fields: tab.fields, operation, + entityAccessPromise, }); } else { executeFieldPolicies({ policiesObj: mutablePolicies, fields: tab.fields, operation, + entityAccessPromise, }); } }); @@ -172,24 +175,28 @@ export function getEntityPolicies(args: T): ReturnType { }; operations.forEach((operation) => { - executeFieldPolicies({ - policiesObj: policies.fields, - fields: entity.fields, - operation, - }); + let entityAccessPromise: Promise; if (typeof entity.access[operation] === 'function') { - promises.push(createAccessPromise({ + entityAccessPromise = createAccessPromise({ policiesObj: policies, access: entity.access[operation], operation, accessLevel: 'entity', - })); + }); + promises.push(entityAccessPromise); } else { policies[operation] = { permission: isLoggedIn, }; } + + executeFieldPolicies({ + policiesObj: policies, + fields: entity.fields, + operation, + entityAccessPromise, + }); }); return [policies, promises] as ReturnType; diff --git a/src/versions/drafts/replaceWithDraftIfAvailable.ts b/src/versions/drafts/replaceWithDraftIfAvailable.ts index d367fe6150..f1531f4239 100644 --- a/src/versions/drafts/replaceWithDraftIfAvailable.ts +++ b/src/versions/drafts/replaceWithDraftIfAvailable.ts @@ -63,6 +63,7 @@ const replaceWithDraftIfAvailable = async ({ where: queryToBuild, req, overrideAccess, + globalSlug: entityType === 'global' ? entity.slug : undefined, }); let draft = await VersionModel.findOne(query, {}, { diff --git a/test/globals/config.ts b/test/globals/config.ts index 4ea54823a5..da911f86eb 100644 --- a/test/globals/config.ts +++ b/test/globals/config.ts @@ -4,6 +4,8 @@ import { buildConfig } from '../buildConfig'; export const slug = 'global'; export const arraySlug = 'array'; +export const accessControlSlug = 'access-control'; + export const englishLocale = 'en'; export const spanishLocale = 'es'; @@ -51,6 +53,33 @@ export default buildConfig({ }, ], }, + { + slug: accessControlSlug, + access: { + read: ({ req: { user } }) => { + if (user) { + return true; + } + + return { + enabled: { + equals: true, + }, + }; + }, + }, + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + { + name: 'enabled', + type: 'checkbox', + }, + ], + }, ], onInit: async (payload) => { await payload.create({ @@ -60,5 +89,12 @@ export default buildConfig({ password: devUser.password, }, }); + + await payload.updateGlobal({ + slug: accessControlSlug, + data: { + title: 'hello', + }, + }); }, }); diff --git a/test/globals/int.spec.ts b/test/globals/int.spec.ts index 7bd009e208..8500a41d02 100644 --- a/test/globals/int.spec.ts +++ b/test/globals/int.spec.ts @@ -1,6 +1,6 @@ import { GraphQLClient } from 'graphql-request'; import { initPayloadTest } from '../helpers/configHelpers'; -import configPromise, { arraySlug, englishLocale, slug, spanishLocale } from './config'; +import configPromise, { accessControlSlug, arraySlug, englishLocale, slug, spanishLocale } from './config'; import payload from '../../src'; import { RESTClient } from '../helpers/rest'; @@ -144,6 +144,29 @@ describe('globals', () => { expect(en).toMatchObject(localized.en); expect(es).toMatchObject(localized.es); }); + + it('should respect valid access query constraint', async () => { + const emptyGlobal = await payload.findGlobal({ + slug: accessControlSlug, + overrideAccess: false, + }); + + expect(Object.keys(emptyGlobal)).toHaveLength(0); + + await payload.updateGlobal({ + slug: accessControlSlug, + data: { + enabled: true, + }, + }); + + const hasAccess = await payload.findGlobal({ + slug: accessControlSlug, + overrideAccess: false, + }); + + expect(hasAccess.title).toBeDefined(); + }); }); describe('graphql', () => { diff --git a/test/localization/int.spec.ts b/test/localization/int.spec.ts index 508c25302a..751971ef3b 100644 --- a/test/localization/int.spec.ts +++ b/test/localization/int.spec.ts @@ -5,11 +5,7 @@ import payload from '../../src'; import type { LocalizedPost, WithLocalizedRelationship, - LocalizedRequired, - RelationshipLocalized, - GlobalArray, } from './payload-types'; -import type { LocalizedPostAllLocale } from './config'; import configPromise, { relationshipLocalizedSlug, slug, withLocalizedRelSlug, withRequiredLocalizedFields } from './config'; import { defaultLocale,