fix: beforeValidate deleting value when access returns false (#11549)

### What?
Regression caused by https://github.com/payloadcms/payload/pull/11433 

If a beforeChange hook was checking for a missing or undefined `value`
in order to change the value before inserting into the database, data
could be lost.

### Why?
In #11433 the logic for setting the fallback field value was moved above
the logic that cleared the value when access control returned false.

### How?
This change ensures that the fallback value is passed into the
beforeValidate function _and_ still available with the fallback value on
siblingData if access control returns false.

Fixes https://github.com/payloadcms/payload/issues/11543
This commit is contained in:
Jarrod Flesch
2025-03-05 13:34:08 -05:00
committed by GitHub
parent 143b6e3b8e
commit 6939a835ca
8 changed files with 290 additions and 110 deletions

View File

@@ -0,0 +1,31 @@
import type { JsonObject, JsonValue, PayloadRequest } from '../../../types/index.js'
import type { FieldAffectingData } from '../../config/types.js'
import { getDefaultValue } from '../../getDefaultValue.js'
import { cloneDataFromOriginalDoc } from '../beforeChange/cloneDataFromOriginalDoc.js'
export async function getFallbackValue({
field,
req,
siblingDoc,
}: {
field: FieldAffectingData
req: PayloadRequest
siblingDoc: JsonObject
}): Promise<JsonValue> {
let fallbackValue = undefined
if ('name' in field && field.name) {
if (typeof siblingDoc[field.name] !== 'undefined') {
fallbackValue = cloneDataFromOriginalDoc(siblingDoc[field.name])
} else if ('defaultValue' in field && typeof field.defaultValue !== 'undefined') {
fallbackValue = await getDefaultValue({
defaultValue: field.defaultValue,
locale: req.locale || '',
req,
user: req.user,
})
}
}
return fallbackValue
}

View File

@@ -8,10 +8,9 @@ import type { Block, Field, TabAsField } from '../../config/types.js'
import { MissingEditorProp } from '../../../errors/index.js'
import { fieldAffectsData, tabHasName, valueIsValueWithRelation } from '../../config/types.js'
import { getDefaultValue } from '../../getDefaultValue.js'
import { getFieldPathsModified as getFieldPaths } from '../../getFieldPaths.js'
import { cloneDataFromOriginalDoc } from '../beforeChange/cloneDataFromOriginalDoc.js'
import { getExistingRowDoc } from '../beforeChange/getExistingRowDoc.js'
import { getFallbackValue } from './getFallbackValue.js'
import { traverseFields } from './traverseFields.js'
type Args<T> = {
@@ -274,21 +273,15 @@ export const promise = async <T>({
}
}
if (typeof siblingData[field.name] === 'undefined') {
// If no incoming data, but existing document data is found, merge it in
if (typeof siblingDoc[field.name] !== 'undefined') {
siblingData[field.name] = cloneDataFromOriginalDoc(siblingDoc[field.name])
// Otherwise compute default value
} else if (typeof field.defaultValue !== 'undefined') {
siblingData[field.name] = await getDefaultValue({
defaultValue: field.defaultValue,
locale: req.locale,
req,
user: req.user,
value: siblingData[field.name],
})
// ensure the fallback value is only computed one time
// either here or when access control returns false
const fallbackResult = {
executed: false,
value: undefined,
}
if (typeof siblingData[field.name] === 'undefined') {
fallbackResult.value = await getFallbackValue({ field, req, siblingDoc })
fallbackResult.executed = true
}
// Execute hooks
@@ -312,7 +305,10 @@ export const promise = async <T>({
schemaPath: schemaPathSegments,
siblingData,
siblingFields,
value: siblingData[field.name],
value:
typeof siblingData[field.name] === 'undefined'
? fallbackResult.value
: siblingData[field.name],
})
if (hookedValue !== undefined) {
@@ -331,6 +327,12 @@ export const promise = async <T>({
delete siblingData[field.name]
}
}
if (typeof siblingData[field.name] === 'undefined') {
siblingData[field.name] = !fallbackResult.executed
? await getFallbackValue({ field, req, siblingDoc })
: fallbackResult.value
}
}
// Traverse subfields

View File

@@ -0,0 +1,44 @@
import type { CollectionConfig } from 'payload'
import { hooksSlug } from '../../shared.js'
export const Hooks: CollectionConfig = {
slug: hooksSlug,
access: {
update: () => true,
},
fields: [
{
name: 'cannotMutateRequired',
type: 'text',
access: {
update: () => false,
},
required: true,
},
{
name: 'cannotMutateNotRequired',
type: 'text',
access: {
update: () => false,
},
hooks: {
beforeChange: [
({ value }) => {
if (!value) {
return 'no value found'
}
return value
},
],
},
},
{
name: 'canMutate',
type: 'text',
access: {
update: () => true,
},
},
],
}

View File

@@ -10,6 +10,7 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { textToLexicalJSON } from '../fields/collections/LexicalLocalized/textToLexicalJSON.js'
import { Disabled } from './collections/Disabled/index.js'
import { Hooks } from './collections/hooks/index.js'
import { Regression1 } from './collections/Regression-1/index.js'
import { Regression2 } from './collections/Regression-2/index.js'
import { RichText } from './collections/RichText/index.js'
@@ -567,6 +568,7 @@ export default buildConfigWithDefaults(
RichText,
Regression1,
Regression2,
Hooks,
],
globals: [
{

View File

@@ -8,7 +8,7 @@ import type {
} from 'payload'
import path from 'path'
import { Forbidden } from 'payload'
import { Forbidden, ValidationError } from 'payload'
import { fileURLToPath } from 'url'
import type { FullyRestricted, Post } from './payload-types.js'
@@ -21,6 +21,7 @@ import {
hiddenAccessCountSlug,
hiddenAccessSlug,
hiddenFieldsSlug,
hooksSlug,
relyOnRequestHeadersSlug,
restrictedVersionsSlug,
secondArrayText,
@@ -58,6 +59,7 @@ describe('Access Control', () => {
}
})
describe('Fields', () => {
it('should not affect hidden fields when patching data', async () => {
const doc = await payload.create({
collection: hiddenFieldsSlug,
@@ -167,6 +169,71 @@ describe('Access Control', () => {
expect(docOverride.array?.[1].text).toBe(secondArrayText)
})
it('should use fallback value when trying to update a field without permission', async () => {
const doc = await payload.create({
collection: hooksSlug,
data: {
cannotMutateRequired: 'original',
},
})
const updatedDoc = await payload.update({
id: doc.id,
collection: hooksSlug,
overrideAccess: false,
data: {
cannotMutateRequired: 'new',
canMutate: 'canMutate',
},
})
expect(updatedDoc.cannotMutateRequired).toBe('original')
})
it('should use fallback value when required data is missing', async () => {
const doc = await payload.create({
collection: hooksSlug,
data: {
cannotMutateRequired: 'original',
},
})
const updatedDoc = await payload.update({
id: doc.id,
collection: hooksSlug,
overrideAccess: false,
data: {
canMutate: 'canMutate',
},
})
// should fallback to original data and not throw validation error
expect(updatedDoc.cannotMutateRequired).toBe('original')
})
it('should pass fallback value through to beforeChange hook when access returns false', async () => {
const doc = await payload.create({
collection: hooksSlug,
data: {
cannotMutateRequired: 'cannotMutateRequired',
cannotMutateNotRequired: 'cannotMutateNotRequired',
},
})
const updatedDoc = await payload.update({
id: doc.id,
collection: hooksSlug,
overrideAccess: false,
data: {
cannotMutateNotRequired: 'updated',
},
})
// should fallback to original data and not throw validation error
expect(updatedDoc.cannotMutateRequired).toBe('cannotMutateRequired')
expect(updatedDoc.cannotMutateNotRequired).toBe('cannotMutateNotRequired')
})
})
describe('Collections', () => {
describe('restricted collection', () => {
it('field without read access should not show', async () => {

View File

@@ -89,6 +89,7 @@ export interface Config {
'rich-text': RichText;
regression1: Regression1;
regression2: Regression2;
hooks: Hook;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
@@ -117,6 +118,7 @@ export interface Config {
'rich-text': RichTextSelect<false> | RichTextSelect<true>;
regression1: Regression1Select<false> | Regression1Select<true>;
regression2: Regression2Select<false> | Regression2Select<true>;
hooks: HooksSelect<false> | HooksSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -680,6 +682,18 @@ export interface Regression2 {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "hooks".
*/
export interface Hook {
id: string;
cannotMutateRequired: string;
cannotMutateNotRequired?: string | null;
canMutate?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
@@ -774,6 +788,10 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'regression2';
value: string | Regression2;
} | null)
| ({
relationTo: 'hooks';
value: string | Hook;
} | null);
globalSlug?: string | null;
user:
@@ -1168,6 +1186,17 @@ export interface Regression2Select<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "hooks_select".
*/
export interface HooksSelect<T extends boolean = true> {
cannotMutateRequired?: T;
cannotMutateNotRequired?: T;
canMutate?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".

View File

@@ -5,6 +5,7 @@ export const slug = 'posts'
export const unrestrictedSlug = 'unrestricted'
export const readOnlySlug = 'read-only-collection'
export const readOnlyGlobalSlug = 'read-only-global'
export const hooksSlug = 'hooks'
export const userRestrictedCollectionSlug = 'user-restricted-collection'
export const fullyRestrictedSlug = 'fully-restricted'

View File

@@ -156,6 +156,8 @@ export interface BeforeValidate {
id: string;
title?: string | null;
selection?: ('a' | 'b') | null;
cannotMutate?: string | null;
canMutate?: string | null;
updatedAt: string;
createdAt: string;
}
@@ -741,6 +743,8 @@ export interface BeforeChangeHooksSelect<T extends boolean = true> {
export interface BeforeValidateSelect<T extends boolean = true> {
title?: T;
selection?: T;
cannotMutate?: T;
canMutate?: T;
updatedAt?: T;
createdAt?: T;
}